diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..fffcc10 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,5 @@ +# GitHub Copilot 项目入口 + +开始任何仓库任务前,必须完整读取并遵守根目录 `AGENTS.md`。 + +`AGENTS.md` 是 Agent 统一加载入口;强制规范正文仍以 `docs/engineering-standards.md` 和仓库可执行配置为准。不得只依赖本文件、聊天上下文或规则摘要。 diff --git a/.github/workflows/pull-request-checks.yml b/.github/workflows/pull-request-checks.yml new file mode 100644 index 0000000..0470a04 --- /dev/null +++ b/.github/workflows/pull-request-checks.yml @@ -0,0 +1,95 @@ +name: Pull Request Checks + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pr-checks-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build-and-migrations: + name: Build and migration contracts + runs-on: windows-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Test build gate + run: npm run test:build + + - name: Test scheduler domain + run: node scripts/tests/test-scheduler-domain.mjs + + - name: Test migration compatibility + run: node scripts/tests/test-migrations.js + + backend-fleet-contract: + name: Backend fleet contract + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout GUI + uses: actions/checkout@v4 + + - name: Checkout AutoWSGR + uses: actions/checkout@v4 + with: + repository: ShiinaKuroko/AutoWSGR + ref: ShiinaKuroko + path: AutoWSGR + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Setup uv + uses: astral-sh/setup-uv@v8.1.0 + with: + enable-cache: true + + - name: Install GUI dependencies + run: npm ci + + - name: Install AutoWSGR dependencies + run: uv sync --project AutoWSGR --no-dev + + - name: Build GUI + run: npm run build + + - name: Test candidate-only GUI services + run: node scripts/tests/test-main-services.js + + - name: Check fleet type snapshot + env: + AUTOWSGR_PYTHON: ${{ github.workspace }}/AutoWSGR/.venv/bin/python + run: npm run check:fleet-types + + - name: Test GUI to AutoWSGR fleet contract + env: + AUTOWSGR_REPO: ${{ github.workspace }}/AutoWSGR + AUTOWSGR_PYTHON: ${{ github.workspace }}/AutoWSGR/.venv/bin/python + run: node scripts/tests/test-api-contract.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f078a7..2eac2ec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,9 +6,9 @@ on: - 'v*' workflow_dispatch: inputs: - prerelease: - description: '标记为预发布' - required: false + publish: + description: 'Create GitHub Releases after all gates pass' + required: true type: boolean default: false @@ -32,8 +32,19 @@ jobs: $pkg = Get-Content package.json | ConvertFrom-Json $ver = $pkg.version } + if ($ver -match '^\d+\.\d+\.\d+$') { + $channel = 'latest' + $prerelease = 'false' + } elseif ($ver -match '^\d+\.\d+\.\d+-alpha(?:\.\d+)?$') { + $channel = 'alpha' + $prerelease = 'true' + } else { + throw "发布版本 $ver 必须是 X.Y.Z 或 X.Y.Z-alpha[.N]" + } echo "VERSION=$ver" >> $env:GITHUB_OUTPUT - echo "Resolved version: $ver" + echo "CHANNEL=$channel" >> $env:GITHUB_OUTPUT + echo "PRERELEASE=$prerelease" >> $env:GITHUB_OUTPUT + echo "Resolved release version: $ver ($channel)" - name: Setup Node.js uses: actions/setup-node@v4 @@ -44,44 +55,227 @@ jobs: - name: Install dependencies run: npm ci - - name: Sync package.json version + - name: Verify checked-in release version shell: pwsh run: | $pkg = Get-Content package.json | ConvertFrom-Json - $pkg.version = "${{ steps.version.outputs.VERSION }}" - $pkg | ConvertTo-Json -Depth 10 | Set-Content package.json -Encoding UTF8 + $lockVersion = node -p "require('./package-lock.json').version" + if ($LASTEXITCODE -ne 0) { + throw "无法读取 package-lock.json 版本" + } + $lockRootVersion = node -p "require('./package-lock.json').packages[''].version" + if ($LASTEXITCODE -ne 0) { + throw "无法读取 package-lock.json 根包版本" + } + if ($pkg.version -ne "${{ steps.version.outputs.VERSION }}") { + throw "package.json 版本与 tag 不一致" + } + if ($lockVersion -ne $pkg.version -or $lockRootVersion -ne $pkg.version) { + throw "package-lock.json 版本与 package.json 不一致" + } + $expectedChannel = "${{ steps.version.outputs.CHANNEL }}" + if ($expectedChannel -notin @('latest', 'alpha')) { + throw "发布频道无效: $expectedChannel" + } - - name: Build & Package - run: npm run dist + - name: Preflight release destinations + if: github.event_name != 'workflow_dispatch' || inputs.publish + shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $version = "${{ steps.version.outputs.VERSION }}" + $releases = gh api ` + "repos/${{ github.repository }}/releases?per_page=100" | + ConvertFrom-Json + if ($LASTEXITCODE -ne 0) { + throw "无法读取当前仓库 Release 列表" + } + if ($releases.tag_name -contains "v$version") { + throw "当前仓库已存在 v$version Release" + } + + - name: Verify pinned backend channels + id: backend + shell: pwsh + run: | + $manifest = Get-Content build/backend-distribution.json | + ConvertFrom-Json + foreach ($channel in @("stable", "alpha")) { + $distribution = $manifest.$channel + if ( + $distribution.id -ne $channel -or + $distribution.repository -notmatch '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$' -or + $distribution.ref -notmatch '^[A-Za-z0-9._/-]+$' -or + $distribution.commit -notmatch '^[0-9a-f]{40}$' + ) { + throw "$channel 后端发行清单无效" + } + echo "Pinned $channel backend: $($distribution.repository)@$($distribution.commit)" + } + echo "repository=$($manifest.stable.repository)" >> $env:GITHUB_OUTPUT + echo "commit=$($manifest.stable.commit)" >> $env:GITHUB_OUTPUT + + - name: Checkout pinned AutoWSGR backend + uses: actions/checkout@v4 + with: + repository: ${{ steps.backend.outputs.repository }} + ref: ${{ steps.backend.outputs.commit }} + path: AutoWSGR + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Setup uv + uses: astral-sh/setup-uv@v8.1.0 + with: + enable-cache: true + + - name: Install pinned backend dependencies + shell: pwsh + run: | + uv sync --project AutoWSGR --no-dev + uv pip install ` + --python AutoWSGR/.venv/Scripts/python.exe ` + "requests>=2.32.5" ` + "beautifulsoup4>=4.12.0" + + - name: Run stable release gates + shell: pwsh + run: | + Remove-Item Env:ELECTRON_RUN_AS_NODE -ErrorAction SilentlyContinue + node scripts/sync-map-resources.js --backend "${{ github.workspace }}/AutoWSGR" --check + npm run test:build + npm run test:migrations + npm run test:main-services + npm run test:main-ipc + npm run test:scheduler-domain + npm run test:fleet-domain + npm run test:settings + npm run test:python-environment + npm run test:backend-distribution + npm run test:event-resources + npm run test:plan-management-delete + npm run test:daily-sortie-stats + npm run test:ship-library-updater + npm run test:ocr-log-analyzer + npm run test:map-intel + $env:AUTOWSGR_REPO = "${{ github.workspace }}/AutoWSGR" + $env:AUTOWSGR_PYTHON = "${{ github.workspace }}/AutoWSGR/.venv/Scripts/python.exe" + npm run test:api-contract + + - name: Build & Package + run: npm run dist - name: List release artifacts shell: pwsh run: Get-ChildItem -Path release -Recurse -File | Select-Object FullName, Length - - name: Create Release + - name: Verify packaged runtime and resources + run: npm run test:release-package + env: + AUTOWSGR_RELEASE_VERSION: ${{ steps.version.outputs.VERSION }} + + - name: Verify update channel metadata + shell: pwsh + run: | + $channel = "${{ steps.version.outputs.CHANNEL }}" + $releaseRoot = Join-Path release $channel + $expected = Join-Path $releaseRoot "$channel.yml" + if (-not (Test-Path $expected)) { + throw "缺少 $channel 更新清单: $expected" + } + @('latest', 'alpha', 'beta', 'dev') | + Where-Object { $_ -ne $channel } | + ForEach-Object { + $unexpected = Join-Path $releaseRoot "$_.yml" + if (Test-Path $unexpected) { + throw "发布产物混入其他频道清单: $unexpected" + } + } + + - name: Create Alpha Release + if: >- + steps.version.outputs.CHANNEL == 'alpha' + && (github.event_name != 'workflow_dispatch' || inputs.publish) + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.version.outputs.VERSION }} + name: AutoWSGR-GUI v${{ steps.version.outputs.VERSION }} + prerelease: true + generate_release_notes: true + files: | + release/alpha/AutoWSGR-GUI-Setup-*.exe + release/alpha/*.exe.blockmap + release/alpha/alpha.yml + body: | + ## AutoWSGR-GUI v${{ steps.version.outputs.VERSION }} + + 本版本使用 `alpha` 更新频道。此版本为 Stable 迁移桥,默认继续接收 + Alpha,并允许用户切换到 Stable 更新轨道。 + + 首次运行 managed 模式会安装本版本固定的 AutoWSGR 后端,需要联网。 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create Stable Release + if: >- + steps.version.outputs.CHANNEL == 'latest' + && (github.event_name != 'workflow_dispatch' || inputs.publish) uses: softprops/action-gh-release@v2 with: tag_name: v${{ steps.version.outputs.VERSION }} name: AutoWSGR-GUI v${{ steps.version.outputs.VERSION }} - prerelease: ${{ github.event.inputs.prerelease == 'true' }} + prerelease: false + draft: true generate_release_notes: true files: | - release/*-Setup-*.exe - release/*.exe.blockmap - release/latest.yml + release/latest/AutoWSGR-GUI-Setup-*.exe + release/latest/*.exe.blockmap + release/latest/latest.yml body: | ## AutoWSGR-GUI v${{ steps.version.outputs.VERSION }} - ### 使用方法 - 1. 下载 `AutoWSGR-GUI-Setup-*.exe`(安装版) + 本版本使用 `latest` 更新频道。Stable 默认不接收 Alpha;设置中开启 + “允许测试版更新”后可接收版本号更高的 Alpha。 + + ### 主要变化 + - 主导航统一为“作战 / 计划 / 设置” + - 新增可视化舰队规划、出征规划和计划管理 + - 系统只读资源与用户配置、计划分离,执行前展开舰队引用 + - 完善任务列表、执行队列、迁移、环境管理和更新边界 + + ### 安装与升级 + 1. 下载 `AutoWSGR-GUI-Setup-*.exe` 2. 软件已内置 Python 3.12,无需单独安装 3. 首次运行时程序会自动安装 Python 依赖(需联网) - 4. 已安装用户将自动收到增量更新提示 + 4. 1.4.x 覆盖升级会先将旧用户数据备份到 `%LOCALAPPDATA%\AutoWSGR-GUI\legacy-upgrade`;确认迁移结果前不要删除该目录 + 5. 关闭“允许测试版更新”时只接收稳定版;开启后也接收版本号更高的 Alpha + + ### 自动强化 + 2.0.0 仅保存自动强化策略,不加入 Scheduler,生产路径为零后端调用,也不会操作舰船。 + + ### 回退 + 如需回退,请退出 2.0,使用旧安装器重新安装,并从 `legacy-upgrade` 备份恢复旧格式数据;不要让旧版直接写入或覆盖唯一的 2.0 `userData`。 ### 系统要求 - Windows 10/11 x64 - 网络连接(首次运行需安装 pip 依赖) env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish verified Stable release + if: >- + steps.version.outputs.CHANNEL == 'latest' + && (github.event_name != 'workflow_dispatch' || inputs.publish) + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $version = "${{ steps.version.outputs.VERSION }}" + gh release edit "v$version" ` + --repo "${{ github.repository }}" ` + --draft=false ` + --latest diff --git a/.gitignore b/.gitignore index 8485763..de08bc3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ dist/ logs/ log/ *.log +/ocr-log-report/ # 系统文件 .DS_Store @@ -25,6 +26,7 @@ Thumbs.db usersettings.yaml gui_settings.json task_groups.json +!scripts/tests/fixtures/**/task_groups.json templates.json .env_ready plans/ @@ -38,6 +40,7 @@ release/ # 安装临时文件 _setup_tmp/ .tmp/ +.tmp_* node/ python/ redist/ @@ -45,10 +48,14 @@ adb/ # 缓存 .cache/ +.dbg/ +__pycache__/ +*.py[cod] +/debug-backend-pipe-epipe.md # Kilo 工作区 .kilo/ # 测试用地图 resource/maps/99-1.json -plans/99-1.yaml. \ No newline at end of file +plans/99-1.yaml. diff --git a/.tmp_issue360.json b/.tmp_issue360.json deleted file mode 100644 index 9df7d21..0000000 --- a/.tmp_issue360.json +++ /dev/null @@ -1,2 +0,0 @@ -{"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/360","repository_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/360/labels{/name}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/360/comments","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/360/events","html_url":"https://github.com/OpenWSGR/AutoWSGR/issues/360","id":4052520746,"node_id":"I_kwDOHhoLA87xjI8q","number":360,"title":"[Feature] GUI API ǿֹͣÿֽݡ","user":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"labels":[],"state":"open","locked":false,"assignees":[],"milestone":null,"comments":2,"created_at":"2026-03-10T15:21:29Z","updated_at":"2026-04-05T06:37:38Z","closed_at":null,"assignee":null,"author_association":"CONTRIBUTOR","type":null,"active_lock_reason":null,"sub_issues_summary":{"total":0,"completed":0,"percent_completed":0},"issue_dependencies_summary":{"blocked_by":0,"total_blocked_by":0,"blocking":0,"total_blocking":0},"body":"## \n\nAutoWSGR-GUI ʵеȡֹͣ顢ÿսչʾȹܿܣֹ API ݲû issue GUI ĺ API ǿ\n\n---\n\n## 1. ֹͣɿÿֵȼߣ\n\n**ǰ״**\n- ʵ `stopCondition` ƣ`loot_count_ge` / `ship_count_ge`ûáˢ N սƷ/ʱԶֹͣ\n- `/api/game/context` ص `dropped_ship_count` / `dropped_loot_count` ɿ**ֹͣעͽ**\n\n**Ҫģ**\n- ÿսȷۼƱγĵ佢սƷ\n- ͨ `/api/game/context`սشṩȷۼֵ\n- #355 ĵ佢ʶ#355 ʶʲôۼƵ˶١\n\n**ǰеĵô루ǰעͣ**\n```typescript\n// Scheduler.ts\nconst resp = await this.api.gameContext();\nif (data.dropped_loot_count >= cond.loot_count_ge) { /* ֹͣ */ }\nif (data.dropped_ship_count >= cond.ship_count_ge) { /* ֹͣ */ }\n```\n\n---\n\n## 2. ÿսشȼͣ\n\n**ǰ״**\n- `RoundResult` ݽṹѶ `mvp``grade``ship_damage` ֶ\n- WebSocket `task_completed` Ϣе `result.details` ĿǰЩֶΪ\n\n**Ҫģ**\n- սʱ `RoundResult` 䣺\n - `ship_damage`: λ̶ȣѡǰչʾ״̬\n- MVP ƺ͵佢 #355 \n\n** gradeѽ**\n> սۣSS/S/A/B/C/Dͨǰ˽ stderr ־ `[Combat] ս: MVP=0 =SS ڵ: A` ʵɫʾ** API ⷵ**δ `grade` ֶΣǰ˿ֱʹãⲻ\n\n---\n\n## 3. ˵㣨ȼͣ\n\n**ǰ״**\n- WebSocket Զ\n- ˽̿ OCR סģӦȳ\n\n**Ҫģ**\n- ṩ `GET /api/health` ˵㣬غ˴״̬\n- ѡصǰǷij賬쳣ʱ\n\n---\n\n## \n\n- #355 MVPƻش & 佢ʶشϵ\n- GUI ֿ⣺[yltx/AutoWSGR-GUI](https://github.com/yltx/AutoWSGR-GUI)","closed_by":null,"reactions":{"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/360/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"timeline_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/360/timeline","performed_via_github_app":null,"state_reason":null,"pinned_comment":null} - diff --git a/.tmp_issue360_comments.json b/.tmp_issue360_comments.json deleted file mode 100644 index fe5ae76..0000000 --- a/.tmp_issue360_comments.json +++ /dev/null @@ -1,2 +0,0 @@ -[{"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments/4188401106","html_url":"https://github.com/OpenWSGR/AutoWSGR/issues/360#issuecomment-4188401106","issue_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/360","id":4188401106,"node_id":"IC_kwDOHhoLA875pe3S","user":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"created_at":"2026-04-05T06:37:17Z","updated_at":"2026-04-05T06:37:17Z","body":"ǰ˷һ£issueǷѾ@github-actions","author_association":"CONTRIBUTOR","pin":null,"reactions":{"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments/4188401106/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"performed_via_github_app":null},{"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments/4188401263","html_url":"https://github.com/OpenWSGR/AutoWSGR/issues/360#issuecomment-4188401263","issue_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/360","id":4188401263,"node_id":"IC_kwDOHhoLA875pe5v","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"created_at":"2026-04-05T06:37:29Z","updated_at":"2026-04-05T06:37:38Z","body":"ִԡ\n\n---\n\n˴չ\nError: Model \"gpt-5.4\" from --model flag is not available.\n\n\n?? [GitHub Action м¼](https://github.com/OpenWSGR/AutoWSGR/actions/runs/23996089391)\n","author_association":"CONTRIBUTOR","pin":null,"reactions":{"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments/4188401263/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"performed_via_github_app":{"id":15368,"client_id":"Iv1.05c79e9ad1f6bdfa","slug":"github-actions","node_id":"MDM6QXBwMTUzNjg=","owner":{"login":"github","id":9919,"node_id":"MDEyOk9yZ2FuaXphdGlvbjk5MTk=","avatar_url":"https://avatars.githubusercontent.com/u/9919?v=4","gravatar_id":"","url":"https://api.github.com/users/github","html_url":"https://github.com/github","followers_url":"https://api.github.com/users/github/followers","following_url":"https://api.github.com/users/github/following{/other_user}","gists_url":"https://api.github.com/users/github/gists{/gist_id}","starred_url":"https://api.github.com/users/github/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github/subscriptions","organizations_url":"https://api.github.com/users/github/orgs","repos_url":"https://api.github.com/users/github/repos","events_url":"https://api.github.com/users/github/events{/privacy}","received_events_url":"https://api.github.com/users/github/received_events","type":"Organization","user_view_type":"public","site_admin":false},"name":"GitHub Actions","description":"Automate your workflow from idea to production","external_url":"https://help.github.com/en/actions","html_url":"https://github.com/apps/github-actions","created_at":"2018-07-30T09:30:17Z","updated_at":"2025-12-02T18:13:15Z","permissions":{"actions":"write","administration":"read","artifact_metadata":"write","attestations":"write","checks":"write","contents":"write","copilot_requests":"write","deployments":"write","discussions":"write","issues":"write","merge_queues":"write","metadata":"read","models":"read","packages":"write","pages":"write","pull_requests":"write","repository_hooks":"write","repository_projects":"write","security_events":"write","statuses":"write","vulnerability_alerts":"read"},"events":["branch_protection_rule","check_run","check_suite","create","delete","deployment","deployment_status","discussion","discussion_comment","fork","gollum","issues","issue_comment","label","merge_group","milestone","page_build","public","pull_request","pull_request_review","pull_request_review_comment","push","registry_package","release","repository","repository_dispatch","status","watch","workflow_dispatch","workflow_run"]}}] - diff --git a/.tmp_issue372.json b/.tmp_issue372.json deleted file mode 100644 index 32cc256..0000000 --- a/.tmp_issue372.json +++ /dev/null @@ -1,2 +0,0 @@ -{"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/372","repository_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/372/labels{/name}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/372/comments","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/372/events","html_url":"https://github.com/OpenWSGR/AutoWSGR/issues/372","id":4080457974,"node_id":"I_kwDOHhoLA87zNtj2","number":372,"title":"[Feature Request] ҳ OCR ʶ ֧/ճ״̬ѯ","user":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"labels":[],"state":"open","locked":false,"assignees":[],"milestone":null,"comments":2,"created_at":"2026-03-16T06:12:51Z","updated_at":"2026-04-05T06:10:41Z","closed_at":null,"assignee":null,"author_association":"CONTRIBUTOR","type":null,"active_lock_reason":null,"sub_issues_summary":{"total":0,"completed":0,"percent_completed":0},"issue_dependencies_summary":{"blocked_by":0,"total_blocked_by":0,"blocking":0,"total_blocking":0},"body":"## \n\nϣͨ OCR ʶҳ棨ճ/ܳ/ս۵ȣȡ״̬Ӷ GUI Զűһ\n\n## ʹó\n\n- GUI ǰչʾǰճ/ܳɽ\n- Զűδ̬ѡһִ\n- ÿԶУԽʡʱ\n\n## Ԥʵ\n\n1. ҳ\n2. OCR ʶƺ״̬/δ/У\n3. ؽṹб\n\n## ѵ\n\n- Ҫ OCR ģƥҳ\n- ݿ⣨ Ӧӳ䣩\n- ֱͬµ\n\n## ȼ\n\nе Ŀǰ̶ͨƹ˴˹ܿԴԶܶȡ","closed_by":null,"reactions":{"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/372/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"timeline_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/372/timeline","performed_via_github_app":null,"state_reason":null,"pinned_comment":null} - diff --git a/.tmp_issue372_comments.json b/.tmp_issue372_comments.json deleted file mode 100644 index ba104aa..0000000 --- a/.tmp_issue372_comments.json +++ /dev/null @@ -1,2 +0,0 @@ -[{"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments/4188369930","html_url":"https://github.com/OpenWSGR/AutoWSGR/issues/372#issuecomment-4188369930","issue_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/372","id":4188369930,"node_id":"IC_kwDOHhoLA875pXQK","user":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"created_at":"2026-04-05T06:10:22Z","updated_at":"2026-04-05T06:10:22Z","body":"@github-actions һ","author_association":"CONTRIBUTOR","pin":null,"reactions":{"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments/4188369930/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"performed_via_github_app":null},{"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments/4188370118","html_url":"https://github.com/OpenWSGR/AutoWSGR/issues/372#issuecomment-4188370118","issue_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/372","id":4188370118,"node_id":"IC_kwDOHhoLA875pXTG","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"created_at":"2026-04-05T06:10:33Z","updated_at":"2026-04-05T06:10:41Z","body":"ִԡ\n\n---\n\n˴չ\nError: Authentication failed (Request ID: 9401:14A9D1:93F818:D06440:69D1FCDF)\n\nYour GitHub token may be invalid, expired, or lacking the required permissions.\n\nTo resolve this, try the following:\n ? Start 'copilot' and run the '/login' command to re-authenticate\n ? If using a Fine-Grained PAT, ensure it has the 'Copilot Requests' permission enabled\n ? If using COPILOT_GITHUB_TOKEN, GH_TOKEN or GITHUB_TOKEN environment variable, verify the token is valid and not expired\n ? Run 'gh auth status' to check your current authentication status\n\n\n?? [GitHub Action м¼](https://github.com/OpenWSGR/AutoWSGR/actions/runs/23995683447)\n","author_association":"CONTRIBUTOR","pin":null,"reactions":{"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments/4188370118/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"performed_via_github_app":{"id":15368,"client_id":"Iv1.05c79e9ad1f6bdfa","slug":"github-actions","node_id":"MDM6QXBwMTUzNjg=","owner":{"login":"github","id":9919,"node_id":"MDEyOk9yZ2FuaXphdGlvbjk5MTk=","avatar_url":"https://avatars.githubusercontent.com/u/9919?v=4","gravatar_id":"","url":"https://api.github.com/users/github","html_url":"https://github.com/github","followers_url":"https://api.github.com/users/github/followers","following_url":"https://api.github.com/users/github/following{/other_user}","gists_url":"https://api.github.com/users/github/gists{/gist_id}","starred_url":"https://api.github.com/users/github/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github/subscriptions","organizations_url":"https://api.github.com/users/github/orgs","repos_url":"https://api.github.com/users/github/repos","events_url":"https://api.github.com/users/github/events{/privacy}","received_events_url":"https://api.github.com/users/github/received_events","type":"Organization","user_view_type":"public","site_admin":false},"name":"GitHub Actions","description":"Automate your workflow from idea to production","external_url":"https://help.github.com/en/actions","html_url":"https://github.com/apps/github-actions","created_at":"2018-07-30T09:30:17Z","updated_at":"2025-12-02T18:13:15Z","permissions":{"actions":"write","administration":"read","artifact_metadata":"write","attestations":"write","checks":"write","contents":"write","copilot_requests":"write","deployments":"write","discussions":"write","issues":"write","merge_queues":"write","metadata":"read","models":"read","packages":"write","pages":"write","pull_requests":"write","repository_hooks":"write","repository_projects":"write","security_events":"write","statuses":"write","vulnerability_alerts":"read"},"events":["branch_protection_rule","check_run","check_suite","create","delete","deployment","deployment_status","discussion","discussion_comment","fork","gollum","issues","issue_comment","label","merge_group","milestone","page_build","public","pull_request","pull_request_review","pull_request_review_comment","push","registry_package","release","repository","repository_dispatch","status","watch","workflow_dispatch","workflow_run"]}}] - diff --git a/.tmp_runs.json b/.tmp_runs.json deleted file mode 100644 index 5efad4f..0000000 --- a/.tmp_runs.json +++ /dev/null @@ -1,2 +0,0 @@ -{"total_count":236,"workflow_runs":[{"id":23995683447,"name":"AI Issue Analysis","node_id":"WFR_kwLOHhoLA88AAAAFlkESdw","head_branch":"main","head_sha":"8e568107bd3ccce8bbf4896d30a50c16b793e633","path":".github/workflows/ai-issue-analysis.yml","display_title":"[Feature Request] ҳ OCR ʶ ֧/ճ״̬ѯ","run_number":2,"event":"issue_comment","status":"completed","conclusion":"failure","workflow_id":256224518,"check_suite_id":63358713624,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOwHk_GA","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23995683447","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23995683447","pull_requests":[],"created_at":"2026-04-05T06:10:25Z","updated_at":"2026-04-05T06:10:43Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-05T06:10:25Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23995683447/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23995683447/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/63358713624","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23995683447/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23995683447/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23995683447/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/256224518","head_commit":{"id":"8e568107bd3ccce8bbf4896d30a50c16b793e633","tree_id":"9771f8ad31c04a907fed94274b10c059af83a062","message":"ci: add AI issue analysis bot workflow and skill (#398)","timestamp":"2026-04-04T16:23:59Z","author":{"name":"yltx","email":"37734213+yltx@users.noreply.github.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23992934314,"name":"Sync upstream","node_id":"WFR_kwLOHhoLA88AAAAFlhcfqg","head_branch":"main","head_sha":"8e568107bd3ccce8bbf4896d30a50c16b793e633","path":".github/workflows/sync-upstream.yml","display_title":"Sync upstream","run_number":104,"event":"schedule","status":"completed","conclusion":"skipped","workflow_id":244437590,"check_suite_id":63351563072,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOwAwjQA","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23992934314","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23992934314","pull_requests":[],"created_at":"2026-04-05T02:56:47Z","updated_at":"2026-04-05T02:56:48Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-05T02:56:47Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23992934314/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23992934314/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/63351563072","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23992934314/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23992934314/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23992934314/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/244437590","head_commit":{"id":"8e568107bd3ccce8bbf4896d30a50c16b793e633","tree_id":"9771f8ad31c04a907fed94274b10c059af83a062","message":"ci: add AI issue analysis bot workflow and skill (#398)","timestamp":"2026-04-04T16:23:59Z","author":{"name":"yltx","email":"37734213+yltx@users.noreply.github.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23989486085,"name":"AI Issue Analysis","node_id":"WFR_kwLOHhoLA88AAAAFleKCBQ","head_branch":"main","head_sha":"8e568107bd3ccce8bbf4896d30a50c16b793e633","path":".github/workflows/ai-issue-analysis.yml","display_title":"űȱȷϻ","run_number":1,"event":"issues","status":"completed","conclusion":"failure","workflow_id":256224518,"check_suite_id":63342341233,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOv39scQ","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23989486085","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23989486085","pull_requests":[],"created_at":"2026-04-04T23:04:58Z","updated_at":"2026-04-04T23:05:14Z","actor":{"login":"syokounya","id":93929783,"node_id":"U_kgDOBZlBNw","avatar_url":"https://avatars.githubusercontent.com/u/93929783?v=4","gravatar_id":"","url":"https://api.github.com/users/syokounya","html_url":"https://github.com/syokounya","followers_url":"https://api.github.com/users/syokounya/followers","following_url":"https://api.github.com/users/syokounya/following{/other_user}","gists_url":"https://api.github.com/users/syokounya/gists{/gist_id}","starred_url":"https://api.github.com/users/syokounya/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/syokounya/subscriptions","organizations_url":"https://api.github.com/users/syokounya/orgs","repos_url":"https://api.github.com/users/syokounya/repos","events_url":"https://api.github.com/users/syokounya/events{/privacy}","received_events_url":"https://api.github.com/users/syokounya/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-04T23:04:58Z","triggering_actor":{"login":"syokounya","id":93929783,"node_id":"U_kgDOBZlBNw","avatar_url":"https://avatars.githubusercontent.com/u/93929783?v=4","gravatar_id":"","url":"https://api.github.com/users/syokounya","html_url":"https://github.com/syokounya","followers_url":"https://api.github.com/users/syokounya/followers","following_url":"https://api.github.com/users/syokounya/following{/other_user}","gists_url":"https://api.github.com/users/syokounya/gists{/gist_id}","starred_url":"https://api.github.com/users/syokounya/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/syokounya/subscriptions","organizations_url":"https://api.github.com/users/syokounya/orgs","repos_url":"https://api.github.com/users/syokounya/repos","events_url":"https://api.github.com/users/syokounya/events{/privacy}","received_events_url":"https://api.github.com/users/syokounya/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23989486085/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23989486085/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/63342341233","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23989486085/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23989486085/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23989486085/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/256224518","head_commit":{"id":"8e568107bd3ccce8bbf4896d30a50c16b793e633","tree_id":"9771f8ad31c04a907fed94274b10c059af83a062","message":"ci: add AI issue analysis bot workflow and skill (#398)","timestamp":"2026-04-04T16:23:59Z","author":{"name":"yltx","email":"37734213+yltx@users.noreply.github.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23985336227,"name":"Sync upstream","node_id":"WFR_kwLOHhoLA88AAAAFlaMvow","head_branch":"main","head_sha":"8e568107bd3ccce8bbf4896d30a50c16b793e633","path":".github/workflows/sync-upstream.yml","display_title":"Sync upstream","run_number":103,"event":"schedule","status":"completed","conclusion":"skipped","workflow_id":244437590,"check_suite_id":63331452211,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOvtlFMw","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23985336227","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23985336227","pull_requests":[],"created_at":"2026-04-04T18:51:32Z","updated_at":"2026-04-04T18:51:33Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-04T18:51:32Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23985336227/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23985336227/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/63331452211","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23985336227/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23985336227/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23985336227/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/244437590","head_commit":{"id":"8e568107bd3ccce8bbf4896d30a50c16b793e633","tree_id":"9771f8ad31c04a907fed94274b10c059af83a062","message":"ci: add AI issue analysis bot workflow and skill (#398)","timestamp":"2026-04-04T16:23:59Z","author":{"name":"yltx","email":"37734213+yltx@users.noreply.github.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23982824993,"name":"Lint","node_id":"WFR_kwLOHhoLA88AAAAFlXzeIQ","head_branch":"main","head_sha":"8e568107bd3ccce8bbf4896d30a50c16b793e633","path":".github/workflows/lint.yml","display_title":"ci: add AI issue analysis bot workflow and skill (#398)","run_number":16,"event":"push","status":"completed","conclusion":"success","workflow_id":253260226,"check_suite_id":63324589462,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOvnCNlg","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982824993","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23982824993","pull_requests":[],"created_at":"2026-04-04T16:24:02Z","updated_at":"2026-04-04T16:24:26Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-04T16:24:02Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982824993/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982824993/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/63324589462","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982824993/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982824993/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982824993/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/253260226","head_commit":{"id":"8e568107bd3ccce8bbf4896d30a50c16b793e633","tree_id":"9771f8ad31c04a907fed94274b10c059af83a062","message":"ci: add AI issue analysis bot workflow and skill (#398)","timestamp":"2026-04-04T16:23:59Z","author":{"name":"yltx","email":"37734213+yltx@users.noreply.github.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23982820830,"name":"Copilot code review","node_id":"WFR_kwLOHhoLA88AAAAFlXzN3g","head_branch":"refs/pull/398/head","head_sha":"e994212b43653113829747305dfa8a0018c352d7","path":"dynamic/copilot-pull-request-reviewer/copilot-pull-request-reviewer","display_title":"Copilot code review","run_number":17,"event":"dynamic","status":"completed","conclusion":"success","workflow_id":220767798,"check_suite_id":63324579249,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOvnBlsQ","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982820830","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23982820830","pull_requests":[],"created_at":"2026-04-04T16:23:50Z","updated_at":"2026-04-04T16:26:35Z","actor":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-04T16:23:50Z","triggering_actor":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982820830/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982820830/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/63324579249","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982820830/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982820830/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982820830/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/220767798","head_commit":{"id":"e994212b43653113829747305dfa8a0018c352d7","tree_id":"9771f8ad31c04a907fed94274b10c059af83a062","message":"ci: add AI issue analysis bot workflow and skill","timestamp":"2026-04-04T16:20:48Z","author":{"name":"yltx","email":"2326439151@qq.com"},"committer":{"name":"yltx","email":"2326439151@qq.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23982820779,"name":"Lint","node_id":"WFR_kwLOHhoLA88AAAAFlXzNqw","head_branch":"yltx/ai-issue-analysis-bot","head_sha":"e994212b43653113829747305dfa8a0018c352d7","path":".github/workflows/lint.yml","display_title":"ci: add AI issue analysis bot workflow and skill","run_number":15,"event":"pull_request","status":"completed","conclusion":"success","workflow_id":253260226,"check_suite_id":63324579166,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOvnBlXg","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982820779","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23982820779","pull_requests":[],"created_at":"2026-04-04T16:23:49Z","updated_at":"2026-04-04T16:24:16Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-04T16:23:49Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982820779/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982820779/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/63324579166","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982820779/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982820779/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982820779/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/253260226","head_commit":{"id":"e994212b43653113829747305dfa8a0018c352d7","tree_id":"9771f8ad31c04a907fed94274b10c059af83a062","message":"ci: add AI issue analysis bot workflow and skill","timestamp":"2026-04-04T16:20:48Z","author":{"name":"yltx","email":"2326439151@qq.com"},"committer":{"name":"yltx","email":"2326439151@qq.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23982415777,"name":"Copilot code review","node_id":"WFR_kwLOHhoLA88AAAAFlXafoQ","head_branch":"refs/pull/397/head","head_sha":"3b5d45ec8f6155ad7059e18996bd9f9388a6d83c","path":"dynamic/copilot-pull-request-reviewer/copilot-pull-request-reviewer","display_title":"Copilot code review","run_number":16,"event":"dynamic","status":"completed","conclusion":"success","workflow_id":220767798,"check_suite_id":63323476980,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOvl-T9A","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982415777","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23982415777","pull_requests":[],"created_at":"2026-04-04T16:00:31Z","updated_at":"2026-04-04T16:03:43Z","actor":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-04T16:00:31Z","triggering_actor":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982415777/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982415777/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/63323476980","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982415777/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982415777/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982415777/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/220767798","head_commit":{"id":"3b5d45ec8f6155ad7059e18996bd9f9388a6d83c","tree_id":"fffe0658788dd1a57ad15482ec87ce0de6c0564d","message":"fix(server): forward plan fleet overrides for task routes","timestamp":"2026-04-04T15:58:59Z","author":{"name":"yltx","email":"2326439151@qq.com"},"committer":{"name":"yltx","email":"2326439151@qq.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23982415664,"name":"Lint","node_id":"WFR_kwLOHhoLA88AAAAFlXafMA","head_branch":"yltx/web-fleet-override","head_sha":"3b5d45ec8f6155ad7059e18996bd9f9388a6d83c","path":".github/workflows/lint.yml","display_title":"fix(server): forward plan fleet overrides for task routes","run_number":14,"event":"pull_request","status":"completed","conclusion":"success","workflow_id":253260226,"check_suite_id":63323476662,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOvl-Stg","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982415664","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23982415664","pull_requests":[{"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls/397","id":3489133143,"number":397,"head":{"ref":"yltx/web-fleet-override","sha":"3b5d45ec8f6155ad7059e18996bd9f9388a6d83c","repo":{"id":505023235,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","name":"AutoWSGR"}},"base":{"ref":"main","sha":"77e0a80d72158d1a56c70237a2c12b32b237b383","repo":{"id":505023235,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","name":"AutoWSGR"}}}],"created_at":"2026-04-04T16:00:31Z","updated_at":"2026-04-04T16:00:59Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-04T16:00:31Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982415664/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982415664/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/63323476662","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982415664/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982415664/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23982415664/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/253260226","head_commit":{"id":"3b5d45ec8f6155ad7059e18996bd9f9388a6d83c","tree_id":"fffe0658788dd1a57ad15482ec87ce0de6c0564d","message":"fix(server): forward plan fleet overrides for task routes","timestamp":"2026-04-04T15:58:59Z","author":{"name":"yltx","email":"2326439151@qq.com"},"committer":{"name":"yltx","email":"2326439151@qq.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23979515051,"name":"Sync upstream","node_id":"WFR_kwLOHhoLA88AAAAFlUpcqw","head_branch":"main","head_sha":"77e0a80d72158d1a56c70237a2c12b32b237b383","path":".github/workflows/sync-upstream.yml","display_title":"Sync upstream","run_number":102,"event":"schedule","status":"completed","conclusion":"skipped","workflow_id":244437590,"check_suite_id":63315604070,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOvedyZg","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23979515051","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23979515051","pull_requests":[],"created_at":"2026-04-04T13:07:10Z","updated_at":"2026-04-04T13:07:12Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-04T13:07:10Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23979515051/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23979515051/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/63315604070","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23979515051/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23979515051/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23979515051/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/244437590","head_commit":{"id":"77e0a80d72158d1a56c70237a2c12b32b237b383","tree_id":"2998c9e90d00effc80e88e6562fbea42d20f1c7a","message":"fix: harden page recovery and chapter navigation (#395)","timestamp":"2026-04-02T08:58:55Z","author":{"name":"syokounya","email":"93929783+syokounya@users.noreply.github.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23973883346,"name":"Sync upstream","node_id":"WFR_kwLOHhoLA88AAAAFlPRt0g","head_branch":"main","head_sha":"77e0a80d72158d1a56c70237a2c12b32b237b383","path":".github/workflows/sync-upstream.yml","display_title":"Sync upstream","run_number":101,"event":"schedule","status":"completed","conclusion":"skipped","workflow_id":244437590,"check_suite_id":63300865246,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOvQaM3g","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23973883346","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23973883346","pull_requests":[],"created_at":"2026-04-04T07:07:54Z","updated_at":"2026-04-04T07:07:55Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-04T07:07:54Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23973883346/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23973883346/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/63300865246","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23973883346/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23973883346/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23973883346/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/244437590","head_commit":{"id":"77e0a80d72158d1a56c70237a2c12b32b237b383","tree_id":"2998c9e90d00effc80e88e6562fbea42d20f1c7a","message":"fix: harden page recovery and chapter navigation (#395)","timestamp":"2026-04-02T08:58:55Z","author":{"name":"syokounya","email":"93929783+syokounya@users.noreply.github.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23969581474,"name":"Sync upstream","node_id":"WFR_kwLOHhoLA88AAAAFlLLJog","head_branch":"main","head_sha":"77e0a80d72158d1a56c70237a2c12b32b237b383","path":".github/workflows/sync-upstream.yml","display_title":"Sync upstream","run_number":100,"event":"schedule","status":"completed","conclusion":"skipped","workflow_id":244437590,"check_suite_id":63289590630,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOvFqDZg","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23969581474","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23969581474","pull_requests":[],"created_at":"2026-04-04T02:39:07Z","updated_at":"2026-04-04T02:39:08Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-04T02:39:07Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23969581474/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23969581474/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/63289590630","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23969581474/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23969581474/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23969581474/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/244437590","head_commit":{"id":"77e0a80d72158d1a56c70237a2c12b32b237b383","tree_id":"2998c9e90d00effc80e88e6562fbea42d20f1c7a","message":"fix: harden page recovery and chapter navigation (#395)","timestamp":"2026-04-02T08:58:55Z","author":{"name":"syokounya","email":"93929783+syokounya@users.noreply.github.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23958205726,"name":"Sync upstream","node_id":"WFR_kwLOHhoLA88AAAAFlAU1Hg","head_branch":"main","head_sha":"77e0a80d72158d1a56c70237a2c12b32b237b383","path":".github/workflows/sync-upstream.yml","display_title":"Sync upstream","run_number":99,"event":"schedule","status":"completed","conclusion":"skipped","workflow_id":244437590,"check_suite_id":63256920847,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOumgDDw","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23958205726","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23958205726","pull_requests":[],"created_at":"2026-04-03T18:55:21Z","updated_at":"2026-04-03T18:55:22Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-03T18:55:21Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23958205726/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23958205726/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/63256920847","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23958205726/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23958205726/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23958205726/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/244437590","head_commit":{"id":"77e0a80d72158d1a56c70237a2c12b32b237b383","tree_id":"2998c9e90d00effc80e88e6562fbea42d20f1c7a","message":"fix: harden page recovery and chapter navigation (#395)","timestamp":"2026-04-02T08:58:55Z","author":{"name":"syokounya","email":"93929783+syokounya@users.noreply.github.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23947464491,"name":"Sync upstream","node_id":"WFR_kwLOHhoLA88AAAAFk2FPKw","head_branch":"main","head_sha":"77e0a80d72158d1a56c70237a2c12b32b237b383","path":".github/workflows/sync-upstream.yml","display_title":"Sync upstream","run_number":98,"event":"schedule","status":"completed","conclusion":"skipped","workflow_id":244437590,"check_suite_id":63223243938,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOuGYkog","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23947464491","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23947464491","pull_requests":[],"created_at":"2026-04-03T13:15:08Z","updated_at":"2026-04-03T13:15:09Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-03T13:15:08Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23947464491/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23947464491/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/63223243938","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23947464491/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23947464491/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23947464491/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/244437590","head_commit":{"id":"77e0a80d72158d1a56c70237a2c12b32b237b383","tree_id":"2998c9e90d00effc80e88e6562fbea42d20f1c7a","message":"fix: harden page recovery and chapter navigation (#395)","timestamp":"2026-04-02T08:58:55Z","author":{"name":"syokounya","email":"93929783+syokounya@users.noreply.github.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23937894897,"name":"Sync upstream","node_id":"WFR_kwLOHhoLA88AAAAFks9J8Q","head_branch":"main","head_sha":"77e0a80d72158d1a56c70237a2c12b32b237b383","path":".github/workflows/sync-upstream.yml","display_title":"Sync upstream","run_number":97,"event":"schedule","status":"completed","conclusion":"skipped","workflow_id":244437590,"check_suite_id":63194817144,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOtrRieA","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23937894897","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23937894897","pull_requests":[],"created_at":"2026-04-03T07:17:28Z","updated_at":"2026-04-03T07:17:29Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-03T07:17:28Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23937894897/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23937894897/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/63194817144","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23937894897/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23937894897/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23937894897/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/244437590","head_commit":{"id":"77e0a80d72158d1a56c70237a2c12b32b237b383","tree_id":"2998c9e90d00effc80e88e6562fbea42d20f1c7a","message":"fix: harden page recovery and chapter navigation (#395)","timestamp":"2026-04-02T08:58:55Z","author":{"name":"syokounya","email":"93929783+syokounya@users.noreply.github.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23931542572,"name":"Sync upstream","node_id":"WFR_kwLOHhoLA88AAAAFkm5cLA","head_branch":"main","head_sha":"77e0a80d72158d1a56c70237a2c12b32b237b383","path":".github/workflows/sync-upstream.yml","display_title":"Sync upstream","run_number":96,"event":"schedule","status":"completed","conclusion":"skipped","workflow_id":244437590,"check_suite_id":63177894139,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOtbIo-w","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23931542572","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23931542572","pull_requests":[],"created_at":"2026-04-03T02:48:52Z","updated_at":"2026-04-03T02:48:53Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-03T02:48:52Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23931542572/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23931542572/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/63177894139","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23931542572/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23931542572/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23931542572/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/244437590","head_commit":{"id":"77e0a80d72158d1a56c70237a2c12b32b237b383","tree_id":"2998c9e90d00effc80e88e6562fbea42d20f1c7a","message":"fix: harden page recovery and chapter navigation (#395)","timestamp":"2026-04-02T08:58:55Z","author":{"name":"syokounya","email":"93929783+syokounya@users.noreply.github.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23917157522,"name":"Sync upstream","node_id":"WFR_kwLOHhoLA88AAAAFkZLckg","head_branch":"main","head_sha":"77e0a80d72158d1a56c70237a2c12b32b237b383","path":".github/workflows/sync-upstream.yml","display_title":"Sync upstream","run_number":95,"event":"schedule","status":"completed","conclusion":"skipped","workflow_id":244437590,"check_suite_id":63135940764,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOszIAnA","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23917157522","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23917157522","pull_requests":[],"created_at":"2026-04-02T19:03:25Z","updated_at":"2026-04-02T19:03:26Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-02T19:03:25Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23917157522/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23917157522/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/63135940764","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23917157522/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23917157522/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23917157522/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/244437590","head_commit":{"id":"77e0a80d72158d1a56c70237a2c12b32b237b383","tree_id":"2998c9e90d00effc80e88e6562fbea42d20f1c7a","message":"fix: harden page recovery and chapter navigation (#395)","timestamp":"2026-04-02T08:58:55Z","author":{"name":"syokounya","email":"93929783+syokounya@users.noreply.github.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23902717157,"name":"Sync upstream","node_id":"WFR_kwLOHhoLA88AAAAFkLaE5Q","head_branch":"main","head_sha":"77e0a80d72158d1a56c70237a2c12b32b237b383","path":".github/workflows/sync-upstream.yml","display_title":"Sync upstream","run_number":94,"event":"schedule","status":"completed","conclusion":"skipped","workflow_id":244437590,"check_suite_id":63089076243,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOsGboEw","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23902717157","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23902717157","pull_requests":[],"created_at":"2026-04-02T13:26:34Z","updated_at":"2026-04-02T13:26:35Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-02T13:26:34Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23902717157/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23902717157/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/63089076243","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23902717157/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23902717157/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23902717157/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/244437590","head_commit":{"id":"77e0a80d72158d1a56c70237a2c12b32b237b383","tree_id":"2998c9e90d00effc80e88e6562fbea42d20f1c7a","message":"fix: harden page recovery and chapter navigation (#395)","timestamp":"2026-04-02T08:58:55Z","author":{"name":"syokounya","email":"93929783+syokounya@users.noreply.github.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23892656683,"name":"Lint","node_id":"WFR_kwLOHhoLA88AAAAFkB0CKw","head_branch":"main","head_sha":"77e0a80d72158d1a56c70237a2c12b32b237b383","path":".github/workflows/lint.yml","display_title":"fix: harden page recovery and chapter navigation (#395)","run_number":13,"event":"push","status":"completed","conclusion":"success","workflow_id":253260226,"check_suite_id":63057918939,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOrot72w","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23892656683","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23892656683","pull_requests":[],"created_at":"2026-04-02T08:58:58Z","updated_at":"2026-04-02T08:59:25Z","actor":{"login":"huan-yp","id":85162020,"node_id":"MDQ6VXNlcjg1MTYyMDIw","avatar_url":"https://avatars.githubusercontent.com/u/85162020?v=4","gravatar_id":"","url":"https://api.github.com/users/huan-yp","html_url":"https://github.com/huan-yp","followers_url":"https://api.github.com/users/huan-yp/followers","following_url":"https://api.github.com/users/huan-yp/following{/other_user}","gists_url":"https://api.github.com/users/huan-yp/gists{/gist_id}","starred_url":"https://api.github.com/users/huan-yp/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/huan-yp/subscriptions","organizations_url":"https://api.github.com/users/huan-yp/orgs","repos_url":"https://api.github.com/users/huan-yp/repos","events_url":"https://api.github.com/users/huan-yp/events{/privacy}","received_events_url":"https://api.github.com/users/huan-yp/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-02T08:58:58Z","triggering_actor":{"login":"huan-yp","id":85162020,"node_id":"MDQ6VXNlcjg1MTYyMDIw","avatar_url":"https://avatars.githubusercontent.com/u/85162020?v=4","gravatar_id":"","url":"https://api.github.com/users/huan-yp","html_url":"https://github.com/huan-yp","followers_url":"https://api.github.com/users/huan-yp/followers","following_url":"https://api.github.com/users/huan-yp/following{/other_user}","gists_url":"https://api.github.com/users/huan-yp/gists{/gist_id}","starred_url":"https://api.github.com/users/huan-yp/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/huan-yp/subscriptions","organizations_url":"https://api.github.com/users/huan-yp/orgs","repos_url":"https://api.github.com/users/huan-yp/repos","events_url":"https://api.github.com/users/huan-yp/events{/privacy}","received_events_url":"https://api.github.com/users/huan-yp/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23892656683/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23892656683/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/63057918939","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23892656683/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23892656683/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23892656683/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/253260226","head_commit":{"id":"77e0a80d72158d1a56c70237a2c12b32b237b383","tree_id":"2998c9e90d00effc80e88e6562fbea42d20f1c7a","message":"fix: harden page recovery and chapter navigation (#395)","timestamp":"2026-04-02T08:58:55Z","author":{"name":"syokounya","email":"93929783+syokounya@users.noreply.github.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23891911883,"name":"Lint","node_id":"WFR_kwLOHhoLA88AAAAFkBGkyw","head_branch":"main","head_sha":"5c276f1f1d9239a577e9dfffb54a5042b844307f","path":".github/workflows/lint.yml","display_title":"fix: ǿҳָ½ڵȶ ","run_number":12,"event":"pull_request","status":"completed","conclusion":"success","workflow_id":253260226,"check_suite_id":63055673298,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOrmk30g","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23891911883","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23891911883","pull_requests":[],"created_at":"2026-04-02T08:39:25Z","updated_at":"2026-04-02T08:39:54Z","actor":{"login":"syokounya","id":93929783,"node_id":"U_kgDOBZlBNw","avatar_url":"https://avatars.githubusercontent.com/u/93929783?v=4","gravatar_id":"","url":"https://api.github.com/users/syokounya","html_url":"https://github.com/syokounya","followers_url":"https://api.github.com/users/syokounya/followers","following_url":"https://api.github.com/users/syokounya/following{/other_user}","gists_url":"https://api.github.com/users/syokounya/gists{/gist_id}","starred_url":"https://api.github.com/users/syokounya/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/syokounya/subscriptions","organizations_url":"https://api.github.com/users/syokounya/orgs","repos_url":"https://api.github.com/users/syokounya/repos","events_url":"https://api.github.com/users/syokounya/events{/privacy}","received_events_url":"https://api.github.com/users/syokounya/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-02T08:39:25Z","triggering_actor":{"login":"syokounya","id":93929783,"node_id":"U_kgDOBZlBNw","avatar_url":"https://avatars.githubusercontent.com/u/93929783?v=4","gravatar_id":"","url":"https://api.github.com/users/syokounya","html_url":"https://github.com/syokounya","followers_url":"https://api.github.com/users/syokounya/followers","following_url":"https://api.github.com/users/syokounya/following{/other_user}","gists_url":"https://api.github.com/users/syokounya/gists{/gist_id}","starred_url":"https://api.github.com/users/syokounya/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/syokounya/subscriptions","organizations_url":"https://api.github.com/users/syokounya/orgs","repos_url":"https://api.github.com/users/syokounya/repos","events_url":"https://api.github.com/users/syokounya/events{/privacy}","received_events_url":"https://api.github.com/users/syokounya/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23891911883/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23891911883/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/63055673298","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23891911883/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23891911883/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23891911883/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/253260226","head_commit":{"id":"5c276f1f1d9239a577e9dfffb54a5042b844307f","tree_id":"2998c9e90d00effc80e88e6562fbea42d20f1c7a","message":"fix: harden page recovery and chapter navigation","timestamp":"2026-04-02T03:36:20Z","author":{"name":"syokounya","email":"syokounya0828@gmail.com"},"committer":{"name":"syokounya","email":"syokounya0828@gmail.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":1179848710,"node_id":"R_kgDORlMQBg","name":"AutoWSGR","full_name":"syokounya/AutoWSGR","private":false,"owner":{"login":"syokounya","id":93929783,"node_id":"U_kgDOBZlBNw","avatar_url":"https://avatars.githubusercontent.com/u/93929783?v=4","gravatar_id":"","url":"https://api.github.com/users/syokounya","html_url":"https://github.com/syokounya","followers_url":"https://api.github.com/users/syokounya/followers","following_url":"https://api.github.com/users/syokounya/following{/other_user}","gists_url":"https://api.github.com/users/syokounya/gists{/gist_id}","starred_url":"https://api.github.com/users/syokounya/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/syokounya/subscriptions","organizations_url":"https://api.github.com/users/syokounya/orgs","repos_url":"https://api.github.com/users/syokounya/repos","events_url":"https://api.github.com/users/syokounya/events{/privacy}","received_events_url":"https://api.github.com/users/syokounya/received_events","type":"User","user_view_type":"public","site_admin":false},"html_url":"https://github.com/syokounya/AutoWSGR","description":"սŮRȫͰ","fork":true,"url":"https://api.github.com/repos/syokounya/AutoWSGR","forks_url":"https://api.github.com/repos/syokounya/AutoWSGR/forks","keys_url":"https://api.github.com/repos/syokounya/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/syokounya/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/syokounya/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/syokounya/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/syokounya/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/syokounya/AutoWSGR/events","assignees_url":"https://api.github.com/repos/syokounya/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/syokounya/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/syokounya/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/syokounya/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/syokounya/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/syokounya/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/syokounya/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/syokounya/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/syokounya/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/syokounya/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/syokounya/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/syokounya/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/syokounya/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/syokounya/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/syokounya/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/syokounya/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/syokounya/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/syokounya/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/syokounya/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/syokounya/AutoWSGR/merges","archive_url":"https://api.github.com/repos/syokounya/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/syokounya/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/syokounya/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/syokounya/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/syokounya/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/syokounya/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/syokounya/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/syokounya/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/syokounya/AutoWSGR/deployments"}},{"id":23889006308,"name":"Sync upstream","node_id":"WFR_kwLOHhoLA88AAAAFj-VO5A","head_branch":"main","head_sha":"1b9fb1ec4ed2236b1f50ca235cb760c761a8bdb4","path":".github/workflows/sync-upstream.yml","display_title":"Sync upstream","run_number":93,"event":"schedule","status":"completed","conclusion":"skipped","workflow_id":244437590,"check_suite_id":63047307737,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOremR2Q","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23889006308","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23889006308","pull_requests":[],"created_at":"2026-04-02T07:21:00Z","updated_at":"2026-04-02T07:21:01Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-02T07:21:00Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23889006308/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23889006308/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/63047307737","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23889006308/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23889006308/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23889006308/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/244437590","head_commit":{"id":"1b9fb1ec4ed2236b1f50ca235cb760c761a8bdb4","tree_id":"57d2c142267649a540cdb46fd868e39b57bfb5a2","message":"feat: quick repair retry (#394)\n\n* quick repair retry\n\n* [pre-commit.ci] auto fixes from pre-commit.com hooks\n\nfor more information, see https://pre-commit.ci\n\n---------\n\nCo-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>","timestamp":"2026-03-31T09:45:37Z","author":{"name":"KUAI","email":"ekuai@foxmail.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23881218529,"name":"Sync upstream","node_id":"WFR_kwLOHhoLA88AAAAFj2554Q","head_branch":"main","head_sha":"1b9fb1ec4ed2236b1f50ca235cb760c761a8bdb4","path":".github/workflows/sync-upstream.yml","display_title":"Sync upstream","run_number":92,"event":"schedule","status":"completed","conclusion":"skipped","workflow_id":244437590,"check_suite_id":63026316384,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOrKlEYA","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23881218529","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23881218529","pull_requests":[],"created_at":"2026-04-02T02:46:08Z","updated_at":"2026-04-02T02:46:09Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-02T02:46:08Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23881218529/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23881218529/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/63026316384","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23881218529/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23881218529/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23881218529/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/244437590","head_commit":{"id":"1b9fb1ec4ed2236b1f50ca235cb760c761a8bdb4","tree_id":"57d2c142267649a540cdb46fd868e39b57bfb5a2","message":"feat: quick repair retry (#394)\n\n* quick repair retry\n\n* [pre-commit.ci] auto fixes from pre-commit.com hooks\n\nfor more information, see https://pre-commit.ci\n\n---------\n\nCo-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>","timestamp":"2026-03-31T09:45:37Z","author":{"name":"KUAI","email":"ekuai@foxmail.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23866101683,"name":"Sync upstream","node_id":"WFR_kwLOHhoLA88AAAAFjofPsw","head_branch":"main","head_sha":"1b9fb1ec4ed2236b1f50ca235cb760c761a8bdb4","path":".github/workflows/sync-upstream.yml","display_title":"Sync upstream","run_number":91,"event":"schedule","status":"completed","conclusion":"skipped","workflow_id":244437590,"check_suite_id":62981311854,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOqfqNbg","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23866101683","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23866101683","pull_requests":[],"created_at":"2026-04-01T19:10:33Z","updated_at":"2026-04-01T19:10:34Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-01T19:10:33Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23866101683/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23866101683/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/62981311854","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23866101683/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23866101683/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23866101683/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/244437590","head_commit":{"id":"1b9fb1ec4ed2236b1f50ca235cb760c761a8bdb4","tree_id":"57d2c142267649a540cdb46fd868e39b57bfb5a2","message":"feat: quick repair retry (#394)\n\n* quick repair retry\n\n* [pre-commit.ci] auto fixes from pre-commit.com hooks\n\nfor more information, see https://pre-commit.ci\n\n---------\n\nCo-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>","timestamp":"2026-03-31T09:45:37Z","author":{"name":"KUAI","email":"ekuai@foxmail.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23851946501,"name":"Sync upstream","node_id":"WFR_kwLOHhoLA88AAAAFja_SBQ","head_branch":"main","head_sha":"1b9fb1ec4ed2236b1f50ca235cb760c761a8bdb4","path":".github/workflows/sync-upstream.yml","display_title":"Sync upstream","run_number":90,"event":"schedule","status":"completed","conclusion":"skipped","workflow_id":244437590,"check_suite_id":62934235612,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOpyw53A","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23851946501","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23851946501","pull_requests":[],"created_at":"2026-04-01T13:47:53Z","updated_at":"2026-04-01T13:47:54Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-01T13:47:53Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23851946501/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23851946501/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/62934235612","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23851946501/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23851946501/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23851946501/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/244437590","head_commit":{"id":"1b9fb1ec4ed2236b1f50ca235cb760c761a8bdb4","tree_id":"57d2c142267649a540cdb46fd868e39b57bfb5a2","message":"feat: quick repair retry (#394)\n\n* quick repair retry\n\n* [pre-commit.ci] auto fixes from pre-commit.com hooks\n\nfor more information, see https://pre-commit.ci\n\n---------\n\nCo-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>","timestamp":"2026-03-31T09:45:37Z","author":{"name":"KUAI","email":"ekuai@foxmail.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23837688033,"name":"Sync upstream","node_id":"WFR_kwLOHhoLA88AAAAFjNZA4Q","head_branch":"main","head_sha":"1b9fb1ec4ed2236b1f50ca235cb760c761a8bdb4","path":".github/workflows/sync-upstream.yml","display_title":"Sync upstream","run_number":89,"event":"schedule","status":"completed","conclusion":"skipped","workflow_id":244437590,"check_suite_id":62889571829,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOpIK19Q","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23837688033","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23837688033","pull_requests":[],"created_at":"2026-04-01T07:42:07Z","updated_at":"2026-04-01T07:42:08Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-01T07:42:07Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23837688033/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23837688033/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/62889571829","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23837688033/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23837688033/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23837688033/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/244437590","head_commit":{"id":"1b9fb1ec4ed2236b1f50ca235cb760c761a8bdb4","tree_id":"57d2c142267649a540cdb46fd868e39b57bfb5a2","message":"feat: quick repair retry (#394)\n\n* quick repair retry\n\n* [pre-commit.ci] auto fixes from pre-commit.com hooks\n\nfor more information, see https://pre-commit.ci\n\n---------\n\nCo-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>","timestamp":"2026-03-31T09:45:37Z","author":{"name":"KUAI","email":"ekuai@foxmail.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23830299495,"name":"Sync upstream","node_id":"WFR_kwLOHhoLA88AAAAFjGWDZw","head_branch":"main","head_sha":"1b9fb1ec4ed2236b1f50ca235cb760c761a8bdb4","path":".github/workflows/sync-upstream.yml","display_title":"Sync upstream","run_number":88,"event":"schedule","status":"completed","conclusion":"skipped","workflow_id":244437590,"check_suite_id":62868399833,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOoz-m2Q","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23830299495","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23830299495","pull_requests":[],"created_at":"2026-04-01T03:20:04Z","updated_at":"2026-04-01T03:20:04Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-04-01T03:20:04Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23830299495/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23830299495/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/62868399833","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23830299495/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23830299495/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23830299495/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/244437590","head_commit":{"id":"1b9fb1ec4ed2236b1f50ca235cb760c761a8bdb4","tree_id":"57d2c142267649a540cdb46fd868e39b57bfb5a2","message":"feat: quick repair retry (#394)\n\n* quick repair retry\n\n* [pre-commit.ci] auto fixes from pre-commit.com hooks\n\nfor more information, see https://pre-commit.ci\n\n---------\n\nCo-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>","timestamp":"2026-03-31T09:45:37Z","author":{"name":"KUAI","email":"ekuai@foxmail.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23814811476,"name":"Sync upstream","node_id":"WFR_kwLOHhoLA88AAAAFi3kvVA","head_branch":"main","head_sha":"1b9fb1ec4ed2236b1f50ca235cb760c761a8bdb4","path":".github/workflows/sync-upstream.yml","display_title":"Sync upstream","run_number":87,"event":"schedule","status":"completed","conclusion":"skipped","workflow_id":244437590,"check_suite_id":62821967453,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOoHsmXQ","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23814811476","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23814811476","pull_requests":[],"created_at":"2026-03-31T19:09:17Z","updated_at":"2026-03-31T19:09:19Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-03-31T19:09:17Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23814811476/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23814811476/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/62821967453","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23814811476/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23814811476/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23814811476/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/244437590","head_commit":{"id":"1b9fb1ec4ed2236b1f50ca235cb760c761a8bdb4","tree_id":"57d2c142267649a540cdb46fd868e39b57bfb5a2","message":"feat: quick repair retry (#394)\n\n* quick repair retry\n\n* [pre-commit.ci] auto fixes from pre-commit.com hooks\n\nfor more information, see https://pre-commit.ci\n\n---------\n\nCo-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>","timestamp":"2026-03-31T09:45:37Z","author":{"name":"KUAI","email":"ekuai@foxmail.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23800635818,"name":"Sync upstream","node_id":"WFR_kwLOHhoLA88AAAAFiqDhqg","head_branch":"main","head_sha":"1b9fb1ec4ed2236b1f50ca235cb760c761a8bdb4","path":".github/workflows/sync-upstream.yml","display_title":"Sync upstream","run_number":86,"event":"schedule","status":"completed","conclusion":"skipped","workflow_id":244437590,"check_suite_id":62775432391,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOnbUUxw","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23800635818","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23800635818","pull_requests":[],"created_at":"2026-03-31T13:45:28Z","updated_at":"2026-03-31T13:45:29Z","actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-03-31T13:45:28Z","triggering_actor":{"login":"yltx","id":37734213,"node_id":"MDQ6VXNlcjM3NzM0MjEz","avatar_url":"https://avatars.githubusercontent.com/u/37734213?v=4","gravatar_id":"","url":"https://api.github.com/users/yltx","html_url":"https://github.com/yltx","followers_url":"https://api.github.com/users/yltx/followers","following_url":"https://api.github.com/users/yltx/following{/other_user}","gists_url":"https://api.github.com/users/yltx/gists{/gist_id}","starred_url":"https://api.github.com/users/yltx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/yltx/subscriptions","organizations_url":"https://api.github.com/users/yltx/orgs","repos_url":"https://api.github.com/users/yltx/repos","events_url":"https://api.github.com/users/yltx/events{/privacy}","received_events_url":"https://api.github.com/users/yltx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23800635818/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23800635818/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/62775432391","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23800635818/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23800635818/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23800635818/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/244437590","head_commit":{"id":"1b9fb1ec4ed2236b1f50ca235cb760c761a8bdb4","tree_id":"57d2c142267649a540cdb46fd868e39b57bfb5a2","message":"feat: quick repair retry (#394)\n\n* quick repair retry\n\n* [pre-commit.ci] auto fixes from pre-commit.com hooks\n\nfor more information, see https://pre-commit.ci\n\n---------\n\nCo-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>","timestamp":"2026-03-31T09:45:37Z","author":{"name":"KUAI","email":"ekuai@foxmail.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23791041751,"name":"Lint","node_id":"WFR_kwLOHhoLA88AAAAFig581w","head_branch":"main","head_sha":"1b9fb1ec4ed2236b1f50ca235cb760c761a8bdb4","path":".github/workflows/lint.yml","display_title":"feat: quick repair retry (#394)","run_number":11,"event":"push","status":"completed","conclusion":"success","workflow_id":253260226,"check_suite_id":62744802505,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOm-G0yQ","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23791041751","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23791041751","pull_requests":[],"created_at":"2026-03-31T09:45:40Z","updated_at":"2026-03-31T09:46:06Z","actor":{"login":"kuainx","id":22209650,"node_id":"MDQ6VXNlcjIyMjA5NjUw","avatar_url":"https://avatars.githubusercontent.com/u/22209650?v=4","gravatar_id":"","url":"https://api.github.com/users/kuainx","html_url":"https://github.com/kuainx","followers_url":"https://api.github.com/users/kuainx/followers","following_url":"https://api.github.com/users/kuainx/following{/other_user}","gists_url":"https://api.github.com/users/kuainx/gists{/gist_id}","starred_url":"https://api.github.com/users/kuainx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/kuainx/subscriptions","organizations_url":"https://api.github.com/users/kuainx/orgs","repos_url":"https://api.github.com/users/kuainx/repos","events_url":"https://api.github.com/users/kuainx/events{/privacy}","received_events_url":"https://api.github.com/users/kuainx/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-03-31T09:45:40Z","triggering_actor":{"login":"kuainx","id":22209650,"node_id":"MDQ6VXNlcjIyMjA5NjUw","avatar_url":"https://avatars.githubusercontent.com/u/22209650?v=4","gravatar_id":"","url":"https://api.github.com/users/kuainx","html_url":"https://github.com/kuainx","followers_url":"https://api.github.com/users/kuainx/followers","following_url":"https://api.github.com/users/kuainx/following{/other_user}","gists_url":"https://api.github.com/users/kuainx/gists{/gist_id}","starred_url":"https://api.github.com/users/kuainx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/kuainx/subscriptions","organizations_url":"https://api.github.com/users/kuainx/orgs","repos_url":"https://api.github.com/users/kuainx/repos","events_url":"https://api.github.com/users/kuainx/events{/privacy}","received_events_url":"https://api.github.com/users/kuainx/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23791041751/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23791041751/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/62744802505","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23791041751/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23791041751/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23791041751/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/253260226","head_commit":{"id":"1b9fb1ec4ed2236b1f50ca235cb760c761a8bdb4","tree_id":"57d2c142267649a540cdb46fd868e39b57bfb5a2","message":"feat: quick repair retry (#394)\n\n* quick repair retry\n\n* [pre-commit.ci] auto fixes from pre-commit.com hooks\n\nfor more information, see https://pre-commit.ci\n\n---------\n\nCo-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>","timestamp":"2026-03-31T09:45:37Z","author":{"name":"KUAI","email":"ekuai@foxmail.com"},"committer":{"name":"GitHub","email":"noreply@github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"}},{"id":23790790019,"name":"Lint","node_id":"WFR_kwLOHhoLA88AAAAFigqlgw","head_branch":"quick-repair","head_sha":"daf3afb0c5dafe933e5512d26e80535219a21509","path":".github/workflows/lint.yml","display_title":"feat: quick repair retry","run_number":10,"event":"pull_request","status":"completed","conclusion":"success","workflow_id":253260226,"check_suite_id":62744035802,"check_suite_node_id":"CS_kwDOHhoLA88AAAAOm9YB2g","url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23790790019","html_url":"https://github.com/OpenWSGR/AutoWSGR/actions/runs/23790790019","pull_requests":[],"created_at":"2026-03-31T09:39:30Z","updated_at":"2026-03-31T09:39:58Z","actor":{"login":"pre-commit-ci[bot]","id":66853113,"node_id":"MDM6Qm90NjY4NTMxMTM=","avatar_url":"https://avatars.githubusercontent.com/in/68672?v=4","gravatar_id":"","url":"https://api.github.com/users/pre-commit-ci%5Bbot%5D","html_url":"https://github.com/apps/pre-commit-ci","followers_url":"https://api.github.com/users/pre-commit-ci%5Bbot%5D/followers","following_url":"https://api.github.com/users/pre-commit-ci%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/pre-commit-ci%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/pre-commit-ci%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/pre-commit-ci%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/pre-commit-ci%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/pre-commit-ci%5Bbot%5D/repos","events_url":"https://api.github.com/users/pre-commit-ci%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/pre-commit-ci%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-03-31T09:39:30Z","triggering_actor":{"login":"pre-commit-ci[bot]","id":66853113,"node_id":"MDM6Qm90NjY4NTMxMTM=","avatar_url":"https://avatars.githubusercontent.com/in/68672?v=4","gravatar_id":"","url":"https://api.github.com/users/pre-commit-ci%5Bbot%5D","html_url":"https://github.com/apps/pre-commit-ci","followers_url":"https://api.github.com/users/pre-commit-ci%5Bbot%5D/followers","following_url":"https://api.github.com/users/pre-commit-ci%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/pre-commit-ci%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/pre-commit-ci%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/pre-commit-ci%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/pre-commit-ci%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/pre-commit-ci%5Bbot%5D/repos","events_url":"https://api.github.com/users/pre-commit-ci%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/pre-commit-ci%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23790790019/jobs","logs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23790790019/logs","check_suite_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/check-suites/62744035802","artifacts_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23790790019/artifacts","cancel_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23790790019/cancel","rerun_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/runs/23790790019/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/actions/workflows/253260226","head_commit":{"id":"daf3afb0c5dafe933e5512d26e80535219a21509","tree_id":"57d2c142267649a540cdb46fd868e39b57bfb5a2","message":"[pre-commit.ci] auto fixes from pre-commit.com hooks\n\nfor more information, see https://pre-commit.ci","timestamp":"2026-03-31T09:39:17Z","author":{"name":"pre-commit-ci[bot]","email":"66853113+pre-commit-ci[bot]@users.noreply.github.com"},"committer":{"name":"pre-commit-ci[bot]","email":"66853113+pre-commit-ci[bot]@users.noreply.github.com"}},"repository":{"id":505023235,"node_id":"R_kgDOHhoLAw","name":"AutoWSGR","full_name":"OpenWSGR/AutoWSGR","private":false,"owner":{"login":"OpenWSGR","id":186071259,"node_id":"O_kgDOCxc42w","avatar_url":"https://avatars.githubusercontent.com/u/186071259?v=4","gravatar_id":"","url":"https://api.github.com/users/OpenWSGR","html_url":"https://github.com/OpenWSGR","followers_url":"https://api.github.com/users/OpenWSGR/followers","following_url":"https://api.github.com/users/OpenWSGR/following{/other_user}","gists_url":"https://api.github.com/users/OpenWSGR/gists{/gist_id}","starred_url":"https://api.github.com/users/OpenWSGR/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/OpenWSGR/subscriptions","organizations_url":"https://api.github.com/users/OpenWSGR/orgs","repos_url":"https://api.github.com/users/OpenWSGR/repos","events_url":"https://api.github.com/users/OpenWSGR/events{/privacy}","received_events_url":"https://api.github.com/users/OpenWSGR/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/OpenWSGR/AutoWSGR","description":"սŮRȫͰ","fork":false,"url":"https://api.github.com/repos/OpenWSGR/AutoWSGR","forks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/forks","keys_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/events","assignees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/merges","archive_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/OpenWSGR/AutoWSGR/deployments"},"head_repository":{"id":917691832,"node_id":"R_kgDONrLduA","name":"AutoWSGR","full_name":"kuainx/AutoWSGR","private":false,"owner":{"login":"kuainx","id":22209650,"node_id":"MDQ6VXNlcjIyMjA5NjUw","avatar_url":"https://avatars.githubusercontent.com/u/22209650?v=4","gravatar_id":"","url":"https://api.github.com/users/kuainx","html_url":"https://github.com/kuainx","followers_url":"https://api.github.com/users/kuainx/followers","following_url":"https://api.github.com/users/kuainx/following{/other_user}","gists_url":"https://api.github.com/users/kuainx/gists{/gist_id}","starred_url":"https://api.github.com/users/kuainx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/kuainx/subscriptions","organizations_url":"https://api.github.com/users/kuainx/orgs","repos_url":"https://api.github.com/users/kuainx/repos","events_url":"https://api.github.com/users/kuainx/events{/privacy}","received_events_url":"https://api.github.com/users/kuainx/received_events","type":"User","user_view_type":"public","site_admin":false},"html_url":"https://github.com/kuainx/AutoWSGR","description":"սŮRȫͰ","fork":true,"url":"https://api.github.com/repos/kuainx/AutoWSGR","forks_url":"https://api.github.com/repos/kuainx/AutoWSGR/forks","keys_url":"https://api.github.com/repos/kuainx/AutoWSGR/keys{/key_id}","collaborators_url":"https://api.github.com/repos/kuainx/AutoWSGR/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/kuainx/AutoWSGR/teams","hooks_url":"https://api.github.com/repos/kuainx/AutoWSGR/hooks","issue_events_url":"https://api.github.com/repos/kuainx/AutoWSGR/issues/events{/number}","events_url":"https://api.github.com/repos/kuainx/AutoWSGR/events","assignees_url":"https://api.github.com/repos/kuainx/AutoWSGR/assignees{/user}","branches_url":"https://api.github.com/repos/kuainx/AutoWSGR/branches{/branch}","tags_url":"https://api.github.com/repos/kuainx/AutoWSGR/tags","blobs_url":"https://api.github.com/repos/kuainx/AutoWSGR/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/kuainx/AutoWSGR/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/kuainx/AutoWSGR/git/refs{/sha}","trees_url":"https://api.github.com/repos/kuainx/AutoWSGR/git/trees{/sha}","statuses_url":"https://api.github.com/repos/kuainx/AutoWSGR/statuses/{sha}","languages_url":"https://api.github.com/repos/kuainx/AutoWSGR/languages","stargazers_url":"https://api.github.com/repos/kuainx/AutoWSGR/stargazers","contributors_url":"https://api.github.com/repos/kuainx/AutoWSGR/contributors","subscribers_url":"https://api.github.com/repos/kuainx/AutoWSGR/subscribers","subscription_url":"https://api.github.com/repos/kuainx/AutoWSGR/subscription","commits_url":"https://api.github.com/repos/kuainx/AutoWSGR/commits{/sha}","git_commits_url":"https://api.github.com/repos/kuainx/AutoWSGR/git/commits{/sha}","comments_url":"https://api.github.com/repos/kuainx/AutoWSGR/comments{/number}","issue_comment_url":"https://api.github.com/repos/kuainx/AutoWSGR/issues/comments{/number}","contents_url":"https://api.github.com/repos/kuainx/AutoWSGR/contents/{+path}","compare_url":"https://api.github.com/repos/kuainx/AutoWSGR/compare/{base}...{head}","merges_url":"https://api.github.com/repos/kuainx/AutoWSGR/merges","archive_url":"https://api.github.com/repos/kuainx/AutoWSGR/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/kuainx/AutoWSGR/downloads","issues_url":"https://api.github.com/repos/kuainx/AutoWSGR/issues{/number}","pulls_url":"https://api.github.com/repos/kuainx/AutoWSGR/pulls{/number}","milestones_url":"https://api.github.com/repos/kuainx/AutoWSGR/milestones{/number}","notifications_url":"https://api.github.com/repos/kuainx/AutoWSGR/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/kuainx/AutoWSGR/labels{/name}","releases_url":"https://api.github.com/repos/kuainx/AutoWSGR/releases{/id}","deployments_url":"https://api.github.com/repos/kuainx/AutoWSGR/deployments"}}]} - diff --git a/.tmp_weekly_fix.ps1 b/.tmp_weekly_fix.ps1 deleted file mode 100644 index 433231a..0000000 --- a/.tmp_weekly_fix.ps1 +++ /dev/null @@ -1,93 +0,0 @@ -$utf8 = New-Object System.Text.UTF8Encoding($false) -function Get-Indent([string]$s){ $s.Length - $s.TrimStart(' ').Length } -function Is-PureString([string[]]$b){ - if($b.Count -ne 1){return $false} - if($b[0] -notmatch '^(\s*)-\s*(.*)$'){return $false} - $r=$matches[2].Trim(); if(!$r -or $r.StartsWith('#')){return $false} - if($r -match '^\{.*\}$' -or $r -match '^\[.*\]$' -or $r -match '^[^#]+:\s*'){return $false} - return $true -} -function Split-ItemBlocks([string[]]$lines,[int]$start,[int]$shipsIndent){ - $line=$lines[$start] - $line -match '^(\s*)-\s*(.*)$' | Out-Null - $itemIndent=$matches[1].Length - $block=New-Object System.Collections.Generic.List[string]; $block.Add($line) - $j=$start+1 - while($j -lt $lines.Length){ - $nl=$lines[$j]; $nt=$nl.Trim() - if($nt -ne '' -and -not $nl.TrimStart().StartsWith('#')){ - $nind=Get-Indent $nl - if($nind -le $shipsIndent){break} - if($nind -eq $itemIndent -and $nl -match '^\s*-\s*'){break} - } - $block.Add($nl); $j++ - } - return @($block,$j,$itemIndent) -} -$files=Get-ChildItem resource/builtin_plans -Filter '周常*.yaml' -File | Sort-Object Name -$rows=@() -foreach($f in $files){ - $lines=[IO.File]::ReadAllLines($f.FullName,[Text.Encoding]::UTF8) - $out=New-Object System.Collections.Generic.List[string] - $inFleet=$false;$fleetIndent=-1;$inShips=$false;$shipsIndent=-1 - $nameConverted=0;$shipTypeMinAdded=0;$unchanged=0 - for($i=0;$i -lt $lines.Length;){ - $line=$lines[$i];$t=$line.Trim() - if($inShips -and $t -ne '' -and -not $line.TrimStart().StartsWith('#') -and (Get-Indent $line) -le $shipsIndent){$inShips=$false} - if($inFleet -and $t -ne '' -and -not $line.TrimStart().StartsWith('#') -and (Get-Indent $line) -le $fleetIndent -and $line -notmatch '^\s*fleet_presets:\s*$'){$inFleet=$false;$inShips=$false} - if($line -match '^(\s*)fleet_presets:\s*$'){ $inFleet=$true;$fleetIndent=$matches[1].Length; $out.Add($line);$i++; continue } - if($inFleet -and $line -match '^(\s*)ships:\s*$'){ $inShips=$true;$shipsIndent=$matches[1].Length; $out.Add($line);$i++; continue } - if($inShips -and $line -match '^(\s*)-\s*(.*)$' -and $matches[1].Length -gt $shipsIndent){ - $res=Split-ItemBlocks $lines $i $shipsIndent; $block=[string[]]$res[0]; $j=[int]$res[1]; $itemIndent=[int]$res[2] - $changed=$false - if(Is-PureString $block){ - $block[0] -match '^(\s*)-\s*(.*)$' | Out-Null; $ws=$matches[1]; $rest=$matches[2].Trim(); $comment='' - if($rest -match '^(.*?)(\s+#.*)$'){ $rest=$matches[1].TrimEnd(); $comment=$matches[2] } - $out.Add("$ws- { name: $rest, min_level: 100 }$comment"); $nameConverted++; $changed=$true - } else { - $hasType=($block|Where-Object{$_ -match '\bship_type\s*:'}).Count -gt 0 - $hasMin=($block|Where-Object{$_ -match '\bmin_level\s*:'}).Count -gt 0 - if($hasType -and -not $hasMin){ - $first=$block[0] - if($block.Count -eq 1 -and $first -match '^(\s*)-\s*(.*)$'){ - $ws=$matches[1]; $body=$matches[2].Trim(); $comment='' - if($body -match '^(.*?)(\s+#.*)$'){ $body=$matches[1].TrimEnd(); $comment=$matches[2] } - if($body -match '^\{(.*)\}$'){ $inner=$matches[1].Trim(); $newBody = if($inner){"{ $inner, min_level: 100 }"}else{'{ min_level: 100 }'}; $out.Add("$ws- $newBody$comment") } - else { $out.Add($first); $out.Add((' ' * ($itemIndent+2)) + 'min_level: 100') } - } else { foreach($b in $block){$out.Add($b)}; $out.Add((' ' * ($itemIndent+2)) + 'min_level: 100') } - $shipTypeMinAdded++; $changed=$true - } - } - if(-not $changed){ foreach($b in $block){$out.Add($b)}; $unchanged++ } - $i=$j; continue - } - $out.Add($line); $i++ - } - [IO.File]::WriteAllLines($f.FullName,$out,$utf8) - $rows += [pscustomobject]@{File=$f.Name;nameConverted=$nameConverted;shipTypeMinAdded=$shipTypeMinAdded;unchanged=$unchanged} -} -$rows | Format-Table -AutoSize - -$pure=0;$missing=0 -foreach($f in $files){ - $lines=[IO.File]::ReadAllLines($f.FullName,[Text.Encoding]::UTF8) - $inFleet=$false;$fleetIndent=-1;$inShips=$false;$shipsIndent=-1 - for($i=0;$i -lt $lines.Length;){ - $line=$lines[$i];$t=$line.Trim() - if($inShips -and $t -ne '' -and -not $line.TrimStart().StartsWith('#') -and (Get-Indent $line) -le $shipsIndent){$inShips=$false} - if($inFleet -and $t -ne '' -and -not $line.TrimStart().StartsWith('#') -and (Get-Indent $line) -le $fleetIndent -and $line -notmatch '^\s*fleet_presets:\s*$'){$inFleet=$false;$inShips=$false} - if($line -match '^(\s*)fleet_presets:\s*$'){ $inFleet=$true;$fleetIndent=$matches[1].Length; $i++; continue } - if($inFleet -and $line -match '^(\s*)ships:\s*$'){ $inShips=$true;$shipsIndent=$matches[1].Length; $i++; continue } - if($inShips -and $line -match '^(\s*)-\s*(.*)$' -and $matches[1].Length -gt $shipsIndent){ - $res=Split-ItemBlocks $lines $i $shipsIndent; $block=[string[]]$res[0]; $j=[int]$res[1] - if(Is-PureString $block){$pure++} - $hasType=($block|Where-Object{$_ -match '\bship_type\s*:'}).Count -gt 0 - $hasMin=($block|Where-Object{$_ -match '\bmin_level\s*:'}).Count -gt 0 - if($hasType -and -not $hasMin){$missing++} - $i=$j; continue - } - $i++ - } -} -"pureStringRemain=$pure shipTypeNoMinRemain=$missing" -if($pure -eq 0 -and $missing -eq 0){ 'WEEKLY_SHIPS_LEVEL_RULES_OK' } diff --git a/.trae/rules/project-constraints.md b/.trae/rules/project-constraints.md new file mode 100644 index 0000000..1447886 --- /dev/null +++ b/.trae/rules/project-constraints.md @@ -0,0 +1,9 @@ +--- +alwaysApply: true +--- + +# AutoWSGR-GUI 项目入口 + +在分析、修改文件或执行命令前,必须完整读取并遵守根目录 `AGENTS.md`。 + +必须按照 `AGENTS.md` 规定的顺序继续读取 `docs/engineering-standards.md`、可执行配置、`CONTRIBUTING.md` 和任务相关架构文档。任何摘要均不得替代强制规范原文。 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..61c29ee --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,199 @@ +# AutoWSGR-GUI Agent 约束 + +本文件是所有人工辅助 Agent、自动化 Agent、代码生成/审查工具和 Issue 分析工具的项目入口。用户明确要求优先于本文件;本文件中的 Coding 原则适用于所有分支和发布频道,`alpha` 不是降低质量门槛的理由。 + +## 1. Coding 最高原则 + +### 1.1 四大原则 + +每一次代码改动,无论大小,都必须同时满足: + +1. **最小改动**:只修改实现目标所需的代码、契约和测试,不顺手重构、格式化或清理无关内容。 +2. **最大复用**:优先复用职责、生命周期、输入输出和副作用均匹配的现有实现;不得为了表面复用扩大原模块职责。 +3. **最低影响现有功能**:业务逻辑、现有能力、UI 元素、交互、样式、配置和已发布数据契约默认保持不变。 +4. **严格控制代码边界**:改动必须留在清晰的功能和架构边界内,不得引入跨层补偿、重复状态源或隐式耦合。 + +业务逻辑正确性和现有行为兼容性为第一优先级。用户提出修复功能 A,只代表授权修改 A 的目标行为,不自动授权改变关联业务规则;若根因修复必须改变业务实现、默认行为、数据含义、交互或功能 B,写入前必须说明现状、拟议变化、影响范围和验证方式,并取得用户确认。 + +### 1.2 分批改动 + +- 涉及多个独立功能、多个架构边界或大量手写文件时,必须先给出分批方案,经用户确认后实施。 +- 每一批只解决一个可描述、可验证、可审查、可回滚的行为边界,并独立遵守四大原则。 +- 每批完成后先检查 diff、运行匹配测试并确认没有相邻回归,再开始下一批。 +- 不得先批量搬迁或重写,再依靠后续批次恢复功能;任何中间批次都不能故意处于已知损坏状态。 +- 生成入口文件可随对应源文件在同一批更新,但机械生成内容与手写行为代码必须分开说明。 + +### 1.3 功能边界与防回归 + +- 修改前必须搜索功能 A 的全部调用方、导入方、事件订阅、DOM ID/Class、CSS 选择器、DTO、持久化字段和测试,列出依赖其实现或契约的功能 B。 +- 修复 A 时不得破坏 B 对公共行为的合理依赖;若公共实现必须变化,应先建立兼容边界或同步修改并验证所有消费者。 +- 局部问题不得通过修改全局刷新、渲染、调度、持久化或错误处理语义来解决。 +- 状态只能有一个权威所有者;重渲染、重新绑定、切页、弹窗开关和异步刷新不得意外重置或复制状态。 +- **历史高频回归**:拖动条/滑块在刷新、重渲染或修改其他设置后复位。涉及控件初始化、配置渲染、事件绑定或 View 拆分时,必须验证当前值保持、重复初始化不重置、事件不重复绑定,并回归依赖同一渲染链路的其他控件。 +- Bug 修复必须提供修复前可失败、修复后可通过的复现证据;不能只证明目标路径“现在能跑”。 + +## 2. 接入与事实来源 + +开始写入前必须: + +1. 执行 `git status --short --branch`,识别当前分支和用户已有修改;不得覆盖、回退或整理非当前任务内容。 +2. 首次进入项目或开始新的非平凡实现前,按 `docs/architecture/README.md` 的路由读取 `00-overview.md`、`12-agent-entry-guide.md`、`10-runtime-boundaries-adr.md` 和任务对应专题。后续局部任务不要求重复通读无关专题。 +3. 按任务读取 `package.json`、`tsconfig.json`、构建脚本、CI、受影响源码、直接调用方和最近的专项测试。 +4. 说明行为目标、非目标、可复用实现、最少修改文件、状态所有者、功能消费者、风险和验证计划。 +5. 搜索同领域规则、历史 workaround 和兼容契约;第三次修复或重复回归必须先分析此前失败原因,不能继续叠加 guard、retry、delay 或 fallback。 + +事实与约束分开判断: + +- 用户明确要求最高;本文件是 Coding、质量和协作流程的唯一规范入口。 +- 当前 GUI 的目录、依赖、命令和运行行为以可执行配置、CI、测试和生产源码为准;不得用主库旧基线反向改造当前结构。 +- `docs/architecture/**` 描述当前架构,`10-runtime-boundaries-adr.md` 定义应保持的设计边界;发现文档与实现冲突时先报告并判断是实现偏离还是文档过期,不得静默选择一方。 +- 本文件 4.1 节是基于主库 1.4.4 路线的有效并行协作契约,不是当前 GUI 的目录或完成状态清单。 +- 架构、公共契约或协作职责发生变化时,同一批更新对应架构/工程文档;仅定位文件时可使用 `09-src-typescript-catalog.md`,不得把数量快照当稳定边界。 + +### 2.1 修复止损 + +- 声称修复后问题仍可复现、验证失败、需要绕过上次错误假设,或保留旧 workaround 后再增加特殊处理,均算一次失败尝试;纯诊断日志和不改变行为的 instrumentation 不计入。 + +| 等级 | 触发条件 | 必须执行的动作 | +|---|---|---| +| L0 正常变更 | 无失败尝试、无意外扩散、有直接证据 | 正常实现和审查 | +| L1 记录修正 | 一次失败,或出现一个止损信号 | 记录原假设、失败证据、状态所有者和新验证计划 | +| L2 维护者检查点 | 两次连续失败,或同时出现两个止损信号 | 暂停实现;用户批准重新设计、拆分或干净重写后才能继续 | +| L3 Patch Freeze | 三次失败且仍有止损信号,或出现竞争状态源 | 禁止叠加补丁,先建立确定性复现和替代设计 | +| L4 干净重写 | 无法删除失败 workaround、恢复单一状态源或证明端到端行为 | 从最后已知正常基线建立隔离分支/worktree,先固定行为契约再重新实现 | + +- 止损信号包括范围外跨层补偿、新可写状态/同步标志/影子缓存、规则重复、retry/delay/catch-and-ignore/多级 fallback、放宽类型或测试,以及无法解释完整因果链。 +- 同一问题的失败次数跨 Agent、会话、分支和实现方案累计,不能通过换人、换文件或改名重置。 +- 第三次尝试前必须说明前两次为什么失败、原因链如何变化、新证据如何区分假设;没有新因果模型不得继续补 guard、retry、delay 或 fallback。 +- 修改中出现新状态源、跨层补偿、公共契约变化或范围升级时,立即重新评估 Patch Level,并按更高等级执行。 +- L4 不得复制失败分支或覆盖用户工作树;必须记录正常基线 SHA/Tag,仅迁移仍被当前契约和测试证明需要的行为,并保留旧失败补丁供只读对照。 + +## 3. 当前源码边界 + +| 边界 | 当前职责与入口 | +|---|---| +| Electron 组合根 | `electron/main.ts`:单实例、生命周期、服务装配、IPC 注册和退出协调 | +| Electron 服务 | `electron/services/**`:Service 编排用例,Repository 管理路径/来源/文件,Codec 管理格式和兼容转换 | +| IPC 边界 | `electron/ipc/**` + `electron/preload.ts`:注册受限通道并向 Renderer 暴露白名单 | +| Renderer 组合根 | `src/controller/app/AppController.ts`:构造 Model、Adapter、Controller、View 并绑定生命周期 | +| Adapter | `src/adapter/**`:HTTP、WebSocket、IPC、序列化和浏览器存储等外部能力 | +| Model | `src/model/**`:领域状态、规则和数据转换,不依赖 DOM、具体 View 或 Electron | +| Controller | `src/controller/**`:业务编排、用户意图、Model/View 协调,不直接操作 DOM 或底层 IPC | +| View | `src/view/**`:DOM、组件和局部 UI 状态,不直接调用 Model、ApiClient 或 Electron Bridge | +| Types / Shared | `src/types/**` 定义 DTO/契约;`src/shared/**` 放置跨运行时纯逻辑,不依赖 DOM、Electron 或 Node 专属能力 | + +依赖和实现规则: + +- 外部通信必须经 Adapter;Renderer 不得绕过 Adapter 直接调用 `window.electronBridge`。 +- Controller 可以协调 Model、Adapter 和 View,但不得复制领域状态、解析文件格式或实现路径安全。 +- View 只能消费 ViewObject、Types、Shared 和 View 内组件。当前特例仅是 `src/view/theme.ts` 通过 `StorageAdapter` 保存纯 UI 偏好,不得扩展为通用持久化入口。 +- ViewObject、领域模型、API DTO 和 IPC DTO 必须区分;不得用 `any`、双重类型断言或可选字段堆积掩盖契约。 +- `electron/main.ts` 新增代码应限于装配和生命周期;可独立测试的行为放入 `electron/services/**`,传输适配放入 `electron/ipc/**`。 +- GUI 只能依赖后端公开接口;前后端一起变化时必须检查 `src/model/ApiClient.ts`、Adapter、DTO 和跨仓契约测试。 +- View 创建的事件监听器、Observer、Timer、`requestAnimationFrame`、订阅和缓存必须有唯一所有者、幂等初始化和幂等 `dispose()`,并接入 `AppController.dispose()` 清理链。 +- 拆分复杂 View 时保持原 Facade 公共 API;共享视觉组件至少需要两个真实消费者,并完整负责自身绑定、更新和释放。 +- 新建抽象必须有当前真实调用方,并能减少实质重复或隔离明确边界;不得为未来可能需求预留空接口、Manager、Factory、Registry 或 EventBus。 + +## 4. 高风险不变量 + +- 安装资源只读;用户配置、计划、迁移状态和运行数据写入 Electron `userData`。 +- 文件入口必须经 canonicalize 和 containment 检查;Renderer 不得通过通用 IPC 读写任意绝对路径。 +- 用户数据使用原子写入,替换失败必须保留旧文件;迁移只能在全部文件成功写入后记录阶段完成。 +- 已发布格式、目录、模板 ID、任务索引、配置默认值和未知字段属于兼容契约;不得静默丢弃或覆盖。 +- 单实例锁必须早于迁移、后端启动和窗口初始化;退出、更新和强制关闭必须停止后端、ADB 和相关进程树。 +- Scheduler 是定时、轮换和任务执行的唯一调度所有者;不得增加并行定时器或第二套任务状态机。 +- 强化、解装、购买等不可逆或消耗资源的操作默认关闭,必须由用户明确确认;目标、状态或确认不确定时 fail closed。 +- 不得泄露或提交密钥、Token、用户配置、含隐私日志、运行时数据或本地环境文件。 + +### 4.1 主库 1.4.4 并行协作边界 + +- 当前 GUI 的实际结构和现有业务行为优先;配合主库开发者时复用当前 Model、Adapter、Controller、View、Service、Repository 和 Codec 边界,不建立与当前架构竞争的第二套实现。 +- 当前路线负责让 GUI 2.0 已有功能安全、兼容、可合并。自动强化的后端 API、device lease、正式 Scheduler 任务、业务规则和最终接线由主库开发者负责;未经用户重新分配,不得越界实现或修改其业务语义。 +- 修改双方共享的 Scheduler task type、API 请求、设置 Schema、`ApiClient`、`ConfigModel`、`ConfigController`、`SchedulerBinder`、Fleet/Plan Types 前,必须说明所有权、双方调用方、合并影响和验证方式并取得用户确认。 +- 必须保持舰队方案与出征计划分离、运行前由 `RuntimePlanService` 展开、系统/用户数据边界清晰、candidate-only 平等候选、未知 YAML 字段保留,以及单一 Scheduler 所有权。 +- 仅允许为已经确认的主库协作功能保留最小扩展余量。例如自动强化可在现有 task type、API 和设置 Schema 上保留兼容入口,但不得提前建立空框架、独立定时器、第二状态机或未接线实现。 +- 协作兼容清单中的 `resource/builtin_plans/活动20260730-*.yaml` 是旧安装输入路径,不是当前仓库应恢复的目录;通过现有迁移映射、兼容资源和专项测试验证,不得重新复制一套来源。 +- 第 13 节协作契约在用户明确宣布合并完成或职责调整前持续有效;主库开发者提交的改动也必须通过当前 GUI 的四大原则、架构门禁和非回归验证。 + +## 5. 源文件与生成文件 + +- HTML 源位于 `src/view/html/**`,由 `scripts/build-view-html.js` 生成并校验已提交的 `src/view/index.html`。 +- SCSS 源位于 `src/view/styles/**/*.scss`,构建生成并提交 `src/view/styles/styles.css`。 +- TypeScript 由 `tsc` 编译到 `dist/**`,Renderer 由 `scripts/bundle.js` 生成 `dist/renderer.bundle.js`;`dist/**` 和 `release/**` 不提交。 +- 不得手工修改 `src/view/index.html`、`src/view/styles/styles.css` 或 `dist/**`。修改 HTML/SCSS/TypeScript 源后运行 `npm run build`,提交需要跟踪的生成入口。 +- lockfile 只能随明确依赖变更更新;大型资源、fixture、图片和机械生成内容必须与手写行为代码分开说明。 + +## 6. 风格与实现门禁 + +- 使用 UTF-8、LF 和文件末尾换行;TS/SCSS/JSON/Markdown/YAML 2 空格,Python 4 空格。 +- TypeScript 保持 strict;新代码不得引入隐式 `any` 或用断言绕过边界。类文件 PascalCase,工具/类型文件 camelCase,样式遵循现有 SCSS/BEM 结构。 +- 能修改现有实现时不得新增重复模块、包装层、状态源或兼容分支;每个新增文件、函数、类型、依赖和缓存都必须说明必要性。 +- 除 4.1 节已确认的跨分支协作契约外,不得以“以后可能扩展”“先搭起来”“文件太长”作为增加抽象或拆分文件的理由。 +- 注释解释设计原因、限制和兼容背景,不复述代码;临时 workaround 必须说明触发条件、移除条件和对应测试。 +- 防御式代码只放在真实外部边界或已验证失败路径;不得用宽泛 `try/catch`、静默降级和多层 fallback 掩盖状态所有权或契约错误。 +- 为测试新增注入点时优先使用构造参数或显式依赖,不得把调试开关、测试状态或仅测试使用的 API 暴露到生产路径。 +- 发现范围扩大、新状态源、跨层补偿或依赖功能回归时立即停止,重新说明范围并取得用户确认。 +- 不得关闭 SSL、路径、类型、Schema、测试、签名或权限校验来绕过问题。 + +## 7. 验证路由 + +所有行为修改都需要确定性验证;build、截图或一次手工运行不能替代专项测试。 + +| 改动范围 | 最低验证 | +|---|---| +| TypeScript / SCSS / HTML | `npm run test:build` | +| HTML、DOM ID、View 拆分 | `npm run test:renderer-contract` | +| Controller、View、共享边界 | `npm run test:architecture-boundaries` | +| Electron Service / IPC | `npm run test:main-services`、`npm run test:main-ipc` | +| Scheduler / Fleet / 地图 | `npm run test:scheduler-domain`、`npm run test:fleet-domain`、`npm run check:fleet-types`、`npm run check:maps` | +| 配置 / 迁移 / 活动资源 | `npm run test:settings`、`npm run test:migrations`、`npm run test:event-resources` | +| Python / 后端分发 / API | 选择 `npm run test:python-environment`、`npm run test:backend-distribution`、`npm run test:api-contract` | +| 打包 / 安装 / 发布 | `npm run dist`、`npm run test:release-package`,并实际启动安装后的 GUI | + +- 测试命令以当前 `package.json` 为准;已有专项测试按风险选择,不得只运行最容易通过的测试。 +- 涉及 Electron、端口、`userData`、临时目录、进程或共享缓存的测试默认串行执行。 +- 修改 GUI 交互时必须回归目标功能、共享组件消费者、重复初始化和状态保持;涉及游戏执行链路还须通过模拟器验证。无法验证时明确列出未验证路径。 +- 交付前执行 `git diff --check`,确认 diff 只含本批任务文件,并记录命令、结果、失败尝试和剩余风险。 + +## 8. Git 与发布 + +### 8.1 工作区和提交 + +- 不得覆盖、回退、删除、暂存或格式化用户已有修改;发现无关修改时忽略,只有确实妨碍任务时才请求处理方式。 +- 未经明确要求,不得执行 `git reset --hard`、`git checkout -- `、强制清理、历史重写、`--no-verify` 或无条件 `git push --force`。 +- 不得 `git add .`;按逻辑变更显式暂存。分支使用语义前缀,Commit 使用 Conventional Commits,一个 commit 对应一个可独立审查的逻辑变更。 + +### 8.2 ShiinaKuroko Fork + +- 路径以仓库为基准:GUI 为当前仓库根目录,后端默认是同级 `../AutoWSGR`;不得固化某台机器的绝对路径。 +- 当前仓库是个人 Fork。`main` 只用于同步 `yltx/AutoWSGR-GUI:alpha`,不直接开发功能;同步上游 `alpha` 后再更新个人 Fork 的 `origin/main`。 +- `ShiinaKuroko` 是主力开发分支,代表个人 Fork 的最新有效代码。功能实现、正式自动化测试和必要生成入口必须先合入该分支并完成验证,再由该分支执行普通 push。 +- 未经维护者事先明确允许,不得创建任何本地或远程分支、备份分支、worktree 或发布克隆;不得以隔离脏工作树、备份、测试、打包或发布为理由自行创建。 +- 只有维护者明确要求并批准 PR 时,才允许创建对应 PR 分支;格式为 `feat/<功能>-PR` 或 `fix/<功能>-PR`,Git 分支名不得使用反斜杠。创建下一条 PR 前必须检查上一条临时 PR 的状态,确认代码已经合入目标分支后,删除对应的本地和远端 PR 分支;未合入或状态不明确时不得删除。 +- GitHub 网络操作前读取系统当前代理设置;只允许命令级临时代理,不得硬编码端口或修改全局 Git 配置。 +- 每次 push `ShiinaKuroko` 前必须先 `git fetch origin`。本地落后或出现分叉时,先审查并整合远端提交,不得用强推覆盖远端代码。 +- 只有维护者明确要求并批准时,才以当前 `origin/ShiinaKuroko` 为指针创建远端不可移动备份 `backup/YYYYMMDD-`;不得擅自创建、移动、复用或删除 backup 分支。 +- 普通 push 禁止 `--force` 和 `--force-with-lease`。只有用户明确授权历史改写、已说明将被替换的远端提交且备份完成时,才允许使用 `--force-with-lease`。 +- push 完成后检查其他本地工作分支:只有确认有效提交已进入 `ShiinaKuroko`、没有独有未推送提交、没有未提交修改且未被 worktree 使用时才能删除。不得批量强删;保留 `main`、`ShiinaKuroko` 和仍未合入的活动 PR 分支。 + +### 8.3 Alpha 与正式发布 + +- `alpha` 是预发布版本/更新频道,不是质量豁免分支;四大原则、测试门禁、数据安全和兼容要求全部生效。 +- 只有用户明确要求时,才能改版本、创建 Tag、触发发布或上传产物。 +- Release commit 只含版本和发布元数据;Tag、`package.json`、`package-lock.json` 和产物版本必须一致,不得覆盖已有远程 Tag。 +- 发布前必须完成构建、专项测试、安装包验证和实际启动;无法完成时不得宣称发布可用。 + +## 9. Windows 打包 + +- 正式 Windows 产物使用 `npm run dist` 生成 NSIS 安装程序;`npm run pack` 的 unpacked 目录不是“单 EXE”交付物。 +- 不得通过关闭 `signAndEditExecutable`、签名、权限或产物校验来绕过打包问题。 +- `winCodeSign` 解压或符号链接失败时先区分权限、缓存和工具链问题;不得重复相同命令或用运行时 fallback 掩盖。 +- 单 EXE 交付只取 `AutoWSGR-GUI-Setup-.exe`,检查版本、哈希并实际安装启动;`release/**` 和 Electron Builder 缓存不得提交。 + +## 10. 专用入口与维护 + +- `.github/agents/code-length-audit.agent.md` 只用于代码长度审计;`.github/skills/commit-and-release/SKILL.md` 只用于提交发布;`.claude/skills/generic-issue-log-analysis/SKILL.md` 只用于 Issue/日志分析。 +- `.github/workflows/**` 定义实际自动化;修改前必须说明权限、Secret、触发条件和发布影响。 +- 本文件维护长期工程门禁和主库 1.4.4 协作契约,`docs/architecture/**` 维护当前 GUI 结构与 ADR;发现两者与实现不一致时按第 2 节处理。 +- 工具入口只指向本文件,不复制整套规则。阶段性迁移清单、历史事故和已结束的单次分工放入对应任务文档,不写入长期 Agent 约束。 +- 修改本文件前必须先审查当前代码和门禁;新增、删除或降低规则前,先向用户列明具体动作、原因和影响并取得确认。 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..467ac5b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# Claude Code 项目入口 + +在分析、修改文件或执行命令前,必须完整读取并遵守根目录 `AGENTS.md`。 + +`AGENTS.md` 是唯一 Coding 规范入口;继续按其中路由读取可执行配置、`CONTRIBUTING.md` 和任务相关架构文档。不得用本入口文件或其他摘要替代其规则。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b54981c..2f2db50 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,7 @@ docs/ → 架构文档 & 教程 ## 代码风格与规范 -完整、强制性的工程规范见 [docs/engineering-standards.md](docs/engineering-standards.md)。其中包括架构边界、状态所有权、数据迁移、Electron 安全、验证要求、AI Agent 规则,以及连续修补失败时的 Patch 止损和干净基线重写流程。本文仅保留快速上手摘要;发生冲突时以工程规范和可执行配置为准。 +完整、强制性的工程规范见 [AGENTS.md](AGENTS.md)。其中包括架构边界、状态所有权、数据迁移、Electron 安全、验证要求、AI Agent 规则,以及连续修补失败时的 Patch 止损和干净基线重写流程。本文仅保留快速上手摘要;发生冲突时按 `AGENTS.md` 的事实来源规则处理。 ### 通用规范 @@ -111,4 +111,4 @@ docs: 更新架构文档 - 一个 commit 对应一个逻辑变更,避免把不相关的修改混在一起 - 如果一个功能涉及多层修改(model + controller + view),可以放在同一个 commit 中 -- 同一问题连续修补失败或开始引入额外状态源、跨层补偿时,必须按照[工程规范的 Patch 止损机制](docs/engineering-standards.md#6-patch-止损机制)暂停并升级审查,不得继续叠加 workaround +- 同一问题连续修补失败或开始引入额外状态源、跨层补偿时,必须按照 [AGENTS.md 的修复止损机制](AGENTS.md#21-修复止损)暂停并升级审查,不得继续叠加 workaround diff --git a/README.md b/README.md index 62348b6..059f24b 100644 --- a/README.md +++ b/README.md @@ -1,139 +1,289 @@ -# AutoWSGR GUI - -[AutoWSGR](https://github.com/OpenWSGR/AutoWSGR) 的桌面图形界面,基于 Electron + TypeScript 构建。 - -## 当前 GUI 已实现功能 - -- **主页** - - 连接状态与远征倒计时展示 - - 手动运维操作(收远征 / 收奖励 / 收建造 / 食堂烹饪 / 浴室修理) - - 任务队列开始、停止、清空 - - 任务组管理(新建、重命名、删除、导入导出、整组入队) - - 后端日志实时查看与等级过滤 -- **方案预览** - - 导入 YAML 或从地图新建方案 - - 节点路线可视化与节点级参数编辑 - - 方案级参数配置(修理策略、战况、编队、次数、间隔、终止条件) - - 编队预设与泡澡修理阈值配置 - - 一键加入队列 / 加入任务组 / 保存 YAML / 另存为 -- **模板库** - - 支持普通出击、演习、战役、决战四类模板 - - 模板导入、创建、详情配置、加入任务列表 -- **配置页** - - 模拟器类型、路径、ADB 串号检测 - - Python 解释器路径检测与后端端口设置 - - 自动更新模式与手动检查更新 - - 每日自动任务(远征、战役、演习、常规出击、决战、战利品) - - 主题模式、主色调、调试模式 -- **调度与运行** - - 任务优先级调度 - - 条件停止与失败重试 - - 启动流程检查与运行期日志可观测 - -## 安装 +# AutoWSGR-GUI + +[AutoWSGR](https://github.com/OpenWSGR/AutoWSGR) 的 Windows 桌面图形界面,使用 Electron、TypeScript 和 SCSS 构建。 + +GUI 负责配置、编辑和管理 YAML,并通过 HTTP 与 WebSocket 连接 AutoWSGR 后端。后端模型和 YAML 契约是功能的事实来源,GUI 不额外定义后端不支持的字段或规则。 + +> 当前稳定版本为 **GUI 2.0.0**,使用 `latest` 更新频道。1.4.x 用户覆盖安装时, +> 安装器会先将旧用户数据保存在 +> `%LOCALAPPDATA%\AutoWSGR-GUI\legacy-upgrade`,再由 2.0 首次启动迁移。 +> 包括 `2.0.16-alpha` 在内的 Alpha 客户端不会跨频道自动切换到稳定版, +> 需要手动下载安装 2.0.0。 + +## 功能总览 + +当前主导航分为 **作战**、**计划** 和 **设置**。 + +### 作战 + +- 展示当前任务、剩余次数、任务进度、运行状态和远征倒计时。 +- 导入出征 YAML,或从计划管理中选择已有计划。 +- 管理任务列表:新建、保存、加载及整组加入队列。 +- 管理任务队列:开始、停止和清空。 +- 提供收取远征、收取奖励、收取建造、食堂烹饪和浴室修理等快捷操作。 +- 实时显示后端日志,并支持按日志等级筛选。 +- 当前任务携带明确编队时,在舰队预览中展示最多六艘舰船立绘; + 没有任务执行时显示空状态。 + +### 计划 + +计划页用于生成、保存和管理 YAML,不直接执行出征任务。完成编辑后,需要回到作战页将计划加入任务列表和任务队列。 + +#### 舰队规划 + +- 使用本地舰船资料库展示舰船立绘和资料。 +- 支持舰名或编号搜索、舰种和国籍多选、改造过滤及多种排序方式。 +- 编辑六个主选位置,以及每个位置独立的备选队列。 +- 支持主选、备选和舰船图鉴之间拖拽,空位自动整理到右侧。 +- 支持纯备选位置、位置级等级限制及复制备选队列。 +- 支持新建、保存和加载;未保存修改与同名覆盖均会二次确认。 +- 用户舰队保存到应用用户数据目录下的 + `user_team_plans/team-{预设名称}.yaml`。 + +舰队 YAML 支持主选舰船和位置级 `candidates`。一个位置也可以没有主选 `name`,只保留非空的结构化 `candidates`。 + +#### 出征规划 + +- 编辑地图章节、关卡、执行次数、轮次间隔和舰队编号。 +- 配置战况、维修策略、维修方式、战利品停止条件和掉落停止条件。 +- 选择并预览一个或多个舰队方案。 +- 通过地图编辑节点启用状态、终点、迂回、阵型、战斗动作、最低战果和索敌规则。 +- 支持新建、保存和加载;保存时校验文件名、地图信息及 YAML 内容。 +- 用户计划保存到应用用户数据目录下的 + `user_battle_plans/bettle-{预设名称}.yaml`。 + +出征规划当前没有直接执行入口。保存后的计划应在作战页加载并加入队列。 + +#### 决战计划(旧) + +保留旧版决战计划配置入口,用于现有决战流程。该页面属于兼容功能,不代表新的统一 YAML 任务设计。 + +#### 计划管理 + +- 汇总系统和用户的出征计划、舰队方案及关联状态。 +- 支持按来源、类型、名称和“仅看需要处理”筛选。 +- 标记舰队缺失、未被引用或 YAML 无法读取等状态。 +- 支持从管理列表跳转到舰队规划或出征规划并加载对应文件。 +- 用户 YAML 可以删除;系统 YAML 在界面中按只读资源处理。 +- 对不需要关联舰队的 YAML,可以忽略未关联提示并随时恢复。 + +### 设置 + +设置页分为 **系统设置** 和 **脚本行为**。 + +系统设置包括: + +- 模拟器类型、路径、账号和 ADB 地址。 +- ADB 主动连接、断开、自动检测及在线状态。 +- 自动远征、战役、演习、出征和胖次任务。 +- 日志等级、日志目录和调试模式。 +- Python 路径、后端地址、后端启动模式及本地 AutoWSGR 仓库路径。 +- 默认窗口大小、记录退出时窗口位置和大小。 +- GUI 更新模式、舰船数据库更新。 +- 亮色、暗色、跟随系统主题及主色调。 + +脚本行为包括: + +- 全局操作延迟。 +- 自动强化策略预留。2.0.0 只保存策略,不加入 Scheduler,生产路径为零后端调用。 +- OCR 下载源、加速模式、CUDA 路径和硬件识别。 +- 舰名匹配置信度、系统舰名规则、自定义舰名映射和识别纠错规则。 +- 舰队、船坞、维修、解装及自定义作战方案目录等后端配置。 + +## 推荐使用流程 + +1. 在设置页配置模拟器、ADB、Python 和后端启动模式。 +2. 按需更新舰船数据库。 +3. 在舰队规划中创建并保存舰队方案。 +4. 在出征规划中创建计划、关联舰队并保存 YAML。 +5. 在计划管理中检查计划与舰队的关联状态。 +6. 回到作战页加载计划,加入任务列表和任务队列后执行。 + +## 安装与运行 ### 普通用户 -1. 安装 Android 模拟器(MuMu 12 / 雷电 / 蓝叠) -2. 从 [Releases](https://github.com/yltx/AutoWSGR-GUI/releases) 下载最新安装包 -3. 安装运行,程序自动配置环境(下载便携 Python 3.12、安装 autowsgr 依赖到程序目录,**不影响系统环境**) -4. 确保模拟器已运行,程序自动检测并连接 +1. 安装并启动 MuMu 12、雷电或 BlueStacks。 +2. 从 [Releases](https://github.com/ShiinaKuroko/AutoWSGR-GUI/releases) 下载 Windows x64 安装包。 +3. 安装并启动 AutoWSGR-GUI。 +4. 在设置页确认模拟器、ADB 地址和后端环境。 + +### 从旧版本升级与回退 -> 如果你已有 Python ≥ 3.12,程序也可以使用它(依赖仍安装到 `python/site-packages/`,不修改全局包)。 -> -> 遇到问题时可运行 `debug_deps.bat` 生成诊断报告。 +- 1.4.x 稳定版用户可以使用 2.0.0 安装包覆盖升级。旧安装目录中的设置、任务列表、 + 模板和用户计划会先备份到 + `%LOCALAPPDATA%\AutoWSGR-GUI\legacy-upgrade`;确认迁移结果前不要删除该目录。 +- Alpha 与稳定版使用不同更新频道。Alpha 客户端不会自动收到 `latest` 频道的 + 2.0.0,必须手动运行稳定版安装包。 +- 如果必须回退,应先退出 2.0,使用旧安装器重新安装,再从上述备份恢复旧格式 + 数据;不要让旧版直接使用或覆盖唯一的 2.0 `userData`。 -### 开发者 +默认的 `managed` 模式由 GUI 管理 Python 和 AutoWSGR 依赖。依赖安装到程序自己的 `python/site-packages/`,不会写入系统 Python 的全局包目录。 -```bash +安装包不预装 `python/site-packages`。首次使用 `managed` 模式时需要联网安装 GUI +锁定提交的 AutoWSGR 及其依赖,准备时间受网络影响。离线使用或联调后端源码时, +应在设置页选择 `external` 模式并指定已有 AutoWSGR 仓库和 Python 环境。 + +遇到环境问题时,可以运行 `debug_deps.bat` 生成诊断信息。 + +### 发布包内容 + +GUI 2.0.0 稳定版安装包包含并验收以下运行内容: + +- AutoWSGR-GUI、便携版 Python 3.12、pip、ADB 和 VC++ 运行库。 +- 地图、系统出征计划、系统舰队方案、系统日常计划和舰船资料库。 +- 安装与环境诊断脚本、舰船资料更新工具。 +- AutoWSGR 固定来源信息;主库本体在首次环境准备时安装。 + +用户 YAML、`usersettings.yaml`、`gui_settings.json` 和 `task_groups.json` 不进入 +安装包,也不会因更新系统资源而被覆盖。 + +### 源码运行 + +前置要求: + +- Windows 10/11 +- Node.js 18 或更高版本 +- npm +- AutoWSGR 后端需要 Python 3.12 或 3.13 + +```powershell git clone https://github.com/yltx/AutoWSGR-GUI.git cd AutoWSGR-GUI npm install -setup.bat # 安装便携 Python + autowsgr 依赖 -npm run dev # 编译 + 启动 Electron +npm run dev ``` -## 用户文档 +项目没有热重载。修改代码后需要退出当前 Electron 实例,再次运行 `npm run dev`。 + +## 后端启动模式 + +### managed + +适合普通用户: -- GUI 操作指南(含“从方案预览页开始的新建任务配置流程”):[docs/user-guide.md](docs/user-guide.md) -- YAML 关键字说明(战斗方案编写):[docs/plan-guide.md](docs/plan-guide.md) +- GUI 查找或准备可用的 Python。 +- AutoWSGR 及依赖由 GUI 安装到本地 `python/site-packages/`。 +- GUI 启动并管理 uvicorn 后端进程。 +- 自动更新策略由设置页的更新模式控制。 -## 编写战斗方案 +### external -参见 [docs/plan-guide.md](docs/plan-guide.md),详细说明 YAML 方案的所有关键字和用法。 +适合同时开发 GUI 和 AutoWSGR 后端: -`plans/` 目录提供了多个示例方案可供参考。 +- 使用本地 AutoWSGR 仓库源码及其虚拟环境。 +- 本地仓库根目录必须包含 `autowsgr/server/main.py`。 +- GUI 将仓库根目录加入后端 Python 的模块搜索路径。 +- external 模式不会自动安装或更新远端 `autowsgr`;缺少依赖时,本地仓库本身作为安装 requirement。 + +可以在设置页填写,也可以在开发模式的 `gui_settings.json` 中配置: + +```json +{ + "backend_startup_mode": "external", + "backend_repo_path": "C:\\path\\to\\AutoWSGR", + "python_path": "C:\\path\\to\\AutoWSGR\\.venv\\Scripts\\python.exe", + "update_mode": "manual" +} +``` + +默认后端地址为 `http://127.0.0.1:8438`。MuMu 12 常用 ADB 地址为 `127.0.0.1:16384`,实际值应以模拟器实例为准。 + +## YAML 与数据目录 + +系统资源和用户数据分开存放: + +| 路径 | 内容 | +| --- | --- | +| `resource/system_battle_plans/` | 系统出征计划,界面中只读 | +| `resource/system_team_plans/` | 系统舰队方案,界面中只读 | +| `resource/system_daily_plans/` | 系统日常计划,界面中只读 | +| `resource/ship-library/` | 舰船资料库 manifest、中文标签和本地资源 | +| `resource/maps/` | 出征规划使用的地图数据 | +| `userData/user_battle_plans/` | 用户出征计划,标准文件名为 `bettle-{名称}.yaml` | +| `userData/user_team_plans/` | 用户舰队方案,标准文件名为 `team-{名称}.yaml` | +| `userData/user_daily_plans/` | 用户日常计划 | +| `userData/usersettings.yaml` | 传递给 AutoWSGR 后端的用户配置 | +| `userData/gui_settings.json` | GUI 环境、窗口、调度及界面状态 | +| `userData/task_groups.json` | 作战页任务列表数据 | + +`userData` 表示 Electron 为当前用户分配的应用数据目录。打包运行时,系统资源从 +安装包资源目录读取,用户数据由主进程写入该可写目录。不要直接修改系统方案; +需要调整时应另存为个人副本。1.4.x 覆盖升级时,旧版安装目录中的配置会先移到 +`%LOCALAPPDATA%\AutoWSGR-GUI\legacy-upgrade`,再恢复为迁移源并复制到 +`userData`;备份副本会保留用于迁移重试和手工回退。 + +GUI 保存的 YAML 必须通过当前前端校验,并最终符合 AutoWSGR 后端模型。战斗方案字段说明见 [docs/plan-guide.md](docs/plan-guide.md)。 + +## 开发命令 + +| 命令 | 说明 | +| --- | --- | +| `npm run dev` | 清理、编译、打包并启动 Electron | +| `npm start` | 执行构建后启动 Electron | +| `npm run build` | 编译 TypeScript、SCSS 并打包渲染进程,不启动应用 | +| `npm run build:css` | 单独编译 SCSS | +| `npm run prepare-python` | 准备便携版 Python | +| `npm run prepare-adb` | 准备 ADB 工具 | +| `npm run pack` | 生成未安装的应用目录 | +| `npm run dist` | 准备 Python 和 ADB,并生成 NSIS 安装包 | +| `npm run test:release-package` | 验证安装包版本、频道、运行时和内置资源 | ## 项目结构 ```text -├── electron/ # Electron 主进程 -│ ├── main.ts # 窗口管理、IPC 注册、应用生命周期 -│ ├── preload.ts # contextBridge 安全桥接 -│ ├── backend.ts # Python 后端进程管理 -│ ├── emulatorDetect.ts # 模拟器注册表检测 -│ └── pythonEnv/ # Python 环境检查、安装、更新(7 个模块) +├── electron/ # Electron 主进程、IPC、后端和环境管理 +│ ├── main.ts # 主进程组合根和生命周期 +│ ├── preload.ts # 安全桥接 +│ ├── ipc/ # 最小 IPC 通道注册 +│ ├── services/ # 文件、计划、迁移、更新和后端服务 +│ ├── emulatorDetect.ts # 模拟器检测 +│ └── pythonEnv/ # Python 与依赖环境管理 ├── src/ -│ ├── controller/ # 控制器层 — 业务逻辑与调度 -│ │ ├── app/ # AppController、ConfigController、SchedulerBinder -│ │ ├── plan/ # PlanController(方案编辑) -│ │ ├── startup/ # StartupController(启动流程) -│ │ ├── taskGroup/ # TaskGroupController -│ │ ├── template/ # TemplateController -│ │ └── shared/ # ControllerHost 接口 -│ ├── model/ # 模型层 — 数据、API、调度 -│ │ ├── ApiClient.ts # HTTP + WebSocket 通信 -│ │ ├── ConfigModel.ts # 用户配置读写 -│ │ ├── PlanModel.ts # 方案数据 & YAML 序列化 -│ │ ├── scheduler/ # Scheduler、CronScheduler、TaskQueue -│ │ ├── TaskGroupModel.ts -│ │ └── TemplateModel.ts -│ ├── view/ # 视图层 — 纯 DOM 渲染 -│ │ ├── main/ # 主页面(状态、队列、日志) -│ │ ├── plan/ # 方案预览 & 地图可视化 -│ │ ├── config/ # 配置页 -│ │ ├── template/ # 模板库 -│ │ ├── taskGroup/ # 任务组管理 -│ │ ├── setup/ # 安装向导 -│ │ └── styles/ # SCSS 样式 -│ ├── types/ # TypeScript 类型定义 -│ ├── data/ # 静态数据(舰船信息) -│ └── utils/ # 工具(Logger 等) -├── resource/ # 运行时资源(地图 JSON、内置方案、图片) -├── scripts/ # 构建脚本(esbuild、prepare-python、prepare-adb) -├── plans/ # 示例 YAML 战斗方案 -├── templates/ # 用户模板持久化 -├── docs/ # 文档 & 架构说明 -├── build/ # electron-builder 配置(NSIS 脚本) -├── setup.bat # 环境一键配置 -├── debug_deps.bat # 依赖诊断脚本 +│ ├── controller/ # 页面业务协调与调度 +│ ├── model/ # 配置、计划、API、任务和调度模型 +│ ├── view/ # 作战、计划、设置页面及 SCSS +│ ├── types/ # TypeScript 类型与 bridge 契约 +│ ├── shared/ # 跨层纯契约和无状态规则 +│ └── utils/ # 日志及通用工具 +├── resource/ +│ ├── maps/ # 地图数据 +│ ├── ship-library/ # 舰船资料库 +│ ├── system_battle_plans/ # 系统出征计划 +│ ├── system_daily_plans/ # 系统日常计划 +│ └── system_team_plans/ # 系统舰队方案 +├── tools/ship_library/ # 舰船资料库更新工具 +├── scripts/ # 构建、Python 和 ADB 准备脚本 +├── docs/ # 使用与架构文档 +├── build/ # electron-builder / NSIS 配置 +├── setup.bat # Windows 环境配置脚本 +├── debug_deps.bat # 环境诊断脚本 ├── package.json └── tsconfig.json ``` -## 开发命令 - -| 命令 | 说明 | -| ------ | ------ | -| `npm run dev` | 编译 TypeScript + 打包 + 启动 Electron | -| `npm run build` | 仅编译 + 打包(不启动) | -| `npm run build:css` | 编译 SCSS → CSS | -| `npm run dist` | 下载 Python/ADB + 编译 + 打包为 NSIS 安装程序 | -| `npm run pack` | 编译 + 打包为目录(不生成安装程序) | - -## 架构说明 +## 文档 -采用 **MVC + ViewObject** 模式,详细文档见 [docs/architecture/](docs/architecture/)。 +- [用户使用指南(含节点索敌规则说明)](docs/user-guide.md) +- [战斗方案 YAML 说明](docs/plan-guide.md) +- [架构文档索引](docs/architecture/README.md) +- [开发环境搭建](docs/architecture/08-dev-setup.md) +- [环境管理](docs/architecture/07-environment-management.md) +- [贡献指南](CONTRIBUTING.md) -- **Controller** — 从 Model 提取数据,拼装为只读 ViewObject,单向传递给 View -- **View** — 纯 DOM 渲染,不包含业务逻辑 -- **Model** — API 通信、配置解析、任务调度、数据持久化 +## 技术栈 -Python 后端通过 `pip install autowsgr` 安装(`--target` 安装到程序目录),由 Electron 主进程管理 uvicorn 子进程,前端通过 HTTP + WebSocket 与后端通信。 +- Electron 33 +- TypeScript 5.6 +- esbuild +- Sass / SCSS +- electron-builder / NSIS +- js-yaml +- AutoWSGR FastAPI / uvicorn 后端 ## 贡献 -欢迎参与开发!请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md) 和强制性的[工程与代码规范](docs/engineering-standards.md)。 +欢迎参与开发!请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md) 和强制性的 [AGENTS.md](AGENTS.md)。 ## 许可证 diff --git a/build/backend-distribution.json b/build/backend-distribution.json new file mode 100644 index 0000000..0d8263f --- /dev/null +++ b/build/backend-distribution.json @@ -0,0 +1,16 @@ +{ + "stable": { + "id": "stable", + "repository": "OpenWSGR/AutoWSGR", + "ref": "main", + "commit": "a5effbfc606794ec30fa8bfd2f8edd2cc15d3852", + "forceUpdateOnInstall": true + }, + "alpha": { + "id": "alpha", + "repository": "ShiinaKuroko/AutoWSGR", + "ref": "ShiinaKuroko", + "commit": "77f34b7b30d18f7b86cf736bdd5cf17ae35d5f78", + "forceUpdateOnInstall": true + } +} diff --git a/build/electron-builder.release.cjs b/build/electron-builder.release.cjs new file mode 100644 index 0000000..aa9af33 --- /dev/null +++ b/build/electron-builder.release.cjs @@ -0,0 +1,42 @@ +const packageMetadata = require('../package.json'); + +const base = packageMetadata.build; +const version = process.env.AUTOWSGR_RELEASE_VERSION + || packageMetadata.version; +const channel = /^\d+\.\d+\.\d+$/.test(version) + ? 'latest' + : /^\d+\.\d+\.\d+-alpha(?:\.\d+)?$/.test(version) + ? 'alpha' + : null; + +if (!channel) { + throw new Error( + `Release version ${version} must be X.Y.Z or X.Y.Z-alpha[.N]`, + ); +} + +module.exports = { + ...base, + extraMetadata: { + version, + }, + directories: { + ...base.directories, + output: `release/${channel}`, + }, + publish: { + ...base.publish, + channel, + }, + extraResources: [ + ...base.extraResources, + { + from: 'build/backend-distribution.json', + to: 'backend-distribution.json', + }, + ], + nsis: { + ...base.nsis, + include: 'build/installer.nsh', + }, +}; diff --git a/build/installer-helper.ps1 b/build/installer-helper.ps1 new file mode 100644 index 0000000..2df27f9 --- /dev/null +++ b/build/installer-helper.ps1 @@ -0,0 +1,2466 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateSet( + 'preserve', + 'restore', + 'stop-processes', + 'prepare-upgrade', + 'commit-upgrade', + 'rollback-upgrade' + )] + [string]$Action, + + [string]$Source, + + [string]$Backup, + + [string]$Target, + + [string]$TransactionRoot, + + [ValidateSet('current-user', 'all-users')] + [string]$Scope, + + [string]$HkcuSource, + + [string]$HklmSource, + + [string]$InstallDirectory, + + [int]$ExcludedProcessId = 0, + + [string]$GracefulExecutableName, + + [int]$GracefulTimeoutSeconds = 0 +) + +Set-StrictMode -Version 2.0 +$ErrorActionPreference = 'Stop' + +$script:LegacyItems = @( + [pscustomobject]@{ Relative = 'usersettings.yaml'; IsDirectory = $false }, + [pscustomobject]@{ Relative = 'gui_settings.json'; IsDirectory = $false }, + [pscustomobject]@{ Relative = 'task_groups.json'; IsDirectory = $false }, + [pscustomobject]@{ Relative = 'plans'; IsDirectory = $true }, + [pscustomobject]@{ Relative = 'templates'; IsDirectory = $true }, + [pscustomobject]@{ + Relative = 'resource\user_battle_plans' + IsDirectory = $true + }, + [pscustomobject]@{ + Relative = 'resource\user_daily_plans' + IsDirectory = $true + }, + [pscustomobject]@{ + Relative = 'resource\user_team_plans' + IsDirectory = $true + } +) +$script:PathComparison = [StringComparison]::OrdinalIgnoreCase +$script:TemporaryFilePattern = ( + '^\..+\.autowsgr-upgrade-[0-9a-fA-F]{32}\.tmp$' +) +$script:TransactionSchemaVersion = 2 +$script:RuntimeRelativePath = 'python\site-packages' +$script:TransactionStates = @( + 'prepared', + 'preserved', + 'restoring', + 'restored', + 'complete' +) +$script:Utf8NoBom = New-Object Text.UTF8Encoding($false, $true) + +function Assert-NoReparsePoint { + param( + [Parameter(Mandatory = $true)] + [System.IO.FileSystemInfo]$Item, + + [Parameter(Mandatory = $true)] + [string]$Label + ) + + if (($Item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Label contains a reparse point: $($Item.FullName)" + } +} + +function Get-SafeFileSystemItem { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [string]$Label + ) + + $item = Get-Item -Force -LiteralPath $Path + if ( + -not ($item -is [IO.FileInfo]) -and + -not ($item -is [IO.DirectoryInfo]) + ) { + throw "$Label is not a regular file-system item: $Path" + } + Assert-NoReparsePoint $item $Label + return $item +} + +function Assert-PathChainSafe { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [string]$Label + ) + + $current = [IO.Path]::GetFullPath($Path) + $isLeaf = $true + while ($true) { + if (Test-Path -LiteralPath $current) { + $item = Get-SafeFileSystemItem $current $Label + if (-not $isLeaf -and -not $item.PSIsContainer) { + throw "$Label has a file where a directory is required: $current" + } + } + + $parent = [IO.Directory]::GetParent($current) + if ($null -eq $parent) { + break + } + if ([string]::Equals( + $parent.FullName, + $current, + $script:PathComparison + )) { + break + } + $current = $parent.FullName + $isLeaf = $false + } +} + +function Get-CanonicalRoot { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [string]$Label, + + [Parameter(Mandatory = $true)] + [bool]$MustExist + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { + throw "$Label path is empty" + } + + $fullPath = [IO.Path]::GetFullPath($Path) + $pathRoot = [IO.Path]::GetPathRoot($fullPath) + while ( + $fullPath.Length -gt $pathRoot.Length -and + ( + $fullPath.EndsWith([string][IO.Path]::DirectorySeparatorChar) -or + $fullPath.EndsWith([string][IO.Path]::AltDirectorySeparatorChar) + ) + ) { + $fullPath = $fullPath.Substring(0, $fullPath.Length - 1) + } + + Assert-PathChainSafe $fullPath $Label + if (Test-Path -LiteralPath $fullPath) { + $rootItem = Get-SafeFileSystemItem $fullPath $Label + if (-not $rootItem.PSIsContainer) { + throw "$Label must be a directory: $fullPath" + } + } + elseif ($MustExist) { + throw "$Label directory does not exist: $fullPath" + } + + return $fullPath +} + +function Get-RootPrefix { + param( + [Parameter(Mandatory = $true)] + [string]$Root + ) + + if ( + $Root.EndsWith([string][IO.Path]::DirectorySeparatorChar) -or + $Root.EndsWith([string][IO.Path]::AltDirectorySeparatorChar) + ) { + return $Root + } + return $Root + [IO.Path]::DirectorySeparatorChar +} + +function Test-IsSameOrDescendant { + param( + [Parameter(Mandatory = $true)] + [string]$Candidate, + + [Parameter(Mandatory = $true)] + [string]$Root + ) + + if ([string]::Equals($Candidate, $Root, $script:PathComparison)) { + return $true + } + return $Candidate.StartsWith( + (Get-RootPrefix $Root), + $script:PathComparison + ) +} + +function Assert-SeparateRoots { + param( + [Parameter(Mandatory = $true)] + [string]$Left, + + [Parameter(Mandatory = $true)] + [string]$Right + ) + + if ( + (Test-IsSameOrDescendant $Left $Right) -or + (Test-IsSameOrDescendant $Right $Left) + ) { + throw 'Installer data roots must not contain each other' + } +} + +function Get-ContainedPath { + param( + [Parameter(Mandatory = $true)] + [string]$Root, + + [Parameter(Mandatory = $true)] + [string]$Relative + ) + + if ( + [string]::IsNullOrWhiteSpace($Relative) -or + [IO.Path]::IsPathRooted($Relative) + ) { + throw "Invalid relative installer data path: $Relative" + } + foreach ($segment in @($Relative -split '[\\/]')) { + if ($segment -eq '.' -or $segment -eq '..') { + throw "Relative path traverses outside its root: $Relative" + } + } + + $fullPath = [IO.Path]::GetFullPath((Join-Path $Root $Relative)) + if (-not $fullPath.StartsWith( + (Get-RootPrefix $Root), + $script:PathComparison + )) { + throw "Path escapes installer data root: $Relative" + } + return $fullPath +} + +function Assert-ParentDirectoryTypes { + param( + [Parameter(Mandatory = $true)] + [string]$Root, + + [Parameter(Mandatory = $true)] + [string]$Relative, + + [Parameter(Mandatory = $true)] + [string]$Label + ) + + $parentRelative = Split-Path -Parent $Relative + while (-not [string]::IsNullOrWhiteSpace($parentRelative)) { + $parentPath = Get-ContainedPath $Root $parentRelative + if (Test-Path -LiteralPath $parentPath) { + $parentItem = Get-SafeFileSystemItem $parentPath $Label + if (-not $parentItem.PSIsContainer) { + throw "$Label parent is not a directory: $parentRelative" + } + } + $nextParent = Split-Path -Parent $parentRelative + if ($nextParent -eq $parentRelative) { + break + } + $parentRelative = $nextParent + } +} + +function Assert-WhitelistLayout { + param( + [Parameter(Mandatory = $true)] + [string]$Root, + + [Parameter(Mandatory = $true)] + [string]$Label + ) + + foreach ($definition in $script:LegacyItems) { + Assert-ParentDirectoryTypes $Root $definition.Relative $Label + $itemPath = Get-ContainedPath $Root $definition.Relative + if (-not (Test-Path -LiteralPath $itemPath)) { + continue + } + + $item = Get-SafeFileSystemItem $itemPath $Label + if ([bool]$item.PSIsContainer -ne $definition.IsDirectory) { + throw "$Label has an invalid root item type: $($definition.Relative)" + } + } +} + +function New-LegacyEntry { + param( + [Parameter(Mandatory = $true)] + [System.IO.FileSystemInfo]$Item, + + [Parameter(Mandatory = $true)] + [string]$Relative + ) + + $hash = $null + if (-not $Item.PSIsContainer) { + $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath ( + $Item.FullName + )).Hash + } + + return [pscustomobject]@{ + Relative = $Relative + FullName = [IO.Path]::GetFullPath($Item.FullName) + IsDirectory = [bool]$Item.PSIsContainer + Hash = $hash + } +} + +function Get-LegacyEntries { + param( + [Parameter(Mandatory = $true)] + [string]$Root, + + [Parameter(Mandatory = $true)] + [string]$Label, + + [bool]$IgnoreInstallerTemps = $false + ) + + Assert-WhitelistLayout $Root $Label + $entries = New-Object System.Collections.ArrayList + foreach ($definition in $script:LegacyItems) { + $rootPath = Get-ContainedPath $Root $definition.Relative + if (-not (Test-Path -LiteralPath $rootPath)) { + continue + } + + $rootItem = Get-SafeFileSystemItem $rootPath $Label + [void]$entries.Add((New-LegacyEntry $rootItem $definition.Relative)) + if (-not $rootItem.PSIsContainer) { + continue + } + + $queue = New-Object System.Collections.Queue + $queue.Enqueue([pscustomobject]@{ + FullName = $rootItem.FullName + Relative = $definition.Relative + }) + while ($queue.Count -gt 0) { + $current = $queue.Dequeue() + foreach ($child in @(Get-ChildItem -Force -LiteralPath ( + $current.FullName + ))) { + $child = Get-SafeFileSystemItem $child.FullName $Label + $relative = Join-Path $current.Relative $child.Name + $expected = Get-ContainedPath $Root $relative + $actual = [IO.Path]::GetFullPath($child.FullName) + if (-not [string]::Equals( + $actual, + $expected, + $script:PathComparison + )) { + throw "$Label path changed during enumeration: $relative" + } + + if ( + $IgnoreInstallerTemps -and + -not $child.PSIsContainer -and + $child.Name -match $script:TemporaryFilePattern + ) { + continue + } + + [void]$entries.Add((New-LegacyEntry $child $relative)) + if ($child.PSIsContainer) { + $queue.Enqueue([pscustomobject]@{ + FullName = $child.FullName + Relative = $relative + }) + } + } + } + } + + return @($entries | Sort-Object @{ + Expression = { if ($_.IsDirectory) { 0 } else { 1 } } + }, Relative) +} + +function Get-EntryMap { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$Entries + ) + + $map = @{} + foreach ($entry in $Entries) { + if ($map.ContainsKey($entry.Relative)) { + throw "Duplicate legacy data path: $($entry.Relative)" + } + $map[$entry.Relative] = $entry + } + return $map +} + +function Assert-EntriesEqual { + param( + [Parameter(Mandatory = $true)] + [object]$Expected, + + [Parameter(Mandatory = $true)] + [object]$Actual, + + [Parameter(Mandatory = $true)] + [string]$Label + ) + + if ($Expected.IsDirectory -ne $Actual.IsDirectory) { + throw "$Label type conflict: $($Expected.Relative)" + } + if (-not $Expected.IsDirectory -and $Expected.Hash -ne $Actual.Hash) { + throw "$Label content conflict: $($Expected.Relative)" + } +} + +function Assert-EntriesCovered { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$ExpectedEntries, + + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$ActualEntries, + + [Parameter(Mandatory = $true)] + [string]$Label + ) + + $actualMap = Get-EntryMap $ActualEntries + foreach ($expectedEntry in $ExpectedEntries) { + if (-not $actualMap.ContainsKey($expectedEntry.Relative)) { + throw "$Label is missing: $($expectedEntry.Relative)" + } + Assert-EntriesEqual ( + $expectedEntry + ) $actualMap[$expectedEntry.Relative] $Label + } +} + +function Assert-EntrySetsEqual { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$ExpectedEntries, + + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$ActualEntries, + + [Parameter(Mandatory = $true)] + [string]$Label + ) + + if ($ExpectedEntries.Count -ne $ActualEntries.Count) { + throw "$Label entry count changed" + } + Assert-EntriesCovered $ExpectedEntries $ActualEntries $Label +} + +function Test-PreservedMarker { + param( + [Parameter(Mandatory = $true)] + [string]$BackupRoot, + + [bool]$Required = $false + ) + + $marker = Get-ContainedPath $BackupRoot '.preserved' + if (-not (Test-Path -LiteralPath $marker)) { + if ($Required) { + throw 'Legacy backup is incomplete: .preserved is missing' + } + return $false + } + + $markerItem = Get-SafeFileSystemItem $marker 'Legacy backup marker' + if ($markerItem.PSIsContainer) { + throw 'Legacy backup marker must be a file' + } + if ($markerItem.Length -ne 0) { + throw 'Legacy backup marker must be empty' + } + return $true +} + +function Write-PreservedMarker { + param( + [Parameter(Mandatory = $true)] + [string]$BackupRoot + ) + + if (Test-PreservedMarker $BackupRoot $false) { + return + } + + $marker = Get-ContainedPath $BackupRoot '.preserved' + $stream = [IO.File]::Open( + $marker, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::None + ) + $stream.Dispose() + [void](Test-PreservedMarker $BackupRoot $true) +} + +function Remove-InstallerTemps { + param( + [Parameter(Mandatory = $true)] + [string]$Root, + + [Parameter(Mandatory = $true)] + [string]$Label + ) + + if (-not (Test-Path -LiteralPath $Root)) { + return + } + Assert-WhitelistLayout $Root $Label + + foreach ($definition in $script:LegacyItems) { + $rootPath = Get-ContainedPath $Root $definition.Relative + if (-not $definition.IsDirectory) { + $parent = Split-Path -Parent $rootPath + $leaf = Split-Path -Leaf $rootPath + $pattern = ( + '^\.' + [regex]::Escape($leaf) + + '\.autowsgr-upgrade-[0-9a-fA-F]{32}\.tmp$' + ) + foreach ($candidate in @(Get-ChildItem -Force -LiteralPath $parent)) { + if ($candidate.PSIsContainer -or $candidate.Name -notmatch $pattern) { + continue + } + $candidate = Get-SafeFileSystemItem $candidate.FullName $Label + Remove-Item -Force -LiteralPath $candidate.FullName + } + continue + } + + if (-not (Test-Path -LiteralPath $rootPath)) { + continue + } + $queue = New-Object System.Collections.Queue + $queue.Enqueue($rootPath) + while ($queue.Count -gt 0) { + $current = $queue.Dequeue() + foreach ($candidate in @(Get-ChildItem -Force -LiteralPath $current)) { + $candidate = Get-SafeFileSystemItem $candidate.FullName $Label + if ($candidate.PSIsContainer) { + $queue.Enqueue($candidate.FullName) + } + elseif ($candidate.Name -match $script:TemporaryFilePattern) { + Remove-Item -Force -LiteralPath $candidate.FullName + } + } + } + } +} + +function Ensure-SafeDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [string]$Label + ) + + Assert-PathChainSafe $Path $Label + if (Test-Path -LiteralPath $Path) { + $item = Get-SafeFileSystemItem $Path $Label + if (-not $item.PSIsContainer) { + throw "$Label must be a directory: $Path" + } + return + } + + [void][IO.Directory]::CreateDirectory($Path) + $created = Get-SafeFileSystemItem $Path $Label + if (-not $created.PSIsContainer) { + throw "$Label must be a directory: $Path" + } + Assert-PathChainSafe $Path $Label +} + +function Copy-FileAtomically { + param( + [Parameter(Mandatory = $true)] + [object]$SourceEntry, + + [Parameter(Mandatory = $true)] + [string]$Destination, + + [Parameter(Mandatory = $true)] + [string]$Label + ) + + if ($SourceEntry.IsDirectory) { + throw "Cannot copy a directory as a file: $($SourceEntry.Relative)" + } + + $sourceItem = Get-SafeFileSystemItem $SourceEntry.FullName $Label + if ($sourceItem.PSIsContainer) { + throw "$Label source became a directory: $($SourceEntry.Relative)" + } + $sourceHash = (Get-FileHash -Algorithm SHA256 -LiteralPath ( + $sourceItem.FullName + )).Hash + if ($sourceHash -ne $SourceEntry.Hash) { + throw "$Label source changed before copy: $($SourceEntry.Relative)" + } + + $parent = Split-Path -Parent $Destination + Ensure-SafeDirectory $parent $Label + Assert-PathChainSafe $Destination $Label + if (Test-Path -LiteralPath $Destination) { + throw "$Label destination appeared during copy: $($SourceEntry.Relative)" + } + + $temporary = Join-Path $parent ( + '.' + [IO.Path]::GetFileName($Destination) + + '.autowsgr-upgrade-' + [Guid]::NewGuid().ToString('N') + '.tmp' + ) + try { + [IO.File]::Copy($sourceItem.FullName, $temporary, $false) + $temporaryItem = Get-SafeFileSystemItem $temporary $Label + if ($temporaryItem.PSIsContainer) { + throw "$Label temporary path became a directory" + } + $temporaryHash = (Get-FileHash -Algorithm SHA256 -LiteralPath ( + $temporary + )).Hash + if ($temporaryHash -ne $SourceEntry.Hash) { + throw "$Label source changed while copying: $($SourceEntry.Relative)" + } + if (Test-Path -LiteralPath $Destination) { + throw "$Label destination appeared during copy: $($SourceEntry.Relative)" + } + [IO.File]::Move($temporary, $Destination) + } + finally { + if (Test-Path -LiteralPath $temporary) { + $temporaryItem = Get-SafeFileSystemItem $temporary $Label + Remove-Item -Force -LiteralPath $temporaryItem.FullName + } + } +} + +function Get-LegacyTreeDigest { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$Entries + ) + + $lines = @( + $Entries | + Sort-Object Relative | + ForEach-Object { + $kind = if ($_.IsDirectory) { 'D' } else { 'F' } + $hash = if ($_.IsDirectory) { '' } else { $_.Hash } + "$kind`t$($_.Relative)`t$hash" + } + ) + $bytes = $script:Utf8NoBom.GetBytes(($lines -join "`n")) + $sha = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString( + $sha.ComputeHash($bytes) + )).Replace('-', '') + } + finally { + $sha.Dispose() + } +} + +function Get-RuntimeEntries { + param([string]$Root, [string]$Label) + $rootItem = Get-SafeFileSystemItem $Root $Label + if (-not $rootItem.PSIsContainer) { throw "$Label must be a directory" } + $entries = New-Object System.Collections.ArrayList + $queue = New-Object System.Collections.Queue + $queue.Enqueue([pscustomobject]@{ FullName = $rootItem.FullName; Relative = '' }) + while ($queue.Count -gt 0) { + $current = $queue.Dequeue() + foreach ($child in @(Get-ChildItem -Force -LiteralPath $current.FullName)) { + $child = Get-SafeFileSystemItem $child.FullName $Label + $relative = if ($current.Relative) { + Join-Path $current.Relative $child.Name + } else { $child.Name } + if (-not [string]::Equals( + [IO.Path]::GetFullPath($child.FullName), + (Get-ContainedPath $Root $relative), + $script:PathComparison + )) { throw "$Label path changed during enumeration" } + [void]$entries.Add((New-LegacyEntry $child $relative)) + if ($child.PSIsContainer) { + $queue.Enqueue([pscustomobject]@{ + FullName = $child.FullName; Relative = $relative + }) + } + } + } + return @($entries | Sort-Object @{ + Expression = { if ($_.IsDirectory) { 0 } else { 1 } } + }, Relative) +} + +function Get-RuntimeSnapshot { + param([string]$Root, [string]$Label) + $entries = @(Get-RuntimeEntries $Root $Label) + return [pscustomobject]@{ + EntryCount = $entries.Count + Digest = Get-LegacyTreeDigest $entries + } +} + +function Assert-RuntimeSnapshot { + param([string]$Root, [object]$Artifact, [string]$Label) + if (-not [IO.Directory]::Exists($Root)) { throw "$Label is missing" } + $snapshot = Get-RuntimeSnapshot $Root $Label + if ($snapshot.EntryCount -ne [int]$Artifact.entryCount -or + $snapshot.Digest -ne [string]$Artifact.digest) { + throw "$Label does not match its manifest" + } +} + +function Get-RuntimeStagingPath { + param([string]$SourceRoot, [string]$TransactionId) + $path = [IO.Path]::GetFullPath((Join-Path (Split-Path -Parent $SourceRoot) ( + '.' + (Split-Path -Leaf $SourceRoot) + + '.autowsgr-runtime-' + $TransactionId + ))) + Assert-PathChainSafe $path 'Runtime staging path' + Assert-SeparateRoots $SourceRoot $path + if (-not [string]::Equals( + [IO.Path]::GetPathRoot($SourceRoot), + [IO.Path]::GetPathRoot($path), + $script:PathComparison + )) { throw 'Runtime staging must share the source volume' } + return $path +} + +function Assert-SourceSetsCompatible { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$SourceSets + ) + + $combined = @{} + foreach ($sourceSet in $SourceSets) { + foreach ($entry in $sourceSet.Entries) { + if ($combined.ContainsKey($entry.Relative)) { + Assert-EntriesEqual ( + $combined[$entry.Relative] + ) $entry 'Legacy upgrade sources' + } + else { + $combined[$entry.Relative] = $entry + } + } + } +} + +function New-TransactionSourceDescriptor { + param( + [Parameter(Mandatory = $true)] + [string]$Hive, + + [AllowEmptyString()] + [string]$SourcePath, + + [Parameter(Mandatory = $true)] + [string]$BackupRelative, + + [Parameter(Mandatory = $true)] + [string]$TransactionId + ) + + if ([string]::IsNullOrWhiteSpace($SourcePath)) { + return $null + } + $sourceRoot = Get-CanonicalRoot $SourcePath ( + "$Hive legacy source" + ) $false + if (-not [IO.Directory]::Exists($sourceRoot)) { + return $null + } + Assert-WhitelistLayout $sourceRoot "$Hive legacy source" + $entries = @(Get-LegacyEntries $sourceRoot "$Hive legacy source") + $runtimeArtifact = $null + $runtimePath = Get-ContainedPath $sourceRoot $script:RuntimeRelativePath + if ([IO.Directory]::Exists($runtimePath)) { + $snapshot = Get-RuntimeSnapshot $runtimePath "$Hive runtime" + $runtimeArtifact = [pscustomobject]@{ + RelativePath = $script:RuntimeRelativePath + StagingPath = Get-RuntimeStagingPath $sourceRoot $TransactionId + EntryCount = $snapshot.EntryCount + Digest = $snapshot.Digest + } + } elseif (Test-Path -LiteralPath $runtimePath) { + throw "$Hive runtime must be a directory" + } + return [pscustomobject]@{ + Hives = @($Hive) + Path = $sourceRoot + BackupRelative = $BackupRelative + EntryCount = $entries.Count + Digest = Get-LegacyTreeDigest $entries + Entries = $entries + RuntimeArtifact = $runtimeArtifact + } +} + +function Get-ExpectedTransactionSources { + param( + [AllowEmptyString()] + [string]$HkcuPath, + + [AllowEmptyString()] + [string]$HklmPath, + + [Parameter(Mandatory = $true)] + [string]$InstallScope, + + [Parameter(Mandatory = $true)] + [string]$TransactionId + ) + + $sourceSets = New-Object System.Collections.ArrayList + $hkcu = New-TransactionSourceDescriptor ( + 'HKCU' + ) $HkcuPath 'sources\hkcu' $TransactionId + if ($null -ne $hkcu) { + [void]$sourceSets.Add($hkcu) + } + if ($InstallScope -eq 'all-users') { + $hklm = New-TransactionSourceDescriptor ( + 'HKLM' + ) $HklmPath 'sources\hklm' $TransactionId + if ($null -ne $hklm) { + $matching = @( + $sourceSets | + Where-Object { + [string]::Equals( + $_.Path, + $hklm.Path, + $script:PathComparison + ) + } + ) + if ($matching.Count -eq 1) { + $matching[0].Hives = @('HKCU', 'HKLM') + } + else { + [void]$sourceSets.Add($hklm) + } + } + } + Assert-SourceSetsCompatible $sourceSets + if (@($sourceSets | Where-Object { $null -ne $_.RuntimeArtifact }).Count -gt 1) { + throw 'Multiple legacy sources contain managed runtime artifacts' + } + return @($sourceSets) +} + +function Get-TransactionRoot { + param( + [Parameter(Mandatory = $true)] + [string]$RootPath, + + [Parameter(Mandatory = $true)] + [bool]$Create + ) + + $root = Get-CanonicalRoot $RootPath 'Upgrade transaction root' $false + $transactions = Get-ContainedPath $root 'transactions' + if (-not $Create -and -not [IO.Directory]::Exists($transactions)) { + if (Test-Path -LiteralPath $transactions) { + [void](Get-CanonicalRoot ( + $transactions + ) 'Upgrade transaction directory' $true) + } + return $null + } + Ensure-SafeDirectory $root 'Upgrade transaction root' + Ensure-SafeDirectory $transactions 'Upgrade transaction directory' + return $transactions +} + +function Get-TransactionManifestPath { + param( + [Parameter(Mandatory = $true)] + [string]$TransactionDirectory + ) + + return Get-ContainedPath $TransactionDirectory 'transaction.json' +} + +function Convert-ManifestSourceForWrite { + param( + [Parameter(Mandatory = $true)] + [object]$Source + ) + + $runtimeArtifact = $null + if ($null -ne $Source.RuntimeArtifact) { + $runtimeArtifact = [ordered]@{ + relativePath = $Source.RuntimeArtifact.RelativePath + stagingPath = $Source.RuntimeArtifact.StagingPath + entryCount = [int]$Source.RuntimeArtifact.EntryCount + digest = $Source.RuntimeArtifact.Digest + } + } + return [ordered]@{ + hives = @($Source.Hives) + path = $Source.Path + backupRelative = $Source.BackupRelative + entryCount = [int]$Source.EntryCount + digest = $Source.Digest + runtimeArtifact = $runtimeArtifact + } +} + +function Write-JsonAtomically { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [object]$Value + ) + + $parent = Split-Path -Parent $Path + Ensure-SafeDirectory $parent 'Upgrade transaction manifest directory' + Assert-PathChainSafe $Path 'Upgrade transaction manifest' + $temporary = Join-Path $parent ( + '.transaction.autowsgr-upgrade-' + + [Guid]::NewGuid().ToString('N') + '.tmp' + ) + $json = $Value | ConvertTo-Json -Depth 8 -Compress + $bytes = $script:Utf8NoBom.GetBytes($json) + $stream = $null + try { + $stream = New-Object IO.FileStream( + $temporary, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::None, + 4096, + [IO.FileOptions]::WriteThrough + ) + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + $stream.Dispose() + $stream = $null + [void]( + [IO.File]::ReadAllText( + $temporary, + $script:Utf8NoBom + ) | ConvertFrom-Json + ) + if (Test-Path -LiteralPath $Path) { + $replacementBackup = Join-Path $parent ( + '.transaction.autowsgr-upgrade-' + + [Guid]::NewGuid().ToString('N') + '.bak' + ) + try { + [IO.File]::Replace( + $temporary, + $Path, + $replacementBackup, + $true + ) + } + finally { + if (Test-Path -LiteralPath $replacementBackup) { + Remove-Item -Force -LiteralPath $replacementBackup + } + } + } + else { + [IO.File]::Move($temporary, $Path) + } + } + finally { + if ($null -ne $stream) { + $stream.Dispose() + } + if (Test-Path -LiteralPath $temporary) { + Remove-Item -Force -LiteralPath $temporary + } + } +} + +function Read-TransactionManifest { + param( + [Parameter(Mandatory = $true)] + [string]$ManifestPath + ) + + $manifestItem = Get-SafeFileSystemItem ( + $ManifestPath + ) 'Upgrade transaction manifest' + if ($manifestItem.PSIsContainer) { + throw 'Upgrade transaction manifest must be a file' + } + try { + return [IO.File]::ReadAllText( + $manifestItem.FullName, + $script:Utf8NoBom + ) | ConvertFrom-Json + } + catch { + throw "Invalid upgrade transaction manifest: $($_.Exception.Message)" + } +} + +function Assert-ManifestPropertySet { + param( + [Parameter(Mandatory = $true)] + [object]$Object, + + [Parameter(Mandatory = $true)] + [string[]]$Expected, + + [Parameter(Mandatory = $true)] + [string]$Label + ) + + $actual = @($Object.PSObject.Properties.Name | Sort-Object) + $expectedNames = @($Expected | Sort-Object) + if (($actual -join "`n") -ne ($expectedNames -join "`n")) { + throw "$Label has unexpected fields" + } +} + +function Assert-TransactionManifest { + param( + [Parameter(Mandatory = $true)] + [object]$Manifest, + + [Parameter(Mandatory = $true)] + [string]$TransactionDirectory + ) + + Assert-ManifestPropertySet $Manifest @( + 'schemaVersion', + 'transactionId', + 'state', + 'target', + 'scope', + 'sources' + ) 'Upgrade transaction manifest' + if ([int]$Manifest.schemaVersion -ne $script:TransactionSchemaVersion) { + throw 'Unsupported upgrade transaction schema' + } + $transactionId = [string]$Manifest.transactionId + if ($transactionId -notmatch '^[0-9a-f]{32}$') { + throw 'Invalid upgrade transaction ID' + } + if (-not [string]::Equals( + (Split-Path -Leaf $TransactionDirectory), + $transactionId, + [StringComparison]::Ordinal + )) { + throw 'Upgrade transaction directory does not match its ID' + } + if ($script:TransactionStates -notcontains [string]$Manifest.state) { + throw 'Invalid upgrade transaction state' + } + if (@('current-user', 'all-users') -notcontains [string]$Manifest.scope) { + throw 'Invalid upgrade transaction scope' + } + $target = Get-CanonicalRoot ( + [string]$Manifest.target + ) 'Upgrade transaction target' $false + if (-not [string]::Equals( + $target, + [string]$Manifest.target, + $script:PathComparison + )) { + throw 'Upgrade transaction target is not canonical' + } + + $seenHives = @{} + $seenPaths = @{} + foreach ($source in @($Manifest.sources)) { + Assert-ManifestPropertySet $source @( + 'hives', + 'path', + 'backupRelative', + 'entryCount', + 'digest', + 'runtimeArtifact' + ) 'Upgrade transaction source' + if (@($source.hives).Count -eq 0) { + throw 'Upgrade transaction source must bind at least one registry hive' + } + foreach ($hive in @($source.hives)) { + if (@('HKCU', 'HKLM') -notcontains [string]$hive) { + throw 'Invalid upgrade transaction registry hive' + } + if ( + [string]$Manifest.scope -eq 'current-user' -and + [string]$hive -eq 'HKLM' + ) { + throw 'Current-user transaction cannot bind an HKLM source' + } + if ($seenHives.ContainsKey([string]$hive)) { + throw 'Duplicate upgrade transaction registry hive' + } + $seenHives[[string]$hive] = $true + } + $sourcePath = Get-CanonicalRoot ( + [string]$source.path + ) 'Upgrade transaction source' $false + if (-not [string]::Equals( + $sourcePath, + [string]$source.path, + $script:PathComparison + )) { + throw 'Upgrade transaction source is not canonical' + } + if ($seenPaths.ContainsKey($sourcePath)) { + throw 'Duplicate upgrade transaction source path' + } + $seenPaths[$sourcePath] = $true + [void](Get-ContainedPath ( + $TransactionDirectory + ) ([string]$source.backupRelative)) + if ([int]$source.entryCount -lt 0) { + throw 'Invalid upgrade transaction entry count' + } + if ([string]$source.digest -notmatch '^[0-9A-F]{64}$') { + throw 'Invalid upgrade transaction digest' + } + if ($null -ne $source.runtimeArtifact) { + Assert-ManifestPropertySet $source.runtimeArtifact @( + 'relativePath', 'stagingPath', 'entryCount', 'digest' + ) 'Upgrade runtime artifact' + if ([string]$source.runtimeArtifact.relativePath -ne $script:RuntimeRelativePath) { + throw 'Invalid upgrade runtime relative path' + } + $stagingPath = Get-CanonicalRoot ( + [string]$source.runtimeArtifact.stagingPath + ) 'Upgrade runtime staging' $false + $expectedStagingPath = Get-RuntimeStagingPath ( + $sourcePath + ) $transactionId + if (-not [string]::Equals( + $stagingPath, + $expectedStagingPath, + $script:PathComparison + )) { + throw 'Upgrade runtime staging does not match transaction identity' + } + Assert-SeparateRoots $sourcePath $stagingPath + if (-not [string]::Equals( + [IO.Path]::GetPathRoot($sourcePath), + [IO.Path]::GetPathRoot($stagingPath), + $script:PathComparison + )) { throw 'Upgrade runtime staging must share the source volume' } + if ([int]$source.runtimeArtifact.entryCount -lt 0) { + throw 'Invalid upgrade runtime entry count' + } + if ([string]$source.runtimeArtifact.digest -notmatch '^[0-9A-F]{64}$') { + throw 'Invalid upgrade runtime digest' + } + } + } +} + +function Get-IncompleteTransaction { + param( + [Parameter(Mandatory = $true)] + [string]$TransactionsRoot + ) + + $incomplete = @() + foreach ($directory in @(Get-ChildItem -Force -LiteralPath $TransactionsRoot)) { + $directory = Get-SafeFileSystemItem ( + $directory.FullName + ) 'Upgrade transaction entry' + if (-not $directory.PSIsContainer) { + throw 'Upgrade transaction root contains an unexpected file' + } + $manifestPath = Get-TransactionManifestPath $directory.FullName + if (-not (Test-Path -LiteralPath $manifestPath)) { + throw 'Upgrade transaction directory is missing transaction.json' + } + $manifest = Read-TransactionManifest $manifestPath + Assert-TransactionManifest $manifest $directory.FullName + if ([string]$manifest.state -ne 'complete') { + $incomplete += [pscustomobject]@{ + Directory = $directory.FullName + ManifestPath = $manifestPath + Manifest = $manifest + } + } + } + if ($incomplete.Count -gt 1) { + throw 'Multiple incomplete upgrade transactions exist' + } + if ($incomplete.Count -eq 1) { + return $incomplete[0] + } + return $null +} + +function Test-CompletedTransactionIdentity { + param( + [Parameter(Mandatory = $true)] + [string]$TransactionsRoot, + + [Parameter(Mandatory = $true)] + [string]$ExpectedTarget, + + [Parameter(Mandatory = $true)] + [string]$ExpectedScope + ) + + $hasTransactions = $false + foreach ($directory in @(Get-ChildItem -Force -LiteralPath $TransactionsRoot)) { + $directory = Get-SafeFileSystemItem ( + $directory.FullName + ) 'Upgrade transaction entry' + if (-not $directory.PSIsContainer) { + throw 'Upgrade transaction root contains an unexpected file' + } + $hasTransactions = $true + $manifestPath = Get-TransactionManifestPath $directory.FullName + if (-not (Test-Path -LiteralPath $manifestPath)) { + throw 'Upgrade transaction directory is missing transaction.json' + } + $manifest = Read-TransactionManifest $manifestPath + Assert-TransactionManifest $manifest $directory.FullName + if ( + [string]$manifest.state -eq 'complete' -and + [string]::Equals( + [string]$manifest.target, + $ExpectedTarget, + $script:PathComparison + ) -and + [string]$manifest.scope -eq $ExpectedScope + ) { + return $true + } + } + if ($hasTransactions) { + throw 'No completed upgrade transaction matches this identity' + } + return $false +} + +function Assert-TransactionBaseIdentity { + param( + [Parameter(Mandatory = $true)] + [object]$Manifest, + + [Parameter(Mandatory = $true)] + [string]$ExpectedTarget, + + [Parameter(Mandatory = $true)] + [string]$ExpectedScope + ) + + if (-not [string]::Equals( + [string]$Manifest.target, + $ExpectedTarget, + $script:PathComparison + ) -or [string]$Manifest.scope -ne $ExpectedScope) { + throw 'Incomplete upgrade transaction identity does not match' + } +} + +function Assert-TransactionSourceIdentity { + param( + [Parameter(Mandatory = $true)] + [object]$Manifest, + + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$ExpectedSources + ) + + $actualSources = @($Manifest.sources) + if ($actualSources.Count -ne $ExpectedSources.Count) { + throw 'Incomplete upgrade transaction source set does not match' + } + foreach ($expected in $ExpectedSources) { + $actual = @( + $actualSources | + Where-Object { + [string]::Equals( + [string]$_.path, + $expected.Path, + $script:PathComparison + ) + } + ) + if ($actual.Count -ne 1) { + throw 'Incomplete upgrade transaction source path does not match' + } + if ( + (@($actual[0].hives) -join ',') -ne + (@($expected.Hives) -join ',') -or + [string]$actual[0].backupRelative -ne $expected.BackupRelative -or + [int]$actual[0].entryCount -ne $expected.EntryCount -or + [string]$actual[0].digest -ne $expected.Digest + ) { + throw 'Incomplete upgrade transaction source snapshot does not match' + } + } +} + +function Assert-TransactionInputIdentity { + param( + [Parameter(Mandatory = $true)] + [object]$Manifest, + + [AllowEmptyString()] + [string]$HkcuPath, + + [AllowEmptyString()] + [string]$HklmPath, + + [Parameter(Mandatory = $true)] + [string]$InstallScope + ) + + $expectedInputs = @( + @( + [pscustomobject]@{ Hive = 'HKCU'; Path = $HkcuPath }, + [pscustomobject]@{ Hive = 'HKLM'; Path = $HklmPath } + ) | Where-Object { + -not [string]::IsNullOrWhiteSpace($_.Path) -and + ($_.Hive -eq 'HKCU' -or $InstallScope -eq 'all-users') + } + ) + $manifestHives = @( + $Manifest.sources | + ForEach-Object { @($_.hives) } + ) + if ($expectedInputs.Count -ne $manifestHives.Count) { + throw 'Incomplete upgrade transaction source set does not match' + } + + foreach ($expected in $expectedInputs) { + $canonical = Get-CanonicalRoot $expected.Path ( + "$($expected.Hive) legacy source" + ) $false + $matching = @($Manifest.sources | Where-Object { + @($_.hives) -contains $expected.Hive -and + [string]::Equals( + [string]$_.path, + $canonical, + $script:PathComparison + ) + }) + if ($matching.Count -ne 1) { + throw 'Incomplete upgrade transaction source identity does not match' + } + } +} + +function Write-TransactionManifestState { + param( + [Parameter(Mandatory = $true)] + [object]$Transaction, + + [Parameter(Mandatory = $true)] + [string]$State + ) + + $manifest = [ordered]@{ + schemaVersion = [int]$Transaction.Manifest.schemaVersion + transactionId = [string]$Transaction.Manifest.transactionId + state = $State + target = [string]$Transaction.Manifest.target + scope = [string]$Transaction.Manifest.scope + sources = @($Transaction.Manifest.sources) + } + Write-JsonAtomically $Transaction.ManifestPath $manifest + $Transaction.Manifest = Read-TransactionManifest $Transaction.ManifestPath + Assert-TransactionManifest $Transaction.Manifest $Transaction.Directory +} + +function New-UpgradeTransaction { + param( + [Parameter(Mandatory = $true)] + [string]$TransactionsRoot, + + [Parameter(Mandatory = $true)] + [string]$TargetRoot, + + [Parameter(Mandatory = $true)] + [string]$InstallScope, + + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$Sources, + + [Parameter(Mandatory = $true)] + [string]$TransactionId + ) + + $directory = Get-ContainedPath $TransactionsRoot $transactionId + Ensure-SafeDirectory $directory 'Upgrade transaction directory' + $manifestPath = Get-TransactionManifestPath $directory + $manifest = [ordered]@{ + schemaVersion = $script:TransactionSchemaVersion + transactionId = $transactionId + state = 'prepared' + target = $TargetRoot + scope = $InstallScope + sources = @($Sources | ForEach-Object { + Convert-ManifestSourceForWrite $_ + }) + } + Write-JsonAtomically $manifestPath $manifest + $result = [pscustomobject]@{ + Directory = $directory + ManifestPath = $manifestPath + Manifest = Read-TransactionManifest $manifestPath + } + Assert-TransactionManifest $result.Manifest $result.Directory + return $result +} + +function Assert-TransactionBackups { + param( + [Parameter(Mandatory = $true)] + [object]$Transaction + ) + + foreach ($source in @($Transaction.Manifest.sources)) { + $backup = Get-ContainedPath ( + $Transaction.Directory + ) ([string]$source.backupRelative) + $entries = @(Get-LegacyEntries $backup 'Upgrade transaction backup' $true) + if ($entries.Count -ne [int]$source.entryCount -or ( + Get-LegacyTreeDigest $entries + ) -ne [string]$source.digest) { + throw 'Upgrade transaction backup does not match its manifest' + } + [void](Test-PreservedMarker $backup $true) + } +} + +function Get-ManifestRuntimeSource { + param([object]$Manifest) + $sources = @($Manifest.sources | Where-Object { $null -ne $_.runtimeArtifact }) + if ($sources.Count -gt 1) { throw 'Multiple runtime artifacts in transaction' } + if ($sources.Count -eq 1) { return $sources[0] } + return $null +} + +function Move-RuntimeToStaging { + param([object]$Manifest) + $source = Get-ManifestRuntimeSource $Manifest + if ($null -eq $source) { return } + $artifact = $source.runtimeArtifact + $runtime = Get-ContainedPath ([string]$source.path) ([string]$artifact.relativePath) + $staging = [string]$artifact.stagingPath + $runtimeExists = [IO.Directory]::Exists($runtime) + $stagingExists = [IO.Directory]::Exists($staging) + if ($runtimeExists -and $stagingExists) { + throw 'Managed runtime exists in source and staging' + } + if ($stagingExists) { + Assert-RuntimeSnapshot $staging $artifact 'Managed runtime staging' + return + } + if (-not $runtimeExists) { throw 'Managed runtime is missing' } + Assert-RuntimeSnapshot $runtime $artifact 'Managed runtime source' + [IO.Directory]::Move($runtime, $staging) + Assert-RuntimeSnapshot $staging $artifact 'Managed runtime staging' +} + +function Restore-RuntimeArtifact { + param([object]$Manifest, [string]$DestinationRoot, [string]$Label) + $source = Get-ManifestRuntimeSource $Manifest + if ($null -eq $source) { return } + $artifact = $source.runtimeArtifact + $staging = [string]$artifact.stagingPath + $destination = Get-ContainedPath $DestinationRoot ([string]$artifact.relativePath) + $stagingExists = [IO.Directory]::Exists($staging) + $destinationExists = [IO.Directory]::Exists($destination) + if ($stagingExists -and $destinationExists) { + throw "$Label exists in staging and destination" + } + if ($destinationExists) { + Assert-RuntimeSnapshot $destination $artifact "$Label destination" + return + } + if (-not $stagingExists) { throw "$Label is missing" } + Assert-RuntimeSnapshot $staging $artifact "$Label staging" + Ensure-SafeDirectory (Split-Path -Parent $destination) "$Label parent" + [IO.Directory]::Move($staging, $destination) + Assert-RuntimeSnapshot $destination $artifact "$Label destination" +} + +function Assert-RuntimePreserved { + param([object]$Manifest) + $source = Get-ManifestRuntimeSource $Manifest + if ($null -eq $source) { return } + $runtime = Get-ContainedPath ([string]$source.path) ( + [string]$source.runtimeArtifact.relativePath + ) + if (Test-Path -LiteralPath $runtime) { + throw 'Managed runtime remains in legacy source' + } + Assert-RuntimeSnapshot ( + [string]$source.runtimeArtifact.stagingPath + ) $source.runtimeArtifact 'Managed runtime staging' +} + +function Invoke-PrepareUpgrade { + param( + [Parameter(Mandatory = $true)] + [string]$RootPath, + + [Parameter(Mandatory = $true)] + [string]$TargetPath, + + [Parameter(Mandatory = $true)] + [string]$InstallScope, + + [AllowEmptyString()] + [string]$HkcuPath, + + [AllowEmptyString()] + [string]$HklmPath, + + [Parameter(Mandatory = $true)] + [int]$ExcludedId, + + [Parameter(Mandatory = $true)] + [string]$GracefulName, + + [Parameter(Mandatory = $true)] + [int]$GracefulTimeout + ) + + $targetRoot = Get-CanonicalRoot $TargetPath 'Upgrade target' $false + $transactionsRoot = Get-TransactionRoot $RootPath $false + $transaction = $null + if ($null -ne $transactionsRoot) { + $transaction = Get-IncompleteTransaction $transactionsRoot + } + + if ($null -ne $transaction) { + Assert-TransactionBaseIdentity ( + $transaction.Manifest + ) $targetRoot $InstallScope + Assert-TransactionInputIdentity ( + $transaction.Manifest + ) $HkcuPath $HklmPath $InstallScope + } + + $sources = @() + $transactionId = $null + if ($null -eq $transaction) { + $transactionId = [Guid]::NewGuid().ToString('N') + $sources = @(Get-ExpectedTransactionSources ( + $HkcuPath + ) $HklmPath $InstallScope $transactionId) + } + + if ($null -eq $transaction) { + $transactionsRoot = Get-TransactionRoot $RootPath $true + $transaction = New-UpgradeTransaction ( + $transactionsRoot + ) $targetRoot $InstallScope $sources $transactionId + } + elseif ([string]$transaction.Manifest.state -ne 'prepared') { + Assert-TransactionBackups $transaction + } + + if ([string]$transaction.Manifest.state -eq 'prepared') { + foreach ($source in @($transaction.Manifest.sources)) { + Invoke-StopProcesses ([string]$source.path) ( + $ExcludedId + ) $GracefulName $GracefulTimeout + } + Move-RuntimeToStaging $transaction.Manifest + foreach ($manifestSource in @($transaction.Manifest.sources)) { + $backup = Get-ContainedPath ( + $transaction.Directory + ) ([string]$manifestSource.backupRelative) + $sourceEntries = @(Get-LegacyEntries ( + [string]$manifestSource.path + ) 'Legacy source') + if ($sourceEntries.Count -ne [int]$manifestSource.entryCount -or + (Get-LegacyTreeDigest $sourceEntries) -ne [string]$manifestSource.digest) { + throw 'Legacy source changed before preservation' + } + Invoke-Preserve ([string]$manifestSource.path) $backup + } + Assert-TransactionBackups $transaction + Assert-RuntimePreserved $transaction.Manifest + Write-TransactionManifestState $transaction 'preserved' + } + else { + Assert-TransactionBackups $transaction + Assert-RuntimePreserved $transaction.Manifest + } +} + +function Get-RestoreEntriesFromTransaction { + param( + [Parameter(Mandatory = $true)] + [object]$Transaction + ) + + $entryMap = @{} + foreach ($source in @($Transaction.Manifest.sources)) { + $backup = Get-ContainedPath ( + $Transaction.Directory + ) ([string]$source.backupRelative) + foreach ($entry in @(Get-LegacyEntries ( + $backup + ) 'Upgrade transaction backup' $true)) { + if ($entryMap.ContainsKey($entry.Relative)) { + Assert-EntriesEqual ( + $entryMap[$entry.Relative] + ) $entry 'Upgrade transaction backups' + } + else { + $entryMap[$entry.Relative] = $entry + } + } + } + return @($entryMap.Values | Sort-Object @{ + Expression = { if ($_.IsDirectory) { 0 } else { 1 } } + }, Relative) +} + +function Restore-TransactionEntries { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$BackupEntries, + + [Parameter(Mandatory = $true)] + [string]$TargetRoot + ) + + $targetEntries = @() + if (Test-Path -LiteralPath $TargetRoot) { + Assert-WhitelistLayout $TargetRoot 'Legacy restore target' + $targetEntries = @( + Get-LegacyEntries $TargetRoot 'Legacy restore target' $true + ) + } + Assert-RestoreCompatible $BackupEntries $targetEntries $TargetRoot + if ($BackupEntries.Count -eq 0) { + return + } + Ensure-SafeDirectory $TargetRoot 'Legacy restore target' + Remove-InstallerTemps $TargetRoot 'Legacy restore target' + $targetMap = Get-EntryMap $targetEntries + foreach ($entry in $BackupEntries) { + if (-not $entry.IsDirectory -or $targetMap.ContainsKey($entry.Relative)) { + continue + } + Ensure-SafeDirectory ( + Get-ContainedPath $TargetRoot $entry.Relative + ) 'Legacy restore target' + } + foreach ($entry in $BackupEntries) { + if ($entry.IsDirectory -or $targetMap.ContainsKey($entry.Relative)) { + continue + } + Copy-FileAtomically ( + $entry + ) (Get-ContainedPath $TargetRoot $entry.Relative) 'Legacy restore target' + } + $finalTargetEntries = @( + Get-LegacyEntries $TargetRoot 'Legacy restore target' $true + ) + Assert-EntriesCovered $BackupEntries $finalTargetEntries 'Legacy restore target' +} + +function Invoke-CommitUpgrade { + param( + [Parameter(Mandatory = $true)] + [string]$RootPath, + + [Parameter(Mandatory = $true)] + [string]$TargetPath, + + [Parameter(Mandatory = $true)] + [string]$InstallScope + ) + + $targetRoot = Get-CanonicalRoot $TargetPath 'Upgrade target' $false + $transactionsRoot = Get-TransactionRoot $RootPath $false + if ($null -eq $transactionsRoot) { + return + } + $transaction = Get-IncompleteTransaction $transactionsRoot + if ($null -eq $transaction) { + [void](Test-CompletedTransactionIdentity ( + $transactionsRoot + ) $targetRoot $InstallScope) + return + } + Assert-TransactionBaseIdentity ( + $transaction.Manifest + ) $targetRoot $InstallScope + if ([string]$transaction.Manifest.state -eq 'preserved') { + Write-TransactionManifestState $transaction 'restoring' + } + if ([string]$transaction.Manifest.state -eq 'restoring') { + Assert-TransactionBackups $transaction + $restoreEntries = @(Get-RestoreEntriesFromTransaction $transaction) + Restore-TransactionEntries $restoreEntries $targetRoot + Restore-RuntimeArtifact ( + $transaction.Manifest + ) $targetRoot 'Managed runtime restore' + Write-TransactionManifestState $transaction 'restored' + } + if ([string]$transaction.Manifest.state -eq 'restored') { + $restoreEntries = @(Get-RestoreEntriesFromTransaction $transaction) + $targetEntries = @() + if (Test-Path -LiteralPath $targetRoot) { + $targetEntries = @( + Get-LegacyEntries $targetRoot 'Legacy restore target' $true + ) + } + Assert-EntriesCovered $restoreEntries $targetEntries 'Legacy restore target' + $runtimeSource = Get-ManifestRuntimeSource $transaction.Manifest + if ($null -ne $runtimeSource) { + Assert-RuntimeSnapshot ( + Get-ContainedPath $targetRoot ( + [string]$runtimeSource.runtimeArtifact.relativePath + ) + ) $runtimeSource.runtimeArtifact 'Managed runtime restore target' + } + Write-TransactionManifestState $transaction 'complete' + return + } + if ([string]$transaction.Manifest.state -ne 'complete') { + throw 'Upgrade transaction cannot be committed from its current state' + } +} + +function Invoke-RollbackUpgrade { + param([string]$RootPath, [string]$TargetPath, [string]$InstallScope) + $targetRoot = Get-CanonicalRoot $TargetPath 'Upgrade target' $false + $transactionsRoot = Get-TransactionRoot $RootPath $false + if ($null -eq $transactionsRoot) { return } + $transaction = Get-IncompleteTransaction $transactionsRoot + if ($null -eq $transaction) { return } + Assert-TransactionBaseIdentity ( + $transaction.Manifest + ) $targetRoot $InstallScope + if (@('prepared', 'preserved') -notcontains [string]$transaction.Manifest.state) { + throw 'Upgrade transaction cannot be rolled back from its current state' + } + $source = Get-ManifestRuntimeSource $transaction.Manifest + if ($null -ne $source) { + Restore-RuntimeArtifact ( + $transaction.Manifest + ) ([string]$source.path) 'Managed runtime rollback' + } + if ([string]$transaction.Manifest.state -ne 'prepared') { + Write-TransactionManifestState $transaction 'prepared' + } +} + +function Assert-PreserveCompatible { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$SourceEntries, + + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$BackupEntries, + + [Parameter(Mandatory = $true)] + [string]$BackupRoot, + + [Parameter(Mandatory = $true)] + [bool]$MarkerExists + ) + + $sourceMap = Get-EntryMap $SourceEntries + $backupMap = Get-EntryMap $BackupEntries + foreach ($sourceEntry in $SourceEntries) { + if ($backupMap.ContainsKey($sourceEntry.Relative)) { + Assert-EntriesEqual ( + $sourceEntry + ) $backupMap[$sourceEntry.Relative] 'Legacy backup' + } + else { + Assert-ParentDirectoryTypes ( + $BackupRoot + ) $sourceEntry.Relative 'Legacy backup' + } + } + + if (-not $MarkerExists) { + foreach ($backupEntry in $BackupEntries) { + if (-not $sourceMap.ContainsKey($backupEntry.Relative)) { + throw "Incomplete legacy backup contains stale data: $($backupEntry.Relative)" + } + } + } +} + +function Invoke-Preserve { + param( + [Parameter(Mandatory = $true)] + [string]$SourcePath, + + [Parameter(Mandatory = $true)] + [string]$BackupPath + ) + + $sourceRoot = Get-CanonicalRoot $SourcePath 'Legacy source' $true + $backupRoot = Get-CanonicalRoot $BackupPath 'Legacy backup' $false + Assert-SeparateRoots $sourceRoot $backupRoot + Assert-WhitelistLayout $sourceRoot 'Legacy source' + if (Test-Path -LiteralPath $backupRoot) { + Assert-WhitelistLayout $backupRoot 'Legacy backup' + } + + $sourceEntries = @(Get-LegacyEntries $sourceRoot 'Legacy source') + $backupEntries = @() + $markerExists = $false + if (Test-Path -LiteralPath $backupRoot) { + $markerExists = Test-PreservedMarker $backupRoot $false + $backupEntries = @( + Get-LegacyEntries $backupRoot 'Legacy backup' $true + ) + } + Assert-PreserveCompatible ( + $sourceEntries + ) $backupEntries $backupRoot $markerExists + + Ensure-SafeDirectory $backupRoot 'Legacy backup' + Remove-InstallerTemps $backupRoot 'Legacy backup' + $backupMap = Get-EntryMap $backupEntries + foreach ($sourceEntry in $sourceEntries) { + if ($backupMap.ContainsKey($sourceEntry.Relative)) { + continue + } + + $destination = Get-ContainedPath $backupRoot $sourceEntry.Relative + if ($sourceEntry.IsDirectory) { + Ensure-SafeDirectory $destination 'Legacy backup' + } + else { + Copy-FileAtomically $sourceEntry $destination 'Legacy backup' + } + } + + $finalSourceEntries = @( + Get-LegacyEntries $sourceRoot 'Legacy source' + ) + $finalBackupEntries = @( + Get-LegacyEntries $backupRoot 'Legacy backup' $true + ) + Assert-EntrySetsEqual ( + $sourceEntries + ) $finalSourceEntries 'Legacy source' + Assert-EntriesCovered ( + $finalSourceEntries + ) $finalBackupEntries 'Legacy backup' + Assert-EntriesCovered ( + $backupEntries + ) $finalBackupEntries 'Legacy backup' + if (-not $markerExists) { + Assert-EntrySetsEqual ( + $finalSourceEntries + ) $finalBackupEntries 'Legacy backup' + } + Write-PreservedMarker $backupRoot +} + +function Assert-RestoreCompatible { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$BackupEntries, + + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$TargetEntries, + + [Parameter(Mandatory = $true)] + [string]$TargetRoot + ) + + $targetMap = Get-EntryMap $TargetEntries + foreach ($backupEntry in $BackupEntries) { + if ($targetMap.ContainsKey($backupEntry.Relative)) { + Assert-EntriesEqual ( + $backupEntry + ) $targetMap[$backupEntry.Relative] 'Legacy restore target' + } + else { + Assert-ParentDirectoryTypes ( + $TargetRoot + ) $backupEntry.Relative 'Legacy restore target' + } + } +} + +function Invoke-Restore { + param( + [Parameter(Mandatory = $true)] + [string]$BackupPath, + + [Parameter(Mandatory = $true)] + [string]$TargetPath + ) + + $backupRoot = Get-CanonicalRoot $BackupPath 'Legacy backup' $true + $targetRoot = Get-CanonicalRoot $TargetPath 'Legacy restore target' $false + Assert-SeparateRoots $backupRoot $targetRoot + Assert-WhitelistLayout $backupRoot 'Legacy backup' + [void](Test-PreservedMarker $backupRoot $true) + if (Test-Path -LiteralPath $targetRoot) { + Assert-WhitelistLayout $targetRoot 'Legacy restore target' + } + + $backupEntries = @( + Get-LegacyEntries $backupRoot 'Legacy backup' $true + ) + $targetEntries = @() + if (Test-Path -LiteralPath $targetRoot) { + $targetEntries = @( + Get-LegacyEntries $targetRoot 'Legacy restore target' $true + ) + } + Assert-RestoreCompatible $backupEntries $targetEntries $targetRoot + + if ($backupEntries.Count -eq 0 -and -not (Test-Path -LiteralPath $targetRoot)) { + return + } + Ensure-SafeDirectory $targetRoot 'Legacy restore target' + Remove-InstallerTemps $targetRoot 'Legacy restore target' + $targetMap = Get-EntryMap $targetEntries + foreach ($backupEntry in $backupEntries) { + if ( + -not $backupEntry.IsDirectory -or + $targetMap.ContainsKey($backupEntry.Relative) + ) { + continue + } + $destination = Get-ContainedPath $targetRoot $backupEntry.Relative + Ensure-SafeDirectory $destination 'Legacy restore target' + } + foreach ($backupEntry in $backupEntries) { + if ( + $backupEntry.IsDirectory -or + $targetMap.ContainsKey($backupEntry.Relative) + ) { + continue + } + $destination = Get-ContainedPath $targetRoot $backupEntry.Relative + Copy-FileAtomically $backupEntry $destination 'Legacy restore target' + } + + $finalBackupEntries = @( + Get-LegacyEntries $backupRoot 'Legacy backup' $true + ) + $finalTargetEntries = @( + Get-LegacyEntries $targetRoot 'Legacy restore target' $true + ) + Assert-EntrySetsEqual ( + $backupEntries + ) $finalBackupEntries 'Legacy backup' + [void](Test-PreservedMarker $backupRoot $true) + Assert-EntriesCovered ( + $finalBackupEntries + ) $finalTargetEntries 'Legacy restore target' +} + +function Get-InstallDirectoryProcesses { + param( + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + + [Parameter(Mandatory = $true)] + [int]$ExcludedId + ) + + $processes = @( + Get-CimInstance -ClassName Win32_Process -ErrorAction Stop | + Where-Object { + $_.ProcessId -ne $ExcludedId -and + -not [string]::IsNullOrWhiteSpace($_.ExecutablePath) + } + ) + $matches = New-Object System.Collections.ArrayList + foreach ($process in $processes) { + try { + $executable = [IO.Path]::GetFullPath( + [string]$process.ExecutablePath + ) + } + catch { + throw "Cannot canonicalize process executable path for PID $($process.ProcessId)" + } + if (Test-IsSameOrDescendant $executable $InstallRoot) { + [void]$matches.Add([pscustomobject]@{ + ProcessId = [int]$process.ProcessId + ExecutablePath = $executable + }) + } + } + return @($matches) +} + +function Get-ProcessById { + param( + [Parameter(Mandatory = $true)] + [int]$ProcessId + ) + + return @( + Get-CimInstance -ClassName Win32_Process -Filter ( + "ProcessId = $ProcessId" + ) -ErrorAction Stop + ) +} + +function Assert-ProcessStillMatches { + param( + [Parameter(Mandatory = $true)] + [object]$Expected, + + [Parameter(Mandatory = $true)] + [string]$InstallRoot + ) + + $current = @(Get-ProcessById $Expected.ProcessId) + if ($current.Count -eq 0) { + return $false + } + if ($current.Count -ne 1) { + throw "Process query returned duplicate PID $($Expected.ProcessId)" + } + if ([string]::IsNullOrWhiteSpace($current[0].ExecutablePath)) { + throw "Process executable path became unavailable for PID $($Expected.ProcessId)" + } + + try { + $currentPath = [IO.Path]::GetFullPath( + [string]$current[0].ExecutablePath + ) + } + catch { + throw "Cannot canonicalize process executable path for PID $($Expected.ProcessId)" + } + if ( + -not (Test-IsSameOrDescendant $currentPath $InstallRoot) -or + -not [string]::Equals( + $currentPath, + $Expected.ExecutablePath, + $script:PathComparison + ) + ) { + throw "Process identity changed before stop for PID $($Expected.ProcessId)" + } + return $true +} + +function Request-GracefulProcessExit { + param( + [Parameter(Mandatory = $true)] + [object]$Process, + + [Parameter(Mandatory = $true)] + [string]$InstallRoot + ) + + $taskkill = Join-Path $env:SystemRoot 'System32\taskkill.exe' + if (-not [IO.File]::Exists($taskkill)) { + throw "taskkill.exe is unavailable: $taskkill" + } + $request = Start-Process -FilePath $taskkill -ArgumentList ( + '/PID ' + [string]$Process.ProcessId + ) -Wait -PassThru -WindowStyle Hidden + if ($request.ExitCode -ne 0) { + $remaining = @(Get-ProcessById $Process.ProcessId) + if ($remaining.Count -gt 0) { + [void](Assert-ProcessStillMatches $Process $InstallRoot) + } + } +} + +function Wait-ForGracefulProcesses { + param( + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + + [Parameter(Mandatory = $true)] + [int]$ExcludedId, + + [Parameter(Mandatory = $true)] + [string]$ExpectedExecutable, + + [Parameter(Mandatory = $true)] + [int]$TimeoutSeconds + ) + + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + do { + $remaining = @( + Get-InstallDirectoryProcesses $InstallRoot $ExcludedId | + Where-Object { + [string]::Equals( + $_.ExecutablePath, + $ExpectedExecutable, + $script:PathComparison + ) + } + ) + if ($remaining.Count -eq 0) { + return + } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) +} + +function Invoke-StopProcesses { + param( + [Parameter(Mandatory = $true)] + [string]$InstallPath, + + [Parameter(Mandatory = $true)] + [int]$ExcludedId, + + [Parameter(Mandatory = $true)] + [string]$GracefulName, + + [Parameter(Mandatory = $true)] + [int]$GracefulTimeout + ) + + if ($ExcludedId -le 0) { + throw 'ExcludedProcessId must be a positive process ID' + } + if ( + [string]::IsNullOrWhiteSpace($GracefulName) -or + $GracefulName -ne [IO.Path]::GetFileName($GracefulName) + ) { + throw 'GracefulExecutableName must be a file name' + } + if ($GracefulTimeout -lt 0 -or $GracefulTimeout -gt 120) { + throw 'GracefulTimeoutSeconds must be between 0 and 120' + } + $installRoot = Get-CanonicalRoot ( + $InstallPath + ) 'Installation directory' $false + + $pathRoot = [IO.Path]::GetPathRoot($installRoot) + $rootSeparators = [char[]]@( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar + ) + if ([string]::Equals( + $installRoot.TrimEnd($rootSeparators), + $pathRoot.TrimEnd($rootSeparators), + $script:PathComparison + )) { + throw 'Installation directory must not be a drive root' + } + if (-not [IO.Directory]::Exists($installRoot)) { + return + } + + $gracefulExecutable = Get-ContainedPath $installRoot $GracefulName + $graceful = @( + Get-InstallDirectoryProcesses $installRoot $ExcludedId | + Where-Object { + [string]::Equals( + $_.ExecutablePath, + $gracefulExecutable, + $script:PathComparison + ) + } + ) + foreach ($process in $graceful) { + if (Assert-ProcessStillMatches $process $installRoot) { + Request-GracefulProcessExit $process $installRoot + } + } + if ($graceful.Count -gt 0 -and $GracefulTimeout -gt 0) { + Wait-ForGracefulProcesses ( + $installRoot + ) $ExcludedId $gracefulExecutable $GracefulTimeout + } + + $running = @( + Get-InstallDirectoryProcesses $installRoot $ExcludedId + ) + foreach ($process in $running) { + if (Assert-ProcessStillMatches $process $installRoot) { + Stop-Process -Id $process.ProcessId -Force -ErrorAction Stop + } + } + + $emptyChecks = 0 + for ($attempt = 0; $attempt -lt 8; $attempt++) { + $remaining = @( + Get-InstallDirectoryProcesses $installRoot $ExcludedId + ) + if ($remaining.Count -eq 0) { + $emptyChecks++ + if ($emptyChecks -ge 2) { + return + } + } + else { + $emptyChecks = 0 + foreach ($process in $remaining) { + if (Assert-ProcessStillMatches $process $installRoot) { + Stop-Process -Id $process.ProcessId -Force -ErrorAction Stop + } + } + } + Start-Sleep -Milliseconds 250 + } + $ids = @($remaining | ForEach-Object { $_.ProcessId }) -join ', ' + throw "Processes are still running inside the installation directory: $ids" +} + +try { + switch ($Action) { + 'preserve' { + if ( + -not $PSBoundParameters.ContainsKey('Source') -or + [string]::IsNullOrWhiteSpace($Source) + ) { + throw 'Source is required for preserve' + } + if ( + -not $PSBoundParameters.ContainsKey('Backup') -or + [string]::IsNullOrWhiteSpace($Backup) + ) { + throw 'Backup is required for preserve' + } + if ($PSBoundParameters.ContainsKey('Target')) { + throw 'Target is not valid for preserve' + } + if ( + $PSBoundParameters.ContainsKey('TransactionRoot') -or + $PSBoundParameters.ContainsKey('Scope') -or + $PSBoundParameters.ContainsKey('HkcuSource') -or + $PSBoundParameters.ContainsKey('HklmSource') -or + $PSBoundParameters.ContainsKey('InstallDirectory') -or + $PSBoundParameters.ContainsKey('ExcludedProcessId') -or + $PSBoundParameters.ContainsKey('GracefulExecutableName') -or + $PSBoundParameters.ContainsKey('GracefulTimeoutSeconds') + ) { + throw 'Process arguments are not valid for preserve' + } + Invoke-Preserve $Source $Backup + } + 'restore' { + if ( + -not $PSBoundParameters.ContainsKey('Target') -or + [string]::IsNullOrWhiteSpace($Target) + ) { + throw 'Target is required for restore' + } + if ( + -not $PSBoundParameters.ContainsKey('Backup') -or + [string]::IsNullOrWhiteSpace($Backup) + ) { + throw 'Backup is required for restore' + } + if ($PSBoundParameters.ContainsKey('Source')) { + throw 'Source is not valid for restore' + } + if ( + $PSBoundParameters.ContainsKey('TransactionRoot') -or + $PSBoundParameters.ContainsKey('Scope') -or + $PSBoundParameters.ContainsKey('HkcuSource') -or + $PSBoundParameters.ContainsKey('HklmSource') -or + $PSBoundParameters.ContainsKey('InstallDirectory') -or + $PSBoundParameters.ContainsKey('ExcludedProcessId') -or + $PSBoundParameters.ContainsKey('GracefulExecutableName') -or + $PSBoundParameters.ContainsKey('GracefulTimeoutSeconds') + ) { + throw 'Process arguments are not valid for restore' + } + Invoke-Restore $Backup $Target + } + 'stop-processes' { + if ( + -not $PSBoundParameters.ContainsKey('InstallDirectory') -or + [string]::IsNullOrWhiteSpace($InstallDirectory) + ) { + throw 'InstallDirectory is required for stop-processes' + } + if (-not $PSBoundParameters.ContainsKey('ExcludedProcessId')) { + throw 'ExcludedProcessId is required for stop-processes' + } + if ( + -not $PSBoundParameters.ContainsKey('GracefulExecutableName') -or + [string]::IsNullOrWhiteSpace($GracefulExecutableName) + ) { + throw 'GracefulExecutableName is required for stop-processes' + } + if (-not $PSBoundParameters.ContainsKey('GracefulTimeoutSeconds')) { + throw 'GracefulTimeoutSeconds is required for stop-processes' + } + foreach ($argumentName in @( + 'Source', + 'Backup', + 'Target', + 'TransactionRoot', + 'Scope', + 'HkcuSource', + 'HklmSource' + )) { + if ($PSBoundParameters.ContainsKey($argumentName)) { + throw "$argumentName is not valid for stop-processes" + } + } + Invoke-StopProcesses ( + $InstallDirectory + ) $ExcludedProcessId $GracefulExecutableName $GracefulTimeoutSeconds + } + 'prepare-upgrade' { + foreach ($argumentName in @('TransactionRoot', 'Target', 'Scope')) { + if ( + -not $PSBoundParameters.ContainsKey($argumentName) -or + [string]::IsNullOrWhiteSpace( + [string](Get-Variable -Name $argumentName -ValueOnly) + ) + ) { + throw "$argumentName is required for prepare-upgrade" + } + } + foreach ($argumentName in @( + 'ExcludedProcessId', + 'GracefulExecutableName', + 'GracefulTimeoutSeconds' + )) { + if (-not $PSBoundParameters.ContainsKey($argumentName)) { + throw "$argumentName is required for prepare-upgrade" + } + } + foreach ($argumentName in @('Source', 'Backup', 'InstallDirectory')) { + if ($PSBoundParameters.ContainsKey($argumentName)) { + throw "$argumentName is not valid for prepare-upgrade" + } + } + Invoke-PrepareUpgrade ( + $TransactionRoot + ) $Target $Scope $HkcuSource $HklmSource ( + $ExcludedProcessId + ) $GracefulExecutableName $GracefulTimeoutSeconds + } + 'commit-upgrade' { + foreach ($argumentName in @('TransactionRoot', 'Target', 'Scope')) { + if ( + -not $PSBoundParameters.ContainsKey($argumentName) -or + [string]::IsNullOrWhiteSpace( + [string](Get-Variable -Name $argumentName -ValueOnly) + ) + ) { + throw "$argumentName is required for commit-upgrade" + } + } + foreach ($argumentName in @( + 'Source', + 'Backup', + 'InstallDirectory', + 'ExcludedProcessId', + 'GracefulExecutableName', + 'GracefulTimeoutSeconds', + 'HkcuSource', + 'HklmSource' + )) { + if ($PSBoundParameters.ContainsKey($argumentName)) { + throw "$argumentName is not valid for commit-upgrade" + } + } + Invoke-CommitUpgrade ( + $TransactionRoot + ) $Target $Scope + } + 'rollback-upgrade' { + foreach ($argumentName in @('TransactionRoot', 'Target', 'Scope')) { + if ( + -not $PSBoundParameters.ContainsKey($argumentName) -or + [string]::IsNullOrWhiteSpace( + [string](Get-Variable -Name $argumentName -ValueOnly) + ) + ) { + throw "$argumentName is required for rollback-upgrade" + } + } + foreach ($argumentName in @( + 'Source', + 'Backup', + 'InstallDirectory', + 'ExcludedProcessId', + 'GracefulExecutableName', + 'GracefulTimeoutSeconds', + 'HkcuSource', + 'HklmSource' + )) { + if ($PSBoundParameters.ContainsKey($argumentName)) { + throw "$argumentName is not valid for rollback-upgrade" + } + } + Invoke-RollbackUpgrade ( + $TransactionRoot + ) $Target $Scope + } + } + exit 0 +} +catch { + [Console]::Error.WriteLine($_.Exception.Message) + exit 1 +} diff --git a/build/installer.nsh b/build/installer.nsh index 8e27b9c..9db97b4 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -1,12 +1,190 @@ ; AutoWSGR-GUI NSIS 自定义安装脚本 -; 在安装过程中静默安装 VC++ Redistributable +; 安装 VC++ Redistributable,并让新版 GUI 首次启动时更新指定后端。 + +!macro ExtractInstallerHelper + InitPluginsDir + File "/oname=$PLUGINSDIR\autowsgr-installer-helper.ps1" "${PROJECT_DIR}\build\installer-helper.ps1" +!macroend + +!ifndef BUILD_UNINSTALLER +!macro customInit + StrCpy $InstallerPowerShellPath "$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" + !insertmacro ExtractInstallerHelper + ${If} ${Silent} + ; 静默的 per-machine outer 随后只负责拉起 UAC inner;事务由 inner 唯一接管。 + ${If} $hasPerMachineInstallation == "1" + ${AndIfNot} ${UAC_IsAdmin} + DetailPrint "等待提升后的安装进程接管升级事务" + ${Else} + !insertmacro InstallerUpgradeTransaction RetryPrepareLegacyUpgradeInit + ${EndIf} + ${EndIf} +!macroend + +; 交互安装在目录选择和 UAC 接管均完成后、进入 install Section 前启动事务。 +; electron-builder 到 instfiles pre 才补 APP_FILENAME,因此这里先调用其幂等规范化函数。 +!macro customPageAfterChangeDir + Page custom AutoWsgrPrepareUpgradePage + Function AutoWsgrPrepareUpgradePage + !ifdef allowToChangeInstallationDirectory + Call instFilesPre + !endif + !insertmacro InstallerUpgradeTransaction RetryPrepareLegacyUpgradePage + Abort + FunctionEnd +!macroend +!endif + +; electron-builder 的内置 FIND_PROCESS 使用字符串前缀匹配,并且强制关闭只按 +; AutoWSGR-GUI.exe 名称处理。这里统一按可执行文件的规范路径关闭 $INSTDIR 内 +; 的残留后端、ADB 和 GUI 进程,避免影响同名的系统或其他工具进程。 +!macro StopDirectoryProcesses INSTALL_DIRECTORY RETRY_LABEL + Push $0 + System::Call 'Kernel32::GetCurrentProcessId()i.r0' + DetailPrint "正在结束旧安装目录内残留进程..." + nsExec::ExecToLog '"$PowerShellPath" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\autowsgr-installer-helper.ps1" -Action stop-processes -InstallDirectory "${INSTALL_DIRECTORY}" -ExcludedProcessId "$0" -GracefulExecutableName "${APP_EXECUTABLE_FILENAME}" -GracefulTimeoutSeconds 20' + Pop $R2 + Pop $0 + ${If} $R2 != 0 + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION \ + "无法安全关闭旧安装目录内的全部进程。请用管理员权限关闭它们,然后单击重试。" \ + IDRETRY ${RETRY_LABEL} + Quit + ${EndIf} +!macroend + +; 1.4.x 把用户设置和计划写在安装目录。覆盖升级会先运行旧卸载器, +; 因此事务必须绑定注册表旧源、最终 $INSTDIR、注册表 hive 和安装范围。 +!ifndef BUILD_UNINSTALLER +Var LegacyUpgradeRoot +Var LegacyUpgradeHkcuSource +Var LegacyUpgradeHklmSource +Var LegacyUpgradeScope +Var LegacyUpgradeTarget +Var InstallerPowerShellPath +!endif + +!macro LoadLegacyUpgradeInputs + StrCpy $LegacyUpgradeRoot "$LOCALAPPDATA\AutoWSGR-GUI\legacy-upgrade" + StrCpy $LegacyUpgradeTarget "$INSTDIR" + ReadRegStr $LegacyUpgradeHkcuSource HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation + ReadRegStr $LegacyUpgradeHklmSource HKLM "${INSTALL_REGISTRY_KEY}" InstallLocation + ${If} $installMode == "all" + StrCpy $LegacyUpgradeScope "all-users" + ${Else} + StrCpy $LegacyUpgradeScope "current-user" + StrCpy $LegacyUpgradeHklmSource "" + ${EndIf} +!macroend + +!macro InstallerUpgradeTransaction RETRY_LABEL + !insertmacro LoadLegacyUpgradeInputs + ${RETRY_LABEL}: + DetailPrint "正在保留旧安装数据并建立可重试升级事务..." + System::Call 'Kernel32::GetCurrentProcessId()i.r0' + ${If} $LegacyUpgradeScope == "all-users" + nsExec::ExecToLog '"$InstallerPowerShellPath" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\autowsgr-installer-helper.ps1" -Action prepare-upgrade -TransactionRoot "$LegacyUpgradeRoot" -Target "$LegacyUpgradeTarget" -Scope "$LegacyUpgradeScope" -HkcuSource "$LegacyUpgradeHkcuSource" -HklmSource "$LegacyUpgradeHklmSource" -ExcludedProcessId "$0" -GracefulExecutableName "${APP_EXECUTABLE_FILENAME}" -GracefulTimeoutSeconds 20' + ${Else} + nsExec::ExecToLog '"$InstallerPowerShellPath" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\autowsgr-installer-helper.ps1" -Action prepare-upgrade -TransactionRoot "$LegacyUpgradeRoot" -Target "$LegacyUpgradeTarget" -Scope "$LegacyUpgradeScope" -HkcuSource "$LegacyUpgradeHkcuSource" -ExcludedProcessId "$0" -GracefulExecutableName "${APP_EXECUTABLE_FILENAME}" -GracefulTimeoutSeconds 20' + ${EndIf} + Pop $R2 + ${If} $R2 != 0 + MessageBox MB_RETRYCANCEL|MB_ICONSTOP \ + "无法安全保留旧安装数据,安装已在运行旧卸载器前停止。请勿删除 $LegacyUpgradeRoot;解决冲突后单击重试。" /SD IDCANCEL \ + IDRETRY ${RETRY_LABEL} + SetErrorLevel 1 + Quit + ${EndIf} +!macroend + +!macro CommitInstallerUpgradeTransaction + !insertmacro LoadLegacyUpgradeInputs + DetailPrint "正在恢复旧用户数据迁移源并完成升级事务..." + nsExec::ExecToLog '"$InstallerPowerShellPath" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\autowsgr-installer-helper.ps1" -Action commit-upgrade -TransactionRoot "$LegacyUpgradeRoot" -Target "$LegacyUpgradeTarget" -Scope "$LegacyUpgradeScope"' + Pop $R2 + ${If} $R2 != 0 + MessageBox MB_OK|MB_ICONSTOP \ + "新版文件已安装,但旧用户数据恢复失败。事务备份仍保留在 $LegacyUpgradeRoot;请勿删除并重新运行安装器。" /SD IDOK + SetErrorLevel 1 + Quit + ${EndIf} +!macroend + +!macro RollbackInstallerUpgradeTransaction + !insertmacro LoadLegacyUpgradeInputs + DetailPrint "旧版本卸载失败,正在恢复受控后端目录..." + nsExec::ExecToLog '"$InstallerPowerShellPath" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\autowsgr-installer-helper.ps1" -Action rollback-upgrade -TransactionRoot "$LegacyUpgradeRoot" -Target "$LegacyUpgradeTarget" -Scope "$LegacyUpgradeScope"' + Pop $R2 + ${If} $R2 != 0 + MessageBox MB_OK|MB_ICONSTOP \ + "旧版本卸载失败,且受控后端目录自动恢复失败。事务和用户数据备份仍保留在 $LegacyUpgradeRoot;请勿删除。" /SD IDOK + SetErrorLevel 2 + Quit + ${EndIf} +!macroend + +; electron-builder 在每次旧卸载器返回后调用该 hook。任何非零结果都先由同一 +; helper 依据 manifest 恢复 runtime,再沿用 builder 的失败关闭语义。 +!macro HandleInstallerUninstallResult LABEL_SUFFIX + IfErrors 0 UninstallResultAvailable_${LABEL_SUFFIX} + DetailPrint "旧卸载器无法启动,正在回滚升级事务" + !insertmacro RollbackInstallerUpgradeTransaction + DetailPrint "Uninstall was not successful. Not able to launch uninstaller." + SetErrorLevel 2 + Quit + UninstallResultAvailable_${LABEL_SUFFIX}: + ${If} $R0 != 0 + !insertmacro RollbackInstallerUpgradeTransaction + MessageBox MB_OK|MB_ICONEXCLAMATION "$(uninstallFailed): $R0" + DetailPrint "Uninstall was not successful. Uninstaller error code: $R0." + SetErrorLevel 2 + Quit + ${EndIf} +!macroend + +!macro customUnInstallCheck + !insertmacro HandleInstallerUninstallResult ShellContext +!macroend + +!macro customUnInstallCheckCurrentUser + !insertmacro HandleInstallerUninstallResult CurrentUser +!macroend + +; 仅按安装目录内可执行文件路径请求 GUI 退出,并关闭残留后端进程。 +!macro customCheckAppRunning + ; Installer 的 UAC inner 进程通过 customInit 提取;卸载器没有 customInit, + ; 因而必须在进程检查前为本次卸载器进程提取同一 helper。 + !ifdef BUILD_UNINSTALLER + !insertmacro ExtractInstallerHelper + !endif + RetryCloseApp: + !insertmacro StopDirectoryProcesses "$INSTDIR" RetryCloseApp +!macroend + +; 覆盖升级会调用旧卸载器,此时保留依赖;只有主动卸载才完整清理。 +!macro customUnInstall + ${ifNot} ${isUpdated} + RMDir /r "$INSTDIR\python\site-packages" + ${endIf} +!macroend !macro customInstall - ; 检查 vcruntime140.dll 是否已存在 + ${If} ${isUpdated} + ${If} ${FileExists} "$newDesktopLink" + !insertmacro addDesktopLink "false" + ${EndIf} + ${If} ${FileExists} "$newStartMenuLink" + !insertmacro addStartMenuLink "false" + ${EndIf} + ${EndIf} + !insertmacro CommitInstallerUpgradeTransaction IfFileExists "$SYSDIR\vcruntime140.dll" VCRedistInstalled 0 DetailPrint "正在安装 Microsoft Visual C++ Redistributable..." nsExec::ExecToLog '"$INSTDIR\redist\vc_redist.x64.exe" /install /quiet /norestart' Pop $0 DetailPrint "VC++ Redistributable 安装完成 (exit code: $0)" VCRedistInstalled: + + Delete "$INSTDIR\.env_ready" + DetailPrint "已安排首次启动时更新本包指定的 AutoWSGR 后端" !macroend diff --git a/debug_deps.bat b/debug_deps.bat index 025b12a..5e00d31 100644 --- a/debug_deps.bat +++ b/debug_deps.bat @@ -6,7 +6,9 @@ set "APP_DIR=%~dp0" set "SITE_PKG=%APP_DIR%python\site-packages" set "LOCAL_PY=%APP_DIR%python\python.exe" set "PTH_FILE=%APP_DIR%python\python312._pth" -set "LOG=%APP_DIR%debug_report.txt" +set "REPORT_DIR=%APPDATA%\AutoWSGR-GUI" +if not exist "%REPORT_DIR%" mkdir "%REPORT_DIR%" >nul 2>nul +set "LOG=%REPORT_DIR%\debug_report.txt" :: 清空旧日志 > "%LOG%" echo ============================================ @@ -144,11 +146,10 @@ if !errorlevel! equ 0 ( >> "%LOG%" echo ============================================ :done -endlocal - echo 诊断完成!报告已保存到: echo %LOG% echo. echo 按任意键打开报告文件... pause >nul start "" "%LOG%" +endlocal diff --git a/docs/TODO.md b/docs/TODO.md index e06101c..078fe15 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -77,7 +77,7 @@ 说明:决战 preset 已支持这些字段,并可被调度器执行。 - [x] 默认快修打完一轮 - 说明:决战任务模板已具备基础执行能力,默认执行一轮;更细致的快修策略仍需后端进一步支撑。 + 说明:决战任务模板已具备基础执行能力;自动决战固定执行一轮,更细致的快修策略仍需后端进一步支撑。 #### 日常调度 @@ -85,7 +85,7 @@ 说明:已支持自动演习、舰队编号配置、刷新窗口触发与离线补发。 - [x] 自动战役调度(空闲时,调度打哪个) - 说明:已支持自动战役、战役类型选择与次数配置。 + 说明:已支持自动战役与战役类型选择,每日固定完成 8 次结算。 #### 模拟器设置 @@ -203,8 +203,8 @@ - [x] 自动演习:支持 0:00 / 12:00 / 18:00 刷新窗口检测,按时段自动触发 说明:已支持错过时段后的重启补发。 -- [x] 自动战役:每天自动触发,可配置次数 - 说明:已接入配置页和调度器。 +- [x] 自动战役:每天自动触发,固定完成 8 次结算 + 说明:已接入配置页和调度器;历史次数字段仅保留持久化兼容。 - [x] YAML scheduled_time 字段:方案支持定时启动(HH:MM) 说明:当前已有基础定时启动支持。 @@ -215,8 +215,8 @@ - [x] 离线演习补发:localStorage 缓存关闭时间,重启后检测错过窗口并自动补发 说明:已完成。 -- [x] 配置页 UI:演习舰队选择(1-4)、战役次数输入 - 说明:配置项已提供可视化编辑入口。 +- [x] 配置页 UI:演习舰队选择(1-4)、战役类型选择 + 说明:配置项已提供可视化编辑入口,战役结算次数固定为每日 8 次。 - [x] 日常自动化 UI 分块:远征 / 战役 / 演习三段分隔 说明:界面已具备较清晰的分区布局。 @@ -356,7 +356,7 @@ ### 战利品调度 - [x] 提供战利品任务模板,用户可选择常见地图并配置编队 - 说明:已通过内置模板 (builtin_farm_loot) 实现,包含 9-2、7-4、8-5、2-1 四个地图方案,支持 loot_count_ge 停止条件。 + 说明:已通过内置模板 (builtin_farm_loot) 实现,包含 9-4、9-2、7-4、8-5、8-2、2-1 六个地图方案,支持 loot_count_ge 停止条件。 - [ ] 自动安排合理的战斗方案和修复方案,以获取特定战利品 说明:当前仅能执行既定方案,不会根据目标掉落自动规划路线、编队和修理。 @@ -366,10 +366,14 @@ 说明:当前已支持基于获取数量的停止条件,在单任务维度可自动停止。 - [x] 对用户展示:是否启用每日自动战利品 - 说明:已在配置页自动化设置中添加「每日自动刷战利品」开关,可选择地图方案(9-2/7-4/8-5/2-1)和停止数量。CronScheduler 每日 0 点后自动触发,使用内置 builtin_farm_loot 模板执行。 + 说明:已在配置页自动化设置中添加「每日自动刷战利品」开关,可选择地图方案(9-4/9-2/7-4/8-5/8-2/2-1)和停止数量。CronScheduler 每日 0 点后自动触发,并按稳定系统计划文件名执行。 ### 决战调度 +- [x] 每日自动生成一轮决战任务 + 说明:配置页可选择决战模板。模板舰队为空时回退到决战计划页当前配置;任务实际结束后当天不再重复触发。 + 说明:当前不探测剩余票数,旧版票数保留字段只做无损归档,不参与执行轮数。 + - [ ] 决战实现中断功能 说明:后端依赖。需要后端支持决战任务安全中断、保存中间状态与恢复。 @@ -419,7 +423,7 @@ 说明:当前已支持自动演习和舰队配置。 - [x] 对用户展示:启用自动战役(选择类型) - 说明:当前已支持自动战役开关、类型与次数。 + 说明:当前已支持自动战役开关和类型选择,每日固定完成 8 次结算。 - [ ] 对用户展示:启用自动日常(选择做哪些任务) 说明:前端尚无自动日常配置区。 diff --git a/docs/architecture/00-overview.md b/docs/architecture/00-overview.md index ea9ff62..f705d10 100644 --- a/docs/architecture/00-overview.md +++ b/docs/architecture/00-overview.md @@ -1,247 +1,199 @@ -# AutoWSGR-GUI 总架构文档 +# AutoWSGR-GUI 总架构 -## 项目简介 +## 项目定位 -AutoWSGR-GUI 是一个基于 **Electron** 的桌面应用,为 [AutoWSGR](https://github.com/huan-yp/Auto-WSGR)(战舰少女R 自动化框架)提供图形化操作界面。 +AutoWSGR-GUI 是 AutoWSGR 的 Windows Electron 桌面前端。它负责配置、方案、 +编队、任务队列、环境安装和运行状态展示;实际游戏自动化由 Python AutoWSGR +后端执行。 -- **前端**:TypeScript,经典 MVC 架构,esbuild 打包 -- **后端**:Python FastAPI + uvicorn,由 Electron 主进程作为子进程管理 -- **通信**:Electron IPC(主进程 ↔ 渲染进程)+ HTTP/WebSocket(渲染进程 ↔ Python 后端) +当前技术栈: ---- +- Electron 33、Node.js 22、TypeScript 5.6 +- 原生 HTML、DOM API、SCSS +- esbuild Renderer Bundle +- Python 3.12/3.13、FastAPI/ASGI、Uvicorn +- Electron IPC、HTTP REST、WebSocket +- YAML、JSON、electron-builder、NSIS -## 整体分层架构 +## 运行时全景 ```mermaid -graph TB - subgraph Renderer["渲染进程 (src/)"] - View["View 层MainView(Facade) · PlanPreviewView(Facade)ConfigView · TaskGroupViewTemplateLibraryView · SetupWizardView"] - Controller["Controller 层AppController · StartupControllerPlanController · TaskGroupControllerTemplateController · SchedulerBinder── ControllerHost 接口解耦 ──"] - Model["Model 层Scheduler · CronScheduler · TaskQueueApiClient · ConfigModel · PlanModelTemplateModel · TaskGroupModelRepairManager · StopConditionChecker"] +flowchart TB + subgraph Renderer["Electron Renderer"] + View["ViewDOM、事件、动画"] + Controller["Controller用例编排"] + Model["Model领域状态与规则"] + Adapter["AdapterIPC/HTTP/WS/存储"] + Shared["Shared跨层纯逻辑"] + View -->|"用户意图"| Controller + Controller -->|"ViewObject"| View + Controller --> Model + Model --> Adapter + Controller --> Adapter + Controller --> Shared + Model --> Shared end - subgraph Main["Electron 主进程 (electron/)"] - IPC["IPC Handlersmain.ts · preload.ts"] - PyEnv["Python 环境管理pythonEnv/ (7 个模块)"] - Backend["后端进程管理backend.ts"] - Emulator["模拟器检测emulatorDetect.ts"] - end - - subgraph PythonBackend["Python 后端"] - ASGI["uvicorn + FastAPIautowsgr.server.main"] - end - - View -->|"用户操作回调"| Controller - Controller -->|"ViewObject 单向传递"| View - Controller -->|"调用 / 订阅"| Model - Model -->|"HTTP / WebSocket"| ASGI - Controller -->|"contextBridge (preload.ts)"| IPC - IPC --> PyEnv - IPC --> Backend - IPC --> Emulator - Backend -->|"spawn 子进程"| ASGI + Adapter -->|"window.electronBridge"| Preload["electron/preload.ts"] + Preload -->|"ipcRenderer"| Main["Electron Mainelectron/main.ts"] + Main --> IPC["electron/ipc"] + IPC --> Service["electron/services"] + Service --> FS["文件系统 / 更新 / ADB / Python"] + Service --> Backend["AutoWSGR Python 后端"] + Adapter -->|"HTTP + WebSocket"| Backend ``` -### 分层职责 +通信有两条独立链路: + +- Renderer 到 Electron Main:文件、对话框、环境、计划仓储、更新等系统能力。 +- Renderer 到 Python:任务执行、游戏上下文、实时日志和任务状态。 + +## 分层职责 + +| 层 | 目录 | 责任 | +|---|---|---| +| Renderer 入口 | `src/controller/app/AppController.ts` | 创建 Renderer 对象并连接生命周期 | +| Controller | `src/controller/` | 编排 Model、View、Adapter,不拥有 DOM | +| View | `src/view/` | DOM、浏览器事件、局部视觉状态和资源释放 | +| Model | `src/model/` | 配置、方案、舰队、调度、模板和任务组领域状态 | +| Adapter | `src/adapter/` | 裁剪 ElectronBridge,封装 HTTP、WS、YAML、JSON、Storage | +| Shared | `src/shared/` | Renderer 和 Main 可复用的无状态规则 | +| Types | `src/types/` | API、IPC、Model、Scheduler、ViewObject 契约 | +| Preload | `electron/preload.ts` | 唯一 `window.electronBridge` 暴露点 | +| Main IPC | `electron/ipc/` | 校验输入、保持通道契约、调用 Service | +| Main Service | `electron/services/` | 文件、配置、计划、迁移、更新和进程业务 | +| Python 环境 | `electron/pythonEnv/` | 解释器、依赖、后端来源和 CUDA 环境 | +| Main 组合根 | `electron/main.ts` | 装配依赖、注册 IPC、编排主进程生命周期 | + +标准 Renderer 数据流: + +```text +Repository / Model -> Controller -> ViewObject -> View +View -> 用户意图 -> Controller +``` -| 层 | 位置 | 职责 | -|----|------|------| -| **View** | `src/view/` | 纯 UI 渲染,接收 ViewObject 显示;不含业务逻辑。大型视图采用 Facade 模式内部拆分 | -| **Controller** | `src/controller/` | 从 Model 提取数据 → 拼装 ViewObject → 调用 View 渲染;处理用户事件 → 调用 Model / IPC。通过 ControllerHost 接口解耦 | -| **Model** | `src/model/` | 业务实体 + 领域服务:调度、配置、方案解析、后端通信 | -| **Types** | `src/types/` | 跨层共享的 TypeScript 类型定义,按领域拆分为 5 个文件 | -| **主进程** | `electron/` | 窗口管理、IPC handler、Python 环境发现/安装、后端子进程生命周期、模拟器检测 | -| **Python 后端** | 外部 | 游戏自动化核心逻辑:模拟器连接、战斗执行、OCR 识别 | +## 关键入口 ---- +| 场景 | 入口 | +|---|---| +| Electron 启动 | `electron/main.ts` | +| 安全桥接 | `electron/preload.ts` | +| Renderer Bundle 入口 | `src/controller/app/AppController.ts` | +| Renderer 应用装配 | `src/controller/app/AppController.ts` 中的 `AppController` | +| Python 后端进程 | `electron/services/BackendService.ts` | +| 后端正式运行契约 | `electron/services/BackendRuntimeContract.ts` | +| IPC 类型总契约 | `src/types/ipc.ts` | +| HTTP/WS 客户端 | `src/model/ApiClient.ts` | +| 页面静态源 | `src/view/html/index.html` | +| 样式入口 | `src/view/styles/main.scss` | -## 目录结构 +## 主要目录 -``` +```text AutoWSGR-GUI/ -├── electron/ # Electron 主进程 -│ ├── main.ts # 入口:窗口创建、IPC handler 注册 -│ ├── preload.ts # contextBridge 安全 API 暴露 -│ ├── backend.ts # Python 后端启动/停止 -│ ├── emulatorDetect.ts # 模拟器注册表检测 -│ └── pythonEnv/ # Python 环境管理子模块 -│ ├── context.ts # 共享上下文与缓存状态 -│ ├── finder.ts # Python 可执行文件发现 -│ ├── envCheck.ts # 环境验证主流程 -│ ├── installer.ts # Python 安装与依赖管理 -│ ├── updater.ts # autowsgr 自动更新 -│ ├── utils.ts # 工具函数与共享接口 -│ └── index.ts # 聚合导出 -├── src/ # 渲染进程 (MVC) -│ ├── controller/ # 控制器(6 个子目录) -│ │ ├── app/ # 主控制器:AppController · ConfigController · SchedulerBinder · rendering · theme · constants -│ │ ├── startup/ # 启动流程:StartupController · connection · envAndUpdates -│ │ ├── plan/ # 方案控制器:PlanController · importExport · nodeEditor · presetFlow · rendering -│ │ ├── taskGroup/ # 任务组:TaskGroupController · addItems · contextMenu · importExport · metaLoader · queueLoader -│ │ ├── template/ # 模板:TemplateController · crud · selectors · useTemplate · wizard -│ │ └── shared/ # 共享基接口:ControllerHost · DialogHelper -│ ├── model/ # 数据模型 + 业务服务 -│ │ ├── scheduler/ # 调度子模块:Scheduler · CronScheduler · TaskQueue · ExpeditionTimer · StopConditionChecker · RepairManager -│ │ ├── ApiClient.ts # HTTP/WebSocket 后端通信 -│ │ ├── ConfigModel.ts # 配置数据模型 -│ │ ├── PlanModel.ts # 方案解析/序列化 -│ │ ├── TemplateModel.ts # 模板管理 -│ │ ├── TaskGroupModel.ts # 任务组持久化 -│ │ └── MapDataLoader.ts # 地图数据加载与缓存 -│ ├── view/ # UI 视图(8 个子目录) -│ │ ├── main/ # 主页面 Facade:MainView · LogView · TaskQueueView · StatusBar -│ │ ├── plan/ # 方案预览 Facade:PlanPreviewView · MapView · NodeEditorView · FleetPresetView · FleetEditDialog -│ │ ├── config/ # 配置页:ConfigView -│ │ ├── taskGroup/ # 任务组:TaskGroupView -│ │ ├── template/ # 模板:TemplateLibraryView · TemplateWizardView · SelectorDialog -│ │ ├── setup/ # 初始化向导:SetupWizardView -│ │ ├── shared/ # 共享组件:ShipAutocomplete -│ │ └── styles/ # SCSS 样式(base/ · components/ · pages/) -│ ├── types/ # TypeScript 类型定义 -│ │ ├── api.ts # API / WebSocket 通信类型 -│ │ ├── electronBridge.ts # IPC 桥接口 -│ │ ├── model.ts # 业务实体类型(PlanData 等) -│ │ ├── scheduler.ts # 调度器公共类型 -│ │ └── view.ts # ViewObject 接口 -│ ├── data/ # 静态数据(舰船数据库) -│ └── utils/ # 工具类(Logger) -├── resource/ # 只读资源 -│ ├── builtin_plans/ # 内置战斗方案 (.yaml) -│ ├── builtin_templates.json # 内置模板 -│ ├── maps/ # 地图 JSON(节点坐标、连线) -│ └── images/ # 图片资源 -├── templates/ # 用户自定义模板 -├── plans/ # 用户战斗方案目录 -├── scripts/ # 构建脚本 -├── build/ # electron-builder 配置 -├── usersettings.yaml # 用户配置文件 -├── gui_settings.json # GUI 级配置(端口等) -├── task_groups.json # 任务组持久化 -└── package.json # 项目配置 +├─ electron/ +│ ├─ main.ts # Main 组合根和生命周期 +│ ├─ preload.ts # contextBridge +│ ├─ ipc/ # IPC 边界 +│ ├─ services/ # 主进程用例与持久化 +│ └─ pythonEnv/ # Python/AutoWSGR 环境 +├─ src/ +│ ├─ adapter/ +│ ├─ controller/ +│ ├─ model/ +│ ├─ shared/ +│ ├─ types/ +│ ├─ view/ +│ │ ├─ html/ # HTML 开发源 +│ │ ├─ styles/ # SCSS 开发源及生成 CSS +│ │ └─ index.html # 生成的运行入口 +│ └─ data/ +├─ resource/ # 打包只读资源 +├─ scripts/ +│ ├─ tests/ # 构建、领域、服务和契约测试 +│ ├─ build-view-html.js +│ └─ bundle.js +├─ build/ # electron-builder、NSIS、后端清单 +└─ .github/workflows/ # PR 与发布流水线 ``` ---- - -## 启动流程 - -```mermaid -sequenceDiagram - participant Startup as StartupController - participant App as AppController - participant Bridge as ElectronBridge (IPC) - participant Main as 主进程 (main.ts) - participant Py as Python 后端 - - Startup->>Bridge: getAppRoot() / getPlansDir() - Startup->>App: loadConfigAndSync() - App->>Bridge: readFile('usersettings.yaml') - App->>App: ConfigModel.loadFromYaml() - - Startup->>App: detectAndApplyEmulator() - Bridge->>Main: 注册表查询模拟器 - Main-->>App: 模拟器信息 - - Note over Startup: 首次运行显示引导向导 - - Startup->>App: loadModelsAndRender() - App->>App: 加载模板 + 任务组 + renderMain() - - Startup->>Bridge: checkEnvironment() - Bridge->>Main: findPython() + 依赖检查 - Main-->>Startup: {pythonCmd, allReady} - - alt 环境未就绪 - Startup->>Bridge: installPortablePython() / installDeps() - end - - Startup->>Bridge: startBackend() - Bridge->>Main: spawn Python 子进程 - Main->>Py: uvicorn.run() - Py-->>Main: HTTP 服务就绪 - - Startup->>App: waitForBackendAndConnect() - App->>Py: POST /api/system/start - Py-->>App: 连接成功 - - Startup->>App: cronScheduler.start() - Note over App: 应用就绪,等待用户操作 +## 状态与持久化 + +| 数据 | 权威位置 | +|---|---| +| AutoWSGR 业务配置 | `userData/usersettings.yaml` | +| GUI、窗口、Python、CUDA、自动化 | `userData/gui_settings.json` | +| 用户作战计划 | `userData/user_battle_plans/` | +| 用户编队计划 | `userData/user_team_plans/` | +| 用户日常计划 | `userData/user_daily_plans/` | +| 用户模板 | `userData/templates/templates.json` | +| 任务组 | `userData/task_groups.json` | +| 舰船资料库工作副本 | `userData/ship-library/` | +| 迁移账本 | `userData/.migration-state.json` | +| Cron、额度、轻量 UI 偏好 | Renderer `localStorage` | +| 系统方案、地图、内置模板 | `resource/`,只读 | +| 执行前展开方案 | 系统 temp 下的进程专属目录 | + +安装目录中的同名配置只作为旧版本迁移来源,不能成为新的运行时写入目标。 + +## 主进程启动顺序 + +```text +获取单实例锁 + -> app.whenReady() + -> 处理待安装 GUI 更新 + -> 询问并迁移旧安装数据 + -> 初始化 Python 环境上下文 + -> 初始化后端上下文 + -> 初始化用户方案目录和舰船资料库 + -> 执行预设库存 v6 与旧计划 v7 迁移 + -> 写迁移报告、准备冲突复核 + -> 注册更新 IPC + -> 创建主窗口 ``` ---- - -## 关键架构模式 - -### ControllerHost 依赖注入 - -子控制器(PlanController / TaskGroupController 等)不直接依赖 AppController,而是通过 `ControllerHost` 接口访问共享能力: - -```typescript -interface ControllerHost { - readonly scheduler: Scheduler; - plansDir: string; - renderMain(): void; - switchPage(page: string): void; -} +IPC 的大部分注册在 `whenReady` 前完成,但更新 IPC 依赖启动处理结果,在迁移后 +注册。次实例只唤醒主窗口,不执行迁移、环境检查或 pip。 + +Renderer 启动由 `StartupController` 编排: + +```text +同步路径和配置 + -> 检测模拟器、必要时显示引导 + -> 加载模板/任务组/方案状态并渲染 + -> 检查 Python 与依赖 + -> 检查 GUI 更新 + -> 启动后端 + -> 等待 /api/health + -> POST /api/system/start + -> 启动 Cron、心跳和任务调度 ``` -各子控制器还定义自己的扩展 Host 接口(如 `StartupHost`、`TaskGroupHost`),由 AppController 实现。详见 [Controller 层](01-controller-layer.md)。 +## 退出与释放 -### ViewObject 单向数据传递 +Renderer `beforeunload` 会释放 `SchedulerBinder`、普通舰队和决战页持有的长生命 +周期资源,再保存任务组和刷新日志。 -Controller 从 Model 提取数据,拼装为 **ViewObject**(定义在 `src/types/view.ts`),单向传递给 View 渲染。View 不直接访问 Model,保证视图层的纯净。 +Main `before-quit` 顺序固定: +```text +记录并保存窗口位置 + -> POST /api/system/stop + -> 终止并等待后端进程树 + -> 停止 GUI 内置 ADB server + -> 确认资源释放后 app.quit() ``` -Model → Controller.extractViewObject() → ViewObject → View.render(vo) -``` - -### View Facade 模式 - -大型视图组件采用 Facade 模式:`MainView` 持有 `LogView` / `TaskQueueView` / `StatusBar`,`PlanPreviewView` 持有 `MapView` / `NodeEditorView` / `FleetPresetView`。Controller 只与 Facade 交互,无需感知内部拆分。 - -### 优先级任务队列 - -`Scheduler` 实现三级优先级队列,保证远征收取不会被用户任务阻塞: - -| 优先级 | 值 | 说明 | -|--------|---|------| -| `EXPEDITION` | 0 | 最高优先级:远征收取 | -| `USER_TASK` | 10 | 用户手动添加的战斗任务 | -| `DAILY` | 20 | 定时触发的日常任务 | - -### 本地 Python 隔离 - -所有 Python 包安装到 `{appRoot}/python/site-packages/`,不污染全局 Python 环境。通过 `.env_ready` 标记文件缓存环境状态,实现 < 100ms 的后续启动检查。 - -### 双层通信 - -- **IPC 层**:渲染进程 ↔ 主进程,用于文件 I/O、环境管理、系统对话框 -- **HTTP/WS 层**:渲染进程 ↔ Python 后端,用于游戏操作和实时日志 - ---- - -## Types 层组织 - -类型定义从各模块提取为独立的 `src/types/` 层,按领域划分: -| 文件 | 内容 | 被谁引用 | -|------|------|----------| -| `api.ts` | API 响应、TaskRequest、WebSocket 消息类型 | ApiClient、Controller | -| `electronBridge.ts` | IPC 桥接口 `ElectronBridge` | Controller、StartupController | -| `model.ts` | 业务实体:PlanData、NodeArgs、FleetPreset、StopCondition | Model、Controller | -| `scheduler.ts` | 调度器:TaskPriority、SchedulerTask、SchedulerCallbacks | Scheduler、SchedulerBinder | -| `view.ts` | ViewObject:MainViewObject、PlanPreviewViewObject 等 | Controller → View | +无法确认后端退出时应用保持运行并报错,不能假装关闭成功。 ---- +## 不可绕过的边界 -## 子模块文档导航 +1. `electron/main.ts` 是组合根,不放 YAML、路径或业务规则。 +2. View 不访问 Model、ApiClient、Adapter、ElectronBridge 或持久化。 +3. Controller 不访问 DOM、浏览器存储和 ElectronBridge。 +4. IPC 只做边界工作,文件与业务策略进入 Service/Repository/Codec。 +5. 用户可变数据只写 `userData`,系统资源只读。 +6. `src/view/index.html`、`styles.css` 和 `dist/**` 都是生成物。 +7. 修改公共契约时同步检查 `src/types/`、preload、IPC、Adapter 和契约测试。 -| 文档 | 功能域 | -|------|--------| -| [Controller 层](01-controller-layer.md) | ControllerHost/DI 模式 · 6 个子目录结构 · StartupController 启动编排 | -| [任务调度系统](02-task-scheduling.md) | Scheduler · TaskQueue · CronScheduler · ExpeditionTimer · StopCondition · RepairManager | -| [配置系统](03-configuration.md) | ConfigModel · ConfigView · usersettings.yaml · gui_settings.json | -| [出击计划系统](04-battle-plan.md) | PlanModel · PlanController · PlanPreviewView(Facade) · MapDataLoader | -| [模板与任务组](05-template-and-taskgroup.md) | TemplateModel · TaskGroupModel · 创建向导 · 队列加载 | -| [后端通信](06-backend-communication.md) | IPC Bridge · ApiClient · REST API · WebSocket 事件 | -| [环境管理](07-environment-management.md) | Python 发现/安装 (pythonEnv/) · 模拟器检测 · 后端生命周期 | -| [开发环境搭建](08-dev-setup.md) | 依赖安装 · 开发/构建/打包命令 · SCSS 架构 · 调试技巧 | +下一步按 [AGENT 进入指南](12-agent-entry-guide.md) 定位修改范围。 diff --git a/docs/architecture/01-controller-layer.md b/docs/architecture/01-controller-layer.md index f608ee9..2f8fce5 100644 --- a/docs/architecture/01-controller-layer.md +++ b/docs/architecture/01-controller-layer.md @@ -1,238 +1,188 @@ # Controller 层 -> 涉及文件:`src/controller/` 全部 6 个子目录(33 个文件) +> 主要目录:`src/controller/` -## 概述 +## 定位 -Controller 层采用 **Host 接口依赖注入** 模式组织。`AppController` 作为唯一的根控制器,实现各子控制器定义的 Host 接口,子控制器通过 Host 访问调度器、页面切换等共享能力,避免子控制器之间的直接依赖。 +Controller 是 Renderer 的用例编排层。它读取 Model,通过 Adapter 调用外部能力, +把数据转换成 ViewObject 交给 View,并接收 View 上报的用户意图。 -每个子控制器内部进一步按职责拆分为多个模块文件,主控制器类保持精简("瘦身版"),核心逻辑委托给同目录下的模块。 +Controller 不负责: ---- +- DOM 查询、浏览器事件、动画和元素类型。 +- 直接读取 `window.electronBridge`。 +- 直接读写 `localStorage`。 +- 在 Controller 内重新实现 Model 的业务规则。 +- 把有状态 Model 或 Repository 暴露给 View。 -## ControllerHost — 基础 Host 接口 +这些边界由 `scripts/tests/test-renderer-architecture.js` 静态检查。 -```typescript -// src/controller/shared/ControllerHost.ts -interface ControllerHost { - readonly scheduler: Scheduler; - plansDir: string; - renderMain(): void; - switchPage(page: string): void; -} -``` - -这是所有子控制器的最小依赖接口。各子控制器根据自身需求定义扩展的 Host 接口(如 `StartupHost`、`PlanHost`、`TaskGroupHost`),AppController 统一实现。 - ---- - -## 子目录结构 - -### controller/shared/ — 共享基础设施 - -| 文件 | 职责 | -|------|------| -| `ControllerHost.ts` | 基础 Host 接口定义 | -| `DialogHelper.ts` | 通用对话框工具(`showPrompt` / `showConfirm` / `showAlert`) | - ---- +## 组合根 -### controller/app/ — 主控制器 +`src/controller/app/AppController.ts` 是 Renderer 唯一组合根,负责创建: -顶层协调器,创建并持有所有子控制器实例,实现各 Host 接口。 +- `ApiClient`、配置和领域 Model。 +- `MainView`、`ConfigView`、方案与任务组 View。 +- `PlanController`、`FleetPlannerController`、`DecisivePlanController`。 +- `TaskGroupController`、`TemplateController`、`StartupController`。 +- `Scheduler`、`CronScheduler`、`SchedulerBinder` 及每日额度对象。 -| 文件 | 职责 | -|------|------| -| `AppController.ts` | 根控制器类:初始化子控制器、实现 Host 接口、协调全局状态 | -| `ConfigController.ts` | 配置保存逻辑:从表单收集 → 更新 ConfigModel → 同步 CronScheduler/Scheduler → 写文件 | -| `SchedulerBinder.ts` | 调度器回调绑定:将 Scheduler/CronScheduler 的回调连接到 UI 更新,管理远征/演习/战役等待中任务的 ID 跟踪 | -| `rendering.ts` | 渲染分发:构建 `MainViewObject` → 调用 `MainView.render()` | -| `theme.ts` | 主题管理:亮色/暗色/自动切换、强调色应用 | -| `constants.ts` | 常量定义 | -| `index.ts` | 聚合导出 | - -**SchedulerBinder Host 接口**: +业务子 Controller 不能反向获取整个 `AppController`。所需能力通过 +`src/controller/contracts.ts` 或功能目录内的最小 Host 接口注入。 ```typescript -interface SchedulerBinderHost { +interface PlanHost { readonly scheduler: Scheduler; - readonly cronScheduler: CronScheduler; - readonly api: ApiClient; - readonly templateModel: TemplateModel; + plansDir: string; renderMain(): void; - updateOpsAvailability(connected: boolean): void; + switchPage(page: string): void; } ``` ---- - -### controller/startup/ — 启动流程 - -从 AppController 独立出来的启动编排控制器。 - -| 文件 | 职责 | -|------|------| -| `StartupController.ts` | 启动流程主编排:路径获取 → 配置加载 → 模拟器检测 → 首次引导 → 环境检查 → 后端连接 | -| `envAndUpdates.ts` | 环境检查与更新:调用 IPC `checkEnvironment()` / `installDeps()` / `checkForUpdates()` | -| `connection.ts` | 后端连接:`waitForBackendAndConnect()` 轮询等待后端 HTTP 就绪,然后发送系统启动请求 | -| `index.ts` | 聚合导出 | +Host 表达能力,不表达具体实现。不要为了省参数创建万能 Host 或让子 Controller +依赖另一个具体 Controller。 -**StartupHost 接口**(由 AppController 实现): +## 目录职责 -```typescript -interface StartupHost { - readonly scheduler: Scheduler; - readonly cronScheduler: CronScheduler; - readonly configModel: ConfigModel; - appRoot: string; - plansDir: string; - configDir: string; - pendingGuiVersion: string | null; - - syncPaths(appRoot: string, plansDir: string, configDir: string): void; - initLogger(bridge: ElectronBridge): void; - loadConfigAndSync(): Promise; - detectAndApplyEmulator(): Promise; - showSetupWizard(): Promise; - loadModelsAndRender(bridge: ElectronBridge): Promise; - bindBackendLog(bridge: ElectronBridge): void; - renderMain(): void; - startHeartbeat(): void; -} +```text +src/controller/ +├─ app/ # Renderer 顶层流程、设置、导航、调度绑定 +├─ migration/ # 迁移冲突复核流程 +├─ plan/ # 作战方案、编队、决战和方案管理 +├─ startup/ # 环境检查、后端连接和启动编排 +├─ taskGroup/ # 任务组、日常任务选择和队列加载 +├─ template/ # 模板兼容链路与向导 +└─ contracts.ts # 跨流程最小 Host 契约 ``` -**启动时序**: - -```mermaid -flowchart TD - A["StartupController.run()"] --> B["获取目录路径"] - B --> C["loadConfigAndSync()"] - C --> D["detectAndApplyEmulator()"] - D --> E{"首次运行?"} - E -->|是| F["showSetupWizard()"] - E -->|否| G["loadModelsAndRender()"] - F --> G - G --> H["bindBackendLog()"] - H --> I["checkAndPrepareEnv()"] - I --> J{"环境就绪?"} - J -->|否| K["安装 Python / 依赖"] - K --> I - J -->|是| L["checkForUpdates()"] - L --> M["startBackend()"] - M --> N["waitForBackendAndConnect()"] - N --> O["cronScheduler.start()"] - O --> P["startHeartbeat()"] +### `controller/app` + +| 文件 | 责任 | +|---|---| +| `AppController.ts` | Renderer 对象装配、全局 Host 实现和卸载清理 | +| `StartupController` 的 Host 方法 | 同步路径、配置、模型和后端状态 | +| `ConfigController.ts` | 配置候选值、事务提交和调度同步 | +| `SettingsController.ts` | Python/CUDA/ADB、资料库、更新和主题操作 | +| `SchedulerBinder.ts` | 连接 Scheduler、Cron、日志和主页状态 | +| `SchedulerRuntimeTracker.ts` | 从日志派生进度、掉落和运行状态 | +| `ScheduledTaskLoader.ts` | 把自动化配置转换为 SchedulerTask | +| `AutomaticDecisiveTask.ts` | 用户决战计划与系统预设两种来源 | +| `CurrentFleetController.ts` | 当前任务舰队的 ViewObject | +| `NavigationController.ts` | 页面和标签导航能力 | +| `OperationsController.ts` | 远征、奖励等快捷操作 | +| `rendering.ts` | 主页面 ViewObject 构造 | + +`AppController.onBeforeUnload` 是 Renderer 生命周期终点,当前必须调用: + +```text +SchedulerBinder.dispose() +FleetPlannerController.dispose() +DecisivePlanController.dispose() +TaskGroupModel.save() +Logger.flush() ``` ---- - -### controller/plan/ — 方案控制器 +新增监听器、Observer 或长生命周期资源时,必须沿所有权链补齐 `dispose()`。 -管理方案的导入/导出/编辑和预览渲染。 +### `controller/startup` -| 文件 | 职责 | -|------|------| -| `PlanController.ts` | 方案子控制器类:持有当前方案状态,协调下属模块 | -| `importExport.ts` | 方案文件的导入/导出/新建流程 | -| `presetFlow.ts` | 任务预设的导入/查看/关闭/执行流程 | -| `nodeEditor.ts` | 节点编辑器:从 UI 收集节点阵型/夜战/索敌规则并写回 PlanData | -| `rendering.ts` | 构建 `PlanPreviewViewObject`,协调地图数据和方案数据的合并 | -| `index.ts` | 聚合导出 | +`StartupController.ts` 只编排启动流程,具体步骤拆到: -**PlanHost 接口**: +- `envAndUpdates.ts`:环境准备和 GUI 更新检查。 +- `connection.ts`:等待后端健康、调用系统启动和 WebSocket 连接。 -```typescript -interface PlanHost { - readonly scheduler: Scheduler; - plansDir: string; - renderMain(): void; - switchPage(page: string): void; -} +```mermaid +flowchart LR + A["读取路径/配置"] --> B["检测模拟器/引导"] + B --> C["加载模型并渲染"] + C --> D["环境检查与安装"] + D --> E["检查更新"] + E --> F["启动后端"] + F --> G["健康检查与系统启动"] + G --> H["Cron/心跳"] ``` ---- - -### controller/taskGroup/ — 任务组控制器 - -管理任务组的 CRUD、拖拽排序、队列加载。 - -| 文件 | 职责 | -|------|------| -| `TaskGroupController.ts` | 任务组子控制器类:绑定视图事件,协调下属模块 | -| `addItems.ts` | 向任务组添加项目:从当前方案/文件/预设添加 | -| `queueLoader.ts` | 加载任务组到调度队列:逐项构建 TaskRequest → `Scheduler.addTask()` | -| `metaLoader.ts` | 加载任务项的元数据(方案标题、模板名称)用于 UI 显示 | -| `contextMenu.ts` | 右键上下文菜单:编辑/删除/复制任务项 | -| `importExport.ts` | 任务组的导入/导出 | -| `index.ts` | 聚合导出 | - -**TaskGroupHost 接口**: - -```typescript -interface TaskGroupHost { - readonly scheduler: Scheduler; - plansDir: string; - renderMain(): void; - switchPage(page: string): void; - importTaskPreset(preset: TaskPreset, filePath: string): void; - getCurrentPlan(): PlanModel | null; - setCurrentPlan(plan: PlanModel, mapData: MapData | null): void; - renderPlanPreview(): void; - closePresetDetail(): void; - executePreset(): void; - getCurrentPresetInfo(): { preset: TaskPreset; filePath: string } | null; -} +启动流程只通过 `StartupGateway` 使用主进程能力。不要在该 Controller 中导入 +preload 或 Node API。 + +### `controller/plan` + +| 文件 | 状态所有权或用例 | +|---|---| +| `PlanController.ts` | 当前作战方案和地图状态 | +| `BattlePlanLoaderController.ts` | 受管方案选择浮窗状态 | +| `FleetPlannerController.ts` | 普通编队唯一 `FleetDraft` 和文件 identity | +| `DecisivePlanController.ts` | 决战唯一 `DecisiveFleetDraft` | +| `PlanFleetPresetController.ts` | 当前方案引用的舰队预设清单 | +| `PlanManagementController.ts` | 方案管理目录与操作 | +| `selectedNodes.ts` | 新计划节点、后端节点规范化、执行前校验 | +| `nodeEditor.ts` | 节点表单到 PlanModel 的写入 | +| `rendering.ts` | PlanModel 与地图到 ViewObject | +| `presetFlow.ts` | 独立任务预设详情和执行 | + +普通编队和决战可以共享视觉组件,但不能共享草稿状态。文件名、来源、覆盖保存和 +DTO 转换属于 Controller/Model,不属于 View。 + +### `controller/taskGroup` + +| 文件 | 责任 | +|---|---| +| `TaskGroupController.ts` | 任务组选择、CRUD 和 ViewObject | +| `TaskListLoaderController.ts` | 任务列表文件选择与批量载入 | +| `DailyTaskLoaderController.ts` | 日常计划选择、参数和提交 | +| `queueLoader.ts` | 四类条目解析成 SchedulerTask | +| `managedPlanReader.ts` | 统一读取受管作战/日常方案 | +| `addItems.ts` | 添加方案、预设、日常和模板条目 | +| `metaLoader.ts` | 批量读取展示元数据 | +| `contextMenu.ts` | 编辑、复制、删除和打开来源 | + +`queueLoader.ts` 是任务组到 Scheduler 的唯一集中转换点。新增条目类型时,应同时 +修改 Model 迁移、ViewObject、添加入口、读取逻辑和队列构建。 + +### `controller/template` + +`TemplateController.ts` 与 `crud.ts`、`selectors.ts`、`useTemplate.ts`、 +`wizard.ts` 维护旧用户模板和 `kind: "template"` 任务组兼容。当前没有独立模板 +库页面入口,不代表该链路可以删除。 + +## ViewObject 边界 + +View 接收 `src/types/view.ts` 中的展示数据,或功能目录定义的只读 ViewObject。 + +```text +Model snapshot + -> Controller 映射 + -> readonly ViewObject + -> View.render() + -> 用户事件回调 + -> Controller 应用意图 ``` ---- - -### controller/template/ — 模板控制器 - -管理模板库的 CRUD、创建向导、使用模板。 +不要让 View 为了展示方便直接读取 Model。若多个 Controller/View 需要同一纯 +计算,优先放到 `src/shared/` 或无状态映射模块。 -| 文件 | 职责 | -|------|------| -| `TemplateController.ts` | 模板子控制器类:绑定库视图/向导视图事件 | -| `wizard.ts` | 4 步创建向导:选类型 → 配参数 → 设默认值 → 命名确认 | -| `useTemplate.ts` | "使用模板"流程:展示选项弹窗 → 添加到任务组 / 加入队列 / 直接执行 | -| `selectors.ts` | 选择弹窗:方案选择、战役选择、舰队选择、决战章节选择 | -| `crud.ts` | 模板的编辑/删除/重命名/批量导入 | -| `index.ts` | 聚合导出 | +## Adapter 边界 ---- +`src/adapter/IpcAdapter.ts` 用 `Pick` 按用例裁剪能力,例如: -## 依赖关系 +- `StartupGateway` +- `ConfigurationGateway` +- `SettingsGateway` +- `ScheduledTaskRepository` +- `FleetPlannerRepository` +- `DecisivePlanRepository` -```mermaid -graph TD - AppCtrl["controller/app/AppController"] - Startup["controller/startup/StartupController"] - Plan["controller/plan/PlanController"] - TG["controller/taskGroup/TaskGroupController"] - Tpl["controller/template/TemplateController"] - Shared["controller/shared/ControllerHost"] - - AppCtrl -->|"实现"| Shared - AppCtrl -->|"创建 & 持有"| Plan - AppCtrl -->|"创建 & 持有"| TG - AppCtrl -->|"创建 & 持有"| Tpl - AppCtrl -->|"创建 & 持有"| Startup - - Plan -->|"通过 PlanHost"| AppCtrl - TG -->|"通过 TaskGroupHost"| AppCtrl - Startup -->|"通过 StartupHost"| AppCtrl - - Plan -.->|"无直接依赖"| TG - Plan -.->|"无直接依赖"| Tpl -``` +Controller 应依赖这些窄契约。新增 IPC 后,不要把完整 ElectronBridge 直接传入 +所有控制器。 -**关键设计**:Plan/TaskGroup/Template 之间没有直接依赖,需要跨子控制器协作时通过 Host 接口回调到 AppController,再由 AppController 分发。 +## 修改检查 ---- +修改 Controller 至少执行: -## 与其他系统的关系 +```powershell +npm run test:architecture-boundaries +npm run test:build +``` -- **Model 层**:Controller 持有 Model 实例引用,通过 Model 的公共方法读写数据 -- **View 层**:Controller 构建 ViewObject 传递给 View 渲染,View 通过回调将用户操作传回 Controller -- **IPC 层**:StartupController 和 ConfigController 通过 `window.electronBridge` 调用主进程功能 -- **调度系统**:SchedulerBinder 封装 Scheduler/CronScheduler 的回调绑定;各子控制器通过 Host 的 `scheduler` 属性添加任务 +再按业务运行 Scheduler、配置、舰队、迁移或 IPC 专项测试。若测试要求 +Controller 获得 DOM 类型,通常说明责任放错层,应先重新确认边界。 diff --git a/docs/architecture/02-task-scheduling.md b/docs/architecture/02-task-scheduling.md index 0c9216c..9562c2e 100644 --- a/docs/architecture/02-task-scheduling.md +++ b/docs/architecture/02-task-scheduling.md @@ -1,223 +1,206 @@ # 任务调度系统 -> 涉及文件:`src/model/scheduler/` 子目录(Scheduler.ts · TaskQueue.ts · CronScheduler.ts · ExpeditionTimer.ts · StopConditionChecker.ts · RepairManager.ts)· `src/types/scheduler.ts` +> 主要目录:`src/model/scheduler/`、`src/controller/app/SchedulerBinder.ts` + +## 组件与所有权 + +| 组件 | 责任 | +|---|---| +| `Scheduler` | 消费任务、调用后端、停止、重试、后续轮次和回调 | +| `TaskQueue` | 就绪队列、修理延迟队列、优先级插入和舰队切换 | +| `SchedulerTaskPolicy` | 纯任务构建、后续轮次复制和插入策略 | +| `SchedulerRepairPolicy` | 修理结果到调度动作的纯策略 | +| `CronScheduler` | 每分钟检查自动任务触发条件 | +| `ExpeditionTimer` | 远征间隔和秒级倒计时 | +| `StopConditionChecker` | 启动前、运行中和轮次后的停止条件 | +| `RepairManager` | 泡澡状态、阈值和轮换编队 | +| `CampaignDailyQuota` | 自动战役当日正常结算次数 | +| `NormalFightDailyQuota` | 自动常规出击按计划/舰队的每日有效次数 | +| `SchedulerBinder` | 将 Scheduler/Cron、日志、额度和 UI 生命周期连接起来 | +| `SchedulerRuntimeTracker` | 保存由后端日志派生的当前运行展示状态 | + +Scheduler 是任务生命周期的权威所有者;Cron 只决定“何时应触发”,不直接执行 +后端请求。 + +## 优先级 -## 概述 +```typescript +export enum TaskPriority { + EXPEDITION = 0, + USER_TASK = 10, + DAILY = 20, +} +``` -任务调度系统是 AutoWSGR-GUI 的核心运行引擎,负责将用户的战斗计划、日常自动化任务按优先级排队,逐个发送到 Python 后端执行。 +数值越小优先级越高。队列在同优先级内由 `allowPolling` 决定: -调度模块位于 `src/model/scheduler/`,通过 `index.ts` 聚合导出(外部统一从 `'../model/scheduler'` 导入)。类型定义位于 `src/types/scheduler.ts`。 +- `false` 或未设置:后续轮次插回同优先级前部,连续执行。 +- `true`:插到同优先级尾部,与同级任务轮询。 +- `forceRetry: true`:失败重试优先回到同级前部。 -系统由六个组件构成: +## 任务身份 -| 组件 | 职责 | -|------|------| -| `Scheduler` | 核心调度器,管理任务消费/重试/后触发,持有 TaskQueue || -| `TaskQueue` | 优先级任务队列数据结构,从 Scheduler 提取,封装入队/出队/查找操作 | -| `CronScheduler` | 基于系统时钟的定时触发器,在演习/战役/出击刷新时间自动生成任务 | -| `ExpeditionTimer` | 远征收取定时器,按固定间隔(默认 15 分钟)触发远征检查 | -| `StopConditionChecker` | 多阶段停止条件检查器,通过 OCR/日志/API 判断是否提前终止 | -| `RepairManager` | 泡澡修理管理器,检查舰船血量、送入泡澡、编队预设轮换 | +后端每次只执行一轮,多轮逻辑由 GUI 拆分。 ---- +```typescript +interface SchedulerTask { + id: string; // 当前物理轮次 + logicalId: string; // 整个逻辑任务,后续轮次保持稳定 + remainingTimes: number; + totalTimes: number; + unlimited?: boolean; + maxRetries: number; // 默认 2 + retryCount: number; + forceRetry?: boolean; + allowPolling?: boolean; +} +``` -## 核心组件 +必须区分三个事件: -### Scheduler — 优先级任务队列 +| 事件 | 含义 | +|---|---| +| `onTaskCompleted(id)` | 一轮后端任务结束 | +| `onLogicalTaskCompleted(logicalId)` | 已无后续轮次,整个逻辑任务结束 | +| `onLogicalTaskCanceled(logicalId, reason)` | 用户删除、清空或系统停止 | -`Scheduler` 采用**带优先级的生产者-消费者模型**,同一时间只有一个任务在后端执行。 +Cron pending、等待条目和 UI 逻辑任务状态使用 `logicalId`。不能用单轮 `id` +提前清理整个任务。 -#### 优先级体系 +## 消费流程 -```typescript -// src/types/scheduler.ts -enum TaskPriority { - EXPEDITION = 0, // 远征检查(最高) - USER_TASK = 10, // 用户手动发起的战斗 - DAILY = 20, // 日常自动任务(演习/战役) -} +```mermaid +flowchart TD + A["consumeNext"] --> B["取最高优先级任务"] + B --> C{"需要修理检查?"} + C -->|是| D["RepairManager"] + D --> E{"可继续?"} + E -->|轮换| F["替换舰队预设"] + E -->|等待| G["TaskQueue 延迟 30 秒"] + C -->|否| H + F --> H{"停止条件预检?"} + H -->|已满足| I["逻辑完成"] + H -->|未满足| J["POST /api/task/start"] + J --> K["等待 WebSocket 完成"] + K --> L{"成功?"} + L -->|否| M{"retryCount < maxRetries?"} + M -->|是| N["5 秒后重试"] + M -->|否| O["逻辑失败结束"] + L -->|是| P["终点/战果/停止条件结算"] + P --> Q{"还有有效轮次?"} + Q -->|是| R["生成新 id,保留 logicalId"] + Q -->|否| I ``` -新任务按优先级值**升序插入**队列,确保远征检查不会被长时间的用户任务阻塞。 +gap、retry 和修理等待都必须保持可见、可取消,并仍属于原 `logicalId`。 +`Scheduler.isCompletelyIdle` 只有在运行、就绪、gap/retry 和修理延迟全部为空时 +才为真。 -#### 任务结构 +## 有效轮次计数 -```typescript -interface SchedulerTask { - id: string; // 唯一标识 - name: string; // 显示名称 - type: SchedulerTaskType; // normal_fight | campaign | exercise | decisive | expedition - priority: TaskPriority; - request: TaskRequest; // 发送给后端的 API 请求体 - remainingTimes: number; // 剩余执行次数 - totalTimes: number; // 总次数(用于显示进度) - stopCondition?: StopCondition; // 可选的提前终止条件 - bathRepairConfig?: BathRepairConfig; // 可选的泡澡修理配置 - fleetPresets?: FleetPreset[]; // 可轮换的编队预设列表 - maxRetries: number; // 最大重试次数(默认 2) - retryCount: number; // 当前已重试次数 -} -``` +普通出击可带: -#### 生产者 +- `endpointNodes`:应到达的终点节点。 +- `endpointResult`:终点战斗最低战果。 -三类生产者向队列添加任务: -1. **用户手动**:通过 UI 导入方案或从任务组加载(`USER_TASK` 优先级) -2. **定时触发**:`CronScheduler` 在刷新时间点生成演习/战役任务(`DAILY` 优先级) -3. **后触发**:任务完成后,若 `remainingTimes > 1` 则自动追加下一轮(保持原优先级) +成功响应不等于有效轮次。若没有到达终点,或终点战果不满足要求: -#### 消费流程 +- `remainingTimes` 不减少。 +- 生成后续轮次继续执行。 +- 日志明确说明本轮不计数。 -```mermaid -flowchart TD - A[队列非空 & 状态=idle] --> B[取出队首任务] - B --> C{需要泡澡修理?} - C -->|是| D[RepairManager.checkFleetHealth] - D --> E{有舰船需修理?} - E -->|是| F[尝试编队预设轮换] - F --> G{有可用预设?} - G -->|是| H[切换预设, 继续执行] - G -->|否| I[任务延迟, 30s 后重试] - E -->|否| J{有停止条件?} - C -->|否| J - H --> J - J -->|是| K[preflightCheck: OCR 预飞检查] - K --> L{已满足?} - L -->|是| M[跳过任务, 标记完成] - L -->|否| N[发送 API taskStart] - J -->|否| N - N --> O[状态=running, 等待后端完成] - O --> P{成功?} - P -->|是| Q{remainingTimes > 1?} - Q -->|是| R[后触发: 重新入队] - Q -->|否| S[任务完成, 消费下一个] - P -->|否| T{retryCount < maxRetries?} - T -->|是| U[retryCount++, 5s 后重试] - T -->|否| V[任务失败, 消费下一个] -``` +失败轮次在重试耗尽后按既有失败结算结束,避免异常状态无限循环。 ---- +终点判定优先使用任务显式 `endpointNodes`,否则根据计划数据推导。修改该逻辑 +必须覆盖多节点、无战斗终点、事件列表和旧后端结果格式。 -### CronScheduler — 定时触发 +## 停止条件 -`CronScheduler` 每分钟检查一次系统时间,在特定时间点自动向 `Scheduler` 添加日常任务。 +`StopConditionChecker` 支持战利品数量和舰船数量条件,分三处执行: -#### 触发规则 +| 阶段 | 数据来源 | 目的 | +|---|---|---| +| 启动前预检 | `/api/game/acquisition` | 已满足时不发起新轮次 | +| 运行中 | 后端 `[UI]` 日志 | 尽早请求停止当前任务 | +| 轮次完成后 | acquisition/context/结果 | 决定是否生成后续轮次 | -| 任务类型 | 触发时间 | 去重机制 | -|----------|----------|----------| -| 演习 | 0:00 / 12:00 / 18:00 后 | `localStorage` 记录**实际完成**时间戳 | -| 战役 | 每日 0:00 后 | `localStorage` 记录完成日期 (YYYY-MM-DD) | -| 常规出击 | 每日 0:00 后 | 同上 | -| 刷战利品 | 每日 0:00 后 | 同上 | -| 定时方案 | YAML 中 `scheduled_time: "HH:MM"` | 当日 `firedToday` 标志 | +Controller 只协调检查结果;OCR/后端异常不能被伪装成“已满足”或业务回退。 -**关键设计**:记录的是任务**实际完成**的时间戳而非"是否已触发"。这样即使 App 因 ADB 断开等原因重启,只要任务未真正完成,下次启动后仍会补发。 +## 重试与停止 -#### 事件回调 +- 默认最大重试 2 次。 +- 每次失败等待 5 秒再入队。 +- `forceRetry` 控制是否优先重试当前任务。 +- `allowPolling` 控制同优先级任务是连续还是轮询。 +- 删除任务和清空队列会同步清理就绪、等待和运行中的逻辑任务。 +- `system_stopped` 释放 Cron pending,使下次启动可以重新触发。 +- 用户删除或清空表示主动放弃,Cron 按对应业务规则处理。 -`CronScheduler` 通过回调通知 `AppController`,由 Controller 调用 `Scheduler.addTask()` 入队: +## CronScheduler -```typescript -interface CronCallbacks { - onExerciseDue?: (fleetId: number) => void; - onCampaignDue?: (campaignName: string, times: number) => void; - onNormalFightDue?: () => void; - onLootDue?: (planIndex: number, stopCount: number) => void; -} -``` +Cron 每分钟 tick 一次,负责: ---- +| 自动任务 | 触发和持久化 | +|---|---| +| 演习 | 0:00、12:00、18:00 时段;记录已处理时段 | +| 战役 | 每日触发;固定目标为 8 次正常结算 | +| 常规出击 | 调度器完全空闲时触发配置列表 | +| 决战 | 每日触发用户计划或系统预设 | +| 战利品 | 每日触发稳定计划 ID | +| 定时方案 | 按方案 `scheduled_time` | -### ExpeditionTimer — 远征定时器 +Cron 记录实际完成或明确处理,不是在“刚入队”时就标记完成。加载失败或系统停止 +应清除 pending,让后续 tick 可以重试。 -独立的间隔定时器,默认每 15 分钟触发一次远征收取检查。 +### 自动战役 -- 间隔可配置(1~120 分钟,通过配置页设定) -- 每秒发出 `onTick` 回调用于 UI 倒计时显示 -- 到期时发出 `onTrigger`,`Scheduler` 据此插入 `EXPEDITION` 优先级任务 +`src/shared/campaign.ts` 定义: -```mermaid -sequenceDiagram - participant Timer as ExpeditionTimer - participant Sched as Scheduler - participant API as ApiClient - participant Backend as Python 后端 - - Timer->>Timer: 每秒 onTick(remainingSeconds) - Note over Timer: 倒计时归零 - Timer->>Sched: onTrigger() - Sched->>Sched: addTask(expedition, EXPEDITION) - Note over Sched: 优先级=0, 插入队首 - Sched->>API: POST /api/expedition/check - API->>Backend: 收取远征 - Backend-->>Sched: 完成 +```typescript +export const DAILY_CAMPAIGN_TIMES = 8; ``` ---- +`battleTimes` 仅为旧持久化结构兼容,`ConfigModel`、 +`GuiConfigurationService` 和 `CronScheduler` 都强制归一化为 8。C/D 等可正常 +结算的结果都计入当日完成次数,文案和额度语义是“正常结算”,不是只计某一种 +战果。 -### StopConditionChecker — 停止条件检查 +### 自动常规出击 -支持两种停止条件:`loot_count_ge`(战利品数量 ≥ N)和 `ship_count_ge`(舰船数量 ≥ N)。 +配置中的每个任务有独立每日上限。任务 key 由受管计划来源/文件和舰队覆盖组成: -检查分三个阶段: +- `src/shared/normalFightQuota.ts`:纯限制、key 和去重规则。 +- `NormalFightDailyQuota`:浏览器存储状态和日期重置。 -| 阶段 | 时机 | 数据来源 | 说明 | -|------|------|----------|------| -| **预飞 (preflight)** | 任务发送前 | `GET /api/game/acquisition` (OCR) | 已满足则跳过任务 | -| **运行时 (running)** | 任务执行中 | 后端日志 `[UI] 战利品数量: N/M` | 实时解析日志触发停止 | -| **任务后 (post)** | 单轮完成后 | `GET /api/game/context` | 决定是否继续后触发 | +同一计划和舰队的重复配置先去重。`canStartNormalFight` 必须先读取任务与额度, +同时确认 `Scheduler.isCompletelyIdle`。 ---- +## 远征与修理 -### RepairManager — 泡澡修理 +`ExpeditionTimer` 默认每 15 分钟触发一次,配置范围 1~120 分钟。它每秒提供倒 +计时,触发后生成 `EXPEDITION` 优先级任务。 -在任务执行前检查编队舰船血量,将受损舰船送入泡澡修理。 +`RepairManager` 在任务前检查舰队状态: -#### 修理流程 +1. 读取游戏上下文。 +2. 按默认和单船阈值判定。 +3. 发送修理请求。 +4. 有备用编队时轮换。 +5. 无可用编队时延迟任务,稍后重新检查。 -1. **血量检查**:调用 `GET /api/game/context` 获取编队舰船 HP -2. **阈值匹配**:按舰船名查找修理阈值配置(支持"·改"名称规范化) -3. **送入泡澡**:调用 `POST /api/repair` 发送修理请求 -4. **编队轮换**:若有多组 `FleetPreset`,尝试切换到未受损的预设继续战斗 -5. **延迟重试**:若无可用预设,任务延迟 30 秒后重新检查 +延迟不能消耗 `remainingTimes`。 -```typescript -interface BathRepairConfig { - enabled: boolean; - defaultThreshold: RepairThreshold; // 默认修理阈值 - shipThresholds?: Record; // 按舰船名定制阈值 -} -``` +## 生命周期与验证 ---- +`SchedulerBinder.dispose()` 在 Renderer 卸载时释放运行状态监听。新增日志订阅、 +计时器或回调时必须提供幂等清理。 -## 组件交互全景 +修改调度领域至少执行: -```mermaid -graph LR - User["用户操作"] -->|添加任务| Scheduler - Cron["CronScheduler(每分钟检查)"] -->|日常任务| Scheduler - ExpTimer["ExpeditionTimer(15min 间隔)"] -->|远征任务| Scheduler - - Scheduler -->|执行前| StopCheck["StopConditionChecker(预飞检查)"] - Scheduler -->|执行前| Repair["RepairManager(血量检查)"] - Scheduler -->|taskStart| Backend["Python 后端"] - Backend -->|日志 / 完成| Scheduler - - Scheduler -->|运行时日志| StopCheck - StopCheck -->|满足条件| Scheduler - Repair -->|编队轮换 / 延迟| Scheduler - - Scheduler -->|回调| Controller["AppController(更新 UI)"] +```powershell +npm run test:scheduler-domain +npm run test:build ``` ---- - -## 与其他系统的关系 - -- **Controller 层**:`SchedulerBinder`(`controller/app/SchedulerBinder.ts`)封装 Scheduler/CronScheduler 的回调绑定,管理待完成任务的 ID 跟踪 -- **配置系统**:`CronScheduler` 的触发规则来自 `usersettings.yaml` 的 `daily_automation` 字段;远征间隔 (`expedition_interval`) 同步到 `ExpeditionTimer` -- **模板与任务组**:任务组通过 `loadGroupToQueue()`(`controller/taskGroup/queueLoader.ts`)批量向 `Scheduler` 添加任务 -- **出击计划**:方案解析后构建 `TaskRequest`,通过 `Scheduler.addTask()` 入队 -- **后端通信**:`Scheduler` 持有 `ApiClient` 引用,通过 REST API 发起任务、通过 WebSocket 接收进度和完成通知 +涉及后端 DTO 再执行 `npm run test:api-contract`;涉及配置持久化再执行 +`npm run test:settings`。 diff --git a/docs/architecture/03-configuration.md b/docs/architecture/03-configuration.md index 485a92e..c76c5b9 100644 --- a/docs/architecture/03-configuration.md +++ b/docs/architecture/03-configuration.md @@ -1,23 +1,41 @@ # 配置系统 -> 涉及文件:`src/model/ConfigModel.ts` · `src/view/config/ConfigView.ts` · `src/controller/app/ConfigController.ts` · `src/controller/app/theme.ts` · `src/types/model.ts` · `usersettings.yaml` · `gui_settings.json` +> 主要文件:`src/model/ConfigModel.ts`、`src/controller/app/ConfigController.ts`、 +> `src/view/config/`、`electron/services/Gui*Settings*.ts` -## 概述 +## 配置不是一个文件 -配置系统采用**双层存储**: +| 存储 | 责任 | 所有者 | +|---|---|---| +| `userData/usersettings.yaml` | AutoWSGR 业务配置:模拟器、账号、日常自动化 | `ConfigModel` + 安全文件 IPC | +| `userData/gui_settings.json` | GUI、窗口、Python、CUDA、后端模式、更新和 GUI 自动化 | Main 配置 Service | +| Renderer `localStorage` | 主题、调试、Cron/额度等轻量可恢复状态 | `StorageAdapter` 和对应 Model | -| 层级 | 文件 | 内容 | 读写方式 | -|------|------|------|----------| -| **GUI 级** | `gui_settings.json` | 后端端口、Python 路径 | Electron 主进程直接读写 | -| **用户级** | `usersettings.yaml` | 模拟器、账号、日常自动化 | 渲染进程通过 IPC 读写 | +不能把三者合并成一个配置源,也不能让 Renderer 直接用 Node 文件 API。 -另外,主题/调试模式等纯 UI 偏好存储在浏览器 `localStorage` 中。 +## Renderer 模型 ---- +`ConfigModel` 维护两部分: -## 数据模型 +- `UserSettings`:对应 `usersettings.yaml`。 +- `GuiAutomationSettings`:从 Main 读取,对应 JSON 的 `automation`。 -### UserSettings 结构 +主要行为: + +| 方法/属性 | 作用 | +|---|---| +| `loadFromYaml()` | 解析 YAML,与默认值合并并升级旧字段 | +| `toYaml()` | 输出后端业务配置 | +| `update()` | 合并经过校验的表单值 | +| `current` | 当前只读业务配置 | +| GUI automation 访问器 | 读取和规范化 GUI 调度参数 | + +`ConfigModel` 不负责磁盘写入。缺失字段应回填默认值;未知业务字段不能因为 GUI +未建模而被无意清空。 + +## 配置域 + +### `usersettings.yaml` ```typescript interface UserSettings { @@ -27,175 +45,168 @@ interface UserSettings { } ``` -#### EmulatorConfig — 模拟器配置 - -| 字段 | 类型 | 说明 | -|------|------|------| -| `type` | `string` | 模拟器类型:`"MuMu"` / `"雷电"` / `"蓝叠"` 等 | -| `path` | `string?` | 模拟器可执行文件路径 | -| `serial` | `string?` | ADB 连接串口,如 `"127.0.0.1:16384"` | - -#### AccountConfig — 账号配置 +`daily_automation` 包含远征、奖励、浴室、支援、演习、战役和自动常规出击等 +AutoWSGR 业务设置。自动常规出击条目使用受管计划来源、文件、舰队覆盖和每日 +上限。 + +### `gui_settings.json` + +主进程管理的主要字段: + +```text +backend_port +python_path +update_mode +backend_startup_mode +backend_repo_path +ocr_gpu_mode +cuda_path +save_backend_screenshots +window +automation +decisive_plan +legacy_decisive_automation +``` -| 字段 | 类型 | 说明 | -|------|------|------| -| `game_app` | `string` | 服务器:`"官服"` 等 | -| `account` | `string?` | 账号 | -| `password` | `string?` | 密码 | +`GuiSettingsStore` 是 JSON 存储入口,写入时顶层浅合并,保留调用方未更新的未知 +顶层字段。字段默认、类型、边界和旧字段升级由 +`GuiConfigurationService` 负责。 -#### DailyAutomation — 日常自动化 +`allow_test_updates` 是统一更新通道选择:`false` 表示主库 GUI + 主库后端的 +Stable 通道,`true` 表示个人仓库 GUI + 个人仓库后端的 Alpha 通道。external +后端模式继续使用用户指定仓库,不受该字段影响。 -| 字段 | 类型 | 默认 | 说明 | -|------|------|------|------| -| `auto_expedition` | `boolean` | `true` | 自动远征 | -| `expedition_interval` | `number` | `15` | 远征检查间隔(分钟,1~120) | -| `auto_exercise` | `boolean` | `false` | 自动演习 | -| `exercise_fleet_id` | `number` | `1` | 演习舰队 (1~4) | -| `auto_battle` | `boolean` | `false` | 自动战役 | -| `battle_type` | `string` | `"困难潜艇"` | 战役类型 | -| `battle_times` | `number` | `3` | 战役次数 | -| `auto_normal_fight` | `boolean` | `false` | 自动常规出击 | -| `auto_decisive` | `boolean` | `false` | 自动决战 | -| `decisive_ticket_reserve` | `number` | `0` | 决战票保留数 | -| `decisive_template_id` | `string` | `""` | 决战模板 ID | -| `auto_loot` | `boolean` | `false` | 自动刷战利品 | -| `loot_plan_index` | `number` | `0` | 战利品方案索引 | -| `loot_stop_count` | `number` | `50` | 战利品停止数量 | +`automation` 当前包含: ---- +| 字段 | 语义 | +|---|---| +| `expeditionInterval` | 远征检查间隔,1~120 分钟 | +| `battleTimes` | 旧结构兼容字段,运行时固定为 8 | +| `autoDecisive` | 每日自动决战 | +| `decisiveTemplateId` | `user_plan` 或 `system_preset` | +| `autoLoot` | 自动战利品任务 | +| `lootPlanId` | 稳定的系统/用户计划标识 | +| `lootStopCount` | 停止数量 | -## ConfigModel +旧 `battle_times` 不再控制实际次数,读取和保存都会归一化为 +`DAILY_CAMPAIGN_TIMES`。 -`ConfigModel` 是配置数据的内存表示,提供加载/更新/序列化接口: +## 加载流程 -| 方法 | 说明 | -|------|------| -| `loadFromYaml(yamlStr)` | 解析 YAML 字符串,与默认值合并(缺失字段保留默认) | -| `toYaml()` | 序列化为 YAML 字符串 | -| `update(partial)` | 深合并部分更新 | -| `current` | 只读属性,返回当前 `UserSettings` 对象 | +```mermaid +sequenceDiagram + participant Startup + participant Adapter + participant Main + participant ConfigModel + participant View + + Startup->>Adapter: readFile(usersettings.yaml) + Adapter->>ConfigModel: loadFromYaml() + Startup->>Adapter: getGuiAutomationSettings() + Adapter->>Main: IPC + Main-->>ConfigModel: normalized automation + ConfigModel-->>View: ConfigViewObject +``` -**关键行为**:`loadFromYaml` 对缺失字段做**默认值回填**,确保旧版配置文件升级后新字段不会为 `undefined`。 +文件不存在时创建默认配置。迁移逻辑必须发生在明确的 Model/Service 边界,不能 +在 View 的 `render()` 中偷偷改持久化数据。 ---- +## 保存事务 -## 数据流 +`ConfigController` 先构造候选配置并校验,再调用 +`ConfigurationGateway.commitGuiSettings()`。Main 侧 +`GuiSettingsCommitService` 执行: -```mermaid -flowchart LR - subgraph Storage["持久化存储"] - YAML["usersettings.yaml"] - GUI["gui_settings.json"] - LS["localStorage"] - end - - subgraph Model["Model 层"] - CM["ConfigModel"] - end - - subgraph View["View 层"] - CV["ConfigView"] - end - - subgraph Controller["控制器层"] - CC["ConfigControllercontroller/app/ConfigController.ts"] - AC["AppController"] - end - - subgraph Side["副作用"] - Cron["CronScheduler"] - Sched["Scheduler"] - IPC["Electron IPC"] - end - - YAML -->|"bridge.readFile()"| AC - AC -->|"loadFromYaml()"| CM - CM -->|"extractViewObject()"| CC - CC -->|"render(vo)"| CV - CV -->|"用户编辑"| CV - CV -->|"collect()"| CC - CC -->|"update()"| CM - CM -->|"toYaml()"| CC - CC -->|"bridge.saveFile()"| YAML - CC -->|"updateConfig()"| Cron - CC -->|"setExpeditionInterval()"| Sched - AC -->|"setBackendPort()"| IPC - AC ---|"主题/调试/端口"| LS +```text +读取原 usersettings.yaml + -> 写入新 usersettings.yaml + -> 原子写入 gui_settings.json + -> JSON 成功:提交完成 + -> JSON 失败:恢复原 usersettings.yaml,再抛错 ``` -### 加载流程 +只有整个事务成功后,Renderer 才: -1. `AppController.loadConfigAndSync()` 通过 IPC 读取 `usersettings.yaml` -2. 调用 `ConfigModel.loadFromYaml()` 解析并合并默认值 -3. 若文件不存在,用默认值创建新文件 +- 替换内存 `ConfigModel`。 +- 更新 `CronScheduler` 配置。 +- 更新远征间隔。 +- 写入主题等 UI 偏好。 +- 刷新设置页和主页。 -### 保存流程 +不得先更新内存再希望磁盘保存成功,否则失败后 UI 与运行时会出现两套配置。 -1. 用户点击“保存配置” -2. `ConfigView.collect()` 从表单提取当前值 → `ConfigViewObject` -3. `ConfigController.saveConfig()` 执行以下操作: - - 保存 UI 偏好到 `localStorage`(主题、调试模式) - - 调用 `bridge.setBackendPort()` 更新端口(需重启生效) - - 调用 `ConfigModel.update()` 深合并 - - 同步 `CronScheduler.updateConfig()` 更新定时任务规则 - - 同步 `Scheduler.setExpeditionInterval()` 更新远征检查间隔 - - 序列化写入 `usersettings.yaml` +## Main 配置服务 -### 主题管理 +| 模块 | 责任 | +|---|---| +| `GuiSettingsStore` | JSON 读取、根对象校验、浅合并和原子写入 | +| `GuiConfigurationService` | 默认值、规范化、旧字段升级和同步 getter | +| `GuiSettingsCommitService` | YAML + JSON 事务、窗口偏好提交 | +| `AtomicFileStore` | 临时文件、替换和原子持久化 | +| `SecureFileService` | userData/resource 路径权限和文件操作 | +| `ConfigurationIpc` | 通道注册和参数边界 | -主题相关逻辑位于 `controller/app/theme.ts`,支持亮色/暗色/自动切换和强调色应用。 +preload 中的启动配置 getter 使用 `sendSync`,Main 必须使用 `ipcMain.on`: ---- +- 应用版本 +- 后端端口和启动模式 +- Python 路径 +- OCR GPU/CUDA +- 截图保存 +- 更新模式 +- 窗口偏好 -## ConfigView 表单结构 +不要单方面把同步 getter 改成 Promise;需要同时修改 preload、IPC 类型、Adapter、 +调用点和 IPC 契约测试。 -配置页 UI 分为三个区域,对应 `UserSettings` 的三个子配置: +## 配置页 View -### 模拟器设置 -- 模拟器类型下拉 (`#cfg-emu-type`) -- 安装路径 (`#cfg-emu-path`) + 文件浏览按钮 -- ADB 串口 (`#cfg-emu-serial`) + 自动检测按钮 +`ConfigView` 是 Controller 面向的稳定 Facade,保留完整 `render()`、`collect()` +和事件回调 API。当前拆分为: -### 账号设置 -- 服务器选择 (`#cfg-game-app`) -- 账号/密码(可选) +| 模块 | 局部责任 | +|---|---| +| `ConfigAutomationView` | 自动出击摘要、剩余次数、舰队显示和战利品计划选择 | +| `ConfigRuntimeView` | Python/CUDA/Backend/ADB/资料库状态、按钮 loading、更新进度 | +| `settingSelectWidth.ts` | 根据选项文案设置受控分级宽度 | -### 自动化设置 -- 远征开关 + 间隔滑块 -- 演习开关 + 舰队选择 -- 战役开关 + 类型 + 次数 -- 决战开关 + 模板选择(下拉从 `TemplateModel` 动态填充) -- 战利品开关 + 方案 + 停止数量 +子 View 只持有表单和视觉状态。环境检测、持久化、默认值、调度同步和业务校验仍 +属于 Controller/Model/Main Service。 -### 附加设置(localStorage 存储) -- 主题模式:自动 / 亮色 / 暗色 -- 强调色 -- 调试模式 -- 后端端口 +HTML 源位于: ---- +```text +src/view/html/pages/config/ +├─ index.html +├─ behavior.html +└─ system.html +``` -## gui_settings.json +SCSS 由 `src/view/styles/pages/_config.scss` 聚合 +`src/view/styles/pages/config/` 下的职责 partial。 -由 Electron 主进程直接管理的配置: +## localStorage 边界 -```json -{ - "backend_port": 8438, - "python_path": null -} -``` +允许进入 localStorage 的数据必须可丢失、可重建,不得成为文件 identity 或核心 +业务配置的唯一来源。当前包括: -- `backend_port`:Python 后端 HTTP 服务端口 -- `python_path`:用户手动指定的 Python 路径(`null` = 自动检测) +- 主题和强调色。 +- Scheduler/Cron 已处理时段。 +- 自动战役、常规出击每日额度。 +- 修理和轻量运行恢复状态。 -读写在主进程 `main.ts` 中通过 `readGuiSettings()` / `writeGuiSettings()` 完成,渲染进程通过 IPC (`get-backend-port-sync`, `set-backend-port`, `get-python-path`, `set-python-path`) 间接访问。 +通过 `StorageAdapter` 注入,Controller 和普通 View 不直接调用 +`localStorage.*`。`view/theme.ts` 是经过架构测试允许的 UI 偏好例外。 ---- +## 修改与验证 -## 与其他系统的关系 +| 修改 | 最小验证 | +|---|---| +| ConfigModel 或字段规范化 | `npm run test:settings` | +| Main 配置 Service | `npm run test:main-services` | +| preload/IPC 配置方法 | `npm run test:main-ipc` | +| 配置页 HTML/View/SCSS | `npm run test:build`、`npm run test:settings` | +| 旧字段迁移 | `npm run test:migrations` | -- **任务调度**:`daily_automation` 字段直接驱动 `CronScheduler` 的触发规则和 `ExpeditionTimer` 的间隔 -- **环境管理**:`gui_settings.json` 中的 `python_path` 影响 Python 发现优先级 -- **后端通信**:`backend_port` 决定 `ApiClient` 的连接地址 -- **模拟器检测**:初始化时若 `emulator` 字段为空,自动调用 `detectEmulator()` 填充 +配置改动完成后还应确认 YAML 保存失败和 JSON 保存失败都不会留下半提交状态。 diff --git a/docs/architecture/04-battle-plan.md b/docs/architecture/04-battle-plan.md index ddbf6b4..d33fd90 100644 --- a/docs/architecture/04-battle-plan.md +++ b/docs/architecture/04-battle-plan.md @@ -1,231 +1,208 @@ -# 出击计划系统 +# 方案与编队系统 -> 涉及文件:`src/model/PlanModel.ts` · `src/controller/plan/`(PlanController · importExport · presetFlow · nodeEditor · rendering)· `src/view/plan/`(PlanPreviewView(Facade) · MapView · NodeEditorView · FleetPresetView · FleetEditDialog)· `src/model/MapDataLoader.ts` · `src/types/model.ts` · `resource/builtin_plans/` · `resource/maps/` +> 主要目录:`src/model/PlanModel.ts`、`src/model/fleet/`、 +> `src/controller/plan/`、`electron/services/*Plan*.ts` -## 概述 +## 三类受管方案 -出击计划(Plan)是 AutoWSGR-GUI 的核心数据结构,定义了一次战斗出击的完整策略:打哪张地图、经过哪些节点、每个节点用什么阵型、是否夜战、迂回规则、修理策略等。 +| 类型 | 系统只读目录 | 用户可写目录 | +|---|---|---| +| 作战方案 | `resource/system_battle_plans/` | `userData/user_battle_plans/` | +| 编队方案 | `resource/system_team_plans/` | `userData/user_team_plans/` | +| 日常方案 | `resource/system_daily_plans/` | `userData/user_daily_plans/` | -计划以 YAML 文件存储,可通过 GUI 的可视化地图编辑器进行查看和修改。 +系统和用户方案通过同一管理 UI 展示,但来源 identity 必须保留。系统方案不能 +原地覆盖;编辑后保存为用户副本。 ---- +## 作战方案模型 -## 数据结构 - -### PlanData — 方案主体 +`PlanModel` 负责 Renderer 中的 YAML 解析、编辑和序列化。重要字段包括: ```typescript interface PlanData { - chapter: number; // 章节号 - map: number; // 地图号 - selected_nodes: string[]; // 选中的节点列表,如 ["A", "D", "G", "H"] - fight_condition?: 1|2|3|4|5; // 出击条件 - repair_mode?: number | number[]; // 修理策略(单值或按舰位数组) - fleet_id?: number; // 编队号 (1-4) - node_defaults?: NodeArgs; // 节点默认参数 - node_args?: Record; // 按节点覆盖的参数 - fleet_presets?: FleetPreset[]; // 内嵌的编队预设 - times?: number; // 执行次数 - stop_condition?: StopCondition; // 停止条件 + chapter: number | string; + map: number | string; + selected_nodes: string[]; + endpoint_nodes?: string[]; + node_defaults?: NodeArgs; + node_args?: Record; + fleet_presets?: FleetPreset[]; + times?: number; + gap?: number; + fleet_id?: number; + repair_mode?: number | number[]; } ``` -### NodeArgs — 节点参数 +### 未建模字段保留 + +`PlanModel.fromYaml(content, path)` 保存原始根对象 `rawRoot`。`toYaml()` 以 +`rawRoot` 为基底,仅覆盖 GUI 管理字段,因此后端扩展字段、根注释和 GUI 尚未 +认识的内容不会被整份重建丢失。 + +修改序列化时必须保留这一行为。不要用一个只包含 TypeScript 已知字段的新对象 +替换原始 YAML 根。 + +### 节点默认值 + +节点执行参数必须继承 `node_defaults`: ```typescript -interface NodeArgs { - formation?: 1|2|3|4|5; // 阵型 - night?: boolean; // 是否夜战 - proceed?: boolean; // 是否继续前进 - enemy_rules?: [string, string|number][]; // 索敌规则 +getNodeArgs(nodeId: string): NodeArgs { + const defaults = this.data.node_defaults ?? {}; + const overrides = this.data.node_args?.[nodeId] ?? {}; + const args = { ...defaults, ...overrides }; + if (this.data.endpoint_nodes?.includes(nodeId)) { + args.proceed = false; + } + return args; } ``` -**阵型映射**:`1=单纵阵` `2=复纵阵` `3=轮形阵` `4=梯形阵` `5=单横阵` +`node_args` 只覆盖节点特有字段;终点节点强制 `proceed = false`。 -**出击条件**:`1=稳步前进` `2=火力万岁` `3=全速前进` `4=跛行前进` `5=连续作战` +### 新计划路线 -**索敌规则**示例: -```yaml -enemy_rules: - - [AP >= 1, 4] # 有补给舰 → 梯形阵 - - [AP < 1, detour] # 无补给舰 → 迂回 -``` +`src/controller/plan/selectedNodes.ts` 固定以下规则: -### FleetPreset — 编队预设 +- 新计划只启用节点 `0`。 +- 后端执行的节点白名单始终包含 `0`。 +- 只有 `0` 时禁止入队,提示至少开启一个路线节点。 -```typescript -interface FleetPreset { - name: string; - ships: (string | ShipFilter)[]; // 6 个舰位:具体舰名或模糊筛选 -} +这区分“节点追踪尚未识别字母节点”和“用户尚未选择实际路线”。新增或切换地图 +时不能默认全选路线。 -interface ShipFilter { - nation?: string; // 国籍筛选 - ship_type?: string; // 舰型筛选 -} -``` +## 地图 -编队预设支持**具体舰名**(如 `"85工程"`)和**模糊筛选**(如 `{nation: "苏联", ship_type: "dd"}`),后者在执行时由 `resolveFleetPreset()` 解析为实际舰船。 - ---- - -## YAML 示例 - -```yaml -# 捞胖次 9-2 -chapter: 9 -map: 2 -selected_nodes: [A, D, G, H, M, O, E, K] -fight_condition: 1 -repair_mode: 2 -fleet_id: 1 -node_defaults: - formation: 4 - night: false - proceed: true -node_args: - A: - enemy_rules: - - [AP >= 1, 4] - - [AP < 1, detour] -fleet_presets: - - name: 三响岛风 - ships: [85工程, AIII, 岛风, 科罗廖夫, 列宁格勒, 伏尔加格勒] -``` +`MapDataLoader` 从 `resource/maps/` 加载普通和活动地图,缓存读取结果。 +`controller/plan/rendering.ts` 将地图、PlanModel 和节点编辑状态组合成 +`PlanPreviewViewObject`。 ---- +`MapView` 只渲染节点、连线和选择意图,不决定后端执行参数。地图同步由 +`scripts/sync-map-resources.js` 负责,修改资源后运行 `npm run check:maps`。 -## 方案类型 +无效空地图数据不能覆盖已有有效快照。 -| 类型 | `task_type` | 说明 | -|------|-------------|------| -| 常规出击 | 无 / `normal_fight` | 标准章节出击 | -| 战役 | `campaign` | 战役任务 | -| 演习 | `exercise` | 自动演习 | -| 决战 | `decisive` | 决战模式,含 `level1` / `level2` 目标舰队 | -| 活动 | `event_fight` | 活动地图出击 | +## Renderer 方案控制器 ---- +| 模块 | 所有权 | +|---|---| +| `PlanController` | 当前作战方案和地图 | +| `BattlePlanLoaderController` | 受管方案选择和筛选 | +| `PlanFleetPresetController` | 当前方案引用的编队列表 | +| `PlanManagementController` | 管理目录、关联和删除影响 | +| `FleetPlannerController` | 普通编队唯一草稿及文件 identity | +| `DecisivePlanController` | 决战独立草稿 | +| `presetFlow.ts` | 独立任务预设详情和执行 | +| `nodeEditor.ts` | 节点编辑意图 | +| `selectedNodes.ts` | 路线选择与执行校验 | -## 核心组件 +Controller 负责 `file/source`、保存覆盖和持久化 DTO;View 只看到不透明 ID 和 +ViewObject。 -### PlanModel — 方案解析器 +## 编队领域 -| 方法 | 说明 | -|------|------| -| `fromYaml(yamlStr)` | 解析 YAML → `PlanData` 对象 | -| `toYaml(plan)` | 序列化 `PlanData` → YAML 字符串 | -| `getNodeArgs(plan, node)` | 获取指定节点的合并后参数(node_args 覆盖 node_defaults) | -| `mergeFleetPreset(plan, presetIndex)` | 将编队预设合并到方案的 fleet_id 中 | +`src/model/fleet/` 是编队业务规则边界: -### MapDataLoader — 地图数据加载 +| 文件 | 责任 | +|---|---| +| `FleetDraft.ts` | 普通舰队草稿、校验、与 `UserTeamPlan` 双向转换 | +| `DecisiveFleetDraft.ts` | 决战 level1/level2 草稿 | +| `FleetDraftEditor.ts` | 显式编辑意图的唯一应用入口 | +| `FleetPresetIdentity.ts` | 预设身份和引用 | +| `FleetRuleMapper.ts` | GUI 规则到 API 规则 | +| `ShipMatcher.ts` | 舰船匹配和展示标签 | -地图 JSON 存放在 `resource/maps/` 目录,包含节点坐标、类型、连线等信息。 +普通舰队与决战舰队不能共享同一草稿。它们只共享 +`ShipGalleryView` 的搜索、筛选、排序、增量渲染和卡片交互。 -| 方法 | 说明 | -|------|------| -| `load(chapter, map)` | 通过 IPC 加载地图 JSON,返回 `MapData`,结果缓存 | -| `loadEx(chapter)` | 加载 Ex 章节地图 | +### 舰位语义 -```typescript -interface MapData { - nodes: MapNode[]; // 节点列表 - edges: MapEdge[]; // 连线列表 -} +舰位可以是: -interface MapNode { - id: string; // 节点标识,如 "A", "B" - x: number; // 坐标 X - y: number; // 坐标 Y - type: string; // 节点类型(战斗、资源、Boss) - detour?: boolean; // 是否可迂回 - night?: boolean; // 是否夜战节点 -} +- 空位。 +- 明确主选舰船。 +- 带国籍、舰种、等级等约束的结构化主选。 +- `candidates` 备选规则。 +- candidate-only 槽位。 + +candidate-only 槽位没有顶层 `name`,候选项地位平等;不能把第一项自动提升为 +主选。全局唯一分配优先保留主选,主选不可用后才使用候选并重新执行全局分配。 + +舰种只使用原生 0.3 定义的 22 个 canonical code 和业务组合 +`ss_or_ssg`。导巡 canonical code 为 `KP/kp`。舰种同步快照由: + +```powershell +npm run sync:fleet-types +npm run check:fleet-types +``` + +维护,API 契约测试会与 AutoWSGR 仓库交叉验证。 + +## Main 计划流水线 + +作战方案: + +```text +CombatPlanIpc + -> PlanManagementService / PlanExportService + -> CombatPlanCodec + -> CombatPlanRepository + -> AtomicFileStore / AppPaths ``` -### PlanController — 方案控制器 - -方案控制器位于 `src/controller/plan/`,拆分为多个模块: - -| 文件 | 职责 | -|------|------| -| `PlanController.ts` | 主控制器:持有当前方案状态,协调下属模块 | -| `importExport.ts` | 方案文件的导入/导出/新建流程 | -| `presetFlow.ts` | 任务预设的导入/查看/关闭/执行流程 | -| `nodeEditor.ts` | 从 UI 收集节点阵型/夜战/索敌规则并写回 PlanData | -| `rendering.ts` | 构建 `PlanPreviewViewObject`,协调地图数据和方案数据的合并 | - -### PlanPreviewView — 方案预览视图 (Facade) - -`PlanPreviewView` 作为 Facade 持有三个子视图,Controller 只与 Facade 交互: - -| 子视图 | 文件 | 职责 | -|--------|------|------| -| `MapView` | `view/plan/MapView.ts` | 地图节点/连线渲染、节点类型图标/名称常量 | -| `NodeEditorView` | `view/plan/NodeEditorView.ts` | 节点详细编辑器(阵形、夜战、继续条件) | -| `FleetPresetView` | `view/plan/FleetPresetView.ts` | 编队预设列表管理(添加、编辑、删除) | -| `FleetEditDialog` | `view/plan/FleetEditDialog.ts` | 编队预设编辑弹窗(支持舰船自动补全) | - ---- - -## 数据流 - -```mermaid -flowchart TB - subgraph Input["输入"] - YAML["方案 YAML 文件"] - Builtin["内置方案resource/builtin_plans/"] - end - - subgraph Parse["解析"] - PM["PlanModel.fromYaml()"] - ML["MapDataLoader.load()"] - end - - subgraph Edit["编辑 & 预览"] - PC["PlanController"] - PV["PlanPreviewView"] - end - - subgraph Execute["执行"] - REQ["构建 TaskRequest"] - SCHED["Scheduler.addTask()"] - end - - YAML --> PM - Builtin --> PM - PM --> PC - PC --> ML - ML --> PC - PC -->|"ViewObject"| PV - PV -->|"用户编辑节点"| PC - PC -->|"更新 PlanData"| PM - PM -->|"toYaml()"| YAML - - PC -->|"executePreset()"| REQ - REQ --> SCHED +编队方案: + +```text +TeamPlanIpc + -> TeamPlanService + -> TeamPlanCodec + -> TeamPlanRepository +``` + +日常方案: + +```text +DailyPlanIpc + -> DailyPlanService + -> CombatPlanCodec / TaskPresetCodec ``` ---- +IPC 不直接解析 YAML 或决定命名。Codec 负责结构和兼容,Repository 负责来源目录 +和原子文件操作,Service 负责用例。 + +## 保存与执行 + +保存作战方案时,内嵌 `fleet_presets` 可拆成受管编队方案并建立引用。运行时: + +1. `PlanManagementService` 读取受管方案。 +2. `CombatPlanCodec` 解析并解析编队引用。 +3. `RuntimePlanService` 展开成后端可读 YAML。 +4. 写入 `/AutoWSGR-GUI/runtime_battle_plans//`。 +5. Scheduler 只把临时运行路径发送给后端。 -## 内置方案 +运行时临时文件序号由 `RuntimePlanService` 独占,外部用户选择路径不能直接进入 +任务队列。 -`resource/builtin_plans/` 包含 18 个预制方案: +## 导入、导出与删除 -| 分类 | 数量 | 示例 | -|------|------|------| -| 周常 | 11 | `周常1章-1-2.yaml` ~ `周常9章-9-2.yaml` | -| 捞胖次 | 4 | `捞胖次9-2.yaml`, `捞胖次7-4.yaml` | -| 战役 | 1 | `战役.yaml` | -| 演习 | 1 | `自动演习.yaml` | -| 决战 | 1 | `决战.yaml` | +- 用户显式选择的本地 YAML 通过当前 Codec 升级后导入用户目录。 +- 源文件保持不变。 +- 同名覆盖需要用户确认。 +- 删除编队前必须计算作战方案引用。 +- 删除作战方案前必须计算任务组引用。 +- 系统来源只读,导出只选择用户方案。 ---- +方案管理 View 不读取 Repository;关联状态统一由 +`planManagementViewObjects.ts` 推导。 -## 与其他系统的关系 +## 验证 -- **任务调度**:方案通过 `controller/plan/presetFlow.ts` 的 `executePresetFlow()` 构建 `TaskRequest` 后交给 `Scheduler` -- **模板与任务组**:模板的 `planPaths` 引用方案文件;任务组 item 可以是 `kind: "plan"` 类型 -- **配置系统**:方案中的 `fleet_id` 和 `repair_mode` 可被配置页覆盖 -- **共享组件**:`view/shared/ShipAutocomplete.ts` 提供舰船名自动补全,被 `FleetEditDialog` 使用 +| 修改 | 最小验证 | +|---|---| +| PlanModel、路线和 YAML | `npm run test:api-contract`、`npm run test:main-services` | +| FleetDraft/舰种/候选 | `npm run test:fleet-domain`、`npm run check:fleet-types` | +| 方案管理删除 | `npm run test:plan-management-delete` | +| Main Codec/Repository/Service | `npm run test:main-services` | +| 迁移兼容 | `npm run test:migrations` | +| 方案/编队 View | `npm run test:build`,并在 Electron 中回归交互 | diff --git a/docs/architecture/05-template-and-taskgroup.md b/docs/architecture/05-template-and-taskgroup.md index c5de0d3..65ae58b 100644 --- a/docs/architecture/05-template-and-taskgroup.md +++ b/docs/architecture/05-template-and-taskgroup.md @@ -1,203 +1,186 @@ # 模板与任务组 -> 涉及文件:`src/model/TemplateModel.ts` · `src/controller/template/`(TemplateController · wizard · useTemplate · selectors · crud)· `src/model/TaskGroupModel.ts` · `src/controller/taskGroup/`(TaskGroupController · addItems · queueLoader · metaLoader · contextMenu · importExport)· `src/view/template/`(TemplateLibraryView · TemplateWizardView · SelectorDialog)· `src/view/taskGroup/TaskGroupView.ts` · `src/view/shared/ShipAutocomplete.ts` · `resource/builtin_templates.json` · `templates/templates.json` · `task_groups.json` +> 主要文件:`src/model/TemplateModel.ts`、`src/model/TaskGroupModel.ts`、 +> `src/controller/template/`、`src/controller/taskGroup/` -## 概述 +## 角色 -模板和任务组共同构成 AutoWSGR-GUI 的**任务组织层**: +- 模板描述可复用的任务参数。 +- 任务组保存一个有序任务列表。 +- 受管作战/日常方案保存实际 YAML。 +- `queueLoader.ts` 把上述引用转换成 SchedulerTask。 -- **模板 (Template)**:定义"怎么打"——一种可复用的任务配置,包含任务类型、方案路径、停止条件等 -- **任务组 (Task Group)**:定义"打哪些"——由多个任务项组成的有序列表,可一键加载到调度队列 - -``` -模板库 ──(引用)──→ 任务组 ──(加载)──→ 调度队列 - ↑ - 方案文件也可直接加入 +```text +方案 / 日常方案 / 模板 / 独立预设 + -> TaskGroupItem[] + -> queueLoader + -> Scheduler ``` ---- +## 模板兼容链路 -## 模板系统 +模板来源: -### 模板类型 +| 来源 | 位置 | 权限 | +|---|---|---| +| 内置模板 | `resource/builtin_templates.json` | 只读 | +| 用户模板 | `userData/templates/templates.json` | 可写 | -| 类型 | `type` 值 | 说明 | -|------|-----------|------| -| 常规出击 | `normal_fight` | 包含一到多个方案路径 (`planPaths`) | -| 演习 | `exercise` | 指定舰队 ID | -| 战役 | `campaign` | 战役类型名 | -| 决战 | `decisive` | 章节号 + 目标舰列表 | +`TemplateModel` 合并两个来源,`TemplateController` 与以下模块提供用例: -### 模板结构 +- `crud.ts`:创建、编辑、删除、导入和导出。 +- `selectors.ts`:方案、舰队、战役和决战参数选择。 +- `useTemplate.ts`:实例化并加入任务组/队列。 +- `wizard.ts`:模板向导。 -```typescript -interface TaskTemplate { - id: string; // 唯一标识 - name: string; // 显示名称 - type: TemplateType; // normal_fight | exercise | campaign | decisive - builtin: boolean; // 是否内置 - planPaths?: string[]; // 方案文件列表(常规出击) - defaultTimes: number; // 默认执行次数 - defaultStopCondition?: StopCondition; - fleet_id?: number; // 演习用舰队 - chapter?: number; // 决战章节 - level1?: string[]; // 决战第一阶段目标舰 - level2?: string[]; // 决战第二阶段目标舰 - flagship_priority?: string[]; // 旗舰优先级 -} -``` +当前 Renderer 没有挂载独立模板库页面,但以下依赖仍存在: -### 存储 +- 旧任务组 `kind: "template"`。 +- 用户模板持久化和迁移。 +- 系统自动决战预设。 -| 来源 | 文件 | 可写 | -|------|------|------| -| **内置** | `resource/builtin_templates.json` | 只读 | -| **用户** | `templates/templates.json` | 可读写 | +因此不能把未挂载 UI 误判为死代码。只有完成数据迁移和执行链路替换后,才能 +删除模板模块。 -`TemplateModel.init()` 在启动时合并两个来源,内置模板的 `builtin: true` 标识确保不可删除。 +## 任务组 v4 -### 内置模板 +`TaskGroupModel` 的当前持久化版本是 4: -| ID | 名称 | 说明 | -|----|------|------| -| `builtin_farm_loot` | 刷胖次 | 4 个方案路径可选,`stopCondition: {loot_count_ge: 50}` | -| `builtin_weekly` | 周常任务 | 11 个章节方案 | -| `builtin_exercise` | 自动演习 | 舰队 ID 可配置 | -| `builtin_campaign` | 战役 | 战役类型任务 | -| `builtin_decisive` | 决战 | 决战模式 | +```typescript +interface TaskGroupsData { + version: 4; + activeGroup: string; + groups: TaskGroup[]; +} -### 创建向导 +interface TaskGroup { + name: string; + items: TaskGroupItem[]; +} +``` -向导逻辑位于 `controller/template/wizard.ts`,`TemplateController` 协调调用,视图由 `TemplateWizardView`(`view/template/TemplateWizardView.ts`)渲染: +任务组写入 `userData/task_groups.json`。`TaskGroupModel` 是 Renderer 中该文件 +的状态所有者,加载旧版本时迁移并立即保存 v4。 -```mermaid -flowchart LR - S1["① 选择类型normal_fight / exercisecampaign / decisive"] --> S2["② 配置详情方案列表 / 舰队 / 章节"] - S2 --> S3["③ 默认参数次数 / 间隔 / 停止条件"] - S3 --> S4["④ 命名确认创建"] +### 四类条目 + +```typescript +interface TaskGroupItem { + kind: 'plan' | 'preset' | 'template' | 'daily'; + label: string; + times: number; + + managedSource?: 'system' | 'user'; + managedFile?: string; + dailySource?: 'system' | 'user'; + dailyFile?: string; + dailyTaskType?: 'exercise' | 'campaign' | 'decisive'; + templateId?: string; + + forceRetry?: boolean; + allowPolling?: boolean; + fleetPresetIndex?: number; +} ``` -### 模板视图 +其他按任务类型使用的覆盖字段包括 `campaignName`、`fleet_id`、`chapter` 和 +`useQuickRepair`。接口保留索引签名以无损保存未知扩展字段。 -模板视图位于 `src/view/template/`,拆分为三个组件: +| `kind` | 身份字段 | 读取方式 | +|---|---|---| +| `plan` | `managedSource + managedFile` | 受管作战方案 | +| `preset` | `managedSource + managedFile` | 独立任务预设 | +| `daily` | `dailySource + dailyFile` | 受管日常方案 | +| `template` | `templateId` | TemplateModel | -| 组件 | 文件 | 职责 | -|--------|------|------| -| `TemplateLibraryView` | `TemplateLibraryView.ts` | 模板库卡片列表渲染(纯渲染,通过回调通知 Controller) | -| `TemplateWizardView` | `TemplateWizardView.ts` | 创建向导多步骤表单(含舰船自动补全) | -| `SelectorDialog` | `SelectorDialog.ts` | 通用选择器弹窗(单选/多选方案、战役、舰队等) | +`path` 仅用于旧数据兼容和迁移,新数据不要持久化绝对路径。 ---- +## Model 行为 -## 任务组系统 +`TaskGroupModel` 提供: -### 数据结构 +- 组的新增、更新、重命名、删除和激活。 +- 条目新增、删除、移动和次数更新。 +- v1~v3/无版本数据到 v4 的规范化。 +- 旧系统方案文件名映射。 +- 未知组字段和条目字段保留。 +- `beforeunload` 保存。 -```typescript -// task_groups.json 结构 -{ - activeGroup: string; // 当前激活的组名 - groups: TaskGroup[]; -} +迁移失败不能删除原任务组。旧安装目录中的任务组由 Main 的 +`UserDataMigrationService` 合并到 userData,同名不同内容使用“(旧版)”保留。 -interface TaskGroup { - name: string; // 组名 - items: TaskGroupItem[]; // 有序任务项列表 -} +## Controller 结构 -interface TaskGroupItem { - kind: 'plan' | 'template'; // 类型 - path?: string; // 方案文件路径(kind=plan) - templateId?: string; // 模板 ID(kind=template) - times: number; // 执行次数 - label: string; // 显示标签 - fleetPresetIndex?: number; // 可选的编队预设覆盖 -} -``` +| 文件 | 责任 | +|---|---| +| `TaskGroupController.ts` | 组状态、CRUD、ViewObject 和事件协调 | +| `TaskListLoaderController.ts` | 选择和批量载入任务列表 | +| `DailyTaskLoaderController.ts` | 日常方案分类、参数和提交 | +| `addItems.ts` | 添加 plan/preset/daily/template | +| `queueLoader.ts` | 解析条目并创建 SchedulerTask | +| `managedPlanReader.ts` | 统一读取受管作战和日常文件 | +| `metaLoader.ts` | 加载条目展示摘要 | +| `contextMenu.ts` | 编辑、复制、删除和打开来源 | -### 持久化 +View 只展示 TaskGroup ViewObject 并上报意图,不直接读取方案文件。 -`TaskGroupModel` 通过 IPC 读写 `task_groups.json`: +## 入队流程 -| 方法 | 说明 | -|------|------| -| `load()` | 从文件加载所有组 | -| `save()` | 写回文件(在 `beforeunload` 时自动调用) | -| `addGroup(name)` | 新建空组 | -| `removeGroup(name)` | 删除组 | -| `renameGroup(old, new)` | 重命名 | -| `addItem(groupName, item)` | 添加任务项 | -| `removeItem(groupName, index)` | 移除任务项 | -| `reorderItem(groupName, from, to)` | 拖拽重排 | +```mermaid +flowchart TD + A["选择任务组条目"] --> B{"kind"} + B -->|plan/preset| C["读取 managedSource/managedFile"] + B -->|daily| D["读取 dailySource/dailyFile"] + B -->|template| E["TemplateModel.getTemplate"] + C --> F["Codec/PlanModel 解析"] + D --> G["按 exercise/campaign/decisive 构建请求"] + E --> H["应用模板与条目覆盖"] + F --> I["执行前路线与编队校验"] + G --> J["Scheduler.addTask"] + H --> J + I --> J +``` -### TaskGroupView — 任务组视图 +`queueLoader.ts` 负责把: -UI 包含: -- **组选择器**:下拉菜单 + 新建/重命名/删除按钮 -- **任务列表**:每项显示名称、次数、类型标签 -- **操作**: - - 拖拽排序 - - 右键上下文菜单(编辑/删除/复制) - - "全部加载到队列" / "单项加载" 按钮 - - 任务组导入/导出 +- `times` +- `forceRetry` +- `allowPolling` +- `fleetPresetIndex` +- 终点和战果要求 +- 修理与停止条件 ---- +传入 Scheduler。不要在 View 或 TaskGroupModel 中复制这套执行转换。 -## 任务组控制器 +## 日常方案 -任务组控制器位于 `src/controller/taskGroup/`,拆分为多个模块: +日常方案与作战方案使用独立目录和 identity: -| 文件 | 职责 | -|------|------| -| `TaskGroupController.ts` | 主控制器:绑定视图事件,协调下属模块 | -| `addItems.ts` | 向任务组添加项目:从当前方案/文件/预设添加 | -| `queueLoader.ts` | 加载任务组到调度队列:逐项构建 TaskRequest → `Scheduler.addTask()` | -| `metaLoader.ts` | 加载任务项元数据(方案标题、模板名称) | -| `contextMenu.ts` | 右键上下文菜单:编辑/删除/复制任务项 | -| `importExport.ts` | 任务组的导入/导出 | +- `exercise` +- `campaign` +- `decisive` ---- +`DailyTaskLoaderController` 决定哪些字段可编辑。任务组保存实际来源和文件,不把 +日常方案硬编码成模板 ID。用户决战计划也通过 Main 的 +`DailyPlanService` 保存到用户日常目录。 -## 加载到调度队列 +## 文件身份原则 -从任务组加载任务到 `Scheduler` 的流程: +1. 任务组引用受管来源和文件名,不保存运行时临时路径。 +2. 系统和用户同名文件仍是两个来源,不能只按 basename 判断。 +3. 方案重命名/迁移必须同步任务组引用。 +4. 缺失引用应保留并显示错误,不能静默改成另一个同名文件。 +5. 读取失败时不生成残缺 SchedulerTask。 -```mermaid -sequenceDiagram - participant User as 用户 - participant TGCtrl as TaskGroupController - participant TGModel as TaskGroupModel - participant TplModel as TemplateModel - participant PlanModel as PlanModel - participant Sched as Scheduler - - User->>TGCtrl: 点击"全部加载" - TGCtrl->>TGModel: getActiveGroup() - TGModel-->>TGCtrl: items[] - - loop 逐项处理 - alt kind = plan - TGCtrl->>TGCtrl: bridge.readFile(path) - TGCtrl->>PlanModel: fromYaml(content) - PlanModel-->>TGCtrl: PlanData - TGCtrl->>TGCtrl: 构建 NormalFightReq - else kind = template - TGCtrl->>TplModel: getTemplate(templateId) - TplModel-->>TGCtrl: TaskTemplate - TGCtrl->>TGCtrl: 构建对应类型的 TaskRequest - end - TGCtrl->>Sched: addTask(name, type, request, USER_TASK, times) - end - - TGCtrl->>TGCtrl: renderMain() - Note over Sched: 队列已填充,等待用户点击"开始执行" -``` +## 验证 ---- +修改任务组或模板至少执行: -## 与其他系统的关系 +```powershell +npm run test:migrations +npm run test:scheduler-domain +npm run test:build +``` -- **出击计划**:`kind: "plan"` 类型的任务项直接引用方案 YAML 文件 -- **任务调度**:加载后的任务以 `USER_TASK` 优先级进入 `Scheduler` 队列 -- **配置系统**:`CronScheduler` 的 `autoNormalFight` 开关可以自动执行当前活跃任务组 -- **后端通信**:模板中的配置最终被构建为 `TaskRequest`,通过 `ApiClient` 发送到后端 +涉及日常计划 Main Service 时增加 `npm run test:main-services`;涉及 API 请求时 +增加 `npm run test:api-contract`。 diff --git a/docs/architecture/06-backend-communication.md b/docs/architecture/06-backend-communication.md index d566879..5d8cb8e 100644 --- a/docs/architecture/06-backend-communication.md +++ b/docs/architecture/06-backend-communication.md @@ -1,284 +1,219 @@ -# 后端通信 +# 通信边界 -> 涉及文件:`electron/preload.ts` · `electron/main.ts`(IPC handlers)· `src/model/ApiClient.ts` · `src/types/api.ts` · `src/types/electronBridge.ts` +## 两条通信链路 -## 概述 +```text +Renderer + -> window.electronBridge + -> preload / ipcRenderer + -> Electron Main IPC + -> Service -AutoWSGR-GUI 的通信分为**两层**: - -```mermaid -graph LR - subgraph Renderer["渲染进程"] - View["View / Controller"] - Api["ApiClient"] - end - - subgraph Main["Electron 主进程"] - IPC["IPC Handlers"] - end - - subgraph Py["Python 后端"] - REST["REST API"] - WS["WebSocket"] - end - - View -->|"contextBridge"| IPC - IPC -->|"fs / spawn / exec"| Main - Api -->|"HTTP fetch"| REST - Api -->|"WebSocket"| WS +Renderer + -> ApiClient + -> HTTP / WebSocket + -> AutoWSGR Python 后端 ``` -| 层 | 路径 | 用途 | -|----|------|------| -| **IPC** | 渲染进程 ↔ Electron 主进程 | 文件 I/O、系统对话框、环境管理、后端进程控制 | -| **HTTP/WS** | 渲染进程 ↔ Python 后端 | 游戏操作、任务执行、实时日志 | - ---- - -## IPC 通信层 - -### 暴露机制 - -`preload.ts` 通过 Electron 的 `contextBridge.exposeInMainWorld()` 安全地将 IPC 方法暴露到 `window.electronBridge` 对象上。渲染进程只能通过预定义的方法调用主进程,无法直接访问 Node.js API。 +IPC 处理桌面系统能力;HTTP/WS 处理游戏自动化。不要把文件系统能力加到 Python +API,也不要让 Main 代替 Renderer 转发所有后端请求。 -### API 分类 +## Electron IPC -#### 文件操作 +### 契约层 -| 方法 | 参数 | 返回 | 说明 | -|------|------|------|------| -| `readFile(path)` | 文件路径 | `string` | 读取文件内容 | -| `saveFile(path, content)` | 路径 + 内容 | `void` | 写入文件 | -| `appendFile(path, content)` | 路径 + 内容 | `void` | 追加内容 | -| `openFileDialog(filters, defaultDir?)` | 文件过滤器 | `{path, content} \| null` | 打开文件选择对话框 | -| `saveFileDialog(name, content, filters)` | 默认名 + 内容 | `string \| null` | 保存文件对话框 | -| `openDirectoryDialog(title?)` | 对话框标题 | `string \| null` | 文件夹选择 | +`src/types/ipc.ts` 定义: -#### 路径查询 +- `ElectronBridge` +- 配置、窗口、更新、ADB DTO +- 作战、编队、日常方案 DTO +- 舰船资料库 DTO +- 通用文件操作结果 -| 方法 | 返回 | 说明 | -|------|------|------| -| `getAppRoot()` | `string` | 应用工作目录 | -| `getPlansDir()` | `string` | 方案文件目录 | -| `getConfigDir()` | `string` | 配置文件目录 | -| `listPlanFiles()` | `{name, file}[]` | 列出方案文件 | -| `openFolder(path)` | `void` | 在资源管理器中打开 | +这是 preload、Renderer Adapter 和调用方共同依赖的类型来源。新增 IPC 需要同步: -#### 环境管理 +```text +src/types/ipc.ts + -> electron/preload.ts + -> electron/ipc/Ipc.ts + -> src/adapter/IpcAdapter.ts + -> 调用方 + -> scripts/tests/test-main-ipc.js +``` -| 方法 | 返回 | 说明 | -|------|------|------| -| `checkEnvironment()` | `{pythonCmd, pythonVersion, missingPackages, allReady}` | 检查 Python 环境 | -| `installDeps()` | `{success, output}` | 安装 Python 依赖 | -| `installPortablePython()` | `{success}` | 安装便携版 Python | -| `checkUpdates()` | `{gitAvailable, hasUpdates, ...}` | 检测 autowsgr 库更新 | -| `pullUpdates()` | `{success, output}` | 拉取更新 | +### Preload -#### Python 路径配置 +`electron/preload.ts` 是唯一允许直接使用 `ipcRenderer` 的 Renderer 桥接文件: -| 方法 | 说明 | -|------|------| -| `getPythonPath()` | 同步获取用户配置的 Python 路径(`null` = 自动检测) | -| `setPythonPath(path)` | 设置 Python 路径并清除缓存 | -| `validatePython(path)` | 验证指定路径的 Python 版本是否兼容 | +```typescript +contextBridge.exposeInMainWorld('electronBridge', electronBridge); +``` -#### 后端控制 +Renderer 其他模块不得导入 Electron。同步 getter 使用 `sendSync`,命令和文件 +操作使用 `invoke`,Main 事件使用 `ipcRenderer.on`。 -| 方法 | 说明 | -|------|------| -| `startBackend()` | 启动 Python 后端子进程 | -| `detectEmulator()` | 自动检测模拟器 | -| `checkAdbDevices()` | 查询 ADB 设备列表 | -| `runSetup()` | 运行 setup.bat 脚本 | +### Renderer Adapter -#### GUI 自动更新 +`src/adapter/IpcAdapter.ts` 不把完整 bridge 到处传播,而是按用例裁剪: -| 方法 | 说明 | -|------|------| -| `checkGuiUpdates()` | 检查 GUI 应用更新 | -| `downloadGuiUpdate()` | 下载更新包 | -| `installGuiUpdate()` | 安装更新并重启 | -| `onUpdateStatus(callback)` | 监听更新状态变化 | +| 契约 | 用途 | +|---|---| +| `StartupGateway` | 路径、环境、后端和更新启动流程 | +| `ConfigurationGateway` | 配置事务 | +| `SettingsGateway` | 设置页系统操作 | +| `ManagedCombatPlanRepository` | 作战方案 | +| `ScheduledTaskRepository` | 自动任务计划 | +| `FleetPlannerRepository` | 编队与舰船资料库 | +| `DecisivePlanRepository` | 决战设置 | +| `MigrationConflictRepository` | 迁移冲突 | +| `FileRepository` | 受限文本读写 | -#### 事件监听 +Controller 依赖窄接口,View 不依赖任何 Adapter。 -| 方法 | 事件 | 说明 | -|------|------|------| -| `onBackendLog(callback)` | `backend-log` | 接收 Python 后端日志 | -| `onSetupLog(callback)` | `setup-log` | 接收 setup.bat 输出 | +### Main IPC -#### 同步方法 +| 文件 | 领域 | +|---|---| +| `FileIpc.ts` | 受限文件、对话框、目录打开 | +| `ConfigurationIpc.ts` | GUI 设置、窗口、Python/CUDA 配置 | +| `CombatPlanIpc.ts` | 作战方案管理、导入、导出和运行时准备 | +| `TeamPlanIpc.ts` | 编队方案 | +| `DailyPlanIpc.ts` | 日常方案 | +| `ShipLibraryIpc.ts` | 舰船资料库和更新 | +| `EnvironmentIpc.ts` | Python 环境检查和安装 | +| `DeviceIpc.ts` | 模拟器与 ADB | +| `BackendIpc.ts` | 后端启动和 setup | +| `MigrationConflictIpc.ts` | 迁移冲突复核 | +| `UpdaterIpc.ts` | GUI 更新 | -| 方法 | 说明 | -|------|------| -| `getAppVersion()` | 同步获取应用版本号 | -| `getBackendPort()` | 同步获取后端端口 | -| `setBackendPort(port)` | 设置后端端口 | +IPC 文件只处理参数、结果和异常边界。路径、安全、YAML、持久化和更新策略放入 +Service/Codec/Repository。 ---- +## 文件能力与安全 -## HTTP REST API +通用文件 IPC 经过 `SafePathService` 和 `SecureFileService`: -`ApiClient` 封装与 Python 后端的所有 HTTP 通信。 +- 读取限定在 `userData` 和资源目录。 +- 写入限定在 `userData`。 +- 拒绝 `..`、UNC、盘符跳转和 NTFS ADS。 +- 检查符号链接/junction 的真实目标。 +- 文件对话框返回的外部文件是单次用户授权,不扩大通用访问根。 +- 写入使用 `AtomicFileStore`。 -### 基础配置 +异常应直接返回或抛出,不得因为文件/IPC/页面异常而触发业务 fallback。 -- 默认地址:`http://localhost:8438` -- 端口可通过 `gui_settings.json` 配置 -- 所有请求/响应使用 JSON 格式 +## HTTP 客户端 -### 统一响应结构 +`src/model/ApiClient.ts` 使用 `src/adapter/ApiAdapter.ts` 提供的传输接口。默认: -```typescript -interface ApiResponse { - success: boolean; - data?: T; - message?: string; - error?: string; -} +```text +http://localhost: +ws://localhost: ``` -### 端点列表 +这是 Renderer 客户端地址;Uvicorn 在 Main 启动的 Python 进程内监听 +`127.0.0.1:`。 -#### 系统管理 +当前 HTTP 端点按代码分组: -| 方法 | 端点 | 超时 | 说明 | -|------|------|------|------| -| POST | `/api/system/start` | 300s | 连接模拟器 + 启动游戏 | -| POST | `/api/system/stop` | - | 断开连接 | -| GET | `/api/system/status` | - | 系统状态查询 | -| GET | `/api/system/emulator/devices` | 15s | ADB 设备列表 | +| 领域 | 端点 | +|---|---| +| 系统 | `/api/system/start`、`stop`、`status` | +| 任务 | `/api/task/start`、`stop`、`status` | +| 远征 | `/api/expedition/check` | +| 游戏状态 | `/api/game/context`、`acquisition` | +| 建造/奖励/食堂 | `/api/build/*`、`/api/reward/collect`、`/api/cook` | +| 修理/解体 | `/api/repair/*`、`/api/destroy` | +| 健康检查 | `/api/health` | -#### 任务执行 +具体请求类型定义在 `src/types/api.ts`。新增字段先确认 AutoWSGR 正式 API 契约, +再修改 GUI DTO 和契约 fixture。 -| 方法 | 端点 | Body | 说明 | -|------|------|------|------| -| POST | `/api/task/start` | `TaskRequest` | 启动战斗/演习/战役/决战 | -| POST | `/api/task/stop` | - | 停止当前任务 | -| GET | `/api/task/status` | - | 当前任务状态 | +### TaskRequest -`TaskRequest` 为联合类型,支持 5 种任务: +`TaskRequest` 是联合类型: -```typescript -type TaskRequest = - | NormalFightReq // {type: 'normal_fight', plan, times, gap} - | EventFightReq // {type: 'event_fight', plan, times} - | CampaignReq // {type: 'campaign', campaign_name, times} - | ExerciseReq // {type: 'exercise', fleet_id} - | DecisiveReq // {type: 'decisive', chapter, level1, level2} -``` +- `normal_fight` +- `event_fight` +- `campaign` +- `exercise` +- `decisive` -#### 远征 +Scheduler 对多轮任务每轮只发送一次后端请求。GUI 的 `remainingTimes` 和 +`logicalId` 不应混入后端业务 DTO。 -| 方法 | 端点 | 说明 | -|------|------|------| -| POST | `/api/expedition/check` | 收取所有已完成的远征 | +`ApiClient.taskStart()` 保留必要的旧后端兼容重试。新增兼容分支时必须限定明确 +错误条件,不能对 Controller、页面导航或 OCR 引擎异常做静默 fallback。 -#### 游戏状态 +## WebSocket -| 方法 | 端点 | 返回数据 | 说明 | -|------|------|----------|------| -| GET | `/api/game/context` | 编队/资源/远征/建造槽 | 全局游戏状态 | -| GET | `/api/game/acquisition` | 战利品/舰船 OCR 数量 | 出征面板读数 | +| 路径 | 内容 | +|---|---| +| `/ws/logs` | 后端日志 | +| `/ws/task` | 任务进度与完成 | -#### 操作端点 +类型位于 `src/types/api.ts`: -| 方法 | 端点 | 说明 | -|------|------|------| -| POST | `/api/build/collect` | 收取建造 | -| POST | `/api/build/start` | 开始建造 | -| POST | `/api/reward/collect` | 收取每日奖励 | -| POST | `/api/cook` | 食堂烹饪 | -| POST | `/api/repair/bath` | 浴室快速修理 | -| POST | `/api/repair/ship` | 单船泡澡修理 | -| POST | `/api/destroy` | 解体舰船 | +- `WsLogMessage` +- `WsTaskUpdate` +- `WsTaskCompleted` -#### 健康检查 +`ApiClient` 负责连接、3 秒重连和消息解析;`SchedulerBinder` 与 +`SchedulerRuntimeTracker` 解释业务日志并更新 Scheduler/UI。 -| 方法 | 端点 | 说明 | -|------|------|------| -| GET | `/api/health` | 后端健康状态、运行时间 | +```mermaid +sequenceDiagram + participant Backend + participant ApiClient + participant Scheduler + participant Binder + participant View + + Backend->>ApiClient: /ws/logs + ApiClient->>Binder: 日志 + Binder->>Scheduler: 停止条件/节点/战果 + Binder->>View: 日志与运行状态 + Backend->>ApiClient: /ws/task task_completed + ApiClient->>Scheduler: handleTaskFinished + Scheduler->>Binder: 单轮/逻辑事件 + Binder->>View: ViewObject +``` ---- +WebSocket 完成事件的后端 `task_id` 与 GUI 的轮次 `id/logicalId` 是不同层的身份, +不能混用。 -## WebSocket 通信 +## 后端来源与正式契约 -`ApiClient` 维护两条 WebSocket 连接,支持断线自动重连(3 秒延迟): +Main 启动前由 `BackendRuntimeContract` 验证: -### 连接 +- 实际导入的 `autowsgr` 位于声明的唯一来源。 +- `AUTOWSGR_OCR_GPU_MODE` 行为可用。 +- `AUTOWSGR_SAVE_IMAGES` 行为可用。 +- `autowsgr.server.main:app` 是可调用 ASGI 应用。 -| 路径 | 用途 | -|------|------| -| `ws://localhost:8438/ws/logs` | 实时日志流 | -| `ws://localhost:8438/ws/task` | 任务进度 + 完成通知 | +通过后才使用 Uvicorn 绑定 `127.0.0.1:`。GUI 不修改 AutoWSGR 私有类或 +日志实现。 -### 消息类型 +## 错误边界 -```typescript -// 日志消息 (/ws/logs) -interface WsLogMessage { - type: 'log'; - timestamp: string; - level: string; - channel: string; - message: string; -} - -// 任务进度更新 (/ws/task) -interface WsTaskUpdate { - type: 'task_update'; - task_id: string; - status: string; - progress?: { current: number; total: number; node: string | null }; -} - -// 任务完成 (/ws/task) -interface WsTaskCompleted { - type: 'task_completed'; - task_id: string; - success: boolean; - result?: TaskResult; - error?: string; -} -``` +以下错误直接失败并向上报告: -### 数据流 +- preload/IPC 不可用。 +- 文件路径或结构非法。 +- Controller、页面导航或 View 生命周期异常。 +- 后端来源或 ASGI 契约不符。 +- OCR 引擎异常。 +- 方案 Codec/Repository 失败。 -```mermaid -sequenceDiagram - participant Backend as Python 后端 - participant WsLog as ws/logs - participant WsTask as ws/task - participant Api as ApiClient - participant Sched as Scheduler - participant App as AppController - participant UI as MainView - - Backend->>WsLog: 日志消息 - WsLog->>Api: onLog - Api->>Sched: 解析 [UI] 行 → StopConditionChecker - Api->>App: 日志回调 - App->>UI: appendLog() - - Backend->>WsTask: 进度更新 - WsTask->>Api: onTaskUpdate - Api->>Sched: onProgressUpdate - Sched->>App: 回调 - App->>UI: renderMain() - - Backend->>WsTask: 任务完成 - WsTask->>Api: onTaskCompleted - Api->>Sched: handleTaskFinished() - Sched->>Sched: 后触发 / 重试 / 消费下一个 - Sched->>App: 回调 - App->>UI: renderMain() -``` +业务 fallback 只用于已有明确语义的兼容场景,例如受控的旧 API 请求格式。 ---- +## 验证 -## 与其他系统的关系 +```powershell +npm run test:main-ipc +npm run test:api-contract +npm run test:main-services +``` -- **任务调度**:`Scheduler` 持有 `ApiClient` 实例,通过 REST API 发起/停止任务,通过 WebSocket 接收进度和完成通知 -- **配置系统**:`backend_port` 配置决定 `ApiClient` 的连接地址 -- **环境管理**:所有环境相关操作(Python 检测/安装、后端启停)通过 IPC 层完成 -- **出击计划**:方案数据被构建为 `CombatPlanReq` 嵌入 `TaskRequest` 中 +修改 preload 后还应运行 `npm run test:build`,确认编译产物、桥接和打包入口仍 +正确连接。 diff --git a/docs/architecture/07-environment-management.md b/docs/architecture/07-environment-management.md index e41e30e..fd9a7eb 100644 --- a/docs/architecture/07-environment-management.md +++ b/docs/architecture/07-environment-management.md @@ -1,279 +1,235 @@ -# 环境管理 +# 环境与运行生命周期 -> 涉及文件:`electron/pythonEnv/`(context · finder · envCheck · installer · updater · utils)· `electron/emulatorDetect.ts` · `electron/backend.ts` · `electron/main.ts` +> 主要目录:`electron/pythonEnv/`、`electron/services/Backend*.ts`、 +> `electron/main.ts` -## 概述 +## Python 环境 -环境管理负责三个核心任务: +GUI 只接受 Python 3.12 或 3.13。查找顺序: -1. **Python 环境**:发现/安装/验证 Python,管理依赖包 -2. **模拟器检测**:通过 Windows 注册表自动识别已安装的模拟器 -3. **后端生命周期**:启动/停止 Python 后端子进程 +1. `gui_settings.json.python_path` 指定解释器。 +2. GUI 安装目录内置 `python/python.exe`。 +3. 系统 `python`/`python3`,再解析真实 `sys.executable`。 ---- +查找缓存位于 `electron/pythonEnv/context.ts`,切换 Python、模式或路径时由配置 +服务清除。不要在 Service 中建立第二份 Python 缓存。 -## Python 环境管理 +## `pythonEnv` 模块 -Python 环境管理位于 `electron/pythonEnv/` 子目录,采用依赖注入模式,通过 `index.ts` 聚合导出: +| 文件 | 责任 | +|---|---| +| `context.ts` | 环境依赖和唯一查找缓存 | +| `finder.ts` | 解释器发现与版本校验 | +| `environment.ts` | Python 来源、安装目标和后端来源描述 | +| `dependencies.ts` | GUI/后端/资料库 Python 依赖清单 | +| `envCheck.ts` | 完整检查和 `.env_ready` | +| `installer.ts` | pip、便携 Python 和依赖安装 | +| `backendRequirement.ts` | 打包后端发行清单 | +| `backendContractProbe.ts` | AutoWSGR 正式运行契约探测 | +| `updater.ts` | managed 后端兼容检查和固定提交安装 | +| `cuda.ts` | CUDA 环境变量和 PyTorch 能力 | +| `utils.ts` | `_pth`、pip、环境变量和路径辅助 | +| `index.ts` | 对 Main Service 的聚合出口 | -| 文件 | 职责 | -|------|------| -| `context.ts` | 共享上下文与缓存状态(`PythonEnvContext` 接口、缓存变量) | -| `finder.ts` | Python 可执行文件发现(用户配置 → 便携版 → 系统全局) | -| `envCheck.ts` | 环境验证主流程(VC++ Redistributable 检查、标记文件管理、依赖包验证) | -| `installer.ts` | Python 安装与依赖管理(pip 设置、autowsgr 安装) | -| `updater.ts` | autowsgr 自动更新逻辑(PyPI 版本检查 + 升级) | -| `utils.ts` | 工具函数与共享接口(路径工具、环境变量、pip 命令、.pth 文件处理) | -| `index.ts` | 聚合导出 | +IPC 通过 `PythonEnvironmentService` 使用这些能力。 -### 发现优先级 +## managed 与 external -`finder.ts` 中的 `findPython()` 按以下顺序查找可用的 Python: +| 模式 | 后端来源 | 依赖位置 | +|---|---|---| +| `managed` | `build/backend-distribution.json` 按 GUI 更新通道指定的受控 AutoWSGR | `{appRoot}/python/site-packages` | +| `external` + 内置 Python | 用户指定本地 AutoWSGR 仓库 | GUI `site-packages` + 仓库 | +| `external` + 外部 Python | 用户指定仓库和解释器 | 解释器自身环境 + 仓库 | -```mermaid -flowchart TD - A["① 用户配置路径gui_settings.json → python_path"] -->|存在且版本匹配| Z["使用该 Python"] - A -->|不存在/版本不匹配| B["② 本地便携版{appRoot}/python/python.exe"] - B -->|存在且版本匹配| Z - B -->|不存在| C["③ 系统 Pythonpython / python3"] - C -->|找到且版本匹配| D["解析真实路径python -c 'import sys; print(sys.executable)'"] - D --> Z - C -->|未找到| E["返回 null"] -``` +external 仓库无效时直接失败,不能回退 managed,也不能把 GUI site-packages +偷偷混入外部解释器。 -**版本要求**:仅接受 Python **3.12** 或 **3.13**。 - -**Shim 解析**:pyenv 等工具使用 `.bat` shim 文件,Node.js `spawn()` 无法直接执行。通过 Python 自身的 `sys.executable` 获取真实 `.exe` 路径。 - -**缓存**:发现结果缓存在 `context.ts` 的 `PythonEnvContext` 中,用户切换路径时调用 `clearPythonCache()` 清除。 - -### 便携版 Python - -应用打包时内置 Python 3.12.8 embed 发行版,位于 `{appRoot}/python/`。 - -**安装流程** (`installer.ts` 中的 `installPortablePython()`): -1. 检查 `python/python.exe` 是否存在 -2. 若存在:确保 `._pth` 配置正确 → 检查 pip → 安装 pip(如缺失) -3. 若不存在:在线下载 Python embed zip → 解压 → 安装 pip - -**PTH 文件处理** (`utils.ts` 中的 `ensurePthFile()`): -- Python embed 版默认禁用 `import site` -- 此函数取消注释 `python312._pth` 中的 `import site` 行 -- 添加 `site-packages` 路径条目 -- 使 `site.addsitedir()` 可用于加载 `.pth` 文件 - -### 环境检查 - -`envCheck.ts` 中的 `checkEnvironment()` 检测 Python 和依赖包是否就绪,并包含 VC++ Redistributable 检查: - -```mermaid -flowchart TD - A["checkEnvironment()"] --> B{".env_ready 标记存在?"} - B -->|是| C["读取缓存: pythonCmd, version, autowsgrVersion"] - C --> D{"Python 可执行文件仍存在?autowsgr 版本 ≥ 2.1.0?"} - D -->|是| E["自动更新 autowsgr(后台, 非阻塞)"] - E --> F["返回 {allReady: true}⚡ 快速路径"] - D -->|否| G["删除标记, 走完整路径"] - - B -->|否| H["findPython()"] - H --> I{"找到 Python?"} - I -->|否| J["返回 {allReady: false, pythonCmd: null}"] - I -->|是| K["ensurePthFile()"] - K --> L["单次 Python 调用:检查 uvicorn/fastapi/autowsgr"] - L --> M{"所有依赖就绪?"} - M -->|否| N["返回 {allReady: false, missingPackages}"] - M -->|是| O["自动更新 autowsgr"] - O --> P["写入 .env_ready 标记"] - P --> F -``` +发行清单同时固定两条后端来源: + +- Stable:`OpenWSGR/AutoWSGR@main` 的明确提交。 +- Alpha:`ShiinaKuroko/AutoWSGR@ShiinaKuroko` 的明确提交。 + +运行时使用与 GUI 相同的 `allow_test_updates` 选择后端。安装后清除 +`.env_ready`,首次启动按 `forceUpdateOnInstall` 完成受控更新和复核。 + +## `.env_ready` + +`{appRoot}/.env_ready` 缓存已验证的环境身份,包括: + +- Python 路径和版本。 +- AutoWSGR 版本/来源。 +- 当前受管后端固定来源(仓库和提交)。 +- managed/external 模式和仓库。 +- 依赖安装目标。 -### .env_ready 标记文件 +快速路径仍会检查解释器、环境身份和后端契约。配置、安装目标、GUI 更新通道或 +后端固定来源变化后删除标记;失败时不写完成标记,使下次启动继续检查。 +external 模式始终使用用户指定仓库,不受 GUI 更新通道影响。 -缓存环境状态,避免每次启动的重复检查: +## CUDA 与 OCR -```json -{ - "pythonCmd": "C:\\path\\to\\python.exe", - "pythonVersion": "Python 3.12.8", - "autowsgrVersion": "2.1.9" -} +配置: + +- `ocr_gpu_mode`: `auto | cpu | cuda` +- `cuda_path` + +启动前使用同一 Python 探测 `torch.cuda.is_available()`。最终只向后端传明确 +模式 `cpu` 或 `cuda`: + +- 强制 `cpu` 始终使用 CPU。 +- `auto` 有 CUDA 时用 CUDA,否则用 CPU。 +- 强制 `cuda` 但探测失败时直接报错。 + +正式环境变量: + +```text +AUTOWSGR_OCR_GPU_MODE=cpu|cuda +AUTOWSGR_SAVE_IMAGES=true|false ``` -- **路径**:`{appRoot}/.env_ready` -- **失效时机**:安装依赖后删除、Python 路径配置变更后删除 -- **验证条件**:Python 文件存在 + autowsgr 版本 ≥ 2.1.0 +GUI 不通过 monkey patch 控制 OCR。 -### 依赖安装 +## ADB 与模拟器 -`installer.ts` 中的 `installDependencies()`: -1. 删除 `.env_ready` 标记 -2. 确保 pip 可用 (`ensurePip()`) -3. 安装到本地目录: - ``` - pip install --target {appRoot}/python/site-packages --upgrade setuptools autowsgr - ``` +| 能力 | 所有者 | +|---|---| +| 注册表检测模拟器 | `electron/emulatorDetect.ts` | +| ADB 路径、devices、connect/disconnect | `AdbService` | +| Renderer IPC | `DeviceIpc` | -**所有包安装到 `{appRoot}/python/site-packages/`**,不影响全局 Python 环境。 +当前检测 MuMu、雷电和 BlueStacks。后端启动前读取用户配置的 serial 并连接。 +退出时只停止 GUI 内置 ADB server,不应杀死系统或其他工具的 ADB。 -### 自动更新 +NSIS 覆盖安装也只按完整可执行路径停止安装目录中的 `adb.exe`。 -`updater.ts` 中的 `checkForUpdates()` 在每次启动环境检查通过后自动执行: -1. 单次 Python 调用:获取本地 autowsgr 版本 + PyPI 最新版本 -2. 若有新版:`pip install --target ... --upgrade autowsgr` -3. 清理旧 `.dist-info` 目录避免版本检测错误 -4. 验证升级:重新检查 autowsgr 版本 + 关键依赖 +## 后端启动 ---- +`BackendService.startBackend()` 顺序: -## 模拟器检测 +```text +解析 PythonEnvironment + -> 构建 PATH/CUDA/ADB 环境 + -> 探测 torch CUDA + -> 选择明确 OCR 模式 + -> 验证 AutoWSGR 实际导入来源 + -> 验证正式环境变量行为 + -> 验证 autowsgr.server.main:app 是 ASGI + -> spawn python -X utf8 -c + -> uvicorn 绑定 127.0.0.1: +``` -`detectEmulator()` 通过 Windows 注册表自动识别已安装的模拟器: +`BackendService` 独占活动子进程引用。`BackendIpc` 不能保存另一个进程状态。 -### 支持的模拟器 +stdout/stderr 日志由 Main 过滤 access/debug 噪声后发送 Renderer;原始进程错误 +仍应保留足够上下文用于启动失败诊断。 -| 模拟器 | 检测方式 | 默认 ADB 串口 | -|--------|----------|---------------| -| **MuMu 12** | 注册表 `Uninstall` 项的 `UninstallString` | `127.0.0.1:16384` | -| **雷电 (LDPlayer)** | 注册表 `HKLM\SOFTWARE\leidian\InstallDir` | `127.0.0.1:5555` | -| **BlueStacks** | 注册表 `HKLM\SOFTWARE\BlueStacks_nxt*\InstallDir` | `127.0.0.1:5555` | +## Main 启动生命周期 -### 返回结构 +主进程顺序不可随意交换: -```typescript -interface EmulatorDetectResult { - type: string; // "MuMu" | "雷电" | "蓝叠" - path: string; // 模拟器安装路径 - serial: string; // ADB 连接串口 - adbPath: string; // 模拟器自带的 ADB 路径 -} +```text +SingleInstanceService.acquire() + -> 处理 pending GUI update + -> 旧安装迁移选择 + -> initPythonEnv() + -> initBackend() + -> 初始化作战/编队用户目录 + -> 初始化舰船资料库 + -> v6 预设库存迁移 + -> v7 旧方案迁移 + -> 迁移报告与冲突状态 + -> registerUpdaterIpc() + -> WindowService.createWindow() ``` -### 检测流程 - -```mermaid -flowchart TD - A["detectEmulator()"] --> B["reg query Uninstall /s"] - B --> C{"有 MuMu 条目?"} - C -->|是| D["提取 shell/ 路径组装 ADB 串口"] - D --> Z["返回 MuMu 结果"] - C -->|否| E["reg query leidian"] - E --> F{"有 InstallDir?"} - F -->|是| G["返回雷电结果"] - F -->|否| H["reg query BlueStacks_nxt"] - H --> I{"有 InstallDir?"} - I -->|是| J["返回蓝叠结果"] - I -->|否| K["返回 null"] +次实例立即退出并唤醒已有窗口。更新安装中的次实例只显示更新提示,不能执行 +配置迁移、pip 或创建旧窗口。 + +## 迁移 + +`MigrationStateStore` 独占: + +```text +userData/.migration-state.json ``` ---- +当前主阶段: -## 后端生命周期 +- `UserDataMigrationService`:用户数据迁移版本 6。 +- `migration:v6:preset-inventory:complete`:预设库存。 +- `LegacyPlanMigration`:旧方案版本 7。 +- `migration:v7:legacy-plans:complete`:旧方案分类。 +- 每个旧安装来源的 `started`、`configuration-complete`、`complete`。 -### 启动流程 +规则: -`startBackend()` (`electron/backend.ts`) 负责启动 Python 后端: +1. `mergeCompleted()` 合并旧 marker,不覆盖已完成项。 +2. 所有文件原子写入成功后才完成阶段。 +3. 失败时只重试未完成阶段/文件。 +4. 源文件不删除、不修改。 +5. 同名不同内容以“(旧版)”保留。 +6. 引用随实际迁移目标同步。 +7. 实际发生迁移时显示总数、成功数和失败项。 -```mermaid -sequenceDiagram - participant Main as 主进程 - participant FS as 文件系统 - participant ADB as ADB - participant Py as Python 子进程 +新的配置转换必须使用独立 stage key,不能复用或覆盖已有完成标记。 - Main->>Main: ensurePthFile()确保 ._pth 配置正确 - Main->>Main: findPython()获取 Python 路径 +NSIS 从 1.4.x 覆盖升级时,必须在旧卸载器运行前将旧用户数据移到 +`%LOCALAPPDATA%\AutoWSGR-GUI\legacy-upgrade`,新文件安装后再恢复为迁移源。 +保存冲突或恢复失败时安装停止,备份目录继续保留。回退应使用该备份和旧安装器, +不得让旧版直接写入唯一的 2.0 `userData`。 - Main->>Main: 构建 bootstrap 代码 - Note right of Main: sys.path.insert(0, localSite)site.addsitedir(localSite)uvicorn.run(..., port=8438) +## GUI 更新 - Main->>FS: 读取 usersettings.yaml提取 emulator.serial - Main->>ADB: adb connect {serial} - Note right of ADB: MuMu 多开需要主动连接 +`GuiUpdatePolicy` 支持严格版本/频道: - Main->>Py: spawn(pythonCmd, ['-X', 'utf8', '-c', bootstrap]) - Note right of Py: env: PYTHONUTF8=1, PATH+=adb/ +| 版本 | 频道 | +|---|---| +| `X.Y.Z` | `latest` | +| `X.Y.Z-alpha[.N]` | `alpha` | +| `X.Y.Z-beta.N` | `beta` | +| `X.Y.Z-dev[.N]` | `dev` | - Py->>Py: uvicorn 启动 FastAPI - Py-->>Main: stdout/stderr 日志流 - Main->>Main: 解析 loguru 格式日志过滤 DEBUG + access log转发到渲染进程 -``` +用户设置 `allow_test_updates` 同时选择 GUI 与 managed 后端来源: -### 启动参数 - -| 参数 | 说明 | -|------|------| -| `-X utf8` | 启用 UTF-8 模式 | -| `-c bootstrap` | 内联 Python 代码(注入 site-packages 路径 + 启动 uvicorn) | - -### 环境变量 - -| 变量 | 值 | 说明 | -|------|-----|------| -| `PYTHONUTF8` | `1` | 强制 UTF-8 编码 | -| `PYTHONIOENCODING` | `utf-8` | I/O 编码 | -| `PATH` | 原始 PATH + `{appRoot}/adb/` | 内置 ADB 可被后端发现 | - -### 日志转发 - -后端 stdout/stderr 输出经过处理后转发到渲染进程: -1. 按 loguru 格式(`HH:mm:ss.SSS | LEVEL | module | message`)识别新日志行 -2. 过滤掉 `DEBUG` 级别日志及其多行续行 -3. 过滤掉 uvicorn access log(`GET /api/...` 格式) -4. 通过 `mainWindow.webContents.send('backend-log', line)` 转发 - -### 停止 - -`stopBackend()` 直接 `kill()` 子进程。应用退出时 (`app.on('before-quit')`) 自动调用。 - ---- - -## 启动时序(完整视角) - -```mermaid -sequenceDiagram - participant App as AppController - participant IPC as IPC Bridge - participant PyEnv as pythonEnv.ts - participant Back as backend.ts - participant Py as Python 后端 - - App->>IPC: checkEnvironment() - IPC->>PyEnv: checkEnvironment() - - alt .env_ready 有效 - PyEnv-->>App: {allReady: true} - else 环境缺失 - PyEnv-->>App: {allReady: false} - App->>IPC: installPortablePython() - IPC->>PyEnv: 安装便携版 Python + pip - App->>IPC: installDeps() - IPC->>PyEnv: pip install autowsgr - App->>IPC: checkEnvironment() (重试) - PyEnv-->>App: {allReady: true} - end - - App->>IPC: startBackend() - IPC->>Back: startBackend() - Back->>Back: ensurePthFile() + findPython() - Back->>Back: ADB connect - Back->>Py: spawn 子进程 - - App->>App: waitForBackendAndConnect() - loop 轮询直到就绪 - App->>Py: GET /api/health - end - - App->>Py: POST /api/system/start - Note over App,Py: 连接模拟器 + 启动游戏 - - App->>App: scheduler.start() - App->>App: cronScheduler.start() -``` +- Stable 从 `yltx/AutoWSGR-GUI` 读取 `latest`,后端使用 + `OpenWSGR/AutoWSGR@main` 的固定提交。 +- Alpha 从 `ShiinaKuroko/AutoWSGR-GUI` 读取 `alpha`,后端使用 + `ShiinaKuroko/AutoWSGR@ShiinaKuroko` 的固定提交。 + +Alpha 构建在该字段缺失时默认开启,Stable 默认关闭。关闭预览版后不会自动降级, +而是等待版本号更高的 Stable。频道 setter 可能重新允许降级,因此每次切换后必须 +显式恢复 `allowDowngrade = false`。后端在重启后根据新通道更新。 + +已有 `2.0.16-alpha` 客户端使用旧的 Alpha-only 策略,因此首个 Stable 版本线必须 +先发布更高的 Alpha 桥,再发布同基础版本 Stable。Stable Release 在迁移窗口内同时 +携带桥接版的 `alpha.yml`、安装包和 blockmap,确保休眠旧 Alpha 客户端仍能先升级 +到桥接版,再切换到 Stable。 +更新检查返回 `available | up-to-date | error`,网络错误不能显示为最新版。 ---- +下载完成后用户选择立即重启或下次启动。pending 更新必须在任何迁移和窗口创建前 +处理。 -## 与其他系统的关系 +## 停止与退出 + +`BackendShutdownService` 的固定顺序: + +1. `POST /api/system/stop`,等待正式清理。 +2. Windows 使用 `taskkill /PID /T` 终止进程树。 +3. 等待 `close`。 +4. 超时后 `/T /F` 强制终止并再次等待。 +5. 仍无法确认退出时抛错,保留活动进程引用。 + +Main `before-quit` 再停止内置 ADB,成功后才调用 `app.quit()`。GUI 更新安装复用 +同一资源停止流程。 + +## 验证 + +```powershell +npm run test:python-environment +npm run test:backend-distribution +npm run test:main-services +npm run test:migrations +``` -- **配置系统**:`gui_settings.json` 的 `python_path` 影响 Python 发现优先级;`backend_port` 决定 uvicorn 监听端口 -- **后端通信**:`startBackend()` 的成功是 `ApiClient` 能连接的前提 -- **任务调度**:`Scheduler.start()` 在后端就绪后调用 `POST /api/system/start` 完成最终连接 +修改安装/更新资源后还应执行 `npm run pack` 和 +`npm run test:release-package`。 diff --git a/docs/architecture/08-dev-setup.md b/docs/architecture/08-dev-setup.md index 7eaea6b..054bafd 100644 --- a/docs/architecture/08-dev-setup.md +++ b/docs/architecture/08-dev-setup.md @@ -1,219 +1,205 @@ -# 开发环境搭建 +# 开发、构建与验证 -> 涉及文件:`package.json` · `tsconfig.json` · `scripts/` · `build/installer.nsh` +## 前置环境 -## 前置要求 - -| 工具 | 版本 | 说明 | -|------|------|------| -| **Node.js** | 18+ | 推荐使用 LTS 版本 | -| **Python** | 3.12 / 3.13 | 用于运行 AutoWSGR 后端 | -| **模拟器** | MuMu 12 / 雷电 / 蓝叠 | 运行战舰少女R | - ---- - -## 快速开始 +- Windows 10/11 x64 +- Node.js 22 +- npm +- 仅在 external 后端或后端联调时需要本地 Python 3.12/3.13 ```powershell -# 1. 克隆仓库 -git clone https://github.com/yltx/AutoWSGR-GUI.git -cd AutoWSGR-GUI - -# 2. 安装 Node 依赖 -npm install - -# 3. 开发模式运行 -npm run dev +npm ci +npm start ``` ---- +`npm start` 会完整构建后启动 Electron。项目没有 HMR,源码修改后需要重新构建 +并启动。 -## NPM Scripts +## 开发源与生成物 -| 命令 | 说明 | -|------|------| -| `npm run dev` | 编译 TypeScript + esbuild 打包 + 启动 Electron(开发日常使用) | -| `npm run build` | 仅编译(`tsc` + `esbuild`),不运行 | -| `npm start` | 等同于 `build` + `electron .`(含 chcp 65001) | -| `npm run dist` | 完整打包:下载 Python + ADB → 编译 → electron-builder NSIS 安装包 | -| `npm run pack` | 编译 + `electron-builder --dir`(生成目录,不打安装包) | -| `npm run prepare-python` | 单独下载便携版 Python | -| `npm run prepare-adb` | 单独下载 ADB 工具 | +| 开发源 | 生成物 | +|---|---| +| `src/view/html/**/*.html` | `src/view/index.html` | +| `src/view/styles/**/*.scss` | `src/view/styles/styles.css` | +| `electron/**/*.ts`、`src/**/*.ts` | `dist/**` | +| 编译后的 Renderer 模块 | `dist/renderer.bundle.js` | ---- +规则: -## 构建流程 +1. 不手工修改 `src/view/index.html`。 +2. 不手工修改 `src/view/styles/styles.css`。 +3. 不提交或依赖手工修改的 `dist/**`。 +4. HTML/SCSS/TypeScript 改动后运行 `npm run build`。 +5. 生成的 HTML/CSS 是 Electron 运行入口,需要和源码一起提交。 -### 编译管线 +## 构建管线 ```mermaid flowchart LR - TS["TypeScript 源码electron/ + src/"] -->|"tsc"| JS["dist/CommonJS 输出"] - SRC["src/view/*.ts(渲染进程)"] -->|"esbuild(scripts/bundle.js)"| Bundle["dist/renderer.bundle.js浏览器兼容单文件"] + HTMLSrc["src/view/html/index.html"] -->|"build-view-html.js"| HTML["src/view/index.html"] + SCSS["src/view/styles/main.scss"] -->|"sass"| CSS["src/view/styles/styles.css"] + TS["electron + src TypeScript"] -->|"tsc"| Dist["dist/electron + dist/src"] + Dist -->|"esbuild"| Bundle["dist/renderer.bundle.js"] ``` -- **tsc**:将所有 TypeScript 编译到 `dist/` 目录(主进程 + 渲染进程) -- **esbuild**:将渲染进程代码打包为单个浏览器兼容的 `renderer.bundle.js` - -### 构建脚本 - -#### `scripts/bundle.js` - -用 esbuild 将 `src/` 下的渲染进程代码打包为 `dist/renderer.bundle.js`,配置 `platform: 'browser'`,排除 Node.js 内置模块。 - -#### `scripts/prepare-python.js` - -下载 Python 3.12.8 embed 发行版,解压到 `python/` 目录。在 `npm run dist` 时自动调用。 - -#### `scripts/prepare-adb.js` - -下载 Android Platform-Tools(含 `adb.exe`),解压到 `adb/` 目录。在 `npm run dist` 时自动调用。 - ---- - -## 打包配置 - -### electron-builder - -打包配置在 `package.json` 的 `build` 字段中: - -```json -{ - "appId": "com.autowsgr.gui", - "productName": "AutoWSGR-GUI", - "directories": { "output": "release" }, - "files": ["dist/**/*", "src/view/**/*", "scripts/**/*"], - "extraResources": [ - { "from": "resource", "to": "resource" }, - { "from": "plans", "to": "plans" }, - { "from": "setup.bat", "to": "setup.bat" }, - { "from": "python", "to": "python" }, - { "from": "adb", "to": "adb" } - ] -} +`scripts/build-view-html.js` 递归展开 ``,拒绝目录逃逸和循环 +include。`--check` 只检查生成文件是否过期。 + +`scripts/bundle.js` 从编译后的 +`dist/src/controller/app/AppController.js` 打包浏览器 IIFE。 + +## 常用命令 + +| 命令 | 用途 | +|---|---| +| `npm run build` | HTML、CSS、TypeScript 和 Renderer Bundle 完整构建 | +| `npm run build:html` | 生成 `src/view/index.html` | +| `npm run build:css` | 生成 `styles.css` | +| `npm start` | 完整构建并启动 Electron | +| `npm run dev` | 清理、构建并启动 Electron | +| `npm run pack` | 生成 unpacked electron-builder 目录 | +| `npm run dist` | 准备 Python/ADB 并生成稳定版 NSIS 安装包 | +| `npm run prepare-python` | 下载便携 Python | +| `npm run prepare-adb` | 下载 ADB | +| `npm run check:fleet-types` | 校验 22 舰种快照 | +| `npm run check:maps` | 校验地图资源同步 | + +## 测试布局 + +```text +scripts/tests/ +├─ fixtures/ # 版本化输入语料 +├─ main-services/ # Main Service 分领域测试 +├─ test-support/ # 共享测试目录辅助 +├─ test-build-output-contract.js +├─ test-renderer-dom-contract.js +├─ test-renderer-architecture.js +├─ test-main-services.js +├─ test-main-ipc.js +├─ test-migrations.js +├─ test-api-contract.js +├─ test-fleet-domain.mjs +├─ test-scheduler-domain.mjs +└─ ... ``` -**打包目标**:Windows NSIS 安装包 (`release/AutoWSGR-GUI-Setup-x.x.x.exe`) - -**包含内容**: -- `dist/` — 编译后的 JS -- `src/view/` — HTML/CSS -- `resource/` — 内置方案 + 模板 + 地图 -- `python/` — 便携版 Python -- `adb/` — ADB 工具 - -### NSIS 自定义 - -`build/installer.nsh` 包含 NSIS 安装程序的自定义脚本(如安装向导页面定制)。 - ---- - -## 目录约定 +构建/测试脚本统一放在 `scripts/`,测试实现放在 `scripts/tests/`。不要把新测试 +重新放回 `scripts/test-*.js` 根目录旧布局。 + +## 测试命令 + +| 命令 | 覆盖范围 | +|---|---| +| `npm run test:build` | 构建、生成文件、DOM、架构和打包白名单总门禁 | +| `npm run test:renderer-contract` | HTML 新鲜度、重复/缺失 DOM ID | +| `npm run test:architecture-boundaries` | Controller/View 依赖和共享图库释放 | +| `npm run test:settings` | Electron 设置页渲染、收集和持久化 | +| `npm run test:scheduler-domain` | 调度身份、排序、重试、取消、额度 | +| `npm run test:fleet-domain` | 编队草稿、候选和 DTO 往返 | +| `npm run test:main-services` | Main 路径、配置、方案、环境和资料库 | +| `npm run test:main-ipc` | preload、IPC 通道和同步/异步契约 | +| `npm run test:migrations` | userData、旧方案、任务组和真实语料 | +| `npm run test:api-contract` | GUI 与 AutoWSGR API/舰种契约 | +| `npm run test:python-environment` | managed/external、CUDA 和环境一致性 | +| `npm run test:backend-distribution` | 打包后端来源和更新策略 | +| `npm run test:release-package` | 安装包运行时和资源 | +| `npm run test:event-resources` | 活动资源和地图加载 | +| `npm run test:map-intel` | 地图情报同步、校验和原子快照 | + +`test:build` 证明产品可以正确生成和连接,不代替业务领域测试或 Electron 交互 +回归。 + +## PR CI + +`.github/workflows/pull-request-checks.yml` 当前有两个 job: + +### Windows 构建与迁移 + +```text +npm ci +npm run test:build +node scripts/tests/test-scheduler-domain.mjs +node scripts/tests/test-migrations.js +``` -| 目录 | 运行时 (开发) | 运行时 (打包) | -|------|--------------|--------------| -| `appRoot()` | 项目根目录 | `%LOCALAPPDATA%/autowsgr-gui/` 或安装目录 | -| `resourceRoot()` | 同 appRoot | `resources/` (extraResources) | -| `plans/` | 项目根 `plans/` | extraResources `plans/` | -| `python/` | 项目根 `python/` | extraResources `python/` | -| `adb/` | 项目根 `adb/` | extraResources `adb/` | -| `usersettings.yaml` | 项目根 | appRoot | -| `gui_settings.json` | 项目根 | appRoot | -| `task_groups.json` | 项目根 | appRoot | -| `templates/` | 项目根 | appRoot | +`test:build` 已完成构建,后两个步骤直接运行编译产物测试,避免重复构建。 ---- +### Linux 后端舰种契约 -## 调试技巧 +```text +Checkout GUI + AutoWSGR +Node 22 + Python 3.12 + uv +npm ci +uv sync --project AutoWSGR --no-dev +npm run build +node scripts/tests/test-main-services.js +npm run check:fleet-types +node scripts/tests/test-api-contract.js +``` -### 后端日志 +通过 `AUTOWSGR_REPO` 和 `AUTOWSGR_PYTHON` 指向候选 AutoWSGR 仓库。 -- Python 后端使用 loguru 格式输出日志到 stdout -- 主进程控制台(终端 / VS Code Debug Console)可看到带颜色的原始日志 -- 渲染进程日志面板可看到经过滤的 INFO 及以上级别日志 -- 启用配置页的"调试模式"可在日志面板显示 DEBUG 级别 +## 发布 -### IPC 调试 +`.github/workflows/release.yml` 当前: -- `electronBridge` 对象在渲染进程 DevTools 控制台中可直接访问: - ```javascript - // 在 DevTools Console 中 - await window.electronBridge.checkEnvironment() - await window.electronBridge.getAppRoot() - ``` +1. 接受 `v*` tag 或手动触发。 +2. Stable 使用 `X.Y.Z` / `latest`,Alpha 使用 + `X.Y.Z-alpha[.N]` / `alpha`。 +3. 校验仓库中已经审查的稳定后端固定提交,不跟随移动分支。 +4. `npm run dist` 并按有效版本输出到对应频道目录。 +5. `npm run test:release-package` 校验有效频道、安装包和更新清单。 +6. Stable 发布前要求同版本线 Alpha 桥已经发布,并将桥接 Alpha 资产附加到 + Stable Release,供旧 Alpha 客户端迁移。 +7. Stable 完整资产同时发布到旧 `yltx/AutoWSGR-GUI` feed,供 1.4.x + 客户端发现;目标权限和版本冲突必须在构建前预检。 -### 热重载 +`build/electron-builder.release.cjs` 根据有效版本将输出放到 `release/latest` +或 `release/alpha`,并打包 `build/backend-distribution.json`。本地候选可通过 +`AUTOWSGR_RELEASE_VERSION` 覆盖包内版本而不修改受跟踪版本文件。 -项目未配置 HMR。修改代码后需要: -1. 终止 Electron 进程 -2. 运行 `npm run dev` 重新编译并启动 +## 安装包边界 -### 常见问题 +`package.json.build.files` 只包含: -| 问题 | 原因 | 解决 | -|------|------|------| -| Python 未找到 | 未安装 Python 3.12/3.13 或便携版缺失 | 运行 `npm run prepare-python` 或在配置页手动设置路径 | -| 后端启动失败 | autowsgr 未安装 | 通过 GUI 环境检查自动安装,或手动 `pip install autowsgr` | -| ADB 连接失败 | 模拟器串口不匹配 | 在配置页手动填写 ADB 串口 | -| 端口冲突 | 8438 端口被占用 | 在配置页更改后端端口 | -| TypeScript 编译错误 | 类型定义不匹配 | 确认 Node.js 类型版本与 `@types/node` 一致 | +```text +dist/electron/**/* +dist/src/shared/**/* +dist/renderer.bundle.js +src/view/index.html +src/view/styles/styles.css +``` ---- +`src/view/html/**`、SCSS partial 和 TypeScript 源码不进入安装包。 -## 技术栈一览 +extraResources 包含只读 `resource/`、setup、调试依赖和明确白名单的舰船资料库 +工具;便携 Python、VC++ redist 和 ADB 作为 extraFiles 放到安装目录。 -| 组件 | 技术 | 版本 | -|------|------|------| -| 桌面框架 | Electron | 33+ | -| 前端语言 | TypeScript | 5.6+ | -| 打包工具 | esbuild | 0.27+ | -| 安装包 | electron-builder (NSIS) | 26+ | -| 自动更新 | electron-updater | 6+ | -| YAML 解析 | js-yaml | 4+ | -| 样式预处理 | Sass (SCSS) | — | -| 后端框架 | Python FastAPI + uvicorn | — | -| 自动化核心 | autowsgr | 2.1.0+ | +## SCSS 结构 ---- +```text +src/view/styles/ +├─ main.scss +├─ base/ +├─ components/ +├─ pages/ +│ ├─ main-page/ +│ ├─ config/ +│ └─ plan/ +└─ themes/ +``` -## SCSS 样式架构 +`pages/_config.scss` 和 `pages/plan/_fleet-planner.scss` 是聚合入口。移动选择器时 +保持加载顺序和视觉效果;共享样式只有在多个页面实际复用时才进入 +`components/`。 -样式位于 `src/view/styles/`,采用三层组织: +## 提交前 -``` -styles/ -├── main.scss # 入口:@use 引入所有子模块 -├── styles.css # 编译产物 -├── base/ # 基础层 -│ ├── _variables.scss # CSS 变量、主题色、断点 -│ └── _base.scss # 全局重置、基础样式 -├── components/ # 组件层(跨页面复用) -│ ├── _buttons.scss # 按钮样式 -│ ├── _forms.scss # 表单控件 -│ ├── _modal.scss # 模态弹窗 -│ ├── _nav.scss # 导航栏 -│ ├── _autocomplete.scss # 自动补全下拉 -│ ├── _task-group.scss # 任务组组件 -│ └── _template.scss # 模板卡片/向导 -└── pages/ # 页面层(特定页面布局) - ├── _config.scss # 配置页 - ├── main-page/ # 主页面 - │ ├── _index.scss # 入口 - │ ├── _layout.scss # 布局 - │ ├── _log.scss # 日志面板 - │ └── _task-queue.scss # 任务队列 - └── plan/ # 方案编辑页 - ├── _index.scss # 入口 - ├── _layout.scss # 布局 - ├── _header.scss # 头部 - ├── _node-map.scss # 地图节点 - ├── _node-types.scss # 节点类型图标 - ├── _node-editor.scss # 节点编辑器 - ├── _fleet-preset.scss # 编队预设 - └── _task-config.scss # 任务配置区域 +```powershell +npm run test:build +git diff --check +git status --short ``` -**组织原则**: -- `base/`:全局变量和重置,晚于 `main.scss` 中最先 @use -- `components/`:跨页面复用的 UI 组件样式 -- `pages/`:特定页面的布局和元素样式,复杂页面进一步拆分为子目录 +然后按改动范围增加专项测试。构建后确认生成的 HTML/CSS 已更新,且没有把 +`userData`、临时目录、release 或无关文件带入改动。 diff --git a/docs/architecture/09-src-typescript-catalog.md b/docs/architecture/09-src-typescript-catalog.md new file mode 100644 index 0000000..faf9172 --- /dev/null +++ b/docs/architecture/09-src-typescript-catalog.md @@ -0,0 +1,249 @@ +# `src` TypeScript 模块索引 + +本索引按当前工作区统计,共 134 个 TypeScript 文件。用途是快速定位,不替代 +具体专题文档。 + +```text +src/ +├─ adapter/ 6 +├─ controller/ 42 +├─ model/ 25 +├─ view/ 40 +├─ types/ 7 +├─ shared/ 13 +└─ utils/ 1 +``` + +## Adapter(6) + +| 文件 | 责任 | +|---|---| +| `ApiAdapter.ts` | HTTP 与 WebSocket 传输实现 | +| `IpcAdapter.ts` | 将 ElectronBridge 裁剪为用例 Gateway/Repository | +| `JsonAdapter.ts` | JSON 编解码 | +| `YamlAdapter.ts` | YAML 编解码和结构辅助 | +| `StorageAdapter.ts` | 键值存储契约与 localStorage 实现 | +| `index.ts` | Adapter 实例和类型集中导出 | + +## Controller(42) + +### `controller/app`(12) + +| 文件 | 责任 | +|---|---| +| `AppController.ts` | Renderer 组合根和全局生命周期 | +| `AutomaticDecisiveTask.ts` | 自动决战两种来源的请求构造 | +| `ConfigController.ts` | 配置候选、事务保存和运行时同步 | +| `constants.ts` | 应用层展示和任务常量 | +| `CurrentFleetController.ts` | 当前任务舰队 ViewObject | +| `NavigationController.ts` | 页面与方案标签导航 | +| `OperationsController.ts` | 远征、奖励等快捷操作 | +| `rendering.ts` | 主页面 ViewObject | +| `ScheduledTaskLoader.ts` | 自动化设置到 SchedulerTask | +| `SchedulerBinder.ts` | Scheduler/Cron/日志/UI 联结 | +| `SchedulerRuntimeTracker.ts` | 运行日志派生状态 | +| `SettingsController.ts` | 环境、设备、资料库、更新和主题 | + +### `controller/startup`(3) + +| 文件 | 责任 | +|---|---| +| `StartupController.ts` | Renderer 启动总编排 | +| `connection.ts` | 后端健康、系统启动与连接 | +| `envAndUpdates.ts` | 环境准备和 GUI 更新检查 | + +### `controller/plan`(12) + +| 文件 | 责任 | +|---|---| +| `BattlePlanLoaderController.ts` | 受管方案选择器 | +| `DecisivePlanController.ts` | 决战草稿和持久化 | +| `FleetPlannerController.ts` | 普通编队草稿和持久化 | +| `fleetViewObjects.ts` | 编队 DTO 到 ViewObject | +| `nodeEditor.ts` | 节点编辑意图 | +| `PlanController.ts` | 当前作战方案和地图 | +| `PlanFleetPresetController.ts` | 方案舰队预设清单 | +| `PlanManagementController.ts` | 方案管理用例 | +| `planManagementViewObjects.ts` | 关联和删除影响推导 | +| `presetFlow.ts` | 独立任务预设流程 | +| `rendering.ts` | 方案预览 ViewObject | +| `selectedNodes.ts` | 路线默认、规范化和执行校验 | + +### `controller/taskGroup`(8) + +| 文件 | 责任 | +|---|---| +| `TaskGroupController.ts` | 任务组 CRUD 和 ViewObject | +| `TaskListLoaderController.ts` | 任务列表批量载入 | +| `DailyTaskLoaderController.ts` | 日常方案选择 | +| `addItems.ts` | 添加四类任务条目 | +| `contextMenu.ts` | 条目上下文操作 | +| `managedPlanReader.ts` | 读取受管作战/日常方案 | +| `metaLoader.ts` | 条目展示元数据 | +| `queueLoader.ts` | 条目到 SchedulerTask | + +### `controller/template`(5) + +| 文件 | 责任 | +|---|---| +| `TemplateController.ts` | 模板兼容链路协调 | +| `crud.ts` | 模板 CRUD/导入导出 | +| `selectors.ts` | 模板参数选择 | +| `useTemplate.ts` | 模板实例化 | +| `wizard.ts` | 模板创建向导 | + +### 其他(2) + +| 文件 | 责任 | +|---|---| +| `contracts.ts` | 跨流程最小 Host 契约 | +| `migration/MigrationConflictController.ts` | 迁移冲突复核 | + +## Model(25) + +### 根模型(6) + +| 文件 | 责任 | +|---|---| +| `ApiClient.ts` | AutoWSGR HTTP/WS 客户端 | +| `ConfigModel.ts` | YAML 配置和 GUI automation | +| `MapDataLoader.ts` | 地图读取与缓存 | +| `PlanModel.ts` | 作战方案解析、编辑和未知字段保留 | +| `TaskGroupModel.ts` | 任务组 v4 状态、迁移和持久化 | +| `TemplateModel.ts` | 内置/用户模板兼容 | + +### `model/fleet`(7) + +| 文件 | 责任 | +|---|---| +| `DecisiveFleetDraft.ts` | 决战草稿 | +| `FleetDraft.ts` | 普通编队草稿和 DTO 转换 | +| `FleetDraftEditor.ts` | 编辑意图应用 | +| `FleetPresetIdentity.ts` | 编队预设身份 | +| `FleetRuleMapper.ts` | 规则到 API 映射 | +| `ShipMatcher.ts` | 舰船匹配 | +| `index.ts` | Fleet 领域出口 | + +### `model/scheduler`(11) + +| 文件 | 责任 | +|---|---| +| `CampaignDailyQuota.ts` | 战役每日正常结算额度 | +| `CronScheduler.ts` | 每分钟自动任务触发 | +| `ExpeditionTimer.ts` | 远征倒计时 | +| `NormalFightDailyQuota.ts` | 自动出击每日额度状态 | +| `RepairManager.ts` | 泡澡和轮换编队 | +| `Scheduler.ts` | 任务生命周期 | +| `SchedulerRepairPolicy.ts` | 修理调度纯策略 | +| `SchedulerTaskPolicy.ts` | 任务构建和插入纯策略 | +| `StopConditionChecker.ts` | 三阶段停止条件 | +| `TaskQueue.ts` | 就绪/延迟队列 | +| `index.ts` | Scheduler 领域出口 | + +### 统计(1) + +| 文件 | 责任 | +|---|---| +| `statistics/DailySortieStats.ts` | 今日出征和评级统计 | + +## View(40) + +### 配置(4) + +| 文件 | 责任 | +|---|---| +| `config/ConfigView.ts` | 设置页 Facade | +| `config/ConfigAutomationView.ts` | 自动任务局部视觉 | +| `config/ConfigRuntimeView.ts` | 环境与更新局部视觉 | +| `config/settingSelectWidth.ts` | 下拉宽度纯 DOM 辅助 | + +### 主页面(6) + +`main/MainView.ts` 组合 `NavigationView.ts`、`StatusBar.ts`、 +`TaskQueueView.ts`、`LogView.ts` 和 `FleetPreviewView.ts`。 + +### 方案与编队(16) + +| 文件 | 责任 | +|---|---| +| `BattlePlanLoaderView.ts` | 作战方案选择浮窗 | +| `DecisivePlanView.ts` | 决战页 | +| `FleetEditorView.ts` | 舰位和拖放编辑 | +| `FleetGalleryView.ts` | 普通舰队图库适配 | +| `FleetPlannerView.ts` | 普通舰队 Facade | +| `FleetPresetView.ts` | 方案内舰队预设 | +| `FleetRuleView.ts` | 舰位规则编辑 | +| `GalleryShipCollection.ts` | 图库筛选/排序纯计算 | +| `MapView.ts` | 地图 | +| `NodeEditorView.ts` | 节点编辑器 | +| `PlanManagementView.ts` | 方案管理 | +| `PlanPreviewView.ts` | 方案页 Facade | +| `ShipArtwork.ts` | 舰船卡片图片结构 | +| `ShipGalleryView.ts` | 两页面共享舰船图库 | +| `TeamPlanListUi.ts` | 编队列表纯 UI 辅助 | +| `TeamPlanLoaderView.ts` | 编队方案选择 | + +### 任务组与模板(6) + +- `taskGroup/DailyTaskLoaderView.ts` +- `taskGroup/TaskGroupView.ts` +- `taskGroup/TaskListLoaderView.ts` +- `template/SelectorDialog.ts` +- `template/TemplateLibraryView.ts` +- `template/TemplateWizardView.ts` + +### 共享、迁移、引导和主题(8) + +- `shared/AnimatedSelect.ts` +- `shared/DialogHelper.ts` +- `shared/LoaderDialog.ts` +- `shared/scrollPosition.ts` +- `shared/ShipAutocomplete.ts` +- `migration/MigrationConflictView.ts` +- `setup/SetupWizardView.ts` +- `theme.ts` + +## Types(7) + +| 文件 | 契约 | +|---|---| +| `api.ts` | AutoWSGR REST/WS 和 TaskRequest | +| `fleetEditor.ts` | 编队编辑意图 | +| `ipc.ts` | ElectronBridge 与 Main DTO | +| `model.ts` | 配置、方案、模板等领域类型 | +| `scheduler.ts` | SchedulerTask、状态和回调 | +| `statistics.ts` | 出征统计 | +| `view.ts` | Controller 到 View 的 ViewObject | + +## Shared(13) + +| 文件 | 责任 | +|---|---| +| `campaign.ts` | 每日战役固定次数 | +| `decisiveAutomation.ts` | 自动决战来源 | +| `decisivePlan.ts` | 决战持久化契约 | +| `fleetShipTypes.ts` | 22 舰种公开规则 | +| `legacyDecisiveAutomation.ts` | 旧决战字段归档 | +| `lootPlans.ts` | 战利品计划稳定标识 | +| `migrationConflicts.ts` | 迁移冲突 DTO | +| `nativeFleetShipTypes.generated.ts` | 从 AutoWSGR 同步的生成快照 | +| `nodeDecision.ts` | 节点决策纯规则 | +| `normalFightQuota.ts` | 自动出击额度纯规则 | +| `shipCatalog.ts` | 只读舰船目录辅助 | +| `shipNameNormalizer.ts` | 舰名规范化 | +| `taskPreset.ts` | 独立任务预设 Codec | + +`shared` 只能放无状态、无 DOM、无 Electron、无浏览器存储的跨层逻辑。 + +## Utils(1) + +`utils/Logger.ts` 统一 Renderer 日志缓冲、输出和刷新。 + +## 定位方法 + +文件数会随实现变化。新增、删除或移动模块后,用以下命令核对本索引: + +```powershell +rg --files src -g "*.ts" +rg -n "export (class|interface|type|function|const|enum)" src +``` diff --git a/docs/architecture/10-runtime-boundaries-adr.md b/docs/architecture/10-runtime-boundaries-adr.md new file mode 100644 index 0000000..2c31105 --- /dev/null +++ b/docs/architecture/10-runtime-boundaries-adr.md @@ -0,0 +1,156 @@ +# ADR-001:当前运行时边界 + +- 状态:已接受 +- 基线:当前工作区代码,包括未提交改动 +- 范围:Renderer、Electron Main、Python 后端、存储、迁移和更新 + +## 背景 + +项目同时包含浏览器运行时、Node/Electron 运行时和 Python 后端。方案、配置和 +任务又存在多个持久化来源。若状态所有权或依赖方向不明确,容易出现两份状态、 +View 直连文件系统、安装目录被写入、迁移不可重试等问题。 + +以下决策是当前实现必须保持的边界。 + +## 决策 1:Renderer 单向数据流 + +```text +Repository / Model -> Controller -> ViewObject -> View +View -> 用户意图 -> Controller +``` + +- Controller 编排,不拥有 DOM。 +- View 拥有 DOM 和局部视觉状态,不访问有状态 Model、Adapter、ApiClient 或 + ElectronBridge。 +- Model 拥有领域状态和规则,不操作 DOM。 +- Adapter 隔离 IPC、HTTP、WebSocket、序列化和浏览器存储。 +- Shared 只包含无状态、跨运行时可复用逻辑。 + +边界由 `test-renderer-architecture.js` 强制。 + +## 决策 2:组合根不承载业务规则 + +`AppController` 是 Renderer 组合根,`electron/main.ts` 是 Main 组合根。它们 +允许创建对象、注入依赖、注册生命周期和协调顶层流程,不实现: + +- YAML Codec。 +- 路径安全。 +- 方案归一化。 +- Python/更新策略。 +- 舰队分配规则。 +- 文件持久化细节。 + +业务分别进入 Controller 用例模块、Model、Service、Repository 或 Codec。 + +## 决策 3:Preload 是唯一 Electron 桥 + +Renderer 只通过 `window.electronBridge` 使用 Main 能力,且 Controller 依赖 +`IpcAdapter` 裁剪后的窄 Gateway。`src/types/ipc.ts` 是桥接 DTO 的类型来源。 + +新增通道必须同步 preload、Main IPC、Adapter 和契约测试。同步 getter 的 +`sendSync/ipcMain.on` 配对不能单侧修改。 + +## 决策 4:系统资源只读,用户数据写 userData + +| 数据 | 位置 | +|---|---| +| 系统作战/编队/日常方案、地图、内置模板 | `resource/` | +| 用户作战方案 | `userData/user_battle_plans/` | +| 用户编队方案 | `userData/user_team_plans/` | +| 用户日常方案 | `userData/user_daily_plans/` | +| YAML/GUI 设置、任务组、用户模板 | `userData/` | +| 舰船资料库工作副本 | `userData/ship-library/` | +| 迁移账本 | `userData/.migration-state.json` | +| 执行计划 | temp 的进程专属目录 | + +安装目录中的可变文件只作为旧迁移来源。通用文件 IPC 不获得任意磁盘读写权。 + +## 决策 5:配置是跨文件事务 + +`usersettings.yaml` 和 `gui_settings.json` 是不同域,但设置页一次保存必须保持 +一致: + +```text +写 YAML -> 原子写 JSON -> 失败则恢复 YAML -> 成功后更新 Renderer 内存 +``` + +`GuiSettingsStore` 保留未知顶层字段;新的旧配置转换需要独立迁移标记。 + +## 决策 6:方案使用 Codec/Repository/Service + +- Codec:结构、兼容和未知字段保留。 +- Repository:系统/用户来源、路径、文件和原子写入。 +- Service:导入、保存、重命名、删除、关联和运行时准备。 +- IPC:参数和结果边界。 + +Renderer 的 `PlanModel.rawRoot` 保留未建模 YAML。系统和用户同名文件仍是不同 +identity。运行前由 `RuntimePlanService` 展开到临时目录。 + +## 决策 7:普通编队与决战状态独立 + +`FleetPlannerController` 独占普通 `FleetDraft`, +`DecisivePlanController` 独占 `DecisiveFleetDraft`。两者共享 +`ShipGalleryView` 视觉行为,但不共享草稿、文件 identity 或保存状态。 + +共享图库必须用 `AbortController` 和 `ResizeObserver.disconnect()` 完整释放, +释放链到达 `AppController.onBeforeUnload`。 + +## 决策 8:调度区分轮次与逻辑任务 + +- `id`:物理轮次。 +- `logicalId`:有限/无限任务、重试、gap 和修理等待的稳定身份。 +- Cron pending 和取消使用 `logicalId`。 +- 未到终点或战果不满足的成功轮次不减少 `remainingTimes`。 +- 自动任务记录实际完成/处理,不在入队时提前标记。 + +自动战役固定每日 8 次正常结算。常规出击额度按计划来源、文件和舰队去重。 + +## 决策 9:迁移是可重试状态机 + +`MigrationStateStore` 独占 `.migration-state.json`: + +1. 完成 marker 只合并,不覆盖。 +2. 文件全部成功后才完成阶段。 +3. 失败只重试未完成项。 +4. 源文件不修改、不删除。 +5. 同名不同内容保留“(旧版)”。 +6. 旧来源用 started/configuration-complete/complete 封存。 + +当前主要版本为用户数据 v6、旧方案 v7。 + +## 决策 10:后端来源与能力先验证 + +managed 和 external 使用唯一明确 AutoWSGR 来源。启动前检查: + +- 实际 import 路径。 +- OCR GPU 和截图环境变量行为。 +- `autowsgr.server.main:app` 的 ASGI 能力。 +- Python 3.12/3.13、依赖和 CUDA。 + +不满足时直接失败,不回退到另一后端来源。 + +## 决策 11:启动与退出顺序固定 + +单实例锁必须早于更新、迁移、pip 和窗口。pending 更新必须早于迁移和窗口。 + +退出时必须先保存窗口状态,再正式停止后端、等待进程树、停止内置 ADB,最后 +退出。无法确认释放时阻止退出和更新安装。 + +## 决策 12:生成源与运行产物分离 + +- HTML 源:`src/view/html/**` +- SCSS 源:`src/view/styles/**/*.scss` +- Electron 运行:生成的 `src/view/index.html` 和 `styles.css` +- Renderer 运行:`dist/renderer.bundle.js` + +生成的 HTML/CSS 提交到仓库;partial 和 TypeScript 不打入安装包。生成文件不 +手改。 + +## 后果 + +- 新状态必须先确定唯一所有者。 +- 新文件必须先确定只读资源或 userData 位置。 +- 新共享组件必须有真实复用和完整生命周期。 +- 新迁移必须有独立 marker 与失败重试测试。 +- 新 IPC 必须补桥接契约测试。 +- 修改调度身份、后端来源或退出顺序时必须增加对应领域/Service 测试。 diff --git a/docs/architecture/11-renderer-visual-architecture.md b/docs/architecture/11-renderer-visual-architecture.md new file mode 100644 index 0000000..b25eba1 --- /dev/null +++ b/docs/architecture/11-renderer-visual-architecture.md @@ -0,0 +1,224 @@ +# Renderer 视觉架构 + +> 目录:`src/view/html/`、`src/view/`、`src/view/styles/` + +## 运行形态 + +Renderer 使用原生 TypeScript、DOM API 和 SCSS,没有运行时组件框架。 + +```mermaid +flowchart LR + Partial["HTML partial"] --> BuildHtml["build-view-html.js"] + BuildHtml --> HTML["src/view/index.html"] + SCSS["main.scss + partial"] --> Sass["sass"] + Sass --> CSS["styles.css"] + TS["View/Controller/Model"] --> TSC["tsc + esbuild"] + TSC --> Bundle["renderer.bundle.js"] + HTML --> Window["单一 BrowserWindow"] + CSS --> Window + Bundle --> Window +``` + +Electron 始终加载一个 HTML、一个 CSS 和一个 Renderer Bundle。HTML partial +只在构建期展开,不能改成运行时 fetch、iframe 或多页面入口。 + +## HTML 所有权 + +```text +src/view/html/ +├─ index.html +├─ layout/ +│ └─ navigation.html +├─ pages/ +│ ├─ main.html +│ ├─ config/ +│ └─ plan/ +└─ dialogs/ +``` + +规则: + +1. 只编辑 `src/view/html/**`,不手改 `src/view/index.html`。 +2. partial 按页面/区域职责拆分,不按行数拆分。 +3. View 引用的 DOM ID 是契约。 +4. include 不能逃出 HTML 源目录,也不能循环。 +5. 保持展开后的 DOM 顺序,避免事件和 CSS 选择器行为变化。 +6. 运行时创建的节点放在明确的 View 创建函数。 + +`test-renderer-dom-contract.js` 检查重复 ID、静态 View 引用缺失和例外白名单是否 +过期。 + +## View 边界 + +View 负责: + +- DOM 查找、渲染和浏览器事件。 +- 表单局部值、搜索、筛选、排序、展开和 loading。 +- 动画、滚动和 Observer。 +- 把用户意图通过回调上报。 +- 释放自己创建的监听器和资源。 + +View 不负责: + +- 业务默认值和跨页面状态。 +- 文件 identity 和持久化 DTO。 +- Scheduler、配置或舰队草稿的权威状态。 +- Electron IPC、ApiClient 或 Repository。 +- 导入有状态 Model。 + +架构测试仅允许 `view/theme.ts` 通过 `StorageAdapter` 管理纯 UI 偏好。 + +## 视觉组件类型 + +| 类型 | 作用 | 示例 | +|---|---|---| +| Facade | 保持 Controller 公共 API,组合子 View | `ConfigView`、`FleetPlannerView`、`PlanPreviewView` | +| 职责子 View | 独占一个局部视觉区域 | `ConfigAutomationView`、`ConfigRuntimeView` | +| 共享组件 | 两个以上真实调用方共享完整视觉行为 | `ShipGalleryView` | +| 页面适配 | 把页面差异转换成共享组件 Host | `FleetGalleryView` | +| 纯 UI 函数 | 无状态转换或 DOM 创建 | `GalleryShipCollection`、`ShipArtwork` | + +不要为缩短文件创建只转发一层的包装器。只有存在清晰视觉责任或真实复用时才 +拆分。 + +## 配置页 + +`ConfigView` 保持设置页 Facade API,内部使用: + +- `ConfigAutomationView`:自动出击列表、摘要、剩余次数和战利品计划。 +- `ConfigRuntimeView`:Python、CUDA、后端、ADB、资料库和更新进度。 +- `settingSelectWidth.ts`:根据选项文案计算受控宽度。 + +Controller 仍只面对 `ConfigView`。子 View 不获得 ConfigModel、配置 Gateway 或 +Scheduler。 + +HTML: + +```text +pages/config/index.html +pages/config/behavior.html +pages/config/system.html +``` + +SCSS: + +```text +pages/_config.scss + -> config/layout + -> config/setting-controls + -> config/automation-summary + -> config/form-controls + -> config/automation-list + -> config/status-and-drafts + -> config/responsive +``` + +## 舰船图库 + +`ShipGalleryView` 在普通舰队和决战页复用: + +- 搜索、舰种/国家/改造筛选。 +- 排序与降序。 +- 批量增量渲染。 +- 卡片创建与交互。 +- 滚动位置恢复。 +- 拖拽起点。 + +页面差异由 `ShipGalleryViewHost` 注入: + +- 当前舰位说明。 +- 排除规则。 +- 点击分配。 +- 展示名。 +- 改造筛选偏好。 +- 编辑是否可用。 +- 可选拖拽协议。 + +普通舰队的主选/候选、决战的 level1/level2、脏状态和保存都不能放进共享图库。 +也不能在 `FleetGalleryView` 或 `DecisivePlanView` 复制一套筛选渲染循环。 + +## 生命周期 + +`ShipGalleryView` 创建: + +- 一组带同一 `AbortSignal` 的 DOM/document 监听器。 +- 一个 `ResizeObserver`。 + +`dispose()` 必须幂等: + +```text +eventController.abort() +resizeObserver.disconnect() +``` + +释放链: + +```text +AppController.onBeforeUnload + -> FleetPlannerController.dispose() + -> FleetPlannerView.dispose() + -> ShipGalleryView.dispose() + +AppController.onBeforeUnload + -> DecisivePlanController.dispose() + -> DecisivePlanView.dispose() + -> ShipGalleryView.dispose() +``` + +新增 window/document 监听器、Observer、interval 或 requestAnimationFrame 时, +必须定义所有者和释放入口。 + +## SCSS 所有权 + +```text +src/view/styles/ +├─ base/ # 变量、重置、基础元素 +├─ components/ # 多页面复用组件 +├─ pages/ # 页面布局和页面组件 +├─ themes/ # 主题覆盖 +└─ main.scss # 唯一聚合入口 +``` + +舰队页面聚合: + +```text +pages/plan/_fleet-planner.scss + -> plan-navigation + -> plan-management + -> fleet-editor + -> ship-gallery + -> fleet-dialogs + -> fleet-responsive +``` + +移动 SCSS 时保持选择器、属性和加载顺序。机械拆分不应同时改变视觉效果。 +只有两个以上页面共享的独立组件才进入 `components/`。 + +## ViewObject 与意图 + +View 读取只读展示对象,不保存业务对象引用。编辑流程应是: + +```text +View 用户动作 + -> 显式 intent / callback + -> Controller + -> Model 应用规则 + -> 新 snapshot/ViewObject + -> View render +``` + +表单草稿可属于 View;保存后的配置、方案和舰队草稿不属于 View。 + +## 修改门禁 + +| 范围 | 验证 | +|---|---| +| HTML partial/DOM ID | `npm run build:html`、`npm run test:renderer-contract` | +| View TypeScript | `npm run test:architecture-boundaries` | +| 配置页 | 上述命令 + `npm run test:settings` | +| 舰队/决战图库 | 上述命令 + `npm run test:fleet-domain` | +| SCSS | `npm run build` + 人工检查页面和响应式 | +| 打包入口 | `npm run test:build`、`npm run pack` | + +最终运行 `npm run build`,提交同步生成的 `index.html` 和 `styles.css`。静态测试 +不能替代 Electron 中的点击、拖放、弹窗、滚动和窗口关闭回归。 diff --git a/docs/architecture/12-agent-entry-guide.md b/docs/architecture/12-agent-entry-guide.md new file mode 100644 index 0000000..b78a19d --- /dev/null +++ b/docs/architecture/12-agent-entry-guide.md @@ -0,0 +1,203 @@ +# AGENT 进入指南 + +这份文档用于让第一次进入仓库的 AGENT 在不破坏边界的前提下定位和修改代码。 + +## 先确认基线 + +本项目经常存在未提交实现。不要只看 HEAD: + +```powershell +git status --short +git diff --stat +git diff --cached --stat +``` + +以当前工作区文件为事实来源。遇到已有改动: + +- 不回退用户改动。 +- 阅读当前完整文件和 diff。 +- 在已有实现上继续。 +- 只修改请求涉及的范围。 + +## 5 分钟建立上下文 + +```powershell +Get-Content package.json -Raw +rg --files src electron scripts/tests +rg -n "目标类名|目标方法|界面文案" src electron scripts +``` + +然后阅读: + +1. [总架构](00-overview.md)。 +2. [运行时边界 ADR](10-runtime-boundaries-adr.md)。 +3. 对应专题。 +4. 目标文件的调用方、类型和测试。 + +不要从生成的 `src/view/index.html`、`styles.css` 或 `dist/**` 反推源代码。 + +## 需求到文件 + +| 需求 | 首要入口 | 通常还要检查 | +|---|---|---| +| 应用启动/退出 | `electron/main.ts` | `SingleInstanceService`、`WindowService`、Backend shutdown | +| Renderer 启动 | `StartupController.ts` | `startup/connection.ts`、`envAndUpdates.ts` | +| 页面导航 | `NavigationController.ts`、对应 View | HTML partial、`AppController` 装配 | +| 设置字段 | `ConfigModel.ts`、`ConfigController.ts` | Config View、IPC 类型、GuiConfigurationService | +| 配置持久化 | `GuiSettingsCommitService.ts` | Store、ConfigurationIpc、preload | +| 自动任务 | `CronScheduler.ts`、`SchedulerBinder.ts` | ScheduledTaskLoader、额度 Model、配置 | +| 队列/重试/次数 | `Scheduler.ts` | TaskQueue、SchedulerTaskPolicy、scheduler types | +| 作战方案 YAML | `PlanModel.ts` | CombatPlanCodec、Controller rendering | +| 方案管理 | `PlanManagementController.ts` | Main PlanManagementService、Repository | +| 编队规则 | `src/model/fleet/` | FleetPlannerController、fleetEditor types | +| 决战编队 | `DecisiveFleetDraft.ts` | DecisivePlanController/View、DailyPlanService | +| 舰船图库 | `ShipGalleryView.ts` | FleetGalleryView、DecisivePlanView、SCSS | +| 任务组 | `TaskGroupModel.ts` | `controller/taskGroup/*`、迁移测试 | +| 模板 | `TemplateModel.ts` | `controller/template/*`、旧任务组兼容 | +| IPC 方法 | `src/types/ipc.ts` | preload、Main IPC、IpcAdapter | +| 后端 API | `src/types/api.ts`、`ApiClient.ts` | Scheduler/Controller、API 契约测试 | +| Python/CUDA | `electron/pythonEnv/` | Environment Service、BackendRuntimeContract | +| ADB/模拟器 | `AdbService.ts`、`emulatorDetect.ts` | DeviceIpc、设置页 | +| 迁移 | `UserDataMigrationService.ts` / `LegacyPlanMigration.ts` | MigrationStateStore、fixtures | +| GUI 更新 | `UpdaterIpc.ts`、`GuiUpdatePolicy.ts` | Installer、state store、main lifecycle | +| HTML | `src/view/html/` | 对应 View 和 DOM 契约 | +| 样式 | `src/view/styles/` | HTML/View 所有权和聚合入口 | +| 构建/打包 | `package.json` | scripts、builder config、workflow | + +## 判断代码应该放哪 + +问四个问题: + +1. 是否操作 DOM、浏览器事件或动画?放 View。 +2. 是否是领域状态、校验或纯业务规则?放 Model 或 Shared。 +3. 是否协调多个对象完成一个用例?放 Controller。 +4. 是否访问 Electron、文件、HTTP、WS 或浏览器存储?放 Adapter/Main Service。 + +Main 侧再区分: + +- IPC:输入输出边界。 +- Service:用例和业务策略。 +- Repository:目录、文件和来源。 +- Codec:YAML/JSON 结构和兼容。 +- `main.ts`:只装配和生命周期。 + +## 禁止依赖 + +### Controller 禁止 + +```text +document.* +window.electronBridge +localStorage.* +window.addEventListener +window.matchMedia +HTMLElement / HTMLInputElement / ResizeObserver 等 DOM 类型 +``` + +### View 禁止 + +```text +stateful Model import +ApiClient import +Adapter import +window.electronBridge +localStorage.* +``` + +`view/theme.ts` 通过 StorageAdapter 管理 UI 偏好是已有例外。 + +### Shared 禁止 + +```text +DOM +Electron +Node 文件系统 +浏览器存储 +有状态 singleton +``` + +## 修改步骤 + +1. 用 `rg` 找定义、调用方、类型和测试。 +2. 阅读当前文件,不只读 Git 版本。 +3. 确定状态唯一所有者和依赖方向。 +4. 写出最小变更范围。 +5. 先改开发源,再生成产物。 +6. 跑最小专项测试。 +7. 跑构建/架构门禁。 +8. 检查 diff 是否只包含预期文件。 + +```powershell +git diff --check +git status --short +git diff -- +``` + +## 最小验证矩阵 + +| 改动范围 | 必跑 | +|---|---| +| 任意 TypeScript/HTML/SCSS | `npm run test:build` | +| Controller/View 边界 | `npm run test:architecture-boundaries` | +| HTML/DOM ID | `npm run test:renderer-contract` | +| 设置页 | `npm run test:settings` | +| Scheduler/Cron/额度 | `npm run test:scheduler-domain` | +| Fleet/舰种/候选 | `npm run test:fleet-domain`、`npm run check:fleet-types` | +| Main Service | `npm run test:main-services` | +| preload/IPC | `npm run test:main-ipc` | +| 迁移 | `npm run test:migrations` | +| 后端请求/DTO | `npm run test:api-contract` | +| Python/CUDA/后端来源 | `npm run test:python-environment` | +| 打包资源 | `npm run pack`、`npm run test:release-package` | + +专项测试通过不代表构建门禁可以省略。涉及用户交互时还要运行 Electron 做实际 +页面回归。 + +## 生成文件 + +| 修改源 | 必须生成 | +|---|---| +| `src/view/html/**` | `npm run build:html` -> `src/view/index.html` | +| `src/view/styles/**/*.scss` | `npm run build:css` -> `styles.css` | +| TypeScript | `npm run build` -> `dist/**`,但 `dist` 不作为手改源 | + +安装包只包含生成后的 HTML/CSS 和 Bundle,不包含 HTML/SCSS/TS 源。 + +## 高风险不变量 + +- 新/切换地图计划默认只有节点 `0`;只有 `0` 时禁止入队。 +- 节点参数继承 `node_defaults`。 +- `PlanModel.rawRoot` 和 Main Codec 保留未知 YAML 字段。 +- 普通舰队与决战草稿独立。 +- candidate-only 不提升第一候选为主选。 +- 舰种使用 22 canonical code,导巡为 `KP/kp`。 +- 调度用 `logicalId` 管理整个逻辑任务。 +- 未到终点或战果不足不减少轮次。 +- 自动战役固定每日 8 次正常结算。 +- 系统资源只读,用户数据只写 userData。 +- 迁移完成 marker 不覆盖,写完文件后才标记。 +- 次实例不能执行迁移、pip 或创建第二个主窗口。 +- 退出/安装更新前必须确认后端进程树和内置 ADB 已释放。 + +## 何时停止扩大改动 + +出现以下情况时,回到边界重新设计,而不是继续加特殊判断: + +- View 需要完整 Model 或 Repository。 +- Controller 需要 DOM 元素。 +- IPC 开始解析 YAML 或写业务规则。 +- 同一状态在两个对象中都可写。 +- 新 fallback 吞掉 Controller、页面、OCR 或环境异常。 +- 为一个调用方创建“共享”组件。 +- 修改生成文件才能让源码工作。 + +## 完成标准 + +一个修改只有同时满足以下条件才算完成: + +- 行为由正确层负责。 +- 当前工作区已有改动未被覆盖。 +- 生成文件与开发源一致。 +- 受影响专项测试和构建门禁通过。 +- Electron 交互在需要时已回归。 +- 文档、类型、preload、IPC 和调用方没有遗漏的契约变化。 diff --git a/docs/architecture/13-release-version-governance.md b/docs/architecture/13-release-version-governance.md new file mode 100644 index 0000000..f72b10e --- /dev/null +++ b/docs/architecture/13-release-version-governance.md @@ -0,0 +1,97 @@ +# 发布版本与更新桥接规则 + +本文是维护者和开发 Agent 修改 GUI 版本、更新频道与 Release 工作流时的强制 +契约。目标是确保已安装的 1.4.x Stable 和 2.x Alpha 客户端都能沿单调递增的 +SemVer 路径进入后续 Stable,不依赖自动降级或用户手动覆盖安装。 + +## 版本格式 + +只发布以下两类 GUI 版本: + +| 类型 | 格式 | 频道 | GitHub Release | +|---|---|---|---| +| Stable | `X.Y.Z` | `latest` | 正式版 | +| Alpha | `X.Y.Z-alpha.N` | `alpha` | prerelease | + +新的 Alpha 必须带递增序号 `.N`。不要继续创建无序号的 `X.Y.Z-alpha`。不得在 +同一发布线混用 beta、rc、dev 或非 SemVer 后缀。 + +## 2.1 首次稳定版桥接 + +线上已有最高 Alpha 为 `2.0.16-alpha`,其旧 updater 只接受 `alpha` 频道。 +2.1 首次稳定版必须按以下顺序发布: + +```text +2.0.16-alpha +→ 2.1.0-alpha.1 +→ 2.1.0 +``` + +禁止把首个 Stable 定为 `2.0.1`、`2.0.16` 或任何不高于现有 Alpha 的版本。 +`allowDowngrade` 必须保持 `false`。 + +`2.1.0-alpha.1` 是迁移桥: + +- 旧 `2.0.16-alpha` 通过 `alpha.yml` 发现它; +- Alpha 构建在 `allow_test_updates` 字段缺失时默认继续接收测试版; +- 新 updater 接受 Stable 和更高 Alpha; +- 用户关闭“允许测试版更新”后切换到 `latest`,等待 `2.1.0`,不降级。 + +## 双仓库发布责任 + +两个已发布客户端群使用不同更新源: + +| 已安装用户 | 固定更新源 | 所需资产 | +|---|---|---| +| 1.4.x Stable | `yltx/AutoWSGR-GUI` | `latest.yml`、Stable EXE、blockmap | +| 2.0.x Alpha | `ShiinaKuroko/AutoWSGR-GUI` | `alpha.yml`、桥接 Alpha EXE、blockmap | + +因此,不能只在其中一个仓库发布 `2.1.0` 后宣称所有旧用户可自动升级。 + +Stable 发布必须同时完成: + +1. `yltx/AutoWSGR-GUI` 发布完整 `latest` 资产,覆盖 1.4.x。 +2. `ShiinaKuroko/AutoWSGR-GUI` 发布完整 Stable 资产。 +3. Stable Release 在迁移窗口内附带 `2.1.0-alpha.1` 的 `alpha.yml`、EXE 和 + blockmap,覆盖在 Stable 发布后才恢复检查更新的旧 Alpha 用户。 +4. 两边资产校验通过后再公开 Release;任何权限、Tag、资产冲突都必须 + fail closed。 + +后续 Stable 成为 Release feed 最新条目时,在维护者明确结束旧 Alpha 迁移窗口 +之前,也必须继续携带兼容 Alpha 三件套,否则休眠客户端可能再次被阻断。 + +## 后续版本号选择 + +- `2.1.0-alpha.N` 只能递增 `N`,Stable 为 `2.1.0`。 +- `2.1.0` 发布后,下一个测试线从 `2.1.1-alpha.1` 开始;不要发布 + `2.1.0-alpha.N` 的新构建。 +- 下一个 Stable 是 `2.1.1`,并满足 + `2.1.1-alpha.N < 2.1.1`。 +- 只有不兼容契约或明确产品代际变化才提升 minor/major;不要用版本号规避 + 更新频道或历史 Alpha 排序问题。 +- 已发布版本、Tag、清单和资产不可覆盖或复用。 + +## Agent 修改发布代码前的门禁 + +任何 Agent 在修改 `package.json`、builder 配置、updater、Release workflow 或 +频道清单前,必须: + +1. 查询两个仓库当前最高 Stable 和 Alpha。 +2. 用 SemVer 证明所有受支持客户端到候选版本都是严格向上升级。 +3. 检查旧客户端实际写死的 repository、channel 和候选校验逻辑。 +4. 为 Stable 和 Alpha 各生成一次真实 NSIS 候选并运行 + `npm run test:release-package`。 +5. 验证 Stable 默认不接受 Alpha、桥接 Alpha 可进入 Stable、 + `allowDowngrade` 仍为 `false`。 +6. 核对两个更新源均有写权限、无同名 Tag/Release/资产冲突。 +7. 未完成真实 feed 下载验证时,只能说明“候选与离线契约通过”,不得宣称 + 已安装用户能够在线收到更新。 + +## 禁止事项 + +- 不得让 `alpha.yml` 声明 Alpha 版本却下载版本号不同的 Stable 安装包。 +- 不得通过开启 downgrade 把高版本 Alpha 强制降到低版本 Stable。 +- 不得只改 GitHub Release 的 prerelease 标志而忽略客户端频道清单。 +- 不得在一个仓库发布后静默跳过另一个仓库。 +- 不得在 release workflow 中把写 Token 暴露给构建和测试步骤。 +- 不得删除或移动已有远端 Tag 来修复发布错误。 diff --git a/docs/architecture/README.md b/docs/architecture/README.md index baf8867..8bdf3a7 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -1,25 +1,44 @@ # 架构文档 -面向 AutoWSGR-GUI 开发者的架构参考文档。 +本目录描述 **当前工作区代码** 的实际架构,包括尚未提交或尚未 push 的实现。 +代码、`package.json`、构建脚本和 CI 配置是最终事实来源;文档与代码冲突时, +先按代码确认行为,再同步修正文档。 -> 本目录描述当前系统结构。强制性的依赖方向、状态所有权、迁移、验证和 Patch 止损要求见[工程与代码规范](../engineering-standards.md)。 +## 第一次进入项目 -## 目录 +按以下顺序阅读: -| # | 文档 | 说明 | -|---|------|------| -| 0 | [总架构文档](00-overview.md) | 项目简介、分层架构、目录结构、启动流程、关键模式、Types 层组织 | -| 1 | [Controller 层](01-controller-layer.md) | ControllerHost/DI 模式、6 个子目录结构、StartupController 启动编排 | -| 2 | [任务调度系统](02-task-scheduling.md) | Scheduler、TaskQueue、CronScheduler、远征轮询、停止条件、修理轮换 | -| 3 | [配置系统](03-configuration.md) | ConfigModel、ConfigController、ConfigView、usersettings.yaml、gui_settings.json | -| 4 | [出击计划系统](04-battle-plan.md) | PlanModel、PlanController(拆分)、PlanPreviewView(Facade)、MapDataLoader | -| 5 | [模板与任务组](05-template-and-taskgroup.md) | TemplateController(拆分)、TaskGroupController(拆分)、模板视图、队列加载 | -| 6 | [后端通信](06-backend-communication.md) | IPC Bridge、ApiClient、REST API、WebSocket 事件 | -| 7 | [环境管理](07-environment-management.md) | pythonEnv/ 子模块(7 文件)、模拟器检测、后端进程生命周期 | -| 8 | [开发环境搭建](08-dev-setup.md) | 依赖安装、开发/构建/打包命令、SCSS 架构、调试技巧 | +1. [总架构](00-overview.md):先建立进程、分层、目录和启动顺序的全局认识。 +2. [AGENT 进入指南](12-agent-entry-guide.md):按需求定位文件,确定最小修改和验证命令。 +3. [运行时边界 ADR](10-runtime-boundaries-adr.md):确认不能破坏的状态、存储和生命周期边界。 +4. 再阅读对应业务专题,不需要从头读完全部文档。 -## 阅读建议 +## 专题目录 -- **新上手**:从 [总架构文档](00-overview.md) 开始,了解整体结构和启动流程 -- **改某个功能**:直接跳到对应的子模块文档;Controller 层组织参见 [Controller 层](01-controller-layer.md) -- **搭建开发环境**:参见 [开发环境搭建](08-dev-setup.md) +| # | 文档 | 主要内容 | +|---|---|---| +| 00 | [总架构](00-overview.md) | Electron、Renderer、Python 后端、目录、存储和启动流程 | +| 01 | [Controller 层](01-controller-layer.md) | 组合根、最小 Host、单向数据流和层级禁区 | +| 02 | [任务调度](02-task-scheduling.md) | Scheduler、Cron、逻辑任务、重试、终点计数和每日额度 | +| 03 | [配置系统](03-configuration.md) | YAML/JSON 双存储、事务保存、配置页拆分和迁移 | +| 04 | [方案与编队](04-battle-plan.md) | 作战、编队、日常方案,PlanModel、Codec/Repository/Service | +| 05 | [模板与任务组](05-template-and-taskgroup.md) | 模板兼容链路、任务组 v4、四类条目和入队 | +| 06 | [通信边界](06-backend-communication.md) | Preload/IPC、Adapter、HTTP、WebSocket 和异常边界 | +| 07 | [环境与生命周期](07-environment-management.md) | Python、CUDA、ADB、后端启动、迁移、更新和退出 | +| 08 | [开发与验证](08-dev-setup.md) | 构建、生成文件、测试、CI 和打包 | +| 09 | [`src` 模块索引](09-src-typescript-catalog.md) | Renderer 各目录和关键文件定位 | +| 10 | [运行时边界 ADR](10-runtime-boundaries-adr.md) | 当前必须保持的架构决策 | +| 11 | [Renderer 视觉架构](11-renderer-visual-architecture.md) | HTML partial、View、共享图库、SCSS 和生命周期 | +| 12 | [AGENT 进入指南](12-agent-entry-guide.md) | 需求到文件映射、修改步骤、验证矩阵和止损规则 | +| 13 | [发布版本治理](13-release-version-governance.md) | Stable/Alpha 版本号、双仓更新源和 2.1 桥接规则 | + +## 三条先决规则 + +1. 不直接修改生成文件 `src/view/index.html`、`src/view/styles/styles.css` 或 + `dist/**`;修改源文件后运行 `npm run build`。 +2. Renderer Controller 不接触 DOM、`window.electronBridge` 或 + `localStorage`;View 不导入有状态 Model、Adapter 或 ApiClient。 +3. 系统资源只读,用户可变数据写入 Electron `userData`;主进程文件能力必须 + 经过 Service 和 IPC 边界。 + +工程级强制规范见项目根目录的 [AGENTS.md](../../AGENTS.md)。 diff --git a/docs/developer/ocr-log-analyzer.md b/docs/developer/ocr-log-analyzer.md new file mode 100644 index 0000000..89c513d --- /dev/null +++ b/docs/developer/ocr-log-analyzer.md @@ -0,0 +1,81 @@ +# OCR 日志分析工具 + +`tools/ocr_log_analyzer.py` 是 GUI 源码仓库中的独立开发者工具。它不会被 +Electron、GUI 页面或 AutoWSGR 后端调用,也不在 `electron-builder` 的安装包 +文件白名单中。 + +工具从日志中提取准备页舰名 OCR 结果,用于收集、人工确认和汇总识别差异。 +只依赖 Python 标准库,可以使用仓库的便携版 Python 或系统 Python 运行。 + +## 快速使用 + +分析单个日志: + +```powershell +.\python\python.exe tools\ocr_log_analyzer.py ..\autowsgr_2026-08-05.debug.log +``` + +分析目录或多个日志: + +```powershell +.\python\python.exe tools\ocr_log_analyzer.py ..\*.log ` + --output-dir ocr-log-report +``` + +工具会根据时间、日志来源、槽位和 OCR 内容对重复事件去重。因此可以同时传入 +同一次运行生成的 INFO 和 DEBUG 日志,不会重复统计。 + +## 输出文件 + +- `ocr_report.md`:默认先看这个;按真实舰名列出次数、识别结果和解决办法。 +- `ocr_review.csv`:所有唯一识别结果的人工真值复核表。 +- `ocr_corrections.txt`:根据人工真值生成、可粘贴到 GUI 的纠错规则。 +- `ocr_observations.csv`:排查用的逐槽 OCR 明细。 +- `ocr_summary.csv`:排查用的原文、补丁和程序结果聚合统计。 + +CSV 使用 UTF-8 BOM 编码,可以直接使用 Windows Excel 打开。 + +报告默认只保留日志文件名和行号,不输出用户本机绝对路径,也不会复制与 OCR +无关的日志内容。 + +## 人工复核流程 + +1. 首次运行后先看 `ocr_report.md`,其中直接列出高频未匹配和候选提示。 +2. 在 `ocr_review.csv` 的 `actual_ship` 列填写人工确认的真实舰名。要得到完整的 + “每艘船出现几次”统计,需要确认表中的所有唯一识别结果。 +3. 使用填写后的复核表再次运行: + +```powershell +.\python\python.exe tools\ocr_log_analyzer.py ..\*.log ` + --review ocr-log-report\ocr_review.csv ` + --output-dir ocr-log-report +``` + +工具会按真实舰名汇总: + +```text +U-47:19 次(正确 0,问题 19) +- 14 次:OCR 0.47.狼群 → 补丁 U.47.狼群 → 未匹配 +- 处理:加入 0.47.狼群: U-47 +``` + +`target_slot_hint` 只作为候选提示。换船期间舰队位置可能尚未对齐,不能把它当成 +真实舰名。只有人工填写的 `actual_ship` 会被工具视为真值。 + +## 规则安全 + +当前用户纠错采用“原文包含规则键即替换”的语义。工具不会自动生成单字符或 +两位 ASCII 规则,避免 `71: Z1` 之类的短规则误伤其他舰名;`初戛: 初夏` +这种完整的双汉字规则可以生成。被跳过的规则会记录在 `ocr_report.md`。 + +工具只解析 `[准备页] 编队 OCR 识别` 结构化日志。其他场景需要先在运行时代码中 +输出同等信息的结构化记录,不能通过拼接相邻调试文本推断真值。 + +## 开发验证 + +```powershell +npm run test:ocr-log-analyzer +``` + +测试使用 Python 标准库 `unittest`,依次尝试 `AUTOWSGR_PYTHON`、仓库便携版 +Python 和系统 Python。 diff --git a/docs/engineering-standards.md b/docs/engineering-standards.md deleted file mode 100644 index 2080042..0000000 --- a/docs/engineering-standards.md +++ /dev/null @@ -1,464 +0,0 @@ -# AutoWSGR-GUI 工程与代码规范 - -本文档定义 AutoWSGR-GUI 的强制工程规范。它适用于人工编写、AI 辅助生成和完全由自动化 Agent 生成的所有代码。 - -本文使用以下规范性术语: - -- **必须 / 不得(MUST / MUST NOT)**:合并前必须满足,除非维护者按“例外流程”书面批准。 -- **应当 / 不应(SHOULD / SHOULD NOT)**:默认必须遵守;偏离时必须在 PR 中说明理由。 -- **可以(MAY)**:允许采用的实现方式。 - -## 1. 文档权威顺序 - -发生冲突时,按以下顺序判断: - -1. `package.json`、`tsconfig.json`、`.editorconfig`、`.gitattributes` 等可执行配置决定机器实际执行的命令和规则。 -2. 本文决定贡献者义务、架构约束、验证要求和合并门槛。 -3. [架构文档](architecture/README.md)描述当前系统结构。 -4. [贡献指南](../CONTRIBUTING.md)提供环境搭建、分支和提交入门说明。 -5. [重构教学](teaching/README.md)解释历史设计动机,不是规范来源;其中的历史行数和文件数不得被视为当前事实。 - -如果实现、架构文档和本文不一致,PR 必须修正实现或同步更新文档,不得无说明地选择其中一套。 - -### 1.1 现有技术债务 - -本规范生效前已经存在的违规不自动阻塞所有后续开发,但也不构成先例或许可。 - -- 新代码不得复制、扩大或依赖已有违规模式。 -- PR 触及现有违规代码时,必须在描述中列为已知技术债务,并说明本次是消除、缩小还是保持其影响范围。 -- 如果完成当前行为目标必须扩大既有违规,必须先获得维护者批准并给出后续收敛方案。 -- “主分支原来就这样写”不是偏离规范的有效理由。 -- 审查者不得要求一个小型、无关 PR 顺便清理整个历史系统;整改范围应与当前行为目标和风险相称。 - -## 2. 基本原则 - -### 2.1 正确性必须可证明 - -- 编译成功只能证明代码可以编译,不能证明行为正确。 -- 截图只能证明某一时刻的显示结果,不能证明持久化、调度、异步生命周期或升级兼容正确。 -- “在我的机器上能运行”不是可复现证据。 -- Bug 修复必须提供修复前失败、修复后通过的可复现证据。 -- 无法自动测试时,必须提供确定性的复现步骤、输入、预期输出和实际输出,并说明为什么暂时不能自动化。 - -### 2.2 一份可变数据只能有一个权威所有者 - -任何能够独立写入并影响行为的表示都是一个**状态源**。缓存、镜像字段、生命周期标志、持久化副本和 UI 本地副本都可能成为状态源。 - -- 每份可变状态必须有唯一权威所有者。 -- 派生状态必须可以从权威状态重新计算,且不得独立写入。 -- 跨 View、Controller、Model、Electron 主进程和 Python 后端镜像状态时,必须声明所有者、写入路径、初始化、失效和协调规则。 -- 修复 Bug 时原则上不得新增状态源;确有必要时,必须由维护者明确批准。 -- 禁止使用 `alreadyHandled`、`isSyncing`、影子集合或重复缓存掩盖顺序和所有权缺陷。 - -### 2.3 修复根因,不叠加补丁 - -- 每个修复必须说明因果链:输入如何进入权威状态、经过哪些转换、最终产生什么可观察行为。 -- guard、retry、delay、fallback、兼容分支和重复校验均必须说明所保护的明确不变量。 -- 已被证明错误的 workaround 必须在替代实现中删除,不得以“以防万一”为由保留。 -- 不得通过移动文件、重命名 helper、拆分 commit 或 squash 历史,把补丁累积伪装成干净设计。 - -### 2.4 改动应形成可回滚的行为单元 - -一个 PR 应当完成一个可独立验证和回滚的行为目标。是否过大不只由行数决定,而由审查者能否完成以下工作决定: - -- 用一句话描述该 PR 的主要行为目标; -- 追踪每份状态的唯一所有者; -- 区分必要修改与顺手重构; -- 使用有限且明确的证据完成验收; -- 在不撤销无关功能的情况下回滚。 - -无法满足任一项时,PR 必须拆分或重新设计。 - -## 3. 架构约束 - -项目采用 **MVC + ViewObject**。标准数据流为: - -```text -Model → Controller → ViewObject → View - ↑ │ - └─ 用户意图 ┘ -``` - -### 3.1 View - -View 只负责 DOM 渲染、读取用户输入并上报用户意图。 - -View 不得: - -- 直接调用 Model、ApiClient 或 Electron 文件 IPC; -- 读写配置、计划、模板、任务组或其他持久化数据; -- 决定业务默认值、迁移规则、任务状态或调度语义; -- 在多个页面中重复领域归一化和序列化规则; -- 持有与 Model 可独立修改的业务状态副本。 - -复杂 View 可以使用 Facade 组织子 View,但 Facade 不得借此成为业务层。 - -### 3.2 Controller - -Controller 负责用例编排、把 Model 数据转换为 ViewObject,并把用户意图转成 Model 或系统能力调用。 - -- 子 Controller 之间不得直接调用具体实现;跨域协调必须通过最小 Host 接口或由组合根协调。 -- Controller 不得实现 YAML/JSON parser、路径安全、原子文件替换、Python 环境发现等基础设施细节。 -- Controller 不得成为第二个 Model;长期状态必须由明确的 Model 或专用模块持有。 -- Host 接口必须保持最小,不得为了方便逐渐暴露整个 `AppController`。 - -### 3.3 Model - -Model 负责业务实体、领域规则、调度、配置语义和后端通信,不得感知具体 View 或 DOM。 - -- 同一条领域规则必须有一个实现位置。 -- YAML、JSON、IPC 和 HTTP 的格式转换应放在明确的 adapter 中,不得复制到多个调用方。 -- 调度任务必须区分单轮完成、逻辑任务完成、取消、失败和重试;不得用同一个布尔值表达不同生命周期。 -- 领域模型不得依赖 Electron 或 Node 文件系统实现。 - -### 3.4 Types - -- `src/types/` 中的类型按领域和通信方向组织,不得成为无边界的全局类型垃圾场。 -- ViewObject 与领域模型、API DTO、IPC DTO 必须区分。 -- 不得使用 `any`、宽泛的字典或类型断言绕过契约设计。 -- 当前 `tsconfig.json` 虽启用 `strict`,但仍有 `noImplicitAny: false` 的显式例外;新代码不得以该例外为理由引入隐式 `any`。 - -### 3.5 Electron 主进程 - -`electron/main.ts` 只负责: - -1. 应用和窗口生命周期; -2. IPC 注册; -3. 模块初始化与依赖注入。 - -文件存储、数据迁移、Python 环境、后端进程、更新器和资源管理必须位于独立模块中。IPC handler 应当只做参数校验、调用模块和返回结构化结果。 - -不得把业务逻辑重新集中到 `main.ts`,也不得让子模块反向读取 `main.ts` 的全局变量。 - -### 3.6 Python 后端与跨仓契约 - -- GUI 只能依赖后端公开、版本化的接口,不得 monkey-patch 后端私有方法或读取私有内部结构。 -- GUI 与后端共同使用的数据结构必须有跨仓契约测试或固定 fixture。 -- `name` 缺失、空值、候选列表顺序等语义必须显式定义,不得由两端各自猜测。 -- PR 必须声明支持的后端最低版本、最高已验证版本和不兼容变化。 -- 后端不可用或版本不匹配时必须明确失败,禁止静默切换到另一个后端来源。 - -## 4. 持久化、迁移与资源规范 - -### 4.1 已发布格式是公共契约 - -已发布版本中的以下内容均属于兼容契约: - -- 用户配置字段和未知扩展字段; -- 计划、模板和任务组格式; -- 文件目录; -- 模板 ID 和任务索引的业务语义; -- 调度行为和默认舰队等用户可见默认值。 - -改变契约时必须提供显式迁移。不得通过删除字段、移动目录或复用旧 ID 静默改变行为。 - -### 4.2 迁移要求 - -每个迁移必须: - -- 有版本号和明确的起止版本; -- 可重复执行且结果幂等; -- 在中途失败时保留原始数据; -- 保留未知字段; -- 有真实旧版本 fixture; -- 测试首次迁移、重复迁移、部分数据和失败回滚; -- 记录发生了什么,并在无法安全迁移时停止而不是猜测。 - -手工选择旧文件不是自动升级迁移的替代方案。 - -### 4.3 文件和目录 - -- 安装资源目录视为只读。 -- 用户生成数据必须写入操作系统提供的用户数据目录。 -- 缓存和可重建资源不得与不可丢失的用户数据混放。 -- 原子写入失败时必须保留旧文件;不得先删除有效目标再尝试替换。 -- 打包资源更新必须有 manifest 或版本规则,不得永远采用“不覆盖已有文件”。 - -### 4.4 Electron 文件安全 - -- renderer 不得向通用 IPC 提交任意绝对路径。 -- 每类文件操作必须声明允许的根目录和能力范围。 -- 路径必须 canonicalize 后检查 containment。 -- 必须防止 `..`、盘符切换、UNC、符号链接和大小写差异导致的目录逃逸。 -- preload 只暴露最小、面向用例的能力,不暴露通用文件系统。 -- 路径安全和写入失败必须有自动测试。 - -## 5. Python 环境和进程生命周期 - -- bundled、managed、system 和 external Python 必须是明确区分的环境模式。 -- 一个环境描述必须同时用于检测、安装、CUDA/OCR 检查和后端启动。 -- 不得把依赖安装到一个环境,却使用另一个 `sys.path` 启动。 -- external 模式失效时必须阻止启动并报告原因,不得静默回退。 -- 停止后端时应先优雅停止,再等待,超时后终止完整进程树。 -- 更新安装前必须确认后端及子进程退出、文件锁释放。 -- 环境缓存必须声明失效条件;缓存不得掩盖解释器或依赖变化。 - -## 6. Patch 止损机制 - -### 6.1 什么算一次失败的修复尝试 - -以下任一情况都算一次失败尝试: - -- 声称修复的行为仍可复现; -- 验证没有通过; -- 下一次修改必须绕过或补偿上一次修改的错误假设; -- 上一次 workaround 被保留,新代码又在外围增加一层特殊处理。 - -诊断日志、测试夹具和不改变行为的 instrumentation 不计入修复尝试。一个概念性补丁拆成多个 commit 仍只按其实际尝试计数,也不得借此重置计数。 - -### 6.2 止损信号 - -以下是架构恶化信号: - -1. 修复开始触及原计划之外的架构层,只为了补偿其他层的新行为; -2. 新增可写状态源、同步标志、影子集合、缓存或生命周期分支; -3. 保留已经失败的 workaround; -4. 同一规则开始在多个层重复实现; -5. 新增 retry、delay、catch-and-ignore 或多级 fallback 才能“稳定运行”; -6. 为通过验证而放宽类型、校验或测试期望; -7. 无法通过确定性测试或复现步骤证明行为; -8. 作者无法解释从输入到输出的完整因果链; -9. 删除其中一个补丁会导致另一个补丁失效; -10. 审查者已无法分辨哪些分支是产品需求,哪些只是历史补偿。 - -### 6.3 升级等级 - -| 等级 | 触发条件 | 必须执行的动作 | -|------|----------|----------------| -| L0 正常变更 | 无失败尝试、无意外扩散、有直接证据 | 正常实现和审查 | -| L1 记录修正 | 一次失败尝试,或出现一个止损信号 | 记录原假设、失败证据、状态所有者和新的验证计划 | -| L2 维护者检查点 | 两次连续失败,或同时出现两个止损信号 | 暂停继续实现;维护者批准重新设计、拆分或干净重写后才能继续 | -| L3 Patch Freeze | 三次失败且存在任一止损信号;或已有多个竞争状态源 | 禁止在当前实现上继续叠加修复,先建立复现和替代设计 | -| L4 干净重写 | 现有分支无法删除失败 workaround、恢复单一状态源或证明端到端行为 | 从最后已知正常基线创建干净分支重新实现 | - -计数是触发器,不是可以优化或规避的 KPI: - -- 即使只失败一次,若已出现多个竞争状态源,也可以直接进入 L3。 -- 即使失败三次,若旧尝试已被完整删除,当前实现恢复了清晰且可证明的模型,也不必机械重写。 -- 不得通过 squash、换 Agent、换会话、换文件或新开 PR 把尝试次数归零。 - -### 6.4 第三次修复的强制门槛 - -同一根因连续失败两次后,第三次修复必须先提交以下内容供维护者确认: - -- 两次失败分别基于什么假设; -- 哪些证据证明假设错误; -- 新的因果模型与旧模型有何实质差异; -- 将删除哪些旧 workaround; -- 状态所有者和架构交互是否改变; -- 哪项测试将在旧实现失败、在新实现通过。 - -缺少其中任一项,不得继续提交第三层补丁。 - -## 7. 干净基线重写协议 - -干净重写不是“把整个旧分支复制到新分支”,也不是因为代码难看就推倒重来。满足 L4 时必须执行以下流程。 - -### 7.1 固定基线 - -- 记录最后一个不包含失败 patch 链的已接受 commit SHA 或 release tag。 -- 从该 commit 创建独立 branch 或 worktree。 -- 使用 `git diff --exit-code ` 证明起点干净。 -- 不得从失败 PR 的 head、merge commit 或 squash commit 创建重写分支。 -- 不得覆盖维护者工作树中的未提交文件。 - -### 7.2 先固定行为契约 - -实现前必须记录: - -- 原始问题的确定性复现; -- 必须实现的用户行为; -- 不得回归的旧行为; -- 每份状态的所有者和生命周期; -- 跨层或跨仓交互顺序; -- 失败、取消、回滚和恢复语义; -- 自动测试和手工验证矩阵。 - -条件允许时,回归测试应当先于替代实现单独提交。 - -### 7.3 旧代码复用清单 - -对失败分支必须先进行只读分析,并建立功能—代码映射。每项至少包含: - -- 功能 ID 和用户价值; -- 对应旧文件、类和函数; -- 输入、输出和持久化格式; -- 后端或 IPC 依赖; -- 可原样复用、可复用思路但需重写、仅保留需求、必须丢弃四类结论; -- 兼容要求和验收测试。 - -旧分支中的实现默认不可信。只有被逐项批准的代码才能移植;不得整文件、整目录或整提交搬运后再清理。 - -### 7.4 按垂直切片重建 - -每个重建 PR 必须形成最小闭环,例如: - -```text -加载一份计划 → 编辑一个字段 → 校验 → 保存 → 重新加载 -``` - -存储迁移、安全 IPC、领域契约、UI、环境管理和更新机制原则上应分开提交。每个切片必须能独立构建、验证和回滚。 - -### 7.5 重写合并门槛 - -重写只有同时满足以下条件才能合并: - -- 原始复现在基线失败,并在重写后通过; -- 非回归行为通过验证; -- 失败 workaround 已不存在; -- 每份可变状态只有一个权威所有者; -- 不依赖废弃分支中的隐藏行为; -- PR 对比说明旧假设、删除的 workaround、前后状态源和架构差异; -- 受影响模块的维护者批准。 - -## 8. AI Agent 专用规则 - -模型能力不能代替工程流程。无论使用何种模型,Agent 都必须遵守以下要求。 - -### 8.1 修改前 - -Agent 必须先: - -1. 阅读本规范、相关架构文档和现有测试; -2. 搜索已有实现和历史 workaround; -3. 描述行为不变量、状态所有者、受影响层和验证方式; -4. 区分根因修复与临时 containment; -5. 检查当前工作树,保护无关的未提交修改。 - -不得在完成上述分析前直接生成大规模实现。 - -### 8.2 修改中 - -Agent 必须声明当前修改是替代旧尝试、叠加旧尝试,还是删除旧 workaround。 - -Agent 不得: - -- 用 retry、sleep、debounce、catch-and-ignore 或 fallback 隐藏未知根因; -- 新增平行状态源以绕过所有权分析; -- 使用 `any`、类型断言、关闭校验或修改测试期望使失败消失; -- 修改测试去接受当前错误输出,除非先证明原期望错误; -- 生成无法判断实际采用哪条路径的 fallback 链; -- 仅凭 build、lint 或一次手工运行宣称完成; -- 把行为修改藏在格式化、生成文件或无关重构中; -- 为规避 PR 规模和止损审查而人为拆散同一个 workaround。 - -### 8.3 强制暂停 - -出现以下任一情况,Agent 必须停止继续写修复代码并报告维护者: - -- 两次失败后仍无法提出实质不同的因果模型; -- 下一步需要新增可变标志或第二状态源; -- 修改意外跨入新的架构层; -- 验证无法排除时间巧合、旧缓存、模拟数据或偶然成功; -- 正准备保留失败 workaround“以防万一”; -- 无法解释完整状态转换; -- 当前上下文不足以知道已经做过多少次尝试。 - -报告必须列出观察证据、失败假设、剩余不确定性和建议的下一步。Agent 可以建议重写,但不得自行批准 L2 以上工作的继续实施。 - -### 8.4 AI 交付记录 - -AI 参与的 PR 必须记录: - -- 使用的分析和实现范围; -- 执行的命令和测试; -- 修复前后复现结果; -- 未验证路径; -- 影响的文件和架构层; -- 新增、删除和保留的状态源; -- 本任务中发生的失败尝试; -- 人工复核者。 - -不得把模型名称或流畅的解释当作质量证据。 - -## 9. PR 和提交规范 - -### 9.1 PR 必填信息 - -非平凡 PR 必须说明: - -- 用户可见或外部可观察的行为变化; -- 明确不包含的范围; -- 状态所有权; -- 涉及的架构层和依赖方向; -- 数据格式、目录、模板 ID 或默认行为变化; -- 兼容与迁移方案; -- 回滚方式; -- 自动测试、手工验证和未验证风险; -- 当前 patch 止损等级。 - -### 9.2 规模审查触发器 - -以下情况不是自动拒绝条件,但必须在实现前获得维护者确认并提供拆分理由: - -- 同时修改 UI、持久化、迁移、运行时和发布机制; -- 大量手写逻辑与资源文件混在同一提交; -- 修改范围使单个审查者无法在有限上下文中追踪完整行为; -- 一个 PR 包含多个可独立回滚的产品功能; -- 需要多个仓库尚未合并的补偿性修改才能运行。 - -生成文件、lockfile、fixture、图片和机械重命名应与手写行为代码分别统计和说明。 - -### 9.3 Commit - -- 遵循 Conventional Commits。 -- 一个 commit 对应一个逻辑变更。 -- 同一垂直切片自然涉及 Model、Controller、Types 和 View 时可以同 commit,但必须保持标准依赖方向。 -- 不得用 squash 隐藏决定止损等级的失败历史;可以在审查完成后 squash,但 PR 记录必须保留失败假设和整改过程。 - -## 10. 验证与 CI - -### 10.1 当前最低要求 - -仓库当前没有统一的 `test`、`lint`、`format` 脚本和 GitHub workflow。在这些基础设施加入前: - -- 所有 TypeScript/SCSS 变更必须至少执行 `npm run build`; -- 变更打包和安装资源时,应执行 `npm run pack` 或说明无法执行的原因; -- 行为修改必须提供额外的确定性验证,不能以缺少测试框架为由只做 build; -- 新增复杂、可回归的行为时,应当同时引入最小必要测试基础设施,而不是继续扩大无测试代码。 - -### 10.2 新增测试的原则 - -- 测试应通过受影响的最近公共接口验证行为。 -- IPC、持久化、调度、异步顺序、序列化和跨仓通信问题不得只使用 mock-only 测试。 -- 迁移测试必须使用真实旧版 fixture。 -- 测试名称描述业务行为,不绑定废弃实现结构。 -- 修复 Bug 的测试必须证明它在修复前确实失败。 - -### 10.3 文档同步 - -修改架构、目录、命令、数据格式、后端契约或发布流程时,必须在同一 PR 更新对应文档。文档不得继续描述已经不存在的结构。 - -## 11. 例外流程 - -只有为阻止数据丢失、安全暴露、损坏发布或同等级严重事故,才可以先提交临时 containment。 - -例外必须: - -- 明确标记为临时 containment,不得声称已修复根因; -- 最小化行为变化; -- 说明回滚条件; -- 尽量不增加持久化状态; -- 在合并前关联根因整改项; -- 写明责任人和移除期限; -- 获得受影响模块维护者批准。 - -维护者批准例外时必须写明:豁免哪条规则、等待或重写为何风险更高、仍缺少什么证据、由谁在何时移除。AI Agent 不得自行批准例外。 - -## 12. 审查清单 - -合并前,作者和审查者应确认: - -- [ ] 行为目标和非目标清晰; -- [ ] 状态所有者唯一且写入路径明确; -- [ ] View、Controller、Model、Types、Electron 和后端依赖方向符合规范; -- [ ] 没有新增未经批准的 guard、retry、fallback、缓存或影子状态; -- [ ] 旧 workaround 已删除,而不是继续叠加; -- [ ] 数据格式和用户目录变化有版本化迁移; -- [ ] 未知字段和旧用户数据不会丢失; -- [ ] IPC 路径和文件写入失败模式经过验证; -- [ ] 后端契约和兼容版本明确; -- [ ] 修复前失败、修复后通过的证据可复现; -- [ ] 当前止损等级和失败尝试已披露; -- [ ] 架构和使用文档已同步; -- [ ] 无关文件、用户数据和本地未提交修改未被包含。 diff --git a/docs/features/gui-2.0-alpha-pr-report.md b/docs/features/gui-2.0-alpha-pr-report.md new file mode 100644 index 0000000..debab3c --- /dev/null +++ b/docs/features/gui-2.0-alpha-pr-report.md @@ -0,0 +1,429 @@ +# GUI 2.0.0-alpha PR 前项目说明 + +> 目标版本:`GUI 2.0.0-alpha` +> 更新频道:`alpha` +> 目标平台:Windows 10/11 x64 +> 文档范围:新增功能、优化功能、兼容性、架构拆分和发布验收 + +## 1. 升级结论 + +GUI 2.0 不只是界面换肤。本次升级把舰队、出征计划、任务列表、用户设置和 +AutoWSGR 运行环境整理为可校验、可迁移、可维护的完整流程,主要解决旧版以下问题: + +- 系统资源、用户配置和运行时临时文件边界不清。 +- 舰队主选、候选和出征计划引用难以通过界面可靠维护。 +- 页面直接承担持久化、关系推导或业务状态,修改容易造成状态不同步。 +- Controller 之间存在反向依赖,模块难以独立测试。 +- 旧安装目录更换、同名文件冲突和任务引用升级缺少完整闭环。 +- 发布频道、后端版本和内置资源缺少自动化一致性验收。 + +因此 GUI 2.0 的必要性来自数据可靠性、兼容性和维护成本,而不是单纯增加功能。 + +当前版本可以进入 PR 审查。Controller 的 DOM 和全局 bridge 架构门禁已经整改并 +加入永久自动化检查;真实模拟器业务流程仍须在合并前验收。 + +### 1.1 代码简洁性审计 + +“最少代码”不等于文件最少或行数最短。本次审计以“每个可变状态只有一个所有者、 +每条业务规则只有一个实现位置、基础设施只通过窄接口暴露”为最小充分实现标准。 + +| 指标 | 审计结果 | +| --- | --- | +| 运行时 TypeScript 模块 | 183/183 可从 Main、Preload 或 Renderer 入口到达 | +| 不可达运行时模块 | 0 | +| `any` / `as any` | 0 | +| `src` TypeScript 文件 | 70 → 124 | +| `electron` TypeScript 文件 | 11 → 59 | +| `AppController.ts` | 607 → 436 行 | +| `SchedulerBinder.ts` | 409 → 324 行 | +| `electron/main.ts` | 633 → 443 行 | + +文件数增加来自舰队规划、计划管理、迁移、环境管理和安全 IPC 等完整能力,以及 +把组合根中的职责下沉到可测试模块;三个关键聚合模块同时缩小。因此当前实现不是 +追求物理行数最少,而是在现有功能和可靠性约束下减少重复规则和跨层耦合。 + +已收敛为单一实现位置的规则包括迁移账本、Scheduler 任务加载与运行态、任务列表 +浮窗、决战共享契约,以及普通/决战舰船图库的搜索、筛选、排序和首屏批量计算。 + +### 1.2 发布内容审计 + +- 未发现不可达的 TypeScript 运行时模块。 +- 未发现 `.tmp`、`.bak`、`.orig`、补丁残留或随手调试草稿。 +- `debug_deps.bat` 是安装包明确携带的用户诊断工具,报告写入 + `%APPDATA%\AutoWSGR-GUI\debug_report.txt`,不是临时文件。 +- 生产 `console` 输出集中在后端 stdout/stderr、启动退出、迁移、更新、资料库升级 + 和安全停止失败,均有运行或故障诊断用途。 +- 严格 TypeScript 未使用声明检查已清零。 + +此前审计发现的 Controller 直接 DOM/全局 bridge `major` 问题已经整改。42 个 +Controller 文件的 DOM、DOM 类型、浏览器事件和 `window.electronBridge` 扫描均为 +0;`npm run test:architecture-boundaries` 已把该约束固化为回归门禁。 + +## 2. 新增功能 + +### 2.1 作战页 + +- 展示当前任务、进度、剩余次数、运行状态和远征倒计时。 +- 展示任务中能够可靠识别的最多六艘当前舰船。 +- 统一任务分组、任务队列、快捷操作和后端日志。 +- 受管计划在执行前展开为后端可消费的完整运行时 YAML。 + +### 2.2 舰队规划 + +- 使用内置舰船资料库展示舰船立绘、舰种、国籍和稀有度。 +- 支持搜索、多选筛选、改造过滤和排序。 +- 支持六个主选位置及每个位置的独立候选队列。 +- 支持主选、候选和图鉴之间拖放。 +- 支持纯候选位置、等级限制、候选复制和两种候选跟随方式。 +- 支持系统方案只读、另存个人副本和同名覆盖确认。 + +舰队 YAML 继续遵守 AutoWSGR 契约:一份文件只保存一支舰队, +`candidates` 必须是含 `name` 的对象;位置允许没有顶层 `name`,但必须有非空候选。 + +### 2.3 出征规划与计划管理 + +- 可视化编辑地图、执行参数、停止条件、维修策略和节点行为。 +- 一个出征计划可以引用多个独立舰队方案。 +- 计划管理统一展示系统/用户出征计划、舰队方案、任务分组引用和读取错误。 +- 支持搜索、筛选、跳转编辑、导出、重命名、删除及忽略未关联提示。 +- 删除舰队前展示被引用影响,避免无提示破坏现有计划。 + +### 2.4 设置与环境 + +- 支持模拟器检测、ADB 连接状态和后端环境检查。 +- `managed` 模式管理内置 Python 与 AutoWSGR 依赖。 +- `external` 模式连接用户本地 AutoWSGR 仓库和 Python 环境。 +- 支持亮色、暗色、跟随系统主题和窗口状态持久化。 +- 支持 OCR、自动任务、脚本延迟、维修和日志等后端配置。 +- 保留 GUI 尚未识别的 YAML 扩展字段,降低升级时的数据损失风险。 + +## 3. 优化功能 + +### 3.1 保存语义 + +所有保存入口统一为: + +```text +输入校验 +→ Repository / IPC 写入 +→ 原子替换成功 +→ 更新文件身份和保存快照 +→ 显示成功提示 +``` + +点击按钮不再等同于保存成功。任一阶段失败时只显示错误,不显示成功提示。 + +### 3.2 文件与更新安全 + +- IPC 不接受任意绝对路径,只允许受管目录和受控文件身份。 +- 路径校验拒绝 `..`、盘符跳转、UNC、符号链接和 Junction 逃逸。 +- 关键 JSON/YAML 使用临时文件加重命名的原子写入。 +- 系统方案只读;修改时保存为用户方案,不覆盖安装资源。 +- 安装更新前先请求后端优雅退出,再终止进程树并等待文件锁释放。 +- 无法确认后端停止时阻止安装更新。 + +### 3.3 代码简化 + +- 删除无页面入口的出征直接执行链。 +- 删除未使用的 `FleetEditDialog`、修理刷新方法、迁移辅助类型和调试草稿。 +- `SchedulerBinder` 只保留回调绑定和任务结算,运行态与自动任务加载分别交给 + `SchedulerRuntimeTracker` 和 `ScheduledTaskLoader`。 +- 普通舰队与决战舰队图库复用 `GalleryShipCollection` 中的无状态查询规则, + 仍保留各自不同的槽位、拖拽和保存语义。 +- Scheduler 复用统一的后续任务构造策略,TaskQueue 复用统一的维修等待时间算法。 +- 删除已停用的整块注释代码和重复舰名工具。 +- 舰队领域回归整理为 11 个命名场景,失败时先输出业务场景,再保留断言堆栈。 + +### 3.4 生命周期与事务一致性 + +- Electron 单实例锁在迁移、环境检查和依赖安装前获取;重复启动只唤醒主窗口。 +- `WindowService` 独占窗口生命周期,后端输出和更新回调通过 `sendToRenderer()` + 检查窗口及 `webContents` 是否已销毁。 +- 设置页将 `usersettings.yaml` 与 `gui_settings.json` 作为一次提交处理;JSON + 提交失败时恢复 YAML 快照,Renderer 只在主进程提交成功后更新正式状态。 +- 字符串和二进制文件统一通过 `AtomicFileStore` 原子替换,并只对明确的 Windows + 短暂文件锁进行有限重试。 + +## 4. 兼容性方案 + +### 4.1 用户数据隔离 + +GUI 2.0 将数据分为三类: + +| 类型 | 位置 | 策略 | +| --- | --- | --- | +| 系统资源 | 安装包 `resource/` | 只读,随版本更新 | +| 用户数据 | Electron `userData` | 可写,升级不覆盖 | +| 运行时文件 | 进程临时目录 | 执行结束后可清理 | + +用户数据包括: + +- `usersettings.yaml` +- `gui_settings.json` +- `task_groups.json` +- `user_battle_plans/` +- `user_team_plans/` +- `user_daily_plans/` + +### 4.2 旧配置自动迁移 + +v5 旧安装导入只在 `userData` 尚未初始化,且旧 EXE 目录存在旧安装特征时启动。 +v6、v7 根据独立阶段标记执行,不依赖“版本号看起来足够新”这一单一条件。 + +```mermaid +flowchart TD + A["主实例获取单实例锁"] --> B["读取迁移账本"] + B --> C{"v5 旧来源导入已完成?"} + C -->|否| D["迁移设置、任务组和模板"] + C -->|是| E{"v6 预设库存已完成?"} + D --> E + E -->|否| F["升级系统预设引用和稳定标识"] + E -->|是| G{"v7 计划分类已完成?"} + F --> G + G -->|否| H["演习、战役、决战迁入日常计划目录"] + G -->|是| I["检查迁移冲突"] + H --> I + I --> J{"本阶段全部成功?"} + J -->|否| K["保留源文件和未完成标记,下次重试"] + J -->|是| L["原子合并完成键和最高版本"] + L --> M["输出报告并交由用户处理冲突"] + + classDef start fill:#123A5A,color:#FFFFFF,stroke:#7CC4FF,stroke-width:2px; + classDef decision fill:#5A3200,color:#FFFFFF,stroke:#FFC766,stroke-width:2px; + classDef action fill:#143F2E,color:#FFFFFF,stroke:#72E0A8,stroke-width:2px; + classDef warning fill:#5A1717,color:#FFFFFF,stroke:#FF8A8A,stroke-width:2px; + class A,B,L,M start; + class C,E,G,J decision; + class D,F,H,I action; + class K warning; +``` + +| 阶段 | 处理内容 | 完成条件 | +| --- | --- | --- | +| v5 | 旧设置、任务组和模板 | 所有输入成功写入并保留扩展字段 | +| v6 | 下架系统方案、胖次稳定标识和旧系统计划引用 | 预设库存阶段独立完成键写入 | +| v7 | 旧舰队/出征计划迁移,演习、战役、决战重新分类 | 计划输出和引用全部成功 | + +同名不同内容的文件保存为“(旧版)”副本,任务引用同步更新;旧源文件始终保留。 +设置合并顺序为“新版本默认值 → 旧版已有值 → 旧版未知扩展字段”。 + +### 4.3 迁移账本与失败恢复 + +- `MigrationStateStore` 独占 `userData/.migration-state.json` 的读取、合并和原子写入。 +- 完成键按阶段和内容生成;旧完成键不会被后续写入覆盖,最高版本只升不降。 +- v6 失败时不允许 v7 提前完成;重启只重试未完成阶段。 +- 所有目标文件先原子写入,成功后才登记完成;账本损坏按未完成处理。 +- 最近一次实际迁移结果写入 `userData/.migration-report.json`。 +- 待用户决定的同名或替代冲突保存在冲突清单中,由 GUI 明确选择保留或删除。 +- 第二次启动时,已完成阶段迁移数量应为 0,用户配置内容保持不变。 + +### 4.4 旧任务和稳定标识 + +- 旧 path-form 任务仍可加载,并逐步转换为 `managedSource + managedFile`。 +- “刷胖次”使用稳定计划标识,不再依赖数组下标。 +- 旧数字索引通过明确映射迁移。 +- `fleet_presets` 始终按列表解析。 +- 旧字符串候选只用于兼容读取,新保存统一输出结构化候选。 +- 系统预设只读;用户修改保存为个人副本。 + +### 4.5 兼容方案优势 + +- 更换安装目录不会重置已经初始化的用户数据。 +- 系统资源更新与用户配置互不覆盖。 +- 旧版未知字段继续保留,减少后端配置丢失。 +- 迁移失败可重试,且不修改旧源文件。 +- 同名冲突有明确副本和人工确认,不静默覆盖。 +- 任务引用与文件身份同步迁移,避免只迁移文件不迁移使用关系。 +- 阶段标记允许发布后增加新迁移,而不重跑已完成的旧阶段。 +- 单实例锁避免两个 GUI 进程同时迁移或安装依赖。 + +### 4.6 兼容方案代价与限制 + +- 首次迁移需要扫描和验证旧文件,启动时间会增加。 +- 同名冲突可能产生“(旧版)”副本,需要用户检查取舍。 +- 无法通过当前 Codec 的损坏或非计划 YAML 不会自动迁移。 +- 迁移只保证受支持字段和可验证文件,不猜测损坏 YAML 的业务含义。 +- 从 GUI 2.0 回退到旧版时,旧版不能理解 v7 日常计划目录和新的结构化身份; + 回退方案是继续使用未修改的旧源文件,而不是让旧版覆盖 GUI 2.0 用户目录。 +- `managed` 安装包不预装 `site-packages`,首次环境准备依赖网络。 +- Alpha 频道用于提前验证升级行为,不承诺与稳定版相同的成熟度。 + +这些代价是显式保留用户数据和避免错误覆盖的结果,不能通过静默猜测消除。 + +### 4.7 面向用户的预设与恢复资源 + +| 资源 | 数量 | 用途 | +| --- | ---: | --- | +| 系统出征计划 | 10 | 周常地图等可直接复制使用的出征方案 | +| 系统舰队方案 | 9 | 常用舰队规则和候选配置 | +| 系统日常计划 | 20 | 演习、战役和决战计划 | +| 内置任务模板 | 5 | 刷胖次、周常任务、自动演习、战役、决战 | +| 舰船资料与立绘 | 894 + 894 | 舰名、舰种、国籍、筛选和可视化选船 | +| v6 迁移快照 | 9 | 保留已下架系统计划,供旧引用转换为个人计划 | + +用户还可以使用迁移报告、冲突处理界面、系统方案“另存为个人副本”和 +`debug_deps.bat` 诊断报告定位升级问题。旧源文件不删除,是迁移失败和回退时的 +最后恢复资源。 + +## 5. 架构拆分 + +### 5.1 拆分前 + +部分页面同时持有可写业务状态、读取 Repository、推导关系和执行文件操作。 +Controller 流程模块还会反向依赖主 Controller,形成循环依赖。典型风险是: + +- View 和 Model 同时修改同一份草稿。 +- 页面重建后持久化身份丢失。 +- 计划管理关系计算散落在 DOM 渲染代码中。 +- 测试必须构造完整页面或 Electron bridge。 +- 修改一个流程容易连带影响组合根。 + +### 5.2 拆分后 + +```mermaid +flowchart LR + M["Main Service / Repository"] --> A["Adapter / 窄 IPC 能力"] + A --> C["Controller 用例编排"] + C --> R["Model / 唯一可变状态"] + R --> VO["只读 ViewObject"] + VO --> V["View / DOM"] + V --> I["明确用户意图"] + I --> C + + classDef infra fill:#123A5A,color:#FFFFFF,stroke:#7CC4FF,stroke-width:2px; + classDef logic fill:#143F2E,color:#FFFFFF,stroke:#72E0A8,stroke-width:2px; + classDef view fill:#4A245A,color:#FFFFFF,stroke:#DCA6FF,stroke-width:2px; + class M,A infra; + class C,R,VO logic; + class V,I view; +``` + +已完成的主要边界: + +| 模块 | 唯一职责 | +| --- | --- | +| `FleetPlannerController` | 持有唯一 `FleetDraft` 和持久化身份 | +| `FleetDraftEditor` | 对草稿执行一个显式编辑意图 | +| `PlanFleetPresetController` | 管理出征计划关联的舰队目录 | +| `PlanManagementController` | 编排计划目录操作和对话框 | +| `planManagementViewObjects` | 纯函数推导计划、舰队和任务组关系 | +| `CurrentFleetController` | 解析当前任务舰队并读取舰船资料 | +| `controller/contracts.ts` | 跨流程最小 Host 能力 | +| `MigrationStateStore` | 独占迁移账本读写和版本单调合并 | +| `SchedulerRuntimeTracker` | 持有日志派生的运行状态 | +| `ScheduledTaskLoader` | 读取自动化计划并转换为 Scheduler 任务 | +| `GalleryShipCollection` | 普通/决战图库共享的无状态查询规则 | +| `TaskListLoaderView` | 任务列表浮窗的 DOM、拖拽和意图上报 | +| `NavigationView` | 主导航、计划标签、指示器和 ResizeObserver | +| `StatusBar` / `TaskQueueView` | 快捷操作与队列按钮意图及 Loading 状态 | +| `StartupGateway` / `ConfigurationGateway` | 启动与配置所需的最小 Electron 能力 | +| `view/theme.ts` | 主题 DOM、强调色和系统主题事件 | +| `View` | 渲染 ViewObject 并上报用户意图 | + +结果: + +- 183 个运行时 TypeScript 模块全部可达,未发现无入口模块。 +- 组合根、Scheduler 聚合器和 AppController 的职责及行数下降。 +- 舰队草稿和计划舰队列表都有唯一状态所有者。 +- 关系推导可使用纯数据进行测试。 +- IPC、Repository 和对话框依赖可以按最小接口注入。 +- 迁移、文件写入、窗口生命周期和单实例都由 Main Service 独占。 + +### 5.3 Controller 边界整改 + +- Plan、TaskGroup、Template、Settings、Navigation、Operations 和队列交互均由 + View 绑定 DOM,并通过明确回调上报用户意图。 +- 主题 DOM 和系统主题事件迁入 `view/theme.ts`,偏好读取复用 Storage Adapter。 +- App、Startup、Config 和业务 Controller 通过窄 Gateway/Repository 获取 + Electron 能力,不再读取全局 bridge。 +- 模板模型和 `kind: "template"` 执行链继续承担旧任务组、自动决战和用户模板 + 兼容,但兼容语义与 UI/IPC 基础设施已分离。 +- 永久门禁扫描全部 42 个 Controller 文件,防止跨层访问回流。 + +### 5.4 拆分后的维护与升级方式 + +新增或修改功能时按以下顺序定位: + +1. 后端模型或 YAML 契约变化:先更新 `types`、Codec 和契约测试。 +2. 用户数据格式变化:新增独立迁移阶段和完成键,先写目标文件,再更新账本。 +3. 文件或系统能力变化:更新 `electron/services`,再由最小 IPC 和 Adapter 暴露。 +4. 页面业务流程变化:更新对应 Controller、Model 和 ViewObject 转换。 +5. 交互变化:View 新增用户意图和渲染;Controller 不读取或修改 DOM。 +6. Scheduler 变化:任务来源放入 Loader,日志派生状态放入 Tracker,Binder 只编排。 +7. 多页面共享规则:只有无状态且存在两个真实调用方时才抽到 `shared` 或纯函数模块。 +8. 新模块同步更新架构目录,并通过边界扫描、依赖图、严格 TypeScript 和领域测试。 + +这使维护者能够按职责找到唯一修改位置,减少重复实现和跨层联动。 + +推荐的回归范围与改动边界对应: + +| 修改范围 | 最低验证 | +| --- | --- | +| Codec / DTO | 契约测试、真实计划导入、TypeScript 编译 | +| Main Service / IPC | main services、main IPC、失败回滚测试 | +| 迁移 | v5/v6/v7 首次迁移、中断重试、二次启动 0 迁移 | +| Scheduler | Scheduler 领域测试和日志派生状态测试 | +| 舰队 / 图库 | 11 个舰队命名场景和舰种漂移检查 | +| 安装资源 | `npm run dist`、发布包结构检查和安装包人工验证 | + +## 6. 发布资源 + +GUI 2.0.0-alpha 安装包应包含: + +| 内容 | 发布策略 | +| --- | --- | +| AutoWSGR-GUI | 打入 `app.asar` 和 Windows 可执行文件 | +| Python 3.12 与 pip | 内置便携运行时 | +| AutoWSGR 主库 | 首次联网安装锁定提交,不污染系统 Python | +| ADB | 内置 | +| VC++ Redistributable | 内置 | +| Maps | 内置全部地图资源 | +| 系统出征计划 | 内置 10 份 YAML | +| 系统舰队方案 | 内置 9 份 YAML | +| 系统日常计划 | 内置 20 份 YAML | +| 内置任务模板 | 内置 5 份 | +| 舰船资料库 | 内置 894 条记录、894 张立绘及舰种/稀有度素材 | +| 迁移快照 | 内置 9 份 v6 只读旧计划 | +| 用户配置 | 不打包,由 `userData` 持久化 | + +AutoWSGR 锁定提交为: + +```text +b0f473fb1ec5318c2c4cff4795a804a3d2dd25bd +``` + +锁定提交确保 GUI 与后端运行契约一致。缺点是后端更新必须先完成兼容验证并修改 +明确来源,不能自动追随未知版本。 + +## 7. Alpha 版本与频道 + +- `package.json` 和 `package-lock.json` 版本均为 `2.0.0-alpha`。 +- electron-builder 发布频道为 `alpha`。 +- 更新策略识别 `X.Y.Z-alpha` 和 `X.Y.Z-alpha.N`。 +- Release workflow 生成 `alpha.yml`,并保持 `latest/beta/dev` 频道互斥。 +- Alpha 版本不得进入稳定版 `latest.yml`。 + +## 8. 发布门禁 + +已通过: + +- `npm run build`、SCSS 构建和严格 TypeScript 未使用声明检查。 +- 舰种漂移、地图同步、API 契约和 60/60 真实计划导入。 +- Main services、Main IPC、设置持久化和 Python 环境测试。 +- v5/v6/v7 旧配置、计划、任务组迁移及失败重试测试。 +- Fleet 11 个命名场景、Scheduler、日常统计和删除作用域测试。 +- OCR 报告、活动资源、地图加载和舰船资料库升级测试。 +- 183/183 TypeScript 运行时依赖图、Controller 边界门禁和 `git diff --check`。 + +PR 合并前仍必须处理: + +- 真实模拟器业务流程验证;自动化测试不能替代模拟器验收。 + +发布前仍必须通过: + +- `npm run dist`。 +- `npm run test:release-package`。 +- `AutoWSGR-GUI-Setup-2.0.0-alpha.exe`、`alpha.yml` 和解包资源人工复核。 +- 安装包首次迁移、二次启动 0 迁移、重复启动单实例和强制关闭窗口验证。 + +未完成真实模拟器验收前不应合并;未通过安装包门禁前不应标记为可发布。 diff --git a/docs/features/gui-2.0.md b/docs/features/gui-2.0.md new file mode 100644 index 0000000..95a1a94 --- /dev/null +++ b/docs/features/gui-2.0.md @@ -0,0 +1,1245 @@ +# GUI 2.0 功能说明 + +> 文档状态:GUI 2.0 稳定版功能说明与发布审查 +> 对应版本:`2.0.0` +> 更新频道:`latest` +> 目标平台:Windows 10/11 x64 +> 前端技术:Electron 33、TypeScript 5.6、SCSS +> 后端接口:AutoWSGR FastAPI、WebSocket 和 YAML 模型 +> 文档用途:功能说明、数据契约、代码审查和发布验收 + +## 1. 改造背景 + +GUI 2.0 不是对旧页面做局部换皮,而是重新明确 AutoWSGR-GUI 的职责: + +1. GUI 是用户配置、舰队方案和出征计划的可视化管理器。 +2. AutoWSGR 后端模型和 YAML 契约是业务规则的唯一事实来源。 +3. GUI 可以在数据进入后端前提供明确校验,但不能发明后端不存在的字段。 +4. 用户编辑的是可持久化 YAML;执行时再将引用的舰队展开为后端可消费的完整计划。 +5. 页面按“作战、计划、设置”组织,减少旧版页面之间重复、割裂的操作。 +6. 系统资源与用户文件分开维护,系统资源用于提供默认能力,用户文件用于保存修改。 +7. 开发模式必须能够直接连接本地 AutoWSGR 源码,避免 GUI 调试被远端包版本阻塞。 + +旧版 GUI 已经能够启动后端、管理任务队列和编辑基础出征方案,但存在以下问题: + +- 配置项平铺且缺少清晰分类,部分设置只有界面没有完整持久化链。 +- 舰队规则只能通过文本或旧弹窗维护,无法直观看到舰船、位置和备选关系。 +- 出征计划与舰队数据耦合在同一份 YAML 中,不便复用和独立维护。 +- 系统方案、用户方案、任务分组和模板入口分散,缺少统一的状态总览。 +- 保存、新建和加载的行为不一致,容易误覆盖或误报未保存。 +- 开发环境可能错误使用远端 `autowsgr`,无法稳定联调本地后端源码。 +- 页面在 720P 或更窄窗口下存在控件挤压、换行失控和内容不可见问题。 +- 成功提示没有统一语义,点击保存并不等于数据已经成功落盘。 + +GUI 2.0 围绕这些问题建立新的页面结构、数据边界和持久化流程。 + +## 2. 核心设计原则 + +### 2.1 后端契约优先 + +- YAML 字段、允许值和业务含义以 AutoWSGR 后端模型为准。 +- GUI 只负责收集、展示、预校验和序列化。 +- 舰种参数使用 `autowsgr_native.VesselType` 提供的 canonical code,例如 + `dd`、`cl`、`asdg`、`aadg`、`kp`、`cg` 和 `bg`;业务组合项另有 + `ss_or_ssg`。 +- GUI 舰种清单由 native 契约生成,不接受旧 Wiki code `ddg`、`ddgaa`、 + `cgaa` 和 `cbg`。 +- `fleet_presets` 保持列表结构。 +- 一份舰队 YAML 只描述一支舰队。 +- 一个舰队位置可以有主选,也可以只有结构化 `candidates`。 +- `candidates` 中的每一项必须是含 `name` 的对象,不接受仅有字符串的旧写法。 + +### 2.2 系统资源与用户数据分离 + +| 数据类型 | 系统目录 | 用户目录 | +| --- | --- | --- | +| 出征计划 | `resource/system_battle_plans/` | `userData/user_battle_plans/` | +| 舰队方案 | `resource/system_team_plans/` | `userData/user_team_plans/` | +| 舰船资料 | `resource/ship-library/` | 打包版更新到用户可写资料目录 | + +系统资源在界面中按只读内容展示。用户修改系统方案时,需要保存为用户方案。 +打包时不把用户目录中的本地内容写入安装包。 + +### 2.3 编辑态与运行态分离 + +- 编辑态出征计划只保存舰队名称引用,避免重复保存大量舰船规则。 +- 运行任务前,主进程读取对应舰队文件并展开 `ships`。 +- 展开后的完整 YAML 写入进程级临时目录。 +- 后端收到的仍然是完整、独立、可解析的出征 YAML。 +- 临时运行文件不污染系统或用户方案目录。 + +### 2.4 明确的保存成功语义 + +所有可见保存入口统一遵守以下规则: + +1. 点击按钮不会直接显示成功。 +2. 输入校验必须先通过。 +3. 文件写入、原子替换或设置 IPC 必须明确返回成功。 +4. 更新当前文件身份和保存快照后,才能显示顶部成功提示。 +5. 失败时只显示错误信息,不能同时出现成功提示。 +6. 连续保存会刷新提示计时,提示约 2.4 秒后自动隐藏。 + +### 2.5 保持开发环境可控 + +- `managed` 模式由 GUI 管理 Python 和 AutoWSGR 依赖。 +- `external` 模式使用指定的本地 AutoWSGR 仓库和虚拟环境。 +- `external` 模式不自动安装或更新远端 `autowsgr`。 +- 环境检查会验证实际导入的 `autowsgr.__file__` 是否来自预期目录。 +- 本地仓库路径属于开发配置,不进行便携路径改写。 + +## 3. 页面信息架构 + +GUI 2.0 主导航固定为: + +```text +作战 +计划 + ├─ 舰队规划 + ├─ 出征规划 + ├─ 决战计划(旧) + └─ 计划管理 +设置 + ├─ 系统设置 + └─ 脚本行为 +``` + +顶部导航固定在窗口上方,集中展示: + +- 当前执行任务。 +- 当前进度。 +- 剩余次数。 +- 远征检查倒计时。 +- 后端运行状态。 +- 主导航入口。 + +页面内容在导航下方独立滚动,避免页面较长时丢失任务状态。 + +## 4. 作战页 + +### 4.1 页面定位 + +作战页负责把已经准备好的配置变成任务,并展示执行状态。计划编辑器不再承担 +主要执行入口,用户应先保存 YAML,再在作战页加入任务列表或任务队列。 + +### 4.2 当前任务状态 + +顶部状态区展示: + +- 当前任务名称。 +- 运行、等待、已停止等状态。 +- 已完成次数和总次数。 +- 动态进度条。 +- 远征检查剩余时间。 +- 后端在线状态。 + +没有任务时不会伪造进度,状态栏回到空闲状态。 + +### 4.3 当前舰队预览 + +作战页增加当前舰队预览: + +- 从正在运行的任务请求中读取明确携带的舰队规则。 +- 支持从 `fleet_rules` 或内联计划中的 `fleet_presets[].ships` 提取舰名。 +- 泡澡维修触发队伍切换后,预览同步使用切换后的舰队。 +- 舰名优先使用 `name`,缺少时使用 `search_name`。 +- 使用舰船资料库解析立绘、背景、边框、舰种和稀有度。 +- 复用舰队规划中的舰娘卡片结构。 +- 作战页预览不显示左上角编号,减少小尺寸卡片的信息拥挤。 +- 720P 下最多一行展示六张卡片。 +- 没有任务执行时在预览框中央显示“当前无任务执行”。 +- 任务存在但没有明确舰队规则时不展示虚构卡片。 + +只携带 `fleet_id`、没有携带具体舰名的旧任务无法可靠推断游戏内实际队伍, +因此不会猜测并展示错误舰队。 + +### 4.4 任务列表 + +任务列表以分组方式持久化常用任务: + +- 新建任务分组。 +- 修改分组名称。 +- 保存分组。 +- 删除分组。 +- 导入和导出。 +- 加入快捷动作。 +- 加入系统或用户出征计划。 +- 整组加入任务队列。 +- 记录当前激活分组。 + +任务列表中的出征计划优先保存受管身份: + +```text +managedSource + managedFile +``` + +旧数据如果只有路径,会根据 `system_battle_plans` 或 +`user_battle_plans` 推断来源和文件名。 + +显式保存按钮只有在 `task_groups.json` 写入完成后才显示成功提示。 + +### 4.5 任务队列 + +任务队列继续作为实际调度入口,支持: + +- 查看排队顺序。 +- 开始执行。 +- 停止当前任务。 +- 清空队列。 +- 展示任务次数和完成状态。 +- 将受管出征计划在执行前展开为完整运行时 YAML。 +- 在任务状态变化时同步顶部状态与舰队预览。 + +### 4.6 快捷动作 + +快捷动作与任务列表位于同一区域,包括: + +- 收取远征。 +- 收取奖励。 +- 收取建造。 +- 食堂烹饪。 +- 浴室修理。 + +快捷动作继续通过现有后端 API 执行,不强行转换为不存在的 YAML 契约。 + +### 4.7 日志 + +- 通过 WebSocket 接收后端日志。 +- 支持日志等级过滤。 +- 日志等级由设置页保存。 +- 完整用户配置只在调试模式下使用 `debug` 等级输出。 +- 普通模式不打印敏感的完整配置 YAML。 +- 已移除向本机 `127.0.0.1:7777` 发送调试事件的临时代码。 + +## 5. 舰队规划 + +### 5.1 页面定位 + +舰队规划用于生成独立舰队 YAML。用户通过本地舰船图鉴选择主选和备选, +GUI 将可视化状态转换为后端支持的 `ships` 结构。 + +### 5.2 舰娘卡片 + +舰娘卡片由以下层级组成: + +1. 稀有度背景。 +2. 舰船立绘。 +3. 舰种图标。 +4. 左上角编号。 +5. 底部黑色半透明名称背景。 +6. 根据稀有度显示的舰名颜色。 + +卡片在舰船图鉴、主选舰队、备选队列和出征预览之间复用。 +作战页的小型预览复用同一结构,但按页面需求隐藏编号。 + +### 5.3 舰船搜索与筛选 + +舰船图鉴支持: + +- 按舰名模糊搜索。 +- 按编号搜索。 +- 舰种多选。 +- 舰型集合筛选。 +- 国籍多选。 +- 按类型、名称或编号排序。 +- 正序与倒序切换。 +- 过滤改造:同一舰船存在改造前后形态时只保留改造形态, + 不会过滤掉本身没有改造形态的舰船。 +- 恢复默认筛选。 +- 筛选状态和排序状态持久化。 + +集合按钮与成员按钮联动: + +- 选择“大型舰”等集合时,激活集合包含的舰种。 +- 取消任意成员后,集合按钮立即取消激活。 +- 后续重新补齐成员不会自动重新点亮集合,除非用户再次主动选择集合。 + +### 5.4 主选位置 + +- 一支舰队最多六个位置。 +- 空位统一整理到右侧。 +- 删除前方主选后,后续完整位置规则向左移动。 +- 移动位置时,主选、等级限制和对应备选队列作为整体移动。 +- 点击已有位置可以替换舰船。 +- 相同舰船不会因为连续点击而重复加入多个位置。 +- 图鉴中已经被主选使用的形态会按同名规则隐藏。 +- 选择同舰的另一形态时执行替换,而不是新增重复位置。 + +### 5.5 备选队列 + +每个位置拥有独立备选队列: + +- 标题显示为 `【主选舰名】的备选队列`。 +- 没有主选时显示 `【位置 X】的备选队列`。 +- 支持增加备选槽位。 +- 新舰船默认进入最左侧空位。 +- 删除第七个及后续备选时,同时收缩多余空槽。 +- 选择已有备选后从图鉴点击舰船会替换当前项。 +- 只有焦点位于尾部空槽时,图鉴点击才执行追加。 +- 支持复制当前备选队列到其他位置。 +- 与目标备选完全一致时禁用复制。 +- 目标已有备选时要求用户确认覆盖。 +- 复制完成后保留当前主选焦点。 + +### 5.6 纯备选位置 + +GUI 支持“没有主选,只有备选”的位置: + +- 删除主选时可以保留其备选队列。 +- 主选卡片位置显示“使用备选队列”占位状态。 +- 纯备选位置不能被当作无效空位自动删除。 +- 只有主选和备选都为空时,整个位置才参与左移整理。 +- 保存时不要求顶层 `name`,但要求 `candidates` 非空。 + +这种结构用于让后端按候选顺序寻找第一个可用舰船。 + +### 5.7 等级限制 + +- 主选位置和备选队列分别维护等级限制。 +- 等级限制跟随位置规则移动。 +- 当前没有主选且备选也为空时,不能开启等级限制。 +- 开关关闭时不输出 `min_level` 或 `max_level`。 +- 最小等级不能大于最大等级。 +- 值域和后端支持范围保持一致。 +- 舰种规则根据舰船资料自动写入,不要求普通用户手工输入。 + +### 5.8 拖拽 + +支持以下拖拽方向: + +- 图鉴到主选。 +- 图鉴到备选。 +- 主选到主选。 +- 备选到备选。 +- 主选到备选。 +- 备选到主选。 + +拖拽原则: + +- 拖到空白尾部时执行追加和自动整理。 +- 拖到已有卡片时根据区域执行交换或替换。 +- 位置移动必须携带对应备选和等级规则。 +- 从图鉴拖入后,焦点切换到实际落位的位置。 +- 动态渲染后恢复图鉴和备选容器原有滚动位置。 + +### 5.9 新建、保存和加载 + +舰队文件具有明确生命周期: + +- “新建”只在存在真实未保存修改时确认。 +- 新建后清空当前文件身份和来源。 +- “保存”更新当前文件。 +- 已加载方案修改名称后,仍按当前文件身份处理更新或改名。 +- 新建同名方案时询问是否覆盖。 +- “加载”显示系统和用户方案列表。 +- 当前文件按 `source + file` 预选。 +- 加载前检查未保存修改。 +- 保存成功后更新当前文件、来源和保存快照。 +- 系统方案修改后保存为用户方案。 + +### 5.10 舰队 YAML + +标准文件名: + +```text +team-{预设名称}.yaml +``` + +普通主选和备选示例: + +```yaml +name: 潜艇周常队 +ships: + - name: U-47 + ship_type: [ss, ssg] + min_level: 100 + max_level: 110 + candidates: + - {name: U-96, ship_type: [ss], min_level: 100, max_level: 110} + - {name: U-1206, ship_type: [ss], min_level: 100, max_level: 110} +``` + +纯备选位置示例: + +```yaml +name: 纯备选示例 +ships: + - candidates: + - {name: U-47, ship_type: [ss]} + - {name: U-96, ship_type: [ss]} +``` + +序列化约束: + +- 空字段不输出 `null`。 +- `ships` 的顺序对应位置顺序。 +- 主选字段分行显示。 +- 备选对象使用紧凑行内格式,避免大型候选池纵向膨胀。 +- `candidates` 必须是对象列表。 +- `ship_type` 使用后端代码值。 +- `fleet_id` 不在独立舰队文件中维护。 + +## 6. 舰船资料库 + +### 6.1 数据结构 + +本地资料库位于 `resource/ship-library/`,包括: + +- `database/ships.sqlite3`:可增量维护的数据源。 +- `manifest.json`:GUI 快速加载的舰船索引。 +- `labels.zh-CN.json`:中文显示标签。 +- `assets/portraits/`:舰船立绘。 +- `assets/backgrounds/`:稀有度背景。 +- `assets/frames/`:稀有度边框。 +- `assets/type-icons/`:舰种图标。 + +当前资料库包含: + +- 894 条有效舰船记录。 +- 894 张舰船立绘。 +- 6 张共享稀有度背景。 +- 6 张共享稀有度边框。 +- 23 张舰种图标。 +- 0 个缺失资源。 + +数据库字段和内部枚举使用英文,GUI 显示标签使用中文。 + +### 6.2 增量更新工具 + +`tools/ship_library/update_ship_library.py` 提供: + +- 从舰 R 百科 Lua 数据模块读取结构化舰船数据。 +- 以 Lua `type` 字段作为舰种语义来源,页面图标只用于定位对应资源。 +- 自动识别 Wiki legacy/native 舰种 schema;旧 code 只在导入边界转换, + 输出结果必须通过 `autowsgr_native.VesselType` canonical 校验。 +- Wiki `CF` 在导入边界转换为后端 canonical `cav`,不进入本地舰种集合。 +- GUI 读取 schema 2/3 既有资料库时会在内存中转换旧舰种 code, + 保留原有立绘、背景、边框和舰种图标路径,不改写用户数据库。 +- 从舰娘图鉴页读取立绘、背景、边框和舰种图标地址。 +- 使用稳定舰船编号作为主键。 +- 保存数据源版本、哈希和更新时间。 +- 只下载新增、变更或缺失资源。 +- 下载使用临时文件并原子替换。 +- 失效记录在 SQLite 中标记,不直接破坏历史信息。 +- 更新完成后重新生成 GUI manifest。 +- 输出机器可读结果供 Electron IPC 使用。 + +设置页提供“更新舰船数据库”按钮、当前版本状态和更新进度。 +打包配置只携带更新工具本身,不携带无关脚本。 + +## 7. 出征规划 + +### 7.1 页面定位 + +出征规划负责生成地图级出征 YAML,并引用独立舰队方案。页面分为: + +- 左侧出征配置、任务配置和编队管理。 +- 右侧地图节点路线、节点信息和舰队选择/预览。 + +已删除旧引导页,进入后直接编辑。 + +### 7.2 出征配置 + +支持: + +- 章节选择。 +- 地图选择。 +- 战况目标。 +- 维修策略。 +- 维修方式。 +- 泡澡维修相关旧配置。 + +默认维修阈值由现有后端行为处理,不再为每艘船生成重复的默认 50% 阈值。 + +### 7.3 任务配置 + +支持: + +- 执行次数。 +- 轮次间隔。 +- 战利品停止条件。 +- 掉落停止条件。 +- 自定义预设名称。 + +停止条件规则: + +- 开关关闭时保存为禁用值,不展示输入文案。 +- 开关开启后才显示并允许编辑数量。 +- 战利品数量范围为 1 到 50。 +- 掉落数量范围为 1 到 500。 + +### 7.4 编队关联 + +- 从系统和用户舰队方案中选择。 +- 选择卡片默认只预览。 +- 打开卡片右侧关联开关后,才加入当前出征计划。 +- 支持同时关联多个舰队方案。 +- 左侧显示已关联数量和方案名称。 +- 编队预览展示主选、纯备选占位和全部备选。 +- 主选卡片显示独立等级范围。 +- 预览只读,修改必须回到舰队规划。 + +### 7.5 地图与节点 + +- 地图按原始宽高比缩放。 +- 地图尽可能利用容器空间,但不会超出边框。 +- 点击节点打开节点编辑。 +- 未选择节点时显示“点击地图上的节点查看和编辑”。 +- 节点路线和节点信息位于同一大区域。 + +节点编辑支持: + +- 节点启用开关。 +- 终点节点。 +- 阵型单选。 +- 前进。 +- 夜战。 +- 远程打击。 +- 迂回。 +- 迂回失败后 S/L。 +- 最低战果。 +- 索敌规则文本。 + +节点关闭时: + +- 其他编辑控件隐藏。 +- 本次编辑中的值仍保留在前端内存。 +- 保存 YAML 时不输出关闭节点的其他选项。 +- 切换文件或关闭程序后,未保存内存状态自然丢弃。 + +索敌规则输入框提示 `detour`、`retreat` 等现有后端规则用法。 + +### 7.6 新建、保存和加载 + +出征计划与舰队方案使用相同的生命周期模型: + +- 当前文件由 `source + file` 唯一标识。 +- 新建前只在有真实修改时提示。 +- 加载前检查未保存修改。 +- 保存直接更新当前用户文件。 +- 改名保存时创建标准新文件并删除旧用户文件。 +- 同名覆盖需要确认。 +- 系统计划修改后保存为用户计划。 +- 保存成功后更新快照,不再误报未保存。 +- 新建后自动聚焦预设名称输入框。 + +标准文件名: + +```text +bettle-{预设名称}.yaml +``` + +项目继续沿用现有 `bettle-` 拼写,以避免破坏已经持久化的文件身份。 + +### 7.7 编辑态引用与运行态展开 + +编辑态保存: + +```yaml +chapter: 1 +map: 1 +times: 3 +fleet_presets: + - name: 4日驱周常队 +``` + +任务执行前,主进程会: + +1. 根据计划来源和舰队名称查找独立舰队文件。 +2. 优先匹配同来源方案。 +3. 读取并校验舰队 `ships`。 +4. 将舰队完整嵌入 `fleet_presets`。 +5. 保留原计划顶部注释。 +6. 将完整 YAML 写入进程临时目录。 +7. 把临时文件路径传给后端执行。 + +如果引用舰队不存在,执行准备阶段明确失败,不会向后端提交半完整计划。 + +### 7.8 旧计划自动升级 + +旧计划可能在 `fleet_presets` 中直接包含舰队内容。首次启动迁移和计划加载 +浮窗中的“添加本地 YAML”都会复用当前 Codec 执行以下转换;计划页不再要求 +用户手工点击独立的“转换旧计划”: + +- 读取旧计划。 +- 为每个内嵌舰队生成独立 `team-{名称}.yaml`。 +- 将出征计划中的舰队改为名称引用。 +- 将旧字符串舰名转换为结构化对象。 +- 将旧 `priority` 按顺序转换为主选和 `candidates`。 +- 保留结构化纯备选位置。 +- 同名不同内容的计划或舰队保存为“(旧版)”副本。 +- 通过临时文件和原子替换完成多文件写入。 +- 保证再次读取时可以恢复完整运行时 YAML。 + +手动添加时,升级结果按 `bettle-*`、`team-*` 规范写入用户受管目录,源文件 +保持不变;同名目标必须由用户确认后才能覆盖。旧格式不会作为新编辑器的 +输出格式,外部文件路径也不会直接进入任务队列。 + +v5 启动迁移还会递归识别旧安装目录中的有效计划 YAML。旧设置始终合并;首次 +启动窗口允许分别勾选日常任务 YAML、任务队列和任务 YAML,队列依赖模板与计划 +引用编队跟随对应类别。旧字段覆盖同名字段,当前版本新增字段保留默认值。 +关闭选择窗口或迁移中异常退出不会封存来源,下次启动继续询问;明确不迁移或 +所选项目全部完成后不再询问。本次有实际迁移项时弹窗展示总数、成功数、失败数, +并说明旧版原始文件均已保留。 + +## 8. 计划管理 + +### 8.1 统一清单 + +计划管理读取: + +- 系统出征计划。 +- 用户出征计划。 +- 系统舰队方案。 +- 用户舰队方案。 +- 任务分组。 +- 忽略未关联状态。 + +表格展示: + +- 计划或舰队名称。 +- 来源。 +- 出征计划与舰队关联。 +- 所属任务分组。 +- 当前状态。 +- 可用操作。 + +### 8.2 状态识别 + +能够识别: + +- 出征计划引用了哪些舰队。 +- 舰队被哪些出征计划引用。 +- 出征计划被哪些任务分组引用。 +- 用户舰队未被引用。 +- 出征计划没有关联舰队。 +- 引用舰队文件缺失。 +- YAML 无法读取。 +- 文件名不符合管理规则。 + +“未加入任务分组”只作为信息展示,不自动计入“需要处理”。 + +### 8.3 筛选和搜索 + +支持: + +- 按系统或用户来源筛选。 +- 按出征计划或舰队方案筛选。 +- 搜索计划名称。 +- 搜索舰队名称。 +- 搜索任务分组名称。 +- 仅查看需要处理项目。 + +### 8.4 操作 + +- 跳转到舰队规划并加载指定舰队。 +- 跳转到出征规划并加载指定计划。 +- 删除用户出征计划。 +- 删除用户舰队方案。 +- 忽略不需要处理的未关联提示。 +- 恢复已经忽略的提示。 + +忽略状态写入: + +```json +{ + "plan_management_ignored_unlinked": [] +} +``` + +文件改名时同步迁移忽略键,避免状态丢失。 + +所有动态列表在重新渲染后恢复原滚动位置,避免点击后跳回顶部。 + +## 9. 决战计划(旧) + +GUI 2.0 暂时保留旧决战配置,明确标注为兼容页面: + +- 章节。 +- 快速修理。 +- 两组等级范围。 +- 现有决战筛选和配置。 +- 重置与保存。 + +保存调用主进程设置 IPC。只有 `gui_settings.json` 写入完成并返回规范化结果后, +才显示“决战配置保存成功”。 + +该页面不代表最终统一 YAML 设计。决战流程后续需要从旧直接配置中独立出来, +与普通出征任务形成清晰边界。 + +## 10. 设置页 + +### 10.1 页面结构 + +设置页改为分组列表,标题和备注优先同一行,空间不足时自然换行。 +内容宽度限制在可读范围内并居中,减少大窗口下无意义的横向拉伸。 + +### 10.2 系统设置 + +#### 模拟器与 ADB + +- 模拟器类型。 +- 模拟器路径和浏览按钮。 +- 游戏账号。 +- ADB 地址。 +- 主动连接。 +- 断开连接。 +- 自动检测。 +- 输入框右侧在线、离线或未检测状态。 +- 完整地址和错误信息通过悬停查看。 + +MuMu 12 常用地址为 `127.0.0.1:16384`,但 GUI 不强制覆盖用户输入。 + +#### 自动化设置 + +- 自动强化策略预留。 +- 自动远征。 +- 自动战役和战役类型。 +- 自动演习和舰队编号。 +- 自动出征和有序计划列表。 +- 自动胖次及预设参数。 +- 泡澡维修相关配置。 + +自动出征列表使用受管计划选择器,保存文件来源、名称和执行参数。 + +自动强化在 2.0.0 只持久化策略。生产路径为零后端调用,不调用强化预览或执行 +API;该任务不进入 Scheduler,界面的“功能说明”和“尚未开放”按钮不会操作舰船。 + +#### 日志与调试 + +- 日志等级。 +- 日志目录。 +- 调试模式。 +- 后端截图保存。 + +调试模式控制敏感完整配置日志,不在普通模式展示。 + +#### 环境配置 + +- Python 路径。 +- Python 浏览和检测。 +- 后端端口。 +- 后端启动模式。 +- 本地 AutoWSGR 仓库路径。 +- CUDA 路径。 +- 当前环境状态。 + +#### 更新与界面 + +- GUI 更新模式。 +- 默认窗口宽度和高度。 +- 记住退出时窗口大小与位置。 +- 更新舰船数据库。 +- 舰船数据库状态。 + +#### 颜色主题 + +- 亮色。 +- 暗色。 +- 跟随系统。 +- 主题强调色。 + +主题和强调色即时应用并持久化。 + +### 10.3 脚本行为 + +#### 全局延迟 + +- 最小延迟。 +- 最大延迟。 +- 输入与滑块联动。 +- 范围为 0 到 10 秒。 +- 最小值不能大于最大值。 +- 用户值写入 `usersettings.yaml`,优先于后端默认值。 + +#### OCR + +- 模型下载源。 +- 加速模式。 +- CUDA 路径。 +- 硬件识别开关。 +- 舰名匹配置信度。 +- 舰名别名。 +- OCR 纠错映射。 + +用户 OCR 规则由后端配置模型读取。无效目标舰名应由后端按 `SHIPNAMES` +校验并跳过,同时输出警告。 + +#### 舰队与脚本配置 + +- 船坞和维修相关行为。 +- 解装行为。 +- 浴室数量。 +- 自定义方案目录。 +- 后端已经开放给用户的其他低风险配置。 + +GUI 保留未知 YAML 字段,避免保存设置时破坏后端新增但当前 GUI 尚未展示的配置。 + +### 10.4 设置保存事务 + +设置同时涉及: + +- `localStorage` 主题状态。 +- 多个 GUI 设置 IPC。 +- `usersettings.yaml`。 +- 自动化调度器状态。 + +保存前会检查所有必需 bridge 接口是否存在。任一接口缺失或写入失败时: + +- 显示保存失败。 +- 不显示顶部成功提示。 +- 建议完整重启 GUI,避免旧 preload 与新渲染代码混用。 + +配置持久化全部成功后才显示“设置保存成功”。保存后的后端重连属于后续动作, +重连失败不会否定已经成功写入磁盘的配置。 + +## 11. 主题、响应式和交互统一 + +### 11.1 亮色主题 + +新增完整亮色主题,覆盖: + +- 页面背景。 +- 卡片和边框。 +- 输入框。 +- 下拉框。 +- 模态框。 +- 任务列表。 +- 舰船卡片文字。 +- 状态颜色。 +- 滚动条。 + +暗色和亮色共享语义变量,避免页面单独硬编码造成对比度不一致。 + +### 11.2 下拉框与开关 + +- 下拉框使用统一半透明背景和高对比文字。 +- 布尔配置统一使用开关样式。 +- 重要筛选不再依赖浏览器默认下拉控件。 +- 开关关闭时关联输入框禁用或隐藏。 + +### 11.3 滚动条 + +- 统一使用约 5px 主题细滚动条。 +- 舰船图鉴、备选列表、任务列表和计划管理保持相同视觉语言。 +- 动态 DOM 重建前记录 `scrollTop` 和 `scrollLeft`。 +- 重建后恢复位置,不因点击、删除或拖拽跳回顶部。 + +### 11.4 分辨率适配 + +- 以 720P 为主要布局基准。 +- 支持更窄窗口,不限制大窗口扩展。 +- 小窗口下工具栏允许分组换行。 +- 舰队主选在 720P 下保持一行六张卡片。 +- 图鉴卡片保持可识别尺寸,宽窗口增加每行数量。 +- 表格在列数较多时使用容器横向滚动,不挤坏操作按钮。 +- 标题、备注和操作按钮保持同一视觉层级。 + +## 12. Electron IPC 与文件安全 + +### 12.1 preload 契约 + +所有新增主进程能力通过 `contextBridge` 暴露明确方法,并在 +`src/types/electronBridge.ts` 中维护对应类型,包括: + +- ADB 检测、连接和断开。 +- 窗口设置。 +- 后端模式和仓库路径。 +- OCR 与 CUDA 设置。 +- 自动化设置。 +- 舰船资料库状态和更新。 +- 舰队方案列出、读取、保存和删除。 +- 出征计划列出、读取、保存、转换和删除。 +- 计划管理。 +- 忽略提示状态。 +- 运行时出征计划展开。 +- 决战兼容设置。 + +### 12.2 路径校验 + +- 文件参数必须是纯文件名,拒绝目录穿越。 +- 舰队文件必须以 `team-` 或兼容的 `team_` 开头。 +- 出征计划必须是 YAML 文件。 +- 新文件名会清除 Windows 非法字符。 +- 文件名长度限制在可维护范围内。 +- 系统和用户来源由枚举值控制,不接受任意目录。 + +### 12.3 原子写入 + +关键 YAML 保存采用: + +1. 在目标目录创建唯一临时文件。 +2. 完整写入内容。 +3. 将临时文件重命名为目标文件。 +4. 替换失败时清理临时状态并返回错误。 + +多文件旧计划迁移会先分配无冲突的“(旧版)”文件名,再执行写入;计划写入 +失败时清理本次新建的舰队文件,降低只生成一半文件的风险。 + +### 12.4 覆盖和改名 + +- 同名新建要求用户确认。 +- 当前文件原名保存直接覆盖。 +- 用户文件改名后删除旧文件。 +- 系统文件不能因改名流程被删除。 +- 保存结果返回新文件身份,渲染进程据此更新快照。 + +## 13. 后端环境管理 + +### 13.1 managed 模式 + +- 使用 GUI 管理的 Python。 +- 依赖安装到 GUI 自己的 `python/site-packages`。 +- 不写入系统 Python 全局目录。 +- 启动 GUI 管理的 uvicorn。 +- 根据更新模式检查 AutoWSGR 依赖。 +- 验证导入路径位于 GUI 管理目录。 + +### 13.2 external 模式 + +- 使用用户指定 Python,通常是本地 AutoWSGR `.venv`。 +- `backend_repo_path` 必须是包含 `autowsgr/server/main.py` 的仓库根目录。 +- 通过 Python 模块路径加载本地源码。 +- 验证 `autowsgr.__file__` 位于指定仓库。 +- 不混入 GUI 自带 Python 3.12 的二进制依赖。 +- 不检查或升级远端 `autowsgr`。 +- 缺依赖时使用本地仓库本身作为 pip requirement。 + +这保证 Python 3.13 后端虚拟环境不会错误加载 GUI Python 3.12 的二进制包。 + +### 13.3 启动与错误处理 + +- 后端端口可配置。 +- 外部后端模式支持只连接已有服务。 +- 环境检查输出阶段性进度。 +- 错误信息区分 Python、依赖、导入来源和后端连接问题。 +- 环境检查和安装同时覆盖更新器所需的 `requests` 与 `beautifulsoup4`。 +- 启动过程不再发送无关的本地调试 HTTP 请求。 + +## 14. 数据持久化 + +| 文件 | 用途 | +| --- | --- | +| `usersettings.yaml` | AutoWSGR 后端用户配置 | +| `gui_settings.json` | GUI 环境、窗口、主题、调度和页面状态 | +| `task_groups.json` | 作战页任务分组 | +| `userData/user_team_plans/*.yaml` | 用户舰队方案 | +| `userData/user_battle_plans/*.yaml` | 用户出征计划 | + +GUI 2.0 对持久化作出以下约束: + +- 后端配置值优先于代码默认值。 +- GUI 设置和后端 YAML 设置分开保存。 +- 未识别的后端 YAML 字段尽量保留。 +- 用户目录不会被打包为系统资源。 +- 系统资源更新不覆盖用户文件。 +- 当前文件身份不依赖输入框中的显示名称。 +- 未保存判断使用序列化快照,不用单一 `dirty` 点击标志代替真实比较。 + +## 15. 代码结构调整 + +### 15.1 新增主要模块 + +| 模块 | 职责 | +| --- | --- | +| `FleetPlannerView.ts` | 舰队规划、图鉴筛选、拖拽和计划管理 | +| `FleetDraftEditor.ts` | 在唯一舰队草稿上执行显式编辑意图 | +| `PlanFleetPresetController.ts` | 管理出征计划关联的舰队目录和选择 | +| `PlanManagementController.ts` | 编排计划管理操作和 Repository 调用 | +| `planManagementViewObjects.ts` | 生成计划、舰队和任务组关系的只读行 | +| `CurrentFleetController.ts` | 解析当前任务舰队并读取舰船资料 | +| `ShipArtwork.ts` | 舰船资料与卡片资源解析 | +| `TeamPlanListUi.ts` | 舰队加载列表共用逻辑 | +| `FleetPreviewView.ts` | 作战页当前舰队预览 | +| `DecisivePlanView.ts` | 旧决战配置兼容页面 | +| `TaskListLoaderController.ts` | 作战页受管计划选择 | +| `managedPlanReader.ts` | 任务分组中的计划元信息读取 | +| `contracts.ts` | 集中定义跨流程最小 Host 契约 | +| `scrollPosition.ts` | 动态列表滚动位置保存与恢复 | +| `_fleet-planner.scss` | 舰队规划和计划管理样式 | +| `_decisive-plan.scss` | 旧决战页面样式 | +| `_save-notice.scss` | 顶部保存成功提示 | +| `_light.scss` | 亮色主题 | + +### 15.2 主进程扩展 + +Electron 主进程通过组合根装配以下 Service: + +- GUI 设置规范化。 +- 用户数据目录定位。 +- 舰队 YAML 规范化和序列化。 +- 出征计划引用展开。 +- 旧计划迁移。 +- 计划管理聚合。 +- 文件安全校验。 +- 原子写入。 +- ADB 操作。 +- 舰船资料库更新。 +- 窗口状态持久化。 + +`electron/main.ts` 只负责依赖装配、IPC 注册和生命周期;格式、存储、迁移与 +运行时准备分别由 Codec、Repository 和 Service 负责。 + +### 15.3 旧资源清理 + +- 删除旧 `resource/builtin_plans/`,替换为系统出征和系统舰队目录。 +- 删除四个不再自动加载的旧模板示例 JSON。 +- 删除临时 Issue 数据和一次性 PowerShell 脚本。 +- 删除 `.dbg`、Python 缓存和未引用的舰娘卡片原型。 +- `.gitignore` 增加调试目录、Python 缓存和临时调试文档规则。 +- 打包文件列表只包含运行所需 HTML、CSS、编译产物和资源。 + +模板模型和 `templates/templates.json` 路径暂时保留,用于旧任务和用户模板兼容; +删除的是无运行引用的示例文件,不是兼容模型。 + +## 16. 构建与测试 + +### 16.1 构建 + +```powershell +npm run build +``` + +覆盖: + +- 清理旧 `dist`。 +- 编译 SCSS。 +- TypeScript 编译。 +- 渲染进程 esbuild 打包。 + +### 16.2 旧计划迁移测试 + +```powershell +npm run test:migrations +``` + +测试在系统临时目录中: + +- 模拟已有旧迁移状态的用户升级到 v5。 +- 深度合并旧设置并保留当前版本独有字段。 +- 递归扫描旧安装目录中的有效计划 YAML。 +- 迁移旧 `plans/` 和旧版 `resource/user_*` 目录。 +- 升级旧内嵌舰队计划和独立舰队。 +- 校验普通主选、候选和纯备选结构。 +- 校验计划、舰队拆分及同名“(旧版)”副本。 +- 校验标准文件名、任务组受管引用和幂等状态。 +- 校验迁移完成提示的成功、失败数量和失败文件说明。 +- 校验旧源文件仍然保留。 +- 不接触真实用户配置。 + +### 16.3 设置持久化测试 + +```powershell +npm run test:settings +``` + +测试使用隐藏 Electron 窗口和临时目录: + +- 渲染全部设置值。 +- 从表单重新收集。 +- 验证非法延迟区间。 +- 调用真实 `ConfigController` 保存逻辑。 +- 模拟完整 preload bridge。 +- 重新读取 `usersettings.yaml` 和 `gui_settings.json`。 +- 验证主题、强调色和调试模式。 +- 验证未知 YAML 字段保留。 +- 不修改项目真实用户设置。 + +### 16.4 静态检查 + +```powershell +git diff --check +``` + +用于检查行尾空白和补丁格式。 + +严格未使用声明检查: + +```powershell +npx tsc --noEmit --noUnusedLocals --noUnusedParameters --pretty false +``` + +当前检查通过。Controller 依赖图无环,`src/view` 不直接依赖 Adapter、Model、 +Controller、Repository 或 Electron bridge。 + +## 17. 已知边界和后续工作 + +### 17.1 旧后端源码更新链 + +旧的手动源码检查和拉取实现仍有部分跨层残留。它与以下功能不同: + +- GUI 自身的 `electron-updater`。 +- managed 环境中的 AutoWSGR 依赖更新。 +- external 模式下的本地源码联调。 + +后续需要先确定产品策略,再整体恢复或整体删除,不能只清理其中一层。 + +### 17.2 稳定版安装与升级边界 + +安装包内置 GUI、Python、pip、ADB、VC++ 运行库、地图、系统计划、模板和舰船资料, +但不预装 `python/site-packages`。`managed` 模式首次启动需要联网安装锁定提交的 +AutoWSGR 及其依赖。优点是安装包更小、依赖来源明确且不污染系统 Python;缺点是 +首次准备时间受网络影响,离线环境需要预先准备缓存或改用 `external` 模式。 + +1.4.x 覆盖升级会在旧卸载器运行前,将安装目录中的设置、任务列表、模板和用户 +计划移到 `%LOCALAPPDATA%\AutoWSGR-GUI\legacy-upgrade`,安装完成后恢复为首次 +启动迁移源。保存或恢复失败时安装必须停止,备份目录保留用于重试和手工回退。 + +Alpha 与稳定版使用独立频道;包括 `2.0.16-alpha` 在内的 Alpha 客户端不会自动 +切换到 `latest`,需要手动安装 2.0.0。若必须回退,应使用上述备份和旧安装器, +不得让旧版直接写入唯一的 2.0 `userData`。 + +### 17.3 决战流程 + +决战页面仍是兼容入口,尚未完成统一 YAML 化。未来应将决战执行器从旧页面配置中 +独立出来,并与普通出征形成平级、可测试的执行模型。 + +### 17.4 当前舰队预览 + +预览只显示任务请求中能够可靠识别的具体舰船。只有 `fleet_id` 的旧任务不会读取 +游戏屏幕或猜测队伍内容。若后端未来提供当前实际舰队状态接口,可再接入实时状态。 + +### 17.5 视觉回归 + +稳定版发布前应在 720P、常用 Windows 缩放比例和更窄窗口完成集中视觉回归。 +代码侧的数据结构、保存、加载、YAML 往返、IPC 和构建测试不能替代实际界面验收。 + +## 18. 兼容与迁移说明 + +### 18.1 旧出征计划 + +- v5 启动迁移先提供三类勾选,不再提供独立手工转换入口。 +- 不覆盖不同内容的同名目标,旧内容保存为“(旧版)”副本。 +- 迁移后使用标准受管目录和 `bettle-*`、`team-*` 文件名。 +- 旧源文件保留,迁移失败会在下次启动重试。 +- 迁移完成后弹窗展示本次总数、成功数和失败数;无实际迁移时不弹窗。 +- 新编辑器不继续生成旧字符串候选格式。 + +### 18.2 旧任务分组 + +- 保留路径形式的计划项。 +- 尝试从目录推断系统或用户来源。 +- 保存后逐步迁移到 `managedSource + managedFile`。 + +### 18.3 旧模板 + +- 内置模板继续从 `resource/builtin_templates.json` 读取。 +- 用户模板模型继续支持 `templates/templates.json`。 +- 旧页面中的可见模板库已经移除。 +- 无引用的四个模板示例文件不再保留。 + +### 18.4 旧舰队字段 + +- 新独立舰队不写 `fleet_id`。 +- 读取时可识别有限旧字段用于转换。 +- 新保存严格输出 `name`、`ships` 和位置规则。 +- 不生成 `priority`。 + +## 19. 审查重点 + +建议审查者按以下顺序检查: + +1. `electron/main.ts` 装配的路径校验、原子写入和运行时展开 Service。 +2. `PlanModel.ts` 与舰队/出征 YAML 往返。 +3. `FleetPlannerController.ts` 与 `FleetDraftEditor.ts` 的位置、备选和拖拽状态转换。 +4. `PlanController.ts` 的新建、加载、保存、改名和来源身份。 +5. `ConfigModel.ts` 与 `ConfigController.ts` 的字段保留和保存事务。 +6. `SchedulerBinder.ts`、`TaskQueue.ts` 和运行时计划准备。 +7. preload 与 `electronBridge.ts` 的方法签名一致性。 +8. 系统资源和用户目录在开发版、打包版中的定位。 +9. 舰船资料库更新失败时是否保留现有可用数据。 +10. 720P 布局、亮色主题和动态列表滚动位置。 + +不建议仅根据单个文件评估本次改造。舰队保存、出征引用和任务执行跨越: + +```text +View +→ Controller +→ preload bridge +→ Electron IPC +→ 用户 YAML +→ 运行时展开 +→ Scheduler +→ AutoWSGR API +``` + +任何一层缺失都会造成“界面看起来成功,但任务无法正确执行”的问题。 + +## 20. 验收清单 + +### 作战 + +- [ ] 没有任务时显示“当前无任务执行”。 +- [ ] 有明确舰队的任务显示最多六艘舰船。 +- [ ] 作战页预览不显示编号。 +- [ ] 任务分组保存失败时不显示成功提示。 +- [ ] 动态列表操作后滚动位置不跳回顶部。 + +### 舰队规划 + +- [ ] 图鉴搜索、筛选、排序和过滤改造正确。 +- [ ] 主选、备选和图鉴之间拖拽正确。 +- [ ] 删除主选时备选按规则保留。 +- [ ] 纯备选位置可以保存和加载。 +- [ ] 等级限制跟随对应位置。 +- [ ] 同名覆盖需要确认。 +- [ ] 改名保存更新正确文件身份。 + +### 出征规划 + +- [ ] 新建、保存和加载具有未保存确认。 +- [ ] 关联多个舰队后可以再次加载。 +- [ ] 节点关闭时不输出节点选项。 +- [ ] 停止条件开关和值正确往返。 +- [ ] 用户计划使用标准文件名。 +- [ ] 执行前可以展开完整舰队。 +- [ ] 舰队缺失时明确失败。 + +### 计划管理 + +- [ ] 系统和用户计划均能列出。 +- [ ] 双向舰队关联正确。 +- [ ] 任务分组关联正确。 +- [ ] 忽略和恢复提示持久化。 +- [ ] 用户计划删除后列表刷新。 +- [ ] 编辑跳转加载正确文件。 + +### 设置 + +- [ ] ADB 检测、连接和断开可用。 +- [ ] managed 与 external 模式行为隔离。 +- [ ] 亮色、暗色和跟随系统正确。 +- [ ] 窗口默认尺寸和退出位置持久化。 +- [ ] OCR、自动化和脚本行为字段保存后可重新读取。 +- [ ] 自动强化只保存策略,不进入 Scheduler,且不调用后端预览或执行接口。 +- [ ] 保存接口缺失或写入失败时不显示成功。 +- [ ] 舰船资料库状态和更新进度可见。 + +### 工程 + +- [ ] `npm run build` 通过。 +- [ ] `npm run test:migrations` 通过。 +- [ ] `npm run test:settings` 通过。 +- [ ] `git diff --check` 通过。 +- [ ] `npm run dist` 生成 `2.0.0` 安装包和 `latest.yml`。 +- [ ] `npm run test:release-package` 通过资源完整性验收。 +- [ ] 安装包不包含用户 YAML。 +- [ ] 调试产物和 Python 缓存未进入版本库。 + +## 21. 用户可感知的最终变化 + +GUI 2.0 将原来分散的功能整理为一条明确工作流: + +```text +设置运行环境 +→ 更新舰船资料 +→ 可视化创建舰队 +→ 创建并关联出征计划 +→ 在计划管理检查状态 +→ 加入任务列表 +→ 加入任务队列并执行 +→ 在作战页查看任务、进度、舰队和日志 +``` + +这次改造解决的核心问题不是“增加更多按钮”,而是让每个按钮对应的数据状态、 +文件身份、保存结果和后端输入都能够被明确追踪。用户可以通过图形界面维护 YAML, +同时保留 AutoWSGR 后端模型作为唯一执行标准。 diff --git a/docs/features/plan-batch-export.md b/docs/features/plan-batch-export.md new file mode 100644 index 0000000..6e680d4 --- /dev/null +++ b/docs/features/plan-batch-export.md @@ -0,0 +1,33 @@ +# 计划批量导出 + +## 功能入口 + +计划管理页的用户来源按钮右侧提供“批量导出”按钮。表格最左侧为选择列: + +- 用户出征计划和用户舰队方案支持单选。 +- 表头复选框选择或取消当前筛选结果中的全部用户配置。 +- 系统预设不显示复选框,也不能通过导出 IPC 读取。 +- 切换来源、类型或搜索条件时,已经勾选的用户配置保持选中。 + +## 导出格式 + +保存文件名默认为本地日期生成的 `YYYY-MM-DD-plans.zip`。ZIP 内固定使用以下目录: + +```text +user_bettle_plans/ +user_team_plans/ +``` + +出征计划保留原文件名写入 `user_bettle_plans/`,舰队方案保留原文件名写入 +`user_team_plans/`。 + +## 安全边界 + +Renderer 仅向主进程提交计划类型和文件名。主进程会重新校验: + +- 类型只能是 `battle` 或 `team`。 +- 文件名必须是不含目录的 YAML 文件名。 +- 文件必须真实存在于对应的用户计划目录中。 +- 文件真实路径不能通过符号链接逃逸用户计划目录。 + +ZIP 只能写入系统保存对话框返回的用户授权路径。 diff --git a/docs/plan-guide.md b/docs/plan-guide.md index 1d3f2ac..cb3d2c3 100644 --- a/docs/plan-guide.md +++ b/docs/plan-guide.md @@ -1,396 +1,555 @@ -# 战斗方案 YAML 编写指南 +# 计划与出征 GUI 使用指南 -本文档介绍如何编写 AutoWSGR-GUI 的战斗方案配置文件。方案文件为 YAML 格式,放置在 `plans/` 目录下,可在 GUI 中导入使用。 +本文档按当前 AutoWSGR-GUI 的实际界面和工作区实现编写。普通用户应通过 GUI +创建、保存、管理和执行计划,不再以手写 YAML 作为主要工作流。 + +完整流程分为四步: + +```text +计划 -> 舰队规划 -> 保存舰队方案 +计划 -> 出征规划 -> 保存出征计划 +主页 -> 任务列表 -> 加载计划并加入执行队列 +主页 -> 开始执行 +``` + +> 演习、战役和决战属于独立日常任务。它们从主页的“加载日常任务”进入, +> 不进入普通出征计划管理。 --- -## 方案类型 +## 1. “计划”页概览 -每个 YAML 文件对应一种任务类型。通过 `task_type` 字段(或直接使用 `chapter` + `map` 隐含常规战斗)来区分。 +主导航进入“计划”后,可以看到四个标签: -常规战斗方案可以直接内嵌任务控制字段(`times`、`stop_condition` 等),无需拆分为两个文件。 +| 标签 | 用途 | +|------|------| +| 舰队规划 | 创建主选舰船、备选队列和选船规则,并保存为舰队方案 | +| 出征规划 | 配置地图、路线、节点行为、执行次数和关联舰队方案 | +| 决战计划(旧) | 保留的旧决战编辑入口,不是普通出征计划的主流程 | +| 计划管理 | 查看系统与用户 YAML、引用关系、异常状态,并执行导出或删除 | -### 1. 常规战斗 (normal_fight) +“舰队方案”和“出征计划”是两个独立对象: -最常用的方案类型,用于地图出击。任务控制字段(`times`、`stop_condition` 等)可直接写在方案中。 +- 舰队方案回答“使用哪些舰船,找不到主选时按什么顺序替换”。 +- 出征计划回答“去哪里、走哪些节点、每个节点如何战斗、执行多少次”。 +- 一个出征计划可以关联一个或多个舰队方案。 +- “使用舰队 1~4”表示游戏内舰队编号,不等于关联的舰队方案。 -```yaml -chapter: 9 -map: 2 -selected_nodes: [A, D, G, H, M, O, E, K] -fight_condition: 1 -repair_mode: 1 -fleet_id: 1 -times: 9999 -stop_condition: - loot_count_ge: 50 +--- -node_defaults: - formation: 4 - night: false - proceed: true +## 2. 首次创建计划 -node_args: - E: - enemy_rules: - - [NAP < 1, retreat] -``` +推荐按以下顺序操作: -也可以引用内置方案(纯任务预设,不包含地图数据): +1. 在“舰队规划”中新建并保存至少一个舰队方案。 +2. 切换到“出征规划”,点击“新建”。 +3. 先选择章节和关卡,再配置节点路线。 +4. 填写配置名称、执行次数、战况和维修策略。 +5. 点击“选择预设队伍”,关联已保存的舰队方案。 +6. 至少启用一个实际地图节点,并根据需要设置终点。 +7. 点击“保存”生成用户出征计划。 +8. 返回主页,点击“加载计划”加入任务列表,再将单项或全部任务加入队列。 +9. 检查执行队列后点击“开始执行”。 -```yaml -task_type: normal_fight -plan_id: 2-1捞胖次 -times: 10 -``` +先选地图再编辑路线很重要。切换章节或关卡会清空原路线和节点配置。 -### 2. 战役 (campaign) +--- -```yaml -task_type: campaign -campaign_name: 困难航母 -times: 3 -``` +## 3. 创建舰队方案 -`campaign_name` 可选值:`简单驱逐`、`简单巡洋`、`简单战列`、`简单航母`、`简单潜艇`、`困难驱逐`、`困难巡洋`、`困难战列`、`困难航母`、`困难潜艇`。 +### 3.1 新建和命名 -### 3. 演习 (exercise) +进入“计划 -> 舰队规划”: -```yaml -task_type: exercise -fleet_id: 4 -``` +1. 点击“新建”清空当前草稿。 +2. 在“预设名称”中输入名称。 +3. 配置主选和备选舰船。 +4. 点击“保存”。 -### 4. 决战 (decisive) - -```yaml -task_type: decisive -chapter: 6 -level1: - - U-1206 - - U-96 - - 鹦鹉螺 -level2: - - 大青花鱼 -flagship_priority: - - U-1206 -``` +保存时必须满足: + +- 预设名称不能为空。 +- 至少存在一艘主选或备选舰船。 +- 同一艘舰船不能在一个方案中被重复分配。 + +“加载”可以读取系统和用户舰队方案。系统方案只读;加载系统方案后保存, +会生成用户方案,不会覆盖系统文件。 + +### 3.2 配置主选舰船 + +左侧“主选队列”固定提供六个位置,右侧是“舰娘图鉴”。 + +基本操作: + +1. 点击要编辑的主选位置。 +2. 在图鉴中搜索或筛选舰船。 +3. 点击目标舰船,将其分配到当前选中位置。 +4. 可拖动已分配的主选位置调整顺序。 +5. 删除主选后,完全为空的位置会自动向左压缩。 + +图鉴支持: -### 5. 活动 (event_fight) +- 按舰名或编号搜索。 +- 按大型舰、中型舰、小型舰、主力舰和护卫舰分组筛选。 +- 按具体舰种、国籍和是否改造筛选。 +- 按类型、名称或编号排序。 +- 使用“恢复默认”清除筛选条件。 -与常规战斗类似,但需指定 `task_type: event_fight` 和 `event_name`。 +### 3.3 配置备选队列 + +每个主选位置都有独立的备选队列。运行时会优先保留主选,主选不可用时才 +按备选顺序尝试替换。 + +操作方法: + +1. 点击一个主选位置。 +2. 在“位置 N 的备选队列”中点击“增加备选”。 +3. 点击新增的备选槽位。 +4. 从图鉴中选择舰船。 +5. 拖动备选槽位可调整尝试顺序。 + +“复制备选”可以将当前位置的备选队列复制到其他主选位置。 + +舰队也支持“纯备选位置”:主选保持为空,只配置备选舰船。此时系统不会 +自动把第一艘备选提升为主选,运行时仍按纯备选规则处理。 + +### 3.4 舰船跟随和位置跟随 + +工具栏按钮可以在两种模式之间切换: + +| 模式 | 拖动主选位置时的行为 | +|------|----------------------| +| 舰船跟随 | 主选舰船和它的备选队列一起移动 | +| 位置跟随 | 只移动主选及其规则,备选队列保留在原位置 | + +调整舰位前先确认当前模式,避免主选重排后备选关系与预期不一致。 + +### 3.5 宽泛校验和等级限制 + +主选和每个备选都可以独立设置: + +- “宽泛校验”:编队识别时要求名称一致;等级和舰种仍会检查,但不作为 + 强制失败条件。 +- “等级限制”:启用后可填写最低和最高等级。 + +这些设置作用于当前选中的主选或备选目标,不是整个舰队的统一开关。 + +建议: + +- 有明确舰船要求时保持严格校验。 +- 只有在名称可信、但资料库等级或舰种信息可能不一致时使用宽泛校验。 +- 最低等级不能高于最高等级。 --- -## 常规战斗字段详解 +## 4. 创建出征计划 -以下是常规战斗方案的完整字段说明。 +进入“计划 -> 出征规划”,点击节点路线右上角的“新建”。 -### 顶层字段 +新计划只启用内部起始节点 `0`。节点 `0` 用于后端追踪起点,不代表已经配置 +了可执行路线;保存前应继续启用至少一个实际地图节点。 -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `chapter` | 数字 | 是 | 地图章节 (如 `9`) | -| `map` | 数字 | 是 | 地图编号 (如 `2` 表示 9-2) | -| `selected_nodes` | 字符串列表 | 是 | 途经的节点列表,如 `[A, D, G, H, M, O]` | -| `fleet_id` | 数字 | 否 | 使用的舰队编号 (1-4),默认 `1` | -| `fight_condition` | 数字 | 否 | 战况条件,见下表 | -| `repair_mode` | 数字或数组 | 否 | 修理策略,见下表 | -| `node_defaults` | 对象 | 否 | 所有节点的默认配置 | -| `node_args` | 对象 | 否 | 按节点名覆盖的个性化配置 | -| `times` | 数字 | 否 | 循环执行次数,默认 `1` | -| `gap` | 数字 | 否 | 每次执行间隔(秒),默认 `0` | -| `stop_condition` | 对象 | 否 | 停止条件,满足时自动停止循环 | +### 4.1 基础配置 -### fight_condition 战况条件 +| GUI 字段 | 说明 | +|----------|------| +| 当前配置名称 | 用户方案名称,保存时必填 | +| 章节、关卡 | 当前地图;切换后会重置路线和节点配置 | +| 执行次数 | 计划默认循环次数,最少为 1 | +| 轮次间隔 | 相邻轮次之间等待的秒数 | +| 使用舰队 | 游戏内第 1~4 舰队 | +| 战况处理 | 游戏内战况选择 | +| 维修策略 | 中破就修或大破才修 | +| 战利品检测 | 今日战利品达到指定数量后停止 | +| 掉落检测 | 今日获得舰船达到指定数量后停止 | -| 值 | 含义 | -|----|------| -| `1` | 稳步前进 | -| `2` | 火力万岁 | -| `3` | 小心翼翼 | -| `4` | 瞄准 | -| `5` | 搜索阵型 | +“战况处理”当前提供: -### repair_mode 修理策略 +1. 稳步前进 +2. 火力万岁 +3. 全力以赴 +4. 深入敌阵 +5. 以退为进 -| 值 | 含义 | -|----|------| -| `1` | 中破就修 | -| `2` | 大破才修 | +“维修方式”下拉框当前显示“快速维修”和“泡澡维修”,但泡澡维修尚未接入 +完整的计划保存与生产执行链路。当前版本不要依赖该选项设计任务,应以已生效 +的“维修策略”和默认快速维修流程为准。 -可以设为单个数字(所有舰位统一),也可以设为 6 个数字的列表(每个舰位独立设置): +### 4.2 关联舰队方案 -```yaml -repair_mode: 2 # 所有位置大破才修 -repair_mode: [2, 2, 2, 1, 2, 2] # 4号位中破就修,其余大破才修 -``` +出征计划可以引用“舰队规划”中保存的方案: -### stop_condition 停止条件 +1. 点击“选择预设队伍”。 +2. 使用搜索、系统方案过滤和排序找到目标方案。 +3. 点击方案可预览六个主选位置和各位置备选队列。 +4. 将方案卡片拖到左侧“编队配置”列表。 +5. 点击已关联卡片右上角的 `×` 可以移除引用。 -| 字段 | 类型 | 说明 | -|------|------|------| -| `loot_count_ge` | 数字 | 战利品数量 ≥ 该值时停止 | -| `ship_count_ge` | 数字 | 获取舰船数量 ≥ 该值时停止 | +可以关联多个舰队方案。计划加入任务列表时,可从这些关联方案中选择本次 +使用的方案;计划中的“使用舰队 1~4”仍决定游戏内操作哪个舰队。 -示例: +如果选择器提示没有编队预设,请先回到“舰队规划”保存方案。 -```yaml -stop_condition: - loot_count_ge: 50 -``` +--- -### 编队预设与模糊选船 (fleet_presets) - -常规战斗支持在方案里写 `fleet_presets`,每个槽位可写为固定舰名(字符串)或模糊筛选对象。 - -模糊筛选对象常用字段: - -| 字段 | 类型 | 说明 | -|------|------|------| -| `name` | 字符串 | 指定舰名(可写改造名) | -| `nation` | 字符串 | 国籍筛选,如 `日本`、`德国` | -| `ship_type` | 字符串 | 舰种筛选,如 `dd`、`ss` | -| `min_level` | 数字 | 最低等级 | -| `max_level` | 数字 | 最高等级 | -| `priority` | 字符串列表 | 候选优先顺序(按顺序尝试) | - -示例: - -```yaml -fleet_presets: - - name: 岛风日驱队 - ships: - - { name: 岛风(岛风型驱逐舰)·改, min_level: 100 } - - { - nation: 日本, - ship_type: dd, - min_level: 100, - priority: [岛风(岛风型驱逐舰)·改, 黑潮·改, 阳炎·改, 早春·改] - } -``` +## 5. 配置地图路线和节点 + +### 5.1 地图切换 + +修改章节或关卡时,GUI 会重置: + +- 已选择路线,只保留内部起始节点 `0`。 +- 终点节点。 +- 终点最低战果。 +- 所有节点个性化配置。 + +因此应先确定地图,再编辑节点。地图切换后需要重新检查整条路线。 + +### 5.2 启用节点 + +点击地图上的节点会打开“节点信息”: + +1. 打开“启用节点”,将该节点加入路线。 +2. 配置该节点支持的选项。 +3. 点击“应用”保存当前节点修改。 +4. 完成全部节点编辑后,再点击页面右上角“保存”保存整个出征计划。 + +关闭“启用节点”会将该节点从路线中移除。 -说明: +非战斗节点只支持启用或关闭,不显示阵型、夜战等战斗配置。 -- `priority` 建议直接写在 YAML 中,便于按任务场景维护。 -- 当同一编队有多个同类模糊槽位时,可重复写同一组 `priority`;系统会自动跳过已被前序槽位占用的舰船。 +### 5.3 终点节点和战果 + +启用节点后,可以将其标记为“终点节点”。 + +- 终点默认不再继续前进。 +- “战果判断”为“无”时,经过该节点即认定本轮完成。 +- 选择 `D`、`C`、`B`、`A`、`S` 或 `SS` 时,只有达到最低战果才算本轮完成。 +- 未到达终点,或终点战果不足时,不会扣减剩余执行次数。 + +终点应与实际路线一致。仅启用路线节点但没有正确设置终点,可能导致任务没有 +按预期计次。 + +### 5.4 战斗节点设置 + +| 设置 | 作用 | +|------|------| +| 阵型选择 | 单纵阵、复纵阵、轮型阵、梯形阵或单横阵 | +| 前进 | 战斗结束后继续推进 | +| 夜战 | 进入夜战 | +| 远程打击 | 在支持的节点使用远程打击 | +| 迂回 | 在支持迂回的节点尝试绕过战斗 | +| 迂回失败 S/L | 迂回失败时执行 S/L | +| 索敌规则 | 根据敌方舰队条件撤退、迂回或改用指定阵型 | + +迂回相关选项只会在地图数据支持的节点显示。 + +加载已有 YAML 时,节点会先继承方案中的默认设置,再应用该节点自己的覆盖 +设置。通过 GUI 点击“应用”修改的是当前节点配置。 --- -## 节点配置 (node_defaults / node_args) +## 6. 编写索敌规则 + +索敌规则在节点编辑器中按行填写。每行格式为: -`node_defaults` 设置所有节点的默认行为,`node_args` 为特定节点覆盖配置。两者使用相同的字段。 +```text +条件, 动作 +``` + +也可以使用中文逗号、`=>` 或 `->` 分隔: -| 字段 | 类型 | 说明 | -|------|------|------| -| `formation` | 数字 | 阵型选择 | -| `night` | 布尔 | 是否进行夜战 | -| `proceed` | 布尔 | 战斗后是否继续推进 | -| `proceed_stop` | 数字列表 | 各位置的推进/撤退阈值 (6 个值,对应 6 个舰位) | -| `enemy_rules` | 列表 | 索敌规则,根据敌方编队决定行为 | +```text +CV >= 1, retreat +DD >= 1 and CL >= 2 => 4 +CV == 0 -> detour +``` -### formation 阵型 +### 6.1 支持的动作 -| 值 | 阵型 | -|----|------| +| 动作 | 说明 | +|------|------| +| `retreat` 或 `撤退` | 撤退 | +| `detour` 或 `迂回` | 尝试迂回 | +| `1`~`5` | 使用对应编号的阵型战斗 | + +阵型编号: + +| 编号 | 阵型 | +|------|------| | `1` | 单纵阵 | | `2` | 复纵阵 | | `3` | 轮型阵 | | `4` | 梯形阵 | | `5` | 单横阵 | -### proceed_stop 推进阈值 +### 6.2 条件写法 + +支持的比较符: -6 个数字的列表,分别对应舰队 6 个位置。每个值表示该位置舰船受损到何种程度时停止推进: +```text +> >= < <= == != +``` + +注意: -| 值 | 含义 | -|----|------| -| `1` | 中破停止 | -| `2` | 大破停止 | +- 比较符必须使用英文半角字符,不支持 `≥` 和 `≤`。 +- 舰种代码使用后端接受的大写代码,例如 `CV`、`DD`、`CL`。 +- 多个条件可以使用 `and` 连接。 +- 空行和以 `#` 开头的注释行会被忽略。 +- 规则按填写顺序传递,应把更具体的条件放在前面。 -示例:`[2, 2, 2, 2, 2, 2]` 表示所有位置大破才停。 +无效规则不会阻止整个计划保存,而是会被忽略并写入警告日志。保存后应检查 +日志,确认没有规则解析警告。 --- -## 索敌规则 (enemy_rules) +## 7. 保存、加载和导入 -索敌规则是方案中最重要的部分,决定了索敌成功后根据敌方舰队组成采取的行动。 +### 7.1 保存 -### 格式 +点击“保存”时: -```yaml -enemy_rules: - - [条件表达式, 动作] - - [条件表达式, 动作] -``` +- 配置名称不能为空。 +- 系统方案不会被覆盖。 +- 从系统方案加载后保存,会创建用户配置。 +- 已加载的用户方案会更新对应用户文件。 -规则**按顺序匹配**,第一条满足条件的规则生效。如果所有规则都不满足,则按默认阵型战斗。 +GUI 保存的是受管 YAML,但用户不需要手工选择保存路径。 -### 动作 +### 7.2 加载 -| 动作 | 说明 | -|------|------| -| `1` ~ `5` | 使用对应阵型战斗 (1=单纵 2=复纵 3=轮型 4=梯形 5=单横) | -| `retreat` | 撤退 | +点击“加载”会打开“加载出征配置”: -### 条件表达式 +- 列表同时显示合法的系统方案和用户方案。 +- 可以按名称或关卡搜索。 +- 可以过滤系统预设。 +- 可以按名称或修改时间排序。 +- 右侧预览显示所选方案摘要。 -条件表达式基于敌方舰队中的**舰种数量**进行判断。 +系统方案用于查看和派生用户配置,不能直接改写。 -#### 可用舰种代号 +### 7.3 添加本地 YAML -| 代号 | 舰种 | -|------|------| -| `CV` | 航母 | -| `CVL` | 轻母 | -| `AV` | 装母 | -| `BB` | 战列 | -| `BBV` | 航战 | -| `BC` | 战巡 | -| `CA` | 重巡 | -| `CAV` | 航巡 | -| `CLT` | 雷巡 | -| `CL` | 轻巡 | -| `DD` | 驱逐 | -| `SS` | 潜艇 | -| `SSG` | 导潜 | -| `SC` | 炮潜 | -| `NAP` | 补给舰 | -| `BM` | 重炮 | - -#### 运算符 - -| 运算符 | 说明 | 示例 | -|--------|------|------| -| `+` | 多舰种数量求和 | `DD + CL` | -| `>=` | 大于等于 | `NAP >= 1` | -| `<=` | 小于等于 | `DD + CL <= 1` | -| `>` | 大于 | `CV > 0` | -| `<` | 小于 | `NAP < 1` | -| `==` | 等于 | `CVL == 1` | -| `and` | 逻辑与 | `CVL == 1 and CV == 0` | - -### 迂回节点的特殊行为 - -地图数据中某些节点标记为**迂回节点**(在 GUI 方案预览中以虚线边框显示)。对于迂回节点: - -- **默认行为:尝试迂回**(不战斗直接绕过) -- 如果 `enemy_rules` 命中某条规则且动作为**阵型编号**(1-5),则**取消迂回,改为战斗** -- 如果 `enemy_rules` 命中 `retreat`,则**撤退** -- **迂回失败时默认继续战斗**(除非另有配置) +“添加本地 YAML”用于导入旧方案或外部方案: + +1. 选择一个本地 YAML 文件。 +2. GUI 将其升级为当前受管格式。 +3. 升级结果复制到用户方案目录。 +4. 如果文件包含内嵌舰队,GUI 可将其升级为关联的用户舰队方案。 +5. 后续运行使用复制后的受管文件,不再依赖原文件。 + +原始本地文件不会成为运行时权威来源。导入后应在“出征规划”和“计划管理” +中检查地图、路线、节点、舰队引用和异常状态。 + +演习、战役和决战 YAML 不能导入普通出征管理,应使用主页的“加载日常任务”。 --- -## 完整示例 - -### 示例 1:9-2 捞胖次 - -迂回节点有补给舰时战斗,否则迂回;E/K 点无补给舰则撤退。内嵌循环 9999 次、战利品 ≥ 50 自动停止。 - -```yaml -# 9-2 捞胖次 -chapter: 9 -map: 2 -selected_nodes: [A, D, G, H, M, O, E, K] -fight_condition: 1 -repair_mode: 2 -fleet_id: 1 -times: 9999 -gap: 0 -stop_condition: - loot_count_ge: 50 - -node_defaults: - formation: 4 - night: false - proceed: true - -node_args: - A: - enemy_rules: - - [AP >= 1, 4] - - [AP < 1, detour] - D: - enemy_rules: - - [AP >= 1, 4] - - [AP < 1, detour] - G: - enemy_rules: - - [AP >= 1, 4] - - [AP < 1, detour] - H: - enemy_rules: - - [AP >= 1, 4] - - [AP < 1, detour] - M: - enemy_rules: - - [AP >= 1, 4] - - [AP < 1, detour] - E: - enemy_rules: - - [AP < 1, retreat] - K: - enemy_rules: - - [AP < 1, retreat] - O: - enemy_rules: - - [AP < 1, retreat] -``` +## 8. 计划管理 -### 示例 2:7-4 漂流捞胖次 - -根据敌方编队组成选择不同阵型,Boss 点夜战。 - -```yaml -# 7-4 漂流捞胖次 -chapter: 7 -map: 4 -selected_nodes: [A, B, C, E, D, F, G, H, I, J, L, M, K] -fight_condition: 4 -repair_mode: 1 -fleet_id: 3 - -node_defaults: - enemy_rules: - - [DD + CL <= 1, 4] - - [CVL == 1 and CV == 0, 4] - formation: 2 - night: false - proceed: true - proceed_stop: [2, 2, 2, 2, 2, 2] - -node_args: - M: - enemy_rules: - - [SAP < 1, retreat] - formation: 4 - night: true - I: - enemy_rules: - - [SAP < 1, retreat] -``` +“计划 -> 计划管理”统一展示出征计划和舰队方案。 + +### 8.1 筛选和状态 + +页面支持: + +- 按全部、系统、用户来源筛选。 +- 按出征计划或舰队方案筛选。 +- 按计划名、舰队名或文件名搜索。 +- 使用“过滤非异常”只查看需要处理的项目。 +- 查看出征计划与舰队方案的引用关系。 +- 查看任务列表对计划的引用。 + +“未被引用”不一定表示配置损坏。独立保存、准备后续使用的舰队方案可以点击 +“忽略提示”;需要重新纳入检查时点击“恢复检查”。 + +### 8.2 系统配置和用户配置 + +| 来源 | 可查看 | 可编辑 | 可删除 | 可批量导出 | +|------|--------|--------|--------|------------| +| 系统 | 是 | 只读查看 | 否 | 否 | +| 用户 | 是 | 是 | 是 | 是 | + +编辑系统配置后保存,会生成用户配置,不会修改安装目录中的系统文件。 + +### 8.3 重命名 -### 示例 3:简单挂机方案 +- 重命名用户出征计划时,只修改受管文件名,不改写 YAML 中的后端业务字段。 +- 重命名用户舰队方案时,会同步更新用户出征计划中对该方案名称的精确引用。 +- 系统计划和系统舰队方案不能重命名。 -引用内置方案,设置次数。 +重命名后仍应检查任务列表中的引用状态,尤其是长期保存的旧任务列表。 -```yaml -task_type: normal_fight -plan_id: 2-1捞胖次 -times: 10 +### 8.4 删除 + +删除是不可撤销操作,并且不会级联删除引用方: + +- 删除出征计划不会自动删除引用它的任务列表条目。 +- 删除舰队方案不会自动删除引用它的出征计划。 +- 删除后,引用方可能显示文件缺失或关联异常。 +- 批量删除只处理当前筛选结果中已勾选的用户配置。 + +删除前应先查看“关联关系”和“任务分组”两列。 + +### 8.5 批量导出 + +勾选用户配置后点击“批量导出”,GUI 会生成 ZIP: + +```text +user_battle_plans/ +user_team_plans/ ``` -### 示例 4:战役 +系统配置不会进入导出选择。 + +--- + +## 9. 加入任务列表并执行 + +保存计划后回到主页,在“任务列表”中操作。 + +### 9.1 加载普通出征计划 + +点击“加载计划”: + +1. 选择一个出征计划。 +2. 如果计划关联了多个舰队方案,选择本次使用的方案。 +3. 确认执行次数。 +4. 确认后将计划加入当前任务列表。 + +任务列表中的单项可以: + +- 修改执行次数。 +- 调整顺序。 +- 点击“加入队列”单独加载。 +- 拖入执行队列。 +- 从列表中移除。 + +点击“全部加入队列”会按当前任务列表顺序批量加载。 + +主页空闲区的“导入战斗方案 (.yaml)”是直接入队入口。它会打开受管出征计划 +选择器,选定计划和舰队方案后直接加入执行队列,不经过当前任务列表。 + +### 9.2 保存任务列表 + +任务列表可以作为一组常用任务保存: + +- 输入任务列表名称。 +- 点击“保存”。 +- “新建”用于清空并创建新列表。 +- “队列管理”用于加载或管理已保存的任务列表。 + +任务列表保存的是计划来源、文件名和舰队方案选择等引用,不会复制被引用的 +计划文件。删除或重命名计划后,应重新检查任务列表。 + +### 9.3 开始和停止 + +执行队列确认无误后点击“开始执行”。 + +运行区域会显示: + +- 当前任务和进度。 +- 后续排队任务。 +- 轮次间隔等待。 +- 失败重试等待。 +- 修理等待。 + +等待中的任务仍属于队列,可以取消。运行中可点击“停止任务”;停止完成前 +按钮会显示“停止中”。 + +执行前系统会再次验证路线。如果计划只有内部起始节点 `0`,会拒绝入队并提示: -```yaml -task_type: campaign -campaign_name: 困难航母 -times: 3 +```text +出征计划只启用了起始节点,请至少开启一个路线节点 ``` -### 示例 5:演习 +--- + +## 10. 日常任务和旧决战入口 + +主页的“加载日常任务”提供: + +- 演习 +- 战役 +- 决战 + +选择配置后,可以“加入任务列表”或“加入执行队列”。 + +这些配置使用独立的日常任务目录和加载流程,不进入普通出征计划管理。不要 +为了执行日常任务,把演习、战役或决战 YAML 导入“出征规划”。 + +“计划”页中的“决战计划(旧)”是保留的旧编辑入口。新建日常执行任务时, +以主页“加载日常任务”的实际可用配置为准。 + +--- + +## 11. 常见问题 + +### 保存提示“请先填写预设名称” + +舰队方案和出征计划都要求名称非空。先填写页面顶部的名称,再点击保存。 + +### 新计划为什么看起来没有路线 + +新计划只包含内部起始节点 `0`。选择地图后,必须手动启用至少一个实际节点。 + +### 切换地图后原节点配置为什么消失 + +这是当前 GUI 的明确行为。切换章节或关卡会重置路线、终点、战果和节点配置, +防止旧地图节点错误套用到新地图。 + +### 为什么找不到舰队方案 + +先在“舰队规划”中保存方案,再刷新出征规划的队伍选择器。还应检查是否开启了 +“过滤系统预设”或输入了搜索条件。 -```yaml -task_type: exercise -fleet_id: 4 +### 为什么系统方案不能直接修改 + +系统方案属于安装资源,只读。加载后修改并保存,会生成新的用户配置。 + +### 舰队方案显示“未被引用”是否必须删除 + +不是。它只表示当前没有出征计划引用。确认是备用方案后,可以使用“忽略提示”。 + +### 删除舰队方案后出征计划还在吗 + +仍在,但引用会变成缺失或异常。删除操作不会自动修改或删除引用方。 + +### 索敌规则保存后没有生效 + +检查是否使用英文半角比较符、合法动作和大写舰种代码。无效规则会被忽略, +应查看日志中的解析警告。 + +### 可以使用泡澡维修吗 + +当前 GUI 有“泡澡维修”选项,但尚未接入完整保存和生产执行链路。不要依赖它 +安排任务。 + +--- + +## 12. YAML 和存储边界 + +GUI 仍使用 YAML 作为出征计划和舰队方案的持久化格式: + +```text +resource/system_battle_plans/ 系统出征计划,只读 +resource/system_team_plans/ 系统舰队方案,只读 +resource/system_daily_plans/ 系统日常任务,只读 +userData/user_battle_plans/ 用户出征计划 +userData/user_team_plans/ 用户舰队方案 +userData/user_daily_plans/ 用户日常任务 ``` + +其中 `userData` 是 Electron 的用户数据目录,不是项目源码目录。 + +维护原则: + +- 日常使用通过 GUI 修改,不直接编辑系统目录。 +- 外部 YAML 通过“添加本地 YAML”导入和升级。 +- GUI 保存时会基于原始方案更新受管理字段,并尽量保留未由 GUI 管理的根字段。 +- 手工修改 YAML 后仍必须重新加载并检查计划管理状态。 +- 系统和用户同名方案是不同来源,保存系统方案的修改只会创建用户副本。 + +开发者如需了解字段继承、受管目录和运行时展开规则,参见 +[方案与舰队架构](architecture/04-battle-plan.md)。 diff --git a/docs/reviews/2026-08-03-pr18-migration-ledger.md b/docs/reviews/2026-08-03-pr18-migration-ledger.md new file mode 100644 index 0000000..2c5b054 --- /dev/null +++ b/docs/reviews/2026-08-03-pr18-migration-ledger.md @@ -0,0 +1,67 @@ +# PR #18 Migration Ledger + +基线:GUI 2.0 `da5fd8d` +参考:下午整改代码 `reference/gui2-afternoon-20260803` -> `a58b1d1` + +## 已重新实现 + +- 用户计划、舰队、配置迁移到 Electron `userData`。 +- 任务组 v1→v2 首次加载写回磁盘,根级/组级/条目级未知字段保留。 +- 用户编队根级/槽位级/候选级未知字段 round-trip,candidate-only 结构保留。 +- 旧计划启动自动迁移,保留源文件、同名冲突另存、状态记录和失败重试。 +- 旧任务组 v1 兼容读取并转换为 v2,保留 `path`、managed identity 和未知条目字段。 +- v5 迁移按旧安装目录记录来源和内容哈希;已有 `userData` 时仍会深度合并 + 旧设置,迁移任务组、模板和有效 YAML,并更新队列引用。实际输出文件名写入 + 迁移状态,重复启动不会重复导入;本次完成后弹窗展示成功和失败数量。 +- Electron 文件 IPC 按读写能力限制到 `userData`/只读 `resource`,并拒绝 + `..`、越权绝对路径、UNC、盘符跳转和符号链接逃逸。 +- atomic write 失败时不删除旧文件。 +- Plan / Config 未知字段 round-trip。 +- candidate-only 编队语义。 +- scheduler logicalId 与逻辑完成事件。 +- external 后端无效时显式失败。 +- updater 结果区分 available / up-to-date / error。 +- 舰队 1 可更换编成,首槽保护保留。 +- 计划添加流程允许不绑定舰队预设。 +- AutoWSGR 编队 OCR info 日志。 +- AutoWSGR 主库的 20260730 E1/E5/H1/H5 系统计划恢复到只读 `resource/system_battle_plans`,并恢复 `builtin_event_20260730` 模板。 +- `main.ts` 已缩减为组合根;主进程按 22 个 Service 和 10 个 IPC 文件拆分, + IPC 通道名、参数顺序和同步/异步方式由契约测试锁定。 +- 舰船资料库使用临时目录、备份目录和失败恢复完成版本切换。 +- managed / external 的检查、安装和启动复用同一 Python 环境描述。 +- 作战计划、编队、任务组、API map / NodeDecision 均有兼容 fixture。 + +## 参考分支处理结论 + +- `electron/fileIpc.ts`:未直接移植;已按当前 SafePath 和 selected-file 能力重写为 `ipc/FileIpc.ts`。 +- `electron/appPaths.ts`:未直接移植;已拆为 `AppPaths`、`SafePathService`、`AtomicFileStore` 和迁移服务。 +- `electron/shipLibrary.ts`:未直接移植;已拆为 `ShipLibraryService` 和 `ShipLibraryUpdater` 并覆盖失败恢复。 +- `src/controller/taskGroup/managedPlanReader.ts`:当前 GUI 2.0 已有同名服务,需补 v1 path identity 和 preset/task group 场景测试。 +- `src/controller/taskGroup/TaskListLoaderController.ts`:参考分支没有该文件,不能直接移植;应保留 GUI 2.0 当前任务列表管理器。 +- Python 环境服务拆分:未使用参考分支的私有 monkey-patch;当前通过统一环境描述和显式依赖完成。 + +## 尚未完成的 Issue #18 项目 + +- Windows 文件锁自动化测试。 +- backend graceful shutdown 和完整进程树终止。 +- updater prerelease/stable channel 隔离。 +- managed / external / CPU / CUDA 真实环境矩阵。 +- 新 PR 的提交拆分。 + +## 验证记录 + +- `npm run test:settings` 曾在与其他 Electron 测试并行执行时因 Windows + `dist` 文件锁出现 `EPERM`;清理残留 Electron 进程后串行执行通过。后续 + Electron 测试必须串行运行,避免测试进程竞争构建目录。 +- 当前没有使用这个失败结果放宽测试;根因是测试资源生命周期竞争,不是设置持久化断言失败。 +- `npm run test:migrations` 统一覆盖已有 `userData`、不同安装目录、 + 同名任务组合并、旧 YAML 升级、队列引用更新和重复启动幂等。PR 工作流 + `Build and migration contracts` 会在 Windows runner 自动执行。 +- 使用 `AutoWSGR-GUI-old` 的真实数据在隔离临时目录完成两轮演练:空计划目标 + 下迁移 4 个作战计划和 8 个编队;复制当前完整 `userData` 后再次迁移时, + 当前队列和用户修改过的编队均保留,旧任务组引用升级成功。演练未修改真实 + `%APPDATA%` 和旧目录,临时目录已清理。 +- 主进程拆分完成后已通过: + `test:main-services`、`test:main-ipc`、`test:settings`、 + `test:migrations`、`test:api-contract`、 + `test:python-environment` 和 `test:event-resources`。 diff --git a/docs/reviews/2026-08-03-workspace-review-pending.md b/docs/reviews/2026-08-03-workspace-review-pending.md new file mode 100644 index 0000000..2163a73 --- /dev/null +++ b/docs/reviews/2026-08-03-workspace-review-pending.md @@ -0,0 +1,152 @@ +# GUI 工作区代码审查挂起项 + +> 记录日期:2026-08-03 +> 审查范围:AutoWSGR-GUI 当前工作区全部未提交代码 +> 当前状态:3 组问题挂起,等待相关页面和并行开发稳定后复查 +> 复核结果:两个独立审查线程均确认问题存在 + +## 记录目的 + +本轮审查的目标是清理调试代码、无用代码和可安全精简的结构。 +以下问题暂不修改,不代表问题已经解决,而是相关页面仍在开发, +现在删除可能干扰后续功能接入。 + +```mermaid +flowchart LR + A[发现可清理代码] --> B{相关功能是否稳定} + B -->|否| C[挂起并记录用途] + C --> D[页面与并行开发完成] + D --> E[重新检查调用和严格编译] + E --> F{代码是否仍有用途} + F -->|有| G[补齐入口和测试] + F -->|无| H[执行最小删除] + classDef pending fill:#fff3e0,color:#e65100 + classDef review fill:#bbdefb,color:#0d47a1 + classDef done fill:#c8e6c9,color:#1a5e20 + class C pending + class D,E review + class G,H done +``` + +## 挂起清单 + +| 编号 | 挂起问题 | 挂起原因 | 重新审查条件 | +|---|---|---|---| +| P-01 | 出征规划直接执行链没有调用入口 | 主页任务入口仍在开发,暂时不能判断这套执行链是否还会使用 | 主页任务入口和 YAML 执行方式确定后 | +| P-02 | 已停用的后端源码更新链仍有跨层残留 | 设置页面和更新功能仍在调试,暂时不能决定恢复还是删除 | 设置页面更新策略确定后 | +| P-03 | 严格检查仍有 4 条未使用提示 | 三条属于已挂起功能,一条属于待确认参数 | 当前一轮并行开发完成后 | + +## P-01 出征规划直接执行链 + +### 当前用途 + +这套代码原本用于在出征规划页面直接执行当前方案: + +1. 转换节点规则。 +2. 生成内存战斗计划。 +3. 为未保存方案创建临时 YAML。 +4. 组装舰队、维修和停止条件。 +5. 调用 `Scheduler.addTask()` 加入任务队列。 +6. 切回主页展示执行状态。 + +当前 `executePlan()` 没有按钮、事件绑定或其他代码调用,相关辅助方法只在 +这条封闭执行链中互相调用。 + +代码位置: + +- [节点转换、内存方案和临时文件](../../src/controller/plan/PlanController.ts#L1007-L1078) +- [直接执行和加入任务队列](../../src/controller/plan/PlanController.ts#L1080-L1158) + +### 挂起决定 + +- 当前不删除。 +- 主页任务入口完成后,先确认是否继续支持“出征规划直接加入队列”。 +- 如果保留,需要恢复明确入口并补充方案保存、入队参数和失败处理测试。 +- 如果统一改为从已保存 YAML 执行,则删除 `executePlan()` 及其专用辅助链。 + +## P-02 后端源码更新残留 + +### 当前用途 + +这是一套旧的手动后端源码更新流程,原设计为: + +```text +设置页面检查更新 +→ preload bridge +→ Electron IPC +→ checkForUpdates / pullUpdates +→ 拉取本地 AutoWSGR Git 仓库 +``` + +目前页面调用、preload bridge 和主进程 IPC 已通过注释停用,但以下内容仍保留: + +- 主进程中的未使用导入和注释 IPC: + [main.ts:L16-L17](../../electron/main.ts#L16-L17)、 + [main.ts:L2175-L2201](../../electron/main.ts#L2175-L2201) +- 注释掉的 preload bridge: + [preload.ts:L276-L292](../../electron/preload.ts#L276-L292) +- 保留的 bridge 类型: + [electronBridge.ts:L223-L237](../../src/types/electronBridge.ts#L223-L237) +- 仍然存在的检查和拉取实现: + [installer.ts:L131-L166](../../electron/pythonEnv/installer.ts#L131-L166)、 + [installer.ts:L251](../../electron/pythonEnv/installer.ts#L251) +- 设置页和启动流程中的停用调用: + [AppController.ts:L763-L789](../../src/controller/app/AppController.ts#L763-L789)、 + [envAndUpdates.ts:L88-L108](../../src/controller/startup/envAndUpdates.ts#L88-L108) + +这套旧流程不能和以下仍在使用的功能混为一谈: + +- `electron-updater` 提供的 GUI 自身更新。 +- 环境检查中的 AutoWSGR Python 包更新。 + +### 挂起决定 + +- 当前不删除,也不恢复。 +- 如果后续恢复手动源码更新,需要先定义 `external` 和 `managed` 两种后端模式的更新边界。 +- 如果后续确认不再使用,需要一次性删除导入、IPC、bridge、类型、实现和大段注释。 +- 清理时必须保留 GUI 自动更新和仍在使用的环境依赖更新。 + +## P-03 严格未使用检查 + +检查命令: + +```powershell +npx tsc --noEmit --noUnusedLocals --noUnusedParameters --pretty false +``` + +2026-08-03 最新结果为 4 条提示,全部属于未使用声明,没有发现其他类型错误。 +默认 `npm run build` 不启用这两个严格选项,因此正常构建仍能通过。 + +| 来源 | 数量 | 内容 | +|---|---:|---| +| P-01 | 1 | `executePlan` 没有调用 | +| P-02 | 2 | `checkForUpdates`、`pullUpdates` 导入未使用 | +| 其他开发残留 | 1 | `RepairManager` 的 `fleetId` 参数未使用 | + +前三条需要随 P-01 和 P-02 的功能去留一起处理。`fleetId` 需要先确认泡澡维修流程 +是否仍应按舰队区分,再决定补齐用途或删除参数。 + +### 挂起决定 + +- 当前不做批量清理。 +- 等并行开发稳定后重新运行严格检查,以最新结果为准。 +- 只删除最终版本中仍无调用的声明,不根据本次快照机械修改。 +- 对 `fleetId` 这类可能代表未完成业务逻辑的参数,先判断应该补齐功能还是删除。 + +## 复查清单 + +- [ ] 主页任务入口已经稳定。 +- [ ] 设置页面更新策略已经确定。 +- [ ] 当前一轮并行 Agent 修改已经结束。 +- [ ] 重新搜索 `executePlan` 的调用入口。 +- [ ] 区分旧后端源码更新、GUI 自动更新和环境依赖更新。 +- [ ] 重新运行正常构建和严格未使用检查。 +- [ ] 根据最终代码决定保留、接入或删除。 +- [ ] 更新本文档状态和最新检查结果。 + +## 不属于挂起项 + +- 系统 YAML 只读方案已被否决,不作为后续待办。 +- 后端日志中的 `7777` HTTP 调试请求已经删除。 +- 完整配置 YAML 已改为仅在调试模式下输出 `debug` 日志。 +- `.dbg`、Python 缓存和临时舰娘卡片预览页已经清理。 diff --git a/docs/reviews/2026-08-04-src-module-split-agent-runbook.md b/docs/reviews/2026-08-04-src-module-split-agent-runbook.md new file mode 100644 index 0000000..2792e79 --- /dev/null +++ b/docs/reviews/2026-08-04-src-module-split-agent-runbook.md @@ -0,0 +1,489 @@ +# `src` TypeScript 模块拆分单 Agent 执行任务书 + +## 1. 文档用途 + +本文件把 [`src` TypeScript 模块拆分方案](./2026-08-04-src-module-split-plan.md) +转换为一个长期 Agent 可以分阶段执行的任务书。 + +两份文档的职责不同: + +- 原方案说明为什么拆、最终边界和现有模块的去向。 +- 本任务书规定单个 Agent 每个阶段做什么、允许修改什么、如何验证和何时停止。 + +本任务书不是一次性全目录搬迁,也不是多 Agent 并行队列。实施过程按 S0-S6 +串行推进,并在每个阶段验证行为和架构边界。 + +本任务的目标不是机械复制 `electron/` 的目录名称,而是让 `src/` 形成同样清晰的 +职责边界: + +```text +src/ +├─ adapter/ YAML、JSON、IPC、HTTP、WebSocket、Storage 边界 +├─ controller/ 页面和用例编排 +├─ model/ 领域模型、规则和唯一状态所有者 +├─ view/ DOM/ViewObject 渲染和用户意图回调 +├─ types/ 按领域和通信方向组织的类型 +└─ shared/ 无状态共享工具 +``` + +最终没有为了目录外观增加 `app/` 或 `domain/`:`AppController` 继续是 Renderer +组合根,领域状态继续位于 `model/`。Adapter 按完整边界收敛为 +`ApiAdapter.ts`、`IpcAdapter.ts`、`JsonAdapter.ts`、`StorageAdapter.ts` 和 +`YamlAdapter.ts`,避免微型文件。 + +## 2. 执行启用门槛 + +本轮实际执行记录: + +```text +BASE_SHA=7bd0c65 +WORK_BRANCH=ShiinaKuroko +CURRENT_STAGE=POST_S6_TYPES_CONSOLIDATION_COMPLETED +PATCH_LEVEL=L0 +``` + +维护者已明确要求以当前脏工作树为准并保留全部未提交改动,因此本轮没有创建 +独立 worktree,也没有执行 reset、checkout、stash 或覆盖用户文件。该授权只适用 +于本次连续实施;后续新一轮拆分仍应先记录基线并隔离工作树。 + +## 3. 单 Agent 通用执行协议 + +### 3.1 开始阶段 + +Agent 每次只执行一个阶段,例如 `S2`。阶段完成并经过维护者验收后,才可以开始 +下一个阶段。开始阶段前必须完整读取: + +1. `AGENTS.md` +2. `.editorconfig` +3. `.gitattributes` +4. `tsconfig.json` +5. `package.json` +6. `CONTRIBUTING.md` +7. 原拆分方案 +8. 本任务书 +9. 当前阶段卡指定的架构文档和现有测试 + +### 3.2 修改前报告 + +Agent 在写代码前必须报告: + +```text +阶段 ID: +基线 SHA: +当前分支/worktree: +行为目标: +非目标: +允许修改的主要文件: +状态所有者: +当前数据流: +预期数据流: +Patch 等级: +计划执行的验证: +``` + +没有完成该报告,不得开始写代码。阶段完成后必须暂停,不得自动进入下一阶段。 + +### 3.3 修改范围 + +- 只修改当前阶段卡的“主要范围”。 +- 可以修改直接 import、barrel export、对应测试和对应架构文档。 +- 需要进入未列出的业务模块时,立即停止并报告范围扩散;不得顺手修改其他阶段 + 的核心文件。 +- 不修改 IPC channel、API 字段、YAML/JSON 公共格式和用户目录。 +- 不修改 UI 样式,除非当前阶段明确允许。 +- 不进行无关格式化、依赖升级、资源更新或命名清理。 +- 不使用 `any`、类型断言、retry、sleep、fallback 或第二状态源绕过问题。 + +### 3.4 兼容迁移 + +拆文件时采用以下顺序: + +1. 增加目标模块和最小接口。 +2. 将现有逻辑原样迁入,先保持行为。 +3. 原公共入口暂时改为 facade 或 re-export。 +4. 修改调用方。 +5. 运行验证。 +6. 只有 S6 可以删除跨阶段兼容出口。 + +禁止在同一个任务中一边迁移结构,一边修改业务语义。 + +### 3.5 每个阶段的通用验证 + +所有代码阶段至少执行: + +```powershell +npm run build +git diff --check +git status --short +``` + +`npm run build` 会生成 CSS/构建输出。没有样式变化时,不得把生成差异作为任务 +成果提交。 + +阶段卡列出的专项测试必须全部执行。无法执行时,阶段不得标记完成,交付记录 +必须写明阻塞原因和未验证风险。 + +### 3.6 强制停止条件 + +出现任一情况,Agent 必须停止,不得继续补代码: + +1. 当前工作树包含无法确认归属的修改。 +2. 阶段需要修改另一个未解锁阶段的核心文件。 +3. 需要新增第二份可写状态、同步标志、延时或 fallback。 +4. 两次实现尝试仍未通过同一验证。 +5. 旧测试与架构文档对当前行为给出冲突结论。 +6. 无法证明 candidate-only、YAML 未知字段或任务生命周期保持不变。 +7. 需要改变 IPC、API 或持久化格式才能完成结构拆分。 + +## 4. 状态所有权不变量 + +所有任务都必须遵守: + +| 状态 | 唯一可写所有者 | 禁止行为 | +|---|---|---| +| 当前任务、运行状态 | `Scheduler` | 子策略保存镜像状态 | +| 就绪队列、延迟重试 | `TaskQueue` | Controller 保存第二份任务队列 | +| Cron 定时器、pending | `CronScheduler` | Store 决定触发行为 | +| 泡澡舰船集合 | `RepairManager` | View/Controller 维护影子集合 | +| 当前作战方案 | `PlanController` | 子 View 持有可独立修改副本 | +| 舰队编辑草稿 | 单个 `FleetDraft` | 多个子 View 各自保存草稿 | +| 决战舰队草稿 | 单个 `DecisiveFleetDraft` | 复用普通草稿后再加补偿字段 | + +以下外部语义不得改变: + +- 纯候选舰船槽位不得自动生成顶层 `name`。 +- `candidates` 中每项仍必须有 `name`。 +- YAML 未知字段、头部注释和旧字段迁移行为保持。 +- 队列优先级、延迟重试、后触发和停止条件时序保持。 +- IPC channel、参数、返回值和错误文本保持。 +- REST/WebSocket 路径、请求和回调顺序保持。 + +## 5. 状态所有权与目标边界 + +### 5.1 Renderer 目标数据流 + +```text +Adapter → Domain Model → Controller → ViewObject → View + ↑ │ + └ 用户意图 ───┘ +``` + +职责要求: + +- `adapter/` 只处理 YAML、JSON、IPC、HTTP、WebSocket 和 Storage 边界。 +- `model/` 只持有领域状态和纯业务规则,不访问 `window`、`document`、Electron 或 + Node 文件系统。 +- `controller/` 只编排用例和转换 ViewObject,不直接依赖 YAML/JSON parser。 +- `view/` 只渲染 DOM、读取用户输入并发出意图,不访问有状态 Model、全局 IPC 或 + Storage;允许使用类型、不可变目录和无状态领域函数。 +- `AppController` 是 Renderer 组合根,负责生命周期和跨域协调。 +- `types/` 区分领域类型、API DTO、IPC DTO 和 ViewObject。 +- `shared/` 只放无状态、无业务所有权的共享工具。 + +### 5.2 单一状态所有者 + +| 状态 | 唯一可写所有者 | 其他模块允许做什么 | +|---|---|---| +| 当前任务、运行状态 | `Scheduler` | 读取、发出用户意图 | +| 就绪队列、延迟重试 | `TaskQueue` | 读取、请求入队 | +| Cron timer、pending | `CronScheduler` | 读取、请求触发 | +| 泡澡舰船集合 | `RepairManager` | 读取快照、请求修理 | +| 当前作战方案 | `PlanController` | View 只能通过意图修改 | +| 普通舰队草稿 | `FleetDraft` | Controller 只协调 mutation | +| 决战舰队草稿 | `DecisiveFleetDraft` | Controller 只协调 mutation | + +Controller 不得保存 Draft、Scheduler 或 RepairManager 的镜像字段。Store、Policy、 +Factory 和 View 都不得成为第二个可写状态源。 + +## 6. 单 Agent 阶段顺序 + +严格串行执行以下阶段。每个阶段完成后暂停,维护者验收通过后才能继续: + +```text +S0 只读审计与行为基线 +→ S1 Types 拆分 +→ S2 Adapter 边界 +→ S3 Domain 拆分 +→ S4 App 与 Controller 收口 +→ S5 View 拆分 +→ S6 兼容层清理与文档同步 +``` + +不得把这些阶段拆给多个 Agent 并行执行。单个 Agent 必须持续掌握同一套状态所有权、 +依赖图和目标边界。 + +## 7. 阶段总表 + +| ID | 阶段 | 前置阶段 | 主要状态 | +|---|---|---|---| +| S0 | 只读审计与行为基线 | 基线 SHA | 已完成 | +| S1 | Types 拆分与兼容出口 | S0 | 已完成 | +| S2 | YAML/JSON/IPC/API/Storage Adapter | S1 | 已完成 | +| S3 | Fleet、Plan、Scheduler Domain | S2 | 已完成 | +| S4 | App 与 Controller 收口 | S3 | 已完成 | +| S5 | View 拆分与纯 View 边界 | S4 | 已完成 | +| S6 | 删除兼容层、死代码并同步文档 | S0-S5 | 已完成 | + +## 8. 阶段卡 + +### S0 只读审计与行为基线 + +**目标**:不改变业务行为,固定可复现基线。 + +**允许范围**:`scripts/`、`docs/reviews/`,以及为基线测试新增的最小 fixture。 + +**必须完成**: + +- 记录 `BASE_SHA`、Node/npm 版本、依赖状态和工作树状态。 +- 生成 `src` import 依赖图和直接越层访问清单。 +- 覆盖 YAML round-trip、未知字段、头部注释、candidate-only、任务队列、Cron、 + Repair、TaskGroup/Template 迁移和 API/IPC 契约。 +- 记录每个测试的通过/失败,不在本阶段修业务。 + +**验收**: + +```powershell +npm run build +npm run test:migrations +npm run test:api-contract +npm run test:settings +npm run test:main-services +npm run test:main-ipc +git diff --check +``` + +**完成后必须暂停。** 未固定基线不得进入 S1。 + +### S1 Types 拆分与兼容出口 + +**范围**: + +```text +src/types/api.ts +src/types/ipc.ts +src/types/model.ts +src/types/view.ts +src/types/scheduler.ts +``` + +**要求**: + +- API DTO、IPC DTO、领域类型、ViewObject 和调度类型各自保持完整文件。 +- 不保留只做 re-export 的 Types facade 或二级子目录。 +- 不改变类型语义,不修改运行时行为。 +- 不批量迁移所有 import,只需证明新出口可用。 +- 不把默认值、迁移逻辑和 View 逻辑放入 types。 + +**验收**:`npm run build`、`npm run test:api-contract`。 + +### S2 Adapter 边界 + +**范围**: + +```text +src/adapter/ApiAdapter.ts +src/adapter/IpcAdapter.ts +src/adapter/JsonAdapter.ts +src/adapter/StorageAdapter.ts +src/adapter/YamlAdapter.ts +src/model/PlanModel.ts +src/model/ConfigModel.ts +src/model/TaskGroupModel.ts +src/model/TemplateModel.ts +src/model/MapDataLoader.ts +src/model/ApiClient.ts +``` + +**要求**: + +- YAML/JSON 解析、迁移和序列化各只有一个实现位置。 +- Repository/Store 只处理边界,不决定业务行为。 +- `ApiClient` 保留业务 facade,HTTP/WS 传输实现移入 adapter。 +- 保持未知字段、注释、旧格式、storage key、IPC channel、API path、请求体和回调 + 时序。 +- 不增加通用文件 IPC、备用 endpoint 或第二份持久化状态。 + +**验收**: + +```powershell +npm run test:migrations +npm run test:api-contract +npm run test:main-ipc +``` + +### S3 Domain 拆分 + +**范围**: + +```text +src/model/fleet/ +src/model/scheduler/ +src/data/shipData.ts +``` + +**Fleet 要求**: + +- 集中 `ShipCatalog`、`ShipNameNormalizer`、`ShipMatcher`、`FleetRuleMapper`、 + `FleetDraft`、`DecisiveFleetDraft`。 +- candidate-only 不得自动生成顶层 `name`。 +- candidates 顺序和每项独立规则必须保留。 +- `ship_type`、`search_name`、等级规则不能在 View 中重复解释。 +- `shipData.ts` 在迁移期只作为 facade。 + +**Scheduler 要求**: + +- `Scheduler` 唯一持有当前任务和运行状态。 +- `TaskQueue` 唯一持有 ready/delayed 队列。 +- `CronScheduler` 唯一持有 timer/pending。 +- `RepairManager` 唯一持有 bathingShips。 +- 提取的 Policy/Factory 只能是纯函数或无状态对象。 + +**必须新增或补齐测试**:优先级、延迟、重试、后触发、Cron 恢复、Repair 恢复、舰队 +预设切换、candidate-only、舰种和等级规则。 + +### S4 App 与 Controller 收口 + +**范围**:`src/controller/app/`、`src/controller/startup/`、 +`src/controller/plan/`、`src/controller/taskGroup/`、`src/controller/template/`。 + +**要求**: + +- `AppController` 是唯一 Renderer 组合根。 +- `StartupController` 保留启动顺序和销毁顺序。 +- 子 Controller 只接收最小 Host/Port,不接收整个 AppController。 +- Controller 不直接依赖 YAML/JSON parser,不直接持久化业务状态。 +- Plan、TaskGroup、Template 的请求构造分别收口到明确的 mapper/factory。 +- 保持任务顺序、次数、优先级、重试、停止条件和后端连接时序。 + +**验收**:`npm run test:settings`、`npm run test:main-services`、 +`npm run test:main-ipc`、`npm run test:python-environment`、相关 API/迁移测试。 + +### S5 View 拆分与纯 View 边界 + +**范围**:`src/view/` 以及与 View 直接相关的 Controller/ViewObject mapper。 + +**要求**: + +- View 只渲染 DOM、读取输入和发出用户意图。 +- View 不访问有状态 Model、全局 IPC、localStorage、js-yaml 或持久化。 +- View 可以使用类型、不可变目录和无状态领域函数,但不能取得业务状态所有权。 +- View 不保存可独立修改的业务副本。 +- `FleetPlannerView` 必须先建立唯一 `FleetDraft`,再拆编辑、规则、图鉴、选择和管理子 View。 +- `DecisivePlanView` 使用独立 `DecisiveFleetDraft`,不得复用普通草稿后叠加补偿字段。 +- 保持 candidate-only、舰队规则、保存覆盖、导入导出和预览行为。 + +**验收**:build、API/迁移/服务测试,以及固定步骤的手工验证记录。 + +### S6 兼容层清理与文档同步 + +**前置**:S0-S5 全部完成并通过验收。 + +**要求**: + +- 先用 `rg`、bundle 检查、测试和入口检查证明旧 facade 无内部/外部依赖。 +- 再删除 types facade、`shipData` facade、旧 View re-export、无引用 helper 和 barrel。 +- 不因为目标目录存在就删除仍可能是公共入口的 facade。 +- 同步 `docs/architecture/`、本方案和本任务书,使文档与实现一致。 + +**最终静态检查**: + +```powershell +rg -n "window\.electronBridge|\(window as any\)" src/model src/view +rg -n "localStorage" src/model src/view +rg -n "js-yaml|yaml\.load|yaml\.dump" src/controller src/model src/view +rg -n "\bas any\b" src/controller src/model src/view +rg -n "shipData" src scripts electron +rg -n "types/(api|ipc|model|view)/" src scripts electron +rg -n "controller/shared/ControllerHost|controller/(app|plan|startup|taskGroup|template|shared)/index" src scripts electron +``` + +最终运行全部基线测试和 `git diff --check`。`as any` 只能在明确注释的第三方边界存在, +业务 Controller/Model/View 中不得存在。 + +**实际清理结果**: + +- 原 4 个 Types 根 facade 已由真实定义替代,旧 Types 子目录已删除。 +- 删除 `data/shipData.ts` 和 6 个无引用 Controller barrel。 +- 删除无调用方的 `controller/shared/ControllerHost.ts` 和 + `controller/taskGroup/importExport.ts`。 +- 保留仍有测试或业务调用方的 `model/fleet/index.ts`、 + `model/scheduler/index.ts`、`queueLoader.ts` 和 `managedPlanReader.ts`。 +- 更新 `docs/architecture/`、拆分方案和本任务书。 + +**最终验证结果**: + +- 构建、舰种契约、Fleet/Scheduler Domain、旧配置/旧方案/任务组迁移和 API 契约通过。 +- 设置持久化、主进程服务、主进程 IPC、Python 环境、舰船库更新器和活动资源测试通过。 +- 7 项静态边界/旧入口检查无匹配,删除路径均不存在。 +- `git diff --check` 通过,仅报告资源 JSON 的 CRLF/LF 转换提示。 + +桌面 Electron 启动、舰队拖拽、模拟器连接和实际任务执行未在本轮工具环境中手工 +验证。代码仍处于未提交工作树,没有生成完成 SHA。 + +## 9. 阶段交付格式 + +单 Agent 完成每个阶段后必须按以下格式交付,并在交付后暂停: + +```text +阶段 ID: +基线 SHA: +完成 SHA: + +行为目标: +实际修改: +明确未修改: + +修改文件: +新增文件: +删除文件: + +状态所有者变化: +外部契约变化:无 / 具体说明 +兼容层:新增 / 保留 / 删除 + +执行命令及结果: +1. +2. + +手工验证: +未验证路径: +失败尝试次数: +当前 Patch 等级: +回滚方式: + +git status --short: +下一阶段建议: +``` + +交付中只说“构建通过”不算完成。必须列出专项测试和业务不变量的验证证据。 + +## 10. 可直接派发的提示词 + +维护者可以复制以下内容,替换阶段 ID 和 SHA。不要把多个阶段一次性派给 Agent: + +```text +请执行 C:\ShiinaKuroko\04.Code\AutoWSGR\AutoWSGR-GUI\docs\reviews\2026-08-04-src-module-split-agent-runbook.md 中的阶段 。 + +基线 SHA: +前置阶段完成 SHA: + +你只能执行该阶段,不得开始下一阶段。修改前先按任务书提交预检报告。 +使用独立分支/worktree,保护现有未提交修改。完成后执行通用验证和阶段卡专项验证, +并按“阶段交付格式”报告,然后暂停等待维护者验收。出现强制停止条件时停止写代码 +并报告,不得自行扩大范围。 +``` + +## 11. 阶段验收 + +维护者接受每个阶段前必须确认: + +- [ ] Agent 使用了正确的基线。 +- [ ] 只完成一个阶段 ID。 +- [ ] 没有混入共享工作树的旧改动。 +- [ ] 状态所有者没有复制。 +- [ ] 外部契约没有变化,或已获得单独批准。 +- [ ] 通用验证和专项验证均有结果。 +- [ ] 失败尝试和 Patch 等级已披露。 +- [ ] 文档与实现没有互相矛盾。 +- [ ] 回滚该任务不会要求同时回滚未关联功能。 +- [ ] 下一阶段基于本阶段合并后的新 SHA。 diff --git a/docs/reviews/2026-08-04-src-module-split-plan.md b/docs/reviews/2026-08-04-src-module-split-plan.md new file mode 100644 index 0000000..0aefc59 --- /dev/null +++ b/docs/reviews/2026-08-04-src-module-split-plan.md @@ -0,0 +1,332 @@ +# `src` TypeScript 模块拆分方案 + +## 1. 范围和结论 + +本方案是历史拆分记录,早期阶段曾覆盖 `src/` 下 77 个 TypeScript 文件,阶段中间曾 +记录 97 个文件。它不定义当前目录数量;当前模块和职责以 +`docs/architecture/09-src-typescript-catalog.md` 为准: + +| 目录 | 文件数 | +|---|---:| +| `controller` | 33 | +| `view` | 28 | +| `model` | 22 | +| `types` | 5 | +| `adapter` | 6 | +| 其他 `src` 目录 | 3 | + +拆分不以行数为唯一标准。只有出现以下情况才拆: + +1. 一个文件包含多个独立变化原因。 +2. View、Controller、Model 之间发生越层调用。 +3. YAML、JSON、IPC、HTTP、WebSocket 或 `localStorage` 没有明确适配边界。 +4. 多个界面重复实现同一套舰船筛选、拖拽或规则转换。 +5. 可以提取纯策略,同时不产生第二份可写状态。 + +本次只调整 Renderer 的模块边界,不改变 IPC channel、HTTP API、YAML 格式、 +任务队列行为、candidate-only 语义和用户数据目录。 + +## 2. 目标边界 + +```text +View + 只持有 DOM 引用、渲染 ViewObject、发出用户操作回调 + 不直接调用全局 electronBridge、localStorage、js-yaml 或有状态 Model + 可以读取类型、不可变目录和无状态领域函数 + +Controller + 持有页面/用例状态,协调 View、Model 和 Repository + 不直接依赖 YAML/JSON parser,不使用 any 绕过 Host/Port 契约 + +Model + 持有领域状态和纯业务规则 + 不直接访问 window、document、electronBridge 或 localStorage + +Adapter + 负责 IPC、REST、WebSocket、YAML、JSON 和浏览器存储 + 对 Controller/Model 暴露最小接口 +``` + +状态所有权保持不变: + +| 状态 | 唯一所有者 | +|---|---| +| 当前任务、调度状态 | `Scheduler` | +| 就绪队列、延迟重试队列 | `TaskQueue` | +| Cron 定时器和 pending 标记 | `CronScheduler` | +| 泡澡舰船集合 | `RepairManager` | +| 当前作战方案 | `PlanController` | +| 舰队编辑草稿 | `FleetPlannerController` + 单个 `FleetDraft` | +| 决战舰队草稿 | `DecisivePlanController` + 单个 `DecisiveFleetDraft` | + +## 3. 最终目录 + +实现采用完整边界和完整业务功能作为文件粒度,没有继续执行早期草案中的微型 +Repository、Codec 或 helper 拆分。下列目录是 S6 收口后的实际结构: + +```text +src/ +├─ adapter/ +│ ├─ ApiAdapter.ts +│ ├─ IpcAdapter.ts +│ ├─ JsonAdapter.ts +│ ├─ StorageAdapter.ts +│ ├─ YamlAdapter.ts +│ └─ index.ts +├─ controller/ +│ ├─ app/ +│ │ ├─ NavigationController.ts +│ │ ├─ OperationsController.ts +│ │ ├─ AppController.ts +│ │ ├─ ConfigController.ts +│ │ ├─ SchedulerBinder.ts +│ │ └─ SettingsController.ts +│ ├─ plan/ +│ │ ├─ BattlePlanLoaderController.ts +│ │ ├─ DecisivePlanController.ts +│ │ ├─ FleetPlannerController.ts +│ │ └─ PlanController.ts +│ ├─ startup/ +│ ├─ taskGroup/ +│ └─ template/ +├─ model/ +│ ├─ fleet/ +│ │ ├─ DecisiveFleetDraft.ts +│ │ ├─ FleetDraft.ts +│ │ ├─ FleetRuleMapper.ts +│ │ ├─ ShipCatalog.ts +│ │ ├─ ShipMatcher.ts +│ │ ├─ ShipNameNormalizer.ts +│ │ └─ index.ts +│ └─ scheduler/ +│ ├─ SchedulerRepairPolicy.ts +│ ├─ SchedulerTaskPolicy.ts +│ └─ index.ts +├─ view/ +│ ├─ plan/ +│ │ ├─ FleetPlannerView.ts +│ │ ├─ FleetEditorView.ts +│ │ ├─ FleetRuleView.ts +│ │ ├─ FleetGalleryView.ts +│ │ ├─ PlanManagementView.ts +│ │ └─ TeamPlanLoaderView.ts +│ ├─ config/ +│ ├─ main/ +│ ├─ setup/ +│ ├─ shared/ +│ ├─ taskGroup/ +│ └─ template/ +└─ types/ + ├─ api.ts + ├─ ipc.ts + ├─ model.ts + ├─ view.ts + └─ scheduler.ts +``` + +无引用的 Controller barrel 已删除。`model/fleet/index.ts` 和 +`model/scheduler/index.ts` 仍有实际调用方,因此继续作为领域公共入口。 + +## 4. Controller 逐文件映射 + +| 现有文件 | 处理 | 目标 | +|---|---|---| +| `controller/app/AppController.ts` | 收口完成 | 保留唯一组合根;设置页交互进入 `SettingsController`,心跳由 `StartupController` 持有 | +| `controller/app/ConfigController.ts` | 收口完成 | 保留完整配置用例协调,通过公开方法更新最小依赖,不再绕过私有 Host | +| `controller/app/SchedulerBinder.ts` | 保留并收口 | 继续统一绑定 Scheduler/CronScheduler 回调,不复制调度状态 | +| `controller/app/SettingsController.ts` | 新增并收口 | 集中设置页环境检测、ADB、舰船库、更新检查和主题交互 | +| `controller/app/constants.ts` | 保留并清理 | 保留优先级和状态文案;删除无引用的 `resolveRepairModeLabel()` | +| `controller/app/index.ts` | 已删除 | 无代码、脚本或打包入口引用 | +| `controller/app/rendering.ts` | 保留 | 继续作为纯 ViewObject 构造模块 | +| `controller/app/theme.ts` | 已迁移到 `view/theme.ts` | View 持有 DOM 和系统主题事件;偏好读取复用 Storage Adapter | +| `controller/plan/BattlePlanLoaderController.ts` | 新增并收口 | 独立持有受管方案选择器状态并返回最终选择结果 | +| `controller/plan/PlanController.ts` | 收口完成 | 保留当前方案编辑、地图、保存和执行;方案选择委托给 Loader | +| `controller/plan/index.ts` | 已删除 | 无代码、脚本或打包入口引用 | +| `controller/plan/nodeEditor.ts` | 保留 | 节点编辑用例集中,无需再拆 | +| `controller/plan/presetFlow.ts` | 保留并收口 | 集中预设导入、展示和任务请求构造 | +| `controller/plan/rendering.ts` | 保留 | 继续作为纯 ViewObject mapper | +| `controller/plan/selectedNodes.ts` | 保留 | 单一纯规则 | +| `controller/shared/ControllerHost.ts` | 已删除 | 各控制器使用自己的最小 Host/Port | +| `controller/shared/DialogHelper.ts` | 保留 | 集中的对话框适配层 | +| `controller/shared/index.ts` | 已删除 | 无调用方,不再保留无意义 barrel | +| `controller/startup/StartupController.ts` | 保留并清理 | 保留环境检查、更新、后端连接和销毁顺序 | +| `controller/startup/connection.ts` | 保留 | 后端连接启动流程集中 | +| `controller/startup/envAndUpdates.ts` | 保留 | 环境准备和启动更新仍构成一个完整启动业务边界 | +| `controller/startup/index.ts` | 已删除 | 无代码、脚本或打包入口引用 | +| `controller/taskGroup/TaskListLoaderController.ts` | 保留并收口 | 继续负责完整的任务列表加载用例 | +| `controller/taskGroup/TaskGroupController.ts` | 保留并瘦身 | 继续协调任务组;固定 DOM 事件迁到 View 回调 | +| `controller/taskGroup/addItems.ts` | 保留并收口 | 保留添加条目用例;删除无引用的 `addFileToGroup()`,文件/YAML 处理走 Adapter | +| `controller/taskGroup/contextMenu.ts` | 保留并收口 | 集中上下文菜单和任务编辑意图,不再继续拆成微型文件 | +| `controller/taskGroup/importExport.ts` | 已删除 | 两个导出函数均无调用方,且没有对应 UI 入口 | +| `controller/taskGroup/index.ts` | 已删除 | 无代码、脚本或打包入口引用 | +| `controller/taskGroup/managedPlanReader.ts` | 保留 | 被元数据和队列加载流程共同调用 | +| `controller/taskGroup/metaLoader.ts` | 保留并收口 | 保留批量元数据编排;YAML 解析统一走 `yamlCodec` | +| `controller/taskGroup/queueLoader.ts` | 保留并收口 | 集中 managed/group/template 三种来源的入队规则 | +| `controller/template/TemplateController.ts` | 收口完成 | 保留模板页和向导协调,使用稳定的强类型状态引用 | +| `controller/template/crud.ts` | 保留并收口 | 保留 CRUD 用例,JSON 解析统一走 `jsonCodec` | +| `controller/template/index.ts` | 已删除 | 无代码、脚本或打包入口引用 | +| `controller/template/selectors.ts` | 保留并收口 | 保留选择用例,使用强类型和 `yamlCodec`,移除 `any` | +| `controller/template/useTemplate.ts` | 保留 | 用例单一 | +| `controller/template/wizard.ts` | 保留并改契约 | 保留步骤规则;用明确状态接口替代 `as any` ref-wrapper | + +## 5. View 逐文件映射 + +| 现有文件 | 处理 | 目标 | +|---|---|---| +| `view/config/ConfigView.ts` | 保留并收口 | 完整设置页纯渲染组件;不解析 YAML,不访问 IPC | +| `view/main/FleetPreviewView.ts` | 保留并注入 | 由 Controller 传入舰船库 manifest,移除直接 IPC | +| `view/main/LogView.ts` | 保留 | 日志渲染职责集中 | +| `view/main/MainView.ts` | 保留 | 继续作为主页面 facade | +| `view/main/StatusBar.ts` | 保留 | 状态栏职责集中 | +| `view/main/TaskQueueView.ts` | 保留 | 队列渲染和拖拽回调集中 | +| `view/plan/BattlePlanLoaderView.ts` | 新增并收口 | 集中受管方案选择弹窗、搜索筛选、列表和舰队预览 DOM | +| `view/plan/DecisivePlanView.ts` | 保留并收口 | 通过 `DecisivePlanViewHost` 发出意图,草稿由 `DecisivePlanController` 独立持有 | +| `view/plan/FleetEditDialog.ts` | 保留并收口 | 对话框保留,复用只读舰船目录和唯一名称规范化规则 | +| `view/plan/FleetPlannerView.ts` | 拆分完成 | facade + `FleetEditorView`、`FleetRuleView`、`FleetGalleryView`、`PlanManagementView`、`TeamPlanLoaderView` | +| `view/plan/FleetPresetView.ts` | 保留并收口 | 通过最小 Host 获取计划和舰船库数据,不直接访问全局 IPC | +| `view/plan/MapView.ts` | 保留 | 地图渲染职责集中 | +| `view/plan/NodeEditorView.ts` | 保留 | 节点编辑表单职责集中 | +| `view/plan/PlanPreviewView.ts` | 保留 | 继续组合地图、节点和方案表单 | +| `view/plan/ShipArtwork.ts` | 保留 | 集中计划页舰船图片创建和 fallback | +| `view/plan/TeamPlanListUi.ts` | 保留 | 集中编队计划过滤、排序和卡片渲染 | +| `view/setup/SetupWizardView.ts` | 保留 | 向导渲染职责集中 | +| `view/shared/ShipAutocomplete.ts` | 保留 | 通用自动补全组件 | +| `view/shared/scrollPosition.ts` | 保留 | 通用纯 DOM 工具 | +| `view/taskGroup/TaskGroupView.ts` | 保留 | 任务组面板渲染职责集中 | +| `view/template/SelectorDialog.ts` | 保留 | 通用选择弹窗 | +| `view/template/TemplateLibraryView.ts` | 保留 | 模板列表渲染职责集中 | +| `view/template/TemplateWizardView.ts` | 保留并收口 | 继续负责向导 DOM;固定事件通过回调交给 Controller | + +## 6. Model、Types、Data、Utils 逐文件映射 + +| 现有文件 | 处理 | 目标 | +|---|---|---| +| `model/ApiClient.ts` | 收口完成 | 保留业务 API facade;REST 和 WebSocket 传输委托 `ApiAdapter` | +| `model/ConfigModel.ts` | 收口完成 | 保留配置状态、默认值和迁移;YAML 解析统一走 `yamlCodec` | +| `model/MapDataLoader.ts` | 收口完成 | 保留缓存和地图查询;文件读取和 JSON 解析委托 Adapter | +| `model/PlanModel.ts` | 收口完成 | 保留方案状态、未知字段合并和序列化规则;底层 YAML 解析统一走 `yamlCodec` | +| `model/TaskGroupModel.ts` | 收口完成 | 保留任务组权威状态/CRUD;JSON 和文件持久化委托 Adapter | +| `model/TemplateModel.ts` | 收口完成 | 保留模板 CRUD 和校验;JSON 和文件持久化委托 Adapter | +| `model/scheduler/CronScheduler.ts` | 策略/存储提取 | 保留定时器和 pending 状态;时间规则与 `localStorage` 分离 | +| `model/scheduler/ExpeditionTimer.ts` | 保留 | 单一定时职责 | +| `model/scheduler/RepairManager.ts` | 策略/存储提取 | 保留 `bathingShips`;阈值判断与持久化分离 | +| `model/scheduler/Scheduler.ts` | 提取纯策略 | 保留 `currentTask/status`、消费和 API 回调;纯规则进入 `SchedulerTaskPolicy` 和 `SchedulerRepairPolicy` | +| `model/scheduler/StopConditionChecker.ts` | 保留 | 停止条件职责集中 | +| `model/scheduler/TaskQueue.ts` | 保留并收口 | 继续唯一持有就绪和延迟队列 | +| `model/scheduler/index.ts` | 保留 | 继续作为调度系统公共出口 | +| `types/api.ts` | 真实定义 | 后端请求、响应、任务 DTO 和 WebSocket 事件 | +| `types/ipc.ts` | 真实定义 | IPC DTO、`ElectronBridge` 和全局 Window 声明 | +| `types/model.ts` | 真实定义 | 配置、方案、模板、舰队和修理领域类型 | +| `types/scheduler.ts` | 保留 | 调度类型内聚且规模合理 | +| `types/view.ts` | 真实定义 | 页面 ViewObject、表单值和展示状态 | +| `data/shipData.ts` | 已删除 | 舰船目录、名称规范化、匹配和规则映射已归入 `model/fleet/` | +| `utils/Logger.ts` | 保留 | 日志格式、级别和输出职责集中 | + +## 7. 重点模块的实际拆法 + +### 7.1 `FleetPlannerView.ts` + +最终按完整舰队业务功能拆分,而不是按单个 helper 拆分: + +1. `FleetPlannerController` 持有唯一 `FleetDraft`。 +2. `FleetGalleryView` 负责图鉴筛选、排序、加载缓存等纯展示状态。 +3. `FleetEditorView` 负责舰队槽位编辑和拖拽意图。 +4. `FleetRuleView` 负责主选、备选、舰种和等级规则输入。 +5. `TeamPlanLoaderView` 负责编队计划选择。 +6. `PlanManagementView` 负责计划列表和管理意图。 +7. `FleetPlannerView` 只组合上述完整业务 View 并转发回调。 + +`FleetDraft` 必须保留 candidate-only:没有明确 `name` 的槽位不能把第一个 +candidate 提升为主选。 + +### 7.2 `Scheduler.ts` + +不把执行流程拆成多个可写对象。只提取纯策略: + +- `SchedulerTaskPolicy`:任务完成、重试、后触发和队列请求规则。 +- `SchedulerRepairPolicy`:修理和替换相关的无状态判断。 + +`consumeNext()`、重试时序、`currentTask` 和状态切换继续留在 `Scheduler`。 + +### 7.3 `PlanModel.ts` 和 `ConfigModel.ts` + +Model 不再直接依赖 `js-yaml`,公共方法继续保留: + +```typescript +PlanModel.fromYaml(content) +plan.toYaml() +config.loadFromYaml(content) +config.toYaml() +``` + +这些方法统一委托 `yamlCodec`,保持 YAML 未知字段、头部注释和旧字段迁移行为。 + +## 8. 实施状态 + +| 阶段 | 状态 | 结果 | +|---|---|---| +| S0 行为基线 | 已完成 | 建立迁移、API、Fleet 和 Scheduler 特征测试 | +| S1 Types | 已完成并复核粒度 | 四个领域和 Scheduler 各一个真实定义文件,不保留子目录 barrel | +| S2 Adapter | 已完成 | 形成 5 个完整边界 Adapter 和统一入口 | +| S3 Domain | 已完成 | Fleet/Scheduler 规则收口,状态所有权未复制 | +| S4 Controller | 已完成 | `AppController` 保持唯一组合根,子 Controller 使用最小 Host | +| S5 View | 已完成 | Fleet View 按完整业务功能拆分,业务草稿移出 View | +| S6 清理 | 已完成 | facade、无引用 barrel 和死代码已删除,文档与最终回归通过 | + +## 9. 每阶段验收 + +每个提交至少执行: + +```powershell +npm run build +npm run test:api-contract +git diff --check +``` + +按改动范围追加: + +```powershell +npm run test:migrations +npm run test:settings +npm run test:main-services +npm run test:main-ipc +``` + +最终静态边界检查: + +```powershell +npm run test:architecture-boundaries +rg -n "window\.electronBridge|\(window as any\)" src/model src/view +rg -n "js-yaml|yaml\.load|yaml\.dump" src/controller src/model src/view +rg -n "\bas any\b" src/controller src/model src/view +``` + +Controller 门禁应通过,其余 3 条均应无结果。 + +S6 最终验证已通过: + +- `npm run build`、舰种契约同步检查。 +- Fleet、Scheduler、旧配置、旧方案、任务组迁移和 API 契约测试。 +- 设置持久化、主进程服务、主进程 IPC 和 Python 环境测试。 +- 舰船库更新器、活动资源测试、静态边界检查和 `git diff --check`。 + +桌面 Electron 启动、舰队拖拽、模拟器连接和实际任务执行没有在本轮工具环境中 +进行手工验收,仍需在合并前按下列清单验证。 + +最终手工回归: + +1. 启动、ADB 连接、心跳和后端停止。 +2. 配置加载/保存、外部 Python、CUDA/OCR、更新检查。 +3. 作战方案加载、修改、保存、执行和 candidate-only 请求。 +4. 编队创建、备选拖拽、覆盖确认、计划管理和批量导出。 +5. 任务组保存、加载、排序、单项/整组入队和旧数据迁移。 +6. Cron、重试、停止条件、泡澡轮换和远征任务。 +7. 模板创建、编辑、导入和加入任务组。 + +## 10. 明确不做 + +- 不在拆分提交中修改 IPC channel、接口字段或用户文件格式。 +- 不同时重写 UI 样式。 +- 不把 `Scheduler`、`CronScheduler`、`RepairManager` 的状态复制到新对象。 +- 不以“文件超过多少行”为理由继续细拆单一职责文件。 +- 不在当前脏工作区直接进行全目录搬迁。 diff --git a/docs/reviews/2026-08-10-fix-guide.md b/docs/reviews/2026-08-10-fix-guide.md new file mode 100644 index 0000000..005f8db --- /dev/null +++ b/docs/reviews/2026-08-10-fix-guide.md @@ -0,0 +1,882 @@ +# AutoWSGR-GUI 修复指南(2026-08-10,最新代码核对版) + +> **本指南基于最新远程代码(commit 81ed614 之后)逐项核对生成。15 项问题全部仍然存在,需要修复。** +> Agent 严格按本指南执行,**不得超出"修改范围"字段列出的文件**,不得顺手重构无关代码。 + +--- + +## 执行原则 + +1. **最小改动**:只改指南列出的文件,不顺手重构无关代码。 +2. **最大复用**:能复用现有方法/接口的,不新增重复逻辑。 +3. **最小影响**:不改变现有 IPC 通道名、配置字段名、返回数据结构。 +4. **明确边界**:每个 Agent 任务只负责自己那部分,不跨任务改其他文件。 +5. **验证必做**:每项改完跑 `npm run build`;若涉及调度器,跑 `node scripts/tests/test-scheduler-domain.mjs`。 + +--- + +## 第一部分:真实 BUG(3 项,必须修) + +--- + +## 1. BUG-NEW-1:战利品停止条件被重复计数 + +**状态**:✅ 已确认修复方案 + +### 问题描述 +同一行后端日志通过两个通道进入 Scheduler: +- stdout 通道([Scheduler.ts processBackendLog()](file:///workspace/src/model/scheduler/Scheduler.ts)) +- WebSocket 通道([Scheduler.ts setupApiCallbacks.onLog()](file:///workspace/src/model/scheduler/Scheduler.ts)) + +两个通道都解析 loot/ship 数量并调 `stopChecker.updateTracked()`,导致数量被加两遍。 + +**用户实际遇到**:设了「5 个战利品就停」,实际只有 3 个时 stopChecker 里记成 6,提前触发停止。 + +### 修改范围(Agent 仅可改动) +- `src/model/scheduler/Scheduler.ts`:`setupApiCallbacks` 方法内的 `onLog` 回调 + +**Agent 不得改动**: +- `processBackendLog()` 方法(保留为唯一计数入口) +- `StopConditionChecker` 类 +- `AppController.bindBackendLog` 的双通道分发逻辑 + +### 具体改动 +找到 `setupApiCallbacks` 方法里 `onLog:` 回调。当前代码(约 1088 行附近): + +```ts +onLog: (msg) => { + const loot = parseUiCount(msg.message, '战利品数量'); + const ship = parseUiCount(msg.message, '舰船数量'); + if (loot !== null || ship !== null) { + this.stopChecker.updateTracked(loot, ship); + this.checkAndStopRunningTask(); + } + this.callbacks.onLog?.(msg); +}, +``` + +改为: + +```ts +onLog: (msg) => { + // 计数逻辑由 processBackendLog(stdout 通道)统一负责, + // WebSocket 通道不再重复计数,避免双通道导致 loot/ship 数量翻倍。 + this.callbacks.onLog?.(msg); +}, +``` + +### 原因 +- stdout 通道(Electron 主进程捕获)已完整覆盖解析 + updateTracked + checkAndStopRunningTask。 +- WebSocket 通道是同一条日志的副本,两个通道同时计数会导致数量翻倍。 +- 保留 stdout 而非 WebSocket:链路更短、更可靠,WebSocket 断连时不影响计数。 + +### 验证方式 +1. `npm run build` 通过 +2. 配置 `{ loot_count_ge: 5 }` 的任务 +3. 后端打日志「战利品数量:3」→ stopChecker 内部 loot 计数应为 3(非 6) +4. 跑到第 2 轮「战利品数量:6」才触发停止 + +--- + +## 2. BUG-NEW-2:战役/决战/战利品失败当天不再重试 + +**状态**:✅ 已确认修复方案 + +### 问题描述 +[SchedulerBinder.ts:111-122](file:///workspace/src/controller/app/SchedulerBinder.ts#L111-L122) 的 `onLogicalTaskCompleted` 回调中: +- 演习:成功走 `markExerciseCompleted`,失败走 `clearExercisePending` ✅ +- 战役/决战/战利品:**无论成功失败都走 `markXXXHandled`** ❌ + +`markXXXHandled` 会写当天日期到 localStorage,意味着「今天已处理,不再触发」。失败也被标记成「已处理」,导致当天不再重试。 + +**用户实际遇到**:开了自动战役,10 点触发,任务失败(OCR 错/船坞满/网络断),当天再也不触发。 + +### 修改范围(Agent 仅可改动) +- `src/controller/app/SchedulerBinder.ts`:`onLogicalTaskCompleted` 回调里战役/决战/战利品三段 + +**Agent 不得改动**: +- 演习的处理逻辑(已正确) +- 自动出征(normalFight)的处理逻辑 +- `CronScheduler.markXXXHandled` / `clearXXXPending` 方法本身 + +### 具体改动 +找到 `onLogicalTaskCompleted` 回调(约 96-140 行)。当前: + +```ts +if (logicalId === this.pendingBattleTaskId) { + this.host.cronScheduler.markBattleHandled(); + this.pendingBattleTaskId = null; +} +if (logicalId === this.pendingDecisiveTaskId) { + this.host.cronScheduler.markDecisiveHandled(); + this.pendingDecisiveTaskId = null; +} +if (logicalId === this.pendingLootTaskId) { + this.host.cronScheduler.markLootHandled(); + this.pendingLootTaskId = null; +} +``` + +改为(和演习保持一致的成功/失败分支): + +```ts +if (logicalId === this.pendingBattleTaskId) { + if (success) { + this.host.cronScheduler.markBattleHandled(); + } else { + this.host.cronScheduler.clearBattlePending(); + } + this.pendingBattleTaskId = null; +} +if (logicalId === this.pendingDecisiveTaskId) { + if (success) { + this.host.cronScheduler.markDecisiveHandled(); + } else { + this.host.cronScheduler.clearDecisivePending(); + } + this.pendingDecisiveTaskId = null; +} +if (logicalId === this.pendingLootTaskId) { + if (success) { + this.host.cronScheduler.markLootHandled(); + } else { + this.host.cronScheduler.clearLootPending(); + } + this.pendingLootTaskId = null; +} +``` + +### 原因 +- 失败时不应该写「今天已处理」,应该允许下次 tick 重试。 +- 和演习保持一致:成功才 markXXXHandled(当天不再触发),失败走 clearXXXPending(允许重试)。 + +### 验证方式 +1. `npm run build` 通过 +2. `node scripts/tests/test-scheduler-domain.mjs` 通过 +3. 模拟战役任务失败 → 确认 `markBattleHandled` 未被调用、`clearBattlePending` 被调用 +4. 下次 tick 应能重新触发战役 + +--- + +## 3. resolveNormalFightPlan 兜底路径删除 + +**状态**:✅ 已确认修复方案(用户明确要求:自动出征必须使用用户指定的完整路径,找不到打 error 日志跳过,不阻塞其他任务) + +### 问题描述 +[ScheduledTaskLoader.ts:286-329](file:///workspace/src/controller/app/ScheduledTaskLoader.ts#L286-L329) `resolveNormalFightPlan(name)` 中 `name` 已是 UI 选择器返回的完整绝对路径(见 ConfigView 里 `name: path`),但代码仍有一套「拼后缀 + 拼分类目录 + 拼 autowsgr/data/plan 目录」的 10 条候选路径兜底逻辑。 + +**用户实际遇到**:用户指定的 YAML 不存在时(被删/被移),兜底逻辑可能从系统目录读到同名但内容不同的计划,**默默执行了用户没指定的计划**。 + +**用户明确要求**:自动出征必须绝对使用用户指定的路径。找不到就打 error 日志,跳过此任务,继续加载列表下一个,不阻塞其他流程。 + +### 修改范围(Agent 仅可改动) +- `src/controller/app/ScheduledTaskLoader.ts`:`resolveNormalFightPlan` 方法(286-329 行) + +**Agent 不得改动**: +- 上层 `loadNormalFightTasks()` 的 try/catch + Logger.error + 跳过继续逻辑(已正确) +- `NormalFightTaskConfig` 接口结构 +- ConfigView/SettingsController 中 UI 选择器写入完整路径的逻辑 + +### 具体改动 +当前(约 44 行): + +```ts +private async resolveNormalFightPlan( + name: string, +): Promise<{ path: string; content: string } | null> { + if (!this.repository) return null; + const root = this.host.configModel.current.plan_root + ?.replace(/[\\/]$/, ''); + const suffixes = /\.ya?ml$/i.test(name) + ? [name] + : [name, `${name}.yaml`]; + const candidates = new Set(suffixes); + for (const category of ['normal_fight', 'event']) { + for (const suffix of suffixes) { + if (root) { + candidates.add(`${root}/${category}/${suffix}`); + } + candidates.add( + `autowsgr/data/plan/${category}/${suffix}`, + ); + } + } + + for (const candidate of candidates) { + if (this.repository.readCombatPlanFile) { + const result = await this.repository + .readCombatPlanFile(candidate); + if ( + result.success + && result.content?.trim() + && result.path + ) { + return { + path: result.runtimePath ?? result.path, + content: result.content, + }; + } + continue; + } + const content = await this.repository.readFile(candidate); + if (content.trim()) { + return { path: candidate, content }; + } + } + return null; +} +``` + +改为(约 18 行): + +```ts +private async resolveNormalFightPlan( + name: string, +): Promise<{ path: string; content: string } | null> { + if (!this.repository) return null; + // 自动出征必须使用 UI 选择器返回的用户指定完整绝对路径, + // 绝不兜底到系统目录或猜后缀,避免执行非用户预期的计划。 + if (this.repository.readCombatPlanFile) { + const result = await this.repository.readCombatPlanFile(name); + if (result.success && result.content?.trim() && result.path) { + return { + path: result.runtimePath ?? result.path, + content: result.content, + }; + } + return null; + } + const content = await this.repository.readFile(name); + if (content.trim()) return { path: name, content }; + return null; +} +``` + +### 原因 +- UI 选择器返回的就是完整路径,兜底路径纯冗余。 +- 兜底逻辑在用户文件被删/被移时,可能从系统目录读到同名但内容不同的 YAML,默默执行错误计划。 +- 上层 `loadNormalFightTasks()` 的 for 循环已用 try/catch 包好:单任务 throw → Logger.error → 继续加载下一个。已满足「找不到就报错,不阻塞后续」。 + +### 验证方式 +1. `npm run build` 通过 +2. 用户指定文件存在 → 直接读,行为不变 +3. 用户指定文件不存在 → Logger.error「找不到出征计划: <路径>」,系统目录同名文件不被触碰,后续任务正常加载 + +--- + +## 第二部分:功能性防护(1 项) + +--- + +## 4. H4:安装依赖入口拒绝后端运行时执行 + 日志 + 浮窗提示 + +**状态**:✅ 已确认修复方案 + +### 问题描述 +用户在「设置页/启动页」点「检查环境/安装依赖」时,如果后端正在运行(出征/战役中): +- Windows 下 pip 写入 `.pyd` 被文件锁阻塞 → PermissionError,安装半途失败 +- Linux 下文件能写入成功,但正在运行的后端加载的仍是旧 inode,需重启后端才生效 + +**用户明确要求**: +1. **绝不置灰按钮**(用户任何时候都能点) +2. 点了之后发现后端在运行,**打印一条明确的日志告诉用户为什么不能操作** +3. UI 上弹一个「和保存成功同样式的顶部浮窗」(`showSaveSuccess` 风格的 notice),说明「当前后端正在运行,不能安装/检查环境」 +4. 后端停止后再次点击,功能正常执行 +5. **不得阻塞其他流程**:启动页的自动检查/安装(那时后端一定没起)不受影响 + +**关于 checkEnvironment**:`checkEnvironment` 只读 Python 版本/依赖标记文件,不改磁盘,**后端运行时无害**,正常放行。仅 `installDependencies` 才拦截。 + +### 修改范围 +- `electron/pythonEnv/installer.ts`:`installDependencies(pythonCmd)` 入口加 BackendService 检查 + Logger.warn + 返回结构化错误 +- `electron/services/PythonEnvironmentService.ts`:`installDependencies()` 同语义双保险(如无循环依赖则保留,否则仅 installer.ts 一处即可) +- `src/controller/startup/envAndUpdates.ts`:`installDeps()` 返回值检查 `blockedReason === 'backend-running'` → 调 `showSaveSuccess` 弹顶部浮窗 +- `src/controller/app/SettingsController.ts`:如有 `installDeps` 调用,同启动页处理(Agent 先 grep 确认,没有则跳过) + +**Agent 不得改动**: +- `checkEnvironment()` 逻辑(只读,后端运行时无害,正常放行) +- `installPortablePython()`(仅启动时出现,后端未运行,无需拦截) +- `EnvironmentIpc.ts` 中 IPC 注册签名 +- 按钮 DOM 的 disabled 属性(**不得置灰**) + +### 具体改动 + +#### ① installer.ts 入口检查 +顶部新增 import: +```ts +import { BackendService } from '../services/BackendService'; +import { Logger } from '../Logger'; // 若项目中无此路径,换成 electron 侧实际 Logger 模块路径 +``` + +`installDependencies(pythonCmd)` 函数最开头新增(返回类型加可选字段 `blockedReason?`): +```ts +export async function installDependencies(pythonCmd: string): Promise<{ + success: boolean; + output: string; + blockedReason?: string; +}> { + // ===== 新增开始 ===== + const backend = BackendService.getInstance().getBackendProcess(); + if (backend && !backend.killed) { + const msg = '后端运行中,请先停止任务'; + Logger.warn(msg); + return { + success: false, + output: msg, + blockedReason: 'backend-running', + }; + } + // ===== 新增结束 ===== + + const ctx = getCtx(); + // ... 后续原逻辑完全不变 ... +} +``` + +#### ② PythonEnvironmentService 双保险(可选,有循环依赖则跳过) +```ts +async installDependencies(): Promise<{ + success: boolean; + output: string; + blockedReason?: string; +}> { + const backend = BackendService.getInstance().getBackendProcess(); + if (backend && !backend.killed) { + const msg = '后端运行中,请先停止任务'; + Logger.warn(msg); + return { success: false, output: msg, blockedReason: 'backend-running' }; + } + const pythonPath = await this.dependencies.findPython(); + if (!pythonPath) return { success: false, output: '找不到 Python' }; + return this.dependencies.installDependencies(pythonPath); +} +``` + +**循环依赖判断**:`BackendService` 是否 import 了 `PythonEnvironmentService`?如果是,**跳过 Service 层重复检查**,只保留 installer.ts 一处。 + +#### ③ 渲染进程返回值处理 → 弹浮窗 +位置 A:`src/controller/startup/envAndUpdates.ts` + +当前调用结构(示意): +```ts +const installResult = await bridge.installDeps(); +if (!installResult.success) { + // 原错误处理逻辑 +} +``` + +修改:在 `if (!installResult.success)` 分支最前面加浮窗: +```ts +if (!installResult.success) { + if ((installResult as { blockedReason?: string }).blockedReason === 'backend-running') { + const { showSaveSuccess } = await import('../../view/shared/DialogHelper'); + showSaveSuccess(installResult.output); + } else { + // 原有的其他错误处理(写日志、标 failed 状态等)保留在此 else 分支里 + } +} +``` + +位置 B:`src/controller/app/SettingsController.ts` +**Agent 执行前先确认**:grep SettingsController 是否有 `installDeps`/`checkEnvironment` 调用。**如果没有,此项跳过。** 如果有,处理方式同位置 A。 + +### 原因 +- Windows 文件锁导致安装失败是真实用户痛点,但用户明确不允许置灰按钮。 +- 仅入口返回结构化错误 + Logger.warn + 渲染进程做 UI 浮窗(`showSaveSuccess`,和「保存成功」一样的顶部 notice),三重满足用户要求。 +- `checkEnvironment` 不拦截,因为它只是读 Python 版本和 marker 文件,不写磁盘。 + +### 验证方式 +1. `npm run build` 通过 +2. 启动后端(保持出征状态),点「安装依赖」: + - 主进程日志输出 warn + - UI 顶部出现「保存成功」样式的浮窗 + - 没有执行 pip 命令 +3. 停止后端后,点「安装依赖」:正常进入 pip 安装流程 +4. 「检查环境」按钮(如存在):后端运行时也正常,不拦截 + +--- + +## 第三部分:架构/规范迁移(11 项) + +--- + +## 5. H1:删除 resolveAppPath renderer 暴露链路(死接口) + +**状态**:✅ 已确认修复方案 + +### 问题描述 +`resolveAppPath(filePath)` 通过 IPC 暴露给 renderer,但 renderer 侧 **零调用**(全仓 grep `src/` 仅类型声明,无 `bridge.resolveAppPath` / `gateway.resolveAppPath` 调用)。`readFile` / `saveFile` / `appendFile` 内部调用的 `SecureFileService` 已自行调用 `SafePathService.resolveWritablePath` / `resolveReadablePath` 做 containment 校验,无需 renderer 先 resolve。 + +### 修改范围(只删 4 处暴露,不动方法本体) +1. [src/types/ipc.ts:357](file:///workspace/src/types/ipc.ts#L357):删除 `ElectronBridge` 接口中的 `resolveAppPath: (filePath: string) => Promise;` +2. [electron/preload.ts:344-346](file:///workspace/electron/preload.ts#L344-L346):删除 `resolveAppPath` 暴露 +3. [electron/ipc/FileIpc.ts:119-121](file:///workspace/electron/ipc/FileIpc.ts#L119-L121):删除 `ipc.handle('resolve-app-path', ...)` handler +4. [docs/architecture/06-backend-communication.md:117](file:///workspace/docs/architecture/06-backend-communication.md#L117):删除文档表格中的 `resolveAppPath` 行 + +**Agent 不得改动**: +- `SafePathService.resolveAppPath()` 方法本体(主进程内部 [CombatPlanIpc.ts:155](file:///workspace/electron/ipc/CombatPlanIpc.ts#L155) 仍在使用) +- `saveFile` / `readFile` / `appendFile` / `openFolder` 接口(主进程侧 `SecureFileService` 已做 containment 校验,有真实调用,保留) + +### 具体改动 +逐个删除上述 4 处即可,不新增任何代码。 + +### 原因 +- renderer 零调用,死接口无功能用途 +- 删除减少攻击面(路径探测、目录结构信息泄露) +- 符合 AGENTS.md 4.1「无法说明必要性的代码不得加入」、4.2「交付前删减:能删除的必须删除」 + +### 验证方式 +1. `npm run build` 通过 +2. `npm run test`(如有)通过 +3. grep 全仓 `resolve-app-path` / `resolveAppPath`:仅 `SafePathService` 方法声明 + `CombatPlanIpc` 内部调用 + 测试脚本保留,其余消失 + +--- + +## 6. H2:迁移编排抽到 MigrationOrchestrator + +**状态**:✅ 已确认修复方案 + +### 问题描述 +[main.ts:580-604](file:///workspace/electron/main.ts#L580-L604) 在 `app.whenReady()` 回调内编排了完整迁移工作流: +``` +migratePresetInventory → legacyPlanMigration.migrate → mergeLegacyMigrationSummaries +→ writeMigrationReport → completeLegacySourceMigration → prepareAfterMigration +``` + +违反规范「文件存储、数据迁移必须位于独立模块中」。 + +### 修改范围 +- 新增 `electron/services/MigrationOrchestrator.ts`(或 `electron/migration/MigrationOrchestrator.ts`) +- `electron/main.ts`:移除编排逻辑,改为调 `migrationOrchestrator.run()` + +**Agent 不得改动**: +- 各个迁移服务(`LegacyPlanMigration` / `UserDataMigrationService` / `MigrationConflictService`)本身的实现 +- `MigrationStateStore` 的 stage 管理逻辑 +- 迁移顺序(必须保持原顺序) + +### 具体改动 +新建 `MigrationOrchestrator` 类,把 main.ts 580-604 行的编排逻辑整体搬入: + +```ts +// electron/services/MigrationOrchestrator.ts +export class MigrationOrchestrator { + constructor( + private readonly userDataMigrationService: UserDataMigrationService, + private readonly legacyPlanMigration: LegacyPlanMigration, + private readonly migrationConflictService: MigrationConflictService, + private readonly migrationStateStore: MigrationStateStore, + private readonly legacyUserDataMigration: LegacyUserDataMigration, + private readonly migrationSelection: MigrationSelection, + ) {} + + run(): { total: number; failed: number } { + const presetInventoryResult = this.userDataMigrationService.migratePresetInventory(); + const legacyPlanResult = this.legacyPlanMigration.migrate(this.migrationSelection); + const legacyMigrationResult = mergeLegacyMigrationSummaries( + this.legacyUserDataMigration, + legacyPlanResult, + presetInventoryResult, + ); + this.userDataMigrationService.writeMigrationReport(legacyMigrationResult); + if ( + legacyMigrationResult.failed === 0 + && this.migrationStateStore.isStageComplete(LEGACY_PLAN_MIGRATION_STAGE) + ) { + this.userDataMigrationService.completeLegacySourceMigration(); + } + this.migrationConflictService.prepareAfterMigration(legacyMigrationResult.total > 0); + return { total: legacyMigrationResult.total, failed: legacyMigrationResult.failed }; + } +} +``` + +main.ts 580-604 行改为: +```ts +const migrationOrchestrator = new MigrationOrchestrator(...); +migrationOrchestrator.run(); +``` + +### 原因 +- 迁移编排是业务逻辑,不应在 main.ts。 +- 各个迁移服务已经独立,但「按什么顺序串起来」还在 main.ts。 + +### 验证方式 +1. `npm run build` 通过 +2. 首次启动应用(无旧数据):迁移流程正常跳过 +3. 模拟旧数据:迁移流程正常执行,顺序与改前一致 +4. `npm run test:migrations` 通过 + +--- + +## 7. H3:更新编排抽到 GuiUpdaterOrchestrator + +**状态**:✅ 已确认修复方案 + +### 问题描述 +[UpdaterIpc.ts:80-84](file:///workspace/electron/ipc/UpdaterIpc.ts#L80-L84) 在 IPC 注册模块内沉淀了 5 个模块级可变状态 + 完整下载/重启状态机: +```ts +let approvedUpdateVersion: string | null = null; +let declinedUpdateVersion: string | null = null; +let downloadPromise: Promise | null = null; +let checkPromise: Promise | null = null; +let choosingRestartTiming = false; +``` + +违反规范「IPC 注册模块只负责注册/转发」。 + +### 修改范围 +- 新增 `electron/services/GuiUpdaterOrchestrator.ts`(或 `electron/updater/GuiUpdaterOrchestrator.ts`) +- `electron/ipc/UpdaterIpc.ts`:移除编排逻辑,改为调 orchestrator 方法 + +**Agent 不得改动**: +- `autoUpdater` 的配置(channel、allowPrerelease 等) +- `GuiUpdateStateStore` 持久态 +- IPC 通道名 + +### 具体改动 +新建 `GuiUpdaterOrchestrator` 类,把 UpdaterIpc.ts 的 5 个模块级状态 + `beginDownload` / `offerDownload` / `offerRestart` 等方法整体搬入。 + +UpdaterIpc.ts 改为: +```ts +export function registerUpdaterIpc(ipcMain: IpcMain, context: UpdaterIpcContext): void { + const orchestrator = new GuiUpdaterOrchestrator(context); + ipc.handle('check-for-updates', () => orchestrator.check()); + ipc.handle('approve-update', (_e, version) => orchestrator.approve(version)); + ipc.handle('decline-update', (_e, version) => orchestrator.decline(version)); + ipc.handle('download-update', () => orchestrator.download()); + ipc.handle('restart-and-install', () => orchestrator.restartAndInstall()); + // ... +} +``` + +### 原因 +- 更新编排逻辑(状态机、下载、重启决策)不应在 IPC 注册模块。 +- 5 个模块级状态与 `GuiUpdateStateStore` 持久态形成双状态源,容易不一致。 + +### 验证方式 +1. `npm run build` 通过 +2. 检查更新 → 下载 → 重启 安装流程正常 +3. 拒绝更新 → 不再提示同一版本 + +--- + +## 8. M1:backendShipNamesPath 移到 PythonEnvironmentService + +**状态**:✅ 已确认修复方案 + +### 问题描述 +[main.ts:331-340](file:///workspace/electron/main.ts#L331-L340) `backendShipNamesPath(pythonCmd)` 含 Python 环境路径解析逻辑,应在 `PythonEnvironmentService`。 + +### 修改范围 +- `electron/pythonEnv/environment.ts` 或 `electron/services/PythonEnvironmentService.ts`:新增 `backendShipNamesPath()` 方法 +- `electron/main.ts`:移除 `backendShipNamesPath` 函数,改为调 service 方法 + +**Agent 不得改动**: +- `resolvePythonEnvironment` 逻辑 +- `path.join` 拼接的路径结构 + +### 具体改动 +把 main.ts 331-340 行的 `backendShipNamesPath` 函数整体搬到 `PythonEnvironmentService`(或 environment.ts),main.ts 改为调 `pythonEnvService.backendShipNamesPath(pythonCmd)`。 + +### 验证方式 +1. `npm run build` 通过 +2. 舰船名库加载功能正常 + +--- + +## 9. M5:ADB 错误加 warn 日志 + +**状态**:✅ 已确认修复方案 + +### 问题描述 +[DeviceIpc.ts:21-27](file:///workspace/electron/ipc/DeviceIpc.ts#L21-L27) `check-adb-devices` 用 `catch { return []; }` 静默吞错。 + +### 修改范围 +- `electron/ipc/DeviceIpc.ts`:第 21-27 行 + +**Agent 不得改动**: +- 返回值结构(保持返回数组) +- 其他 IPC handler + +### 具体改动 +当前: +```ts +ipc.handle('check-adb-devices', async () => { + try { + return await dependencies.adb.listDevices(); + } catch { + return []; + } +}); +``` + +改为: +```ts +ipc.handle('check-adb-devices', async () => { + try { + return await dependencies.adb.listDevices(); + } catch (error) { + Logger.warn(`ADB 设备列表查询失败: ${error instanceof Error ? error.message : String(error)}`); + return []; + } +}); +``` + +(需新增 `import { Logger } from '../Logger';` 或对应 Logger 模块路径) + +### 验证方式 +1. `npm run build` 通过 +2. ADB 正常时:返回设备列表 +3. ADB 异常时:日志输出 warn,返回空数组 + +--- + +## 10. M6:Python 环境 4 模式可观测 + +**状态**:✅ 已确认修复方案 + +### 问题描述 +[environment.ts:9](file:///workspace/electron/pythonEnv/environment.ts#L9) `BackendStartupMode = 'managed' | 'external'` 仅 2 枚举,规范要求 4 个明确模式(bundled 便携版、managed 托管、system 系统、external 外部)。 + +### 修改范围 +- `electron/pythonEnv/environment.ts`:扩展枚举或新增 `pythonSource` 字段 + +**Agent 不得改动**: +- `isLocalPython` / `localSitePackages` 等工具函数 +- IPC 通道 + +### 具体改动 +新增 `pythonSource` 字段,区分 4 种来源: + +```ts +export type BackendStartupMode = 'managed' | 'external'; +export type PythonSource = 'bundled' | 'configured' | 'system' | 'external'; + +export interface PythonEnvironment { + startupMode: BackendStartupMode; + pythonSource: PythonSource; // 新增 + pythonCmd: string; + backendRoot: string | null; + // ... +} +``` + +在 `resolvePythonEnvironment` 中根据实际情况设置 `pythonSource`: +- bundled:便携版 Python(随应用打包) +- configured:用户在设置里配置的 Python 路径 +- system:系统 PATH 里的 Python +- external:外部后端(不归 GUI 管) + +### 验证方式 +1. `npm run build` 通过 +2. 日志能输出当前 `pythonSource` +3. 不同启动模式下 `pythonSource` 值正确 + +--- + +## 11. M2:设置页保存事务逻辑抽到 GuiConfigurationService.commitAtomic() + +**状态**:✅ 已确认修复方案 + +### 修改范围 +- `electron/ipc/ConfigurationIpc.ts`:第 97-148 行 `commit-gui-settings` handler 内的事务代码删除 +- `electron/services/GuiConfigurationService.ts`(或同名服务文件):新增 `commitAtomic(payload)` 方法 + +**Agent 不得改动**: +- 事务流程本身的逻辑(快照、保存、提交、失败回滚步骤顺序完全保留) +- `commitGuiSettings` IPC 通道名 +- 返回类型与字段 + +### 具体改动 +ConfigurationIpc.ts 改后: +```ts +ipc.handle('commit-gui-settings', async (_e, payload) => { + return guiConfigurationService.commitAtomic(payload); +}); +``` + +GuiConfigurationService 新增 `commitAtomic` 方法(由原 handler 代码整体搬入)。 + +### 验证方式 +1. `npm run build` 通过 +2. 修改设置→保存:成功写入 YAML +3. 模拟保存失败:确认回滚到旧 settings + +--- + +## 12. M3:计划列表读取逻辑抽到 CombatPlanRepository.listUserFiles() + +**状态**:✅ 已确认修复方案 + +### 修改范围 +- `electron/ipc/FileIpc.ts`:第 127-136 行 `list-plan-files` handler 内的 `fs.readdirSync` 代码删除 +- `electron/services/CombatPlanRepository.ts`(或同名仓储文件):新增 `listUserFiles()` 方法 + +**Agent 不得改动**: +- 返回的数据结构(保持 `[{name, file}]` 形式) +- `list-plan-files` IPC 通道名 +- 过滤规则 + +### 具体改动 +FileIpc.ts 改后: +```ts +ipc.handle('list-plan-files', () => { + return combatPlanRepository.listUserFiles(); +}); +``` + +CombatPlanRepository 新增方法(由原 handler 代码搬入): +```ts +listUserFiles(): { name: string; file: string }[] { + const directory = this.directory('user'); + if (!fs.existsSync(directory)) return []; + return fs.readdirSync(directory) + .filter(file => /\.ya?ml$/i.test(file)) + .map(file => ({ + name: file.replace(/\.ya?ml$/i, ''), + file, + })); +} +``` + +### 验证方式 +1. `npm run build` 通过 +2. 打开计划列表页:能正常列出用户计划文件 + +--- + +## 13. M7:NavigationController 通过 Host 接口解耦 + +**状态**:✅ 已确认修复方案 + +### 修改范围 +- `src/controller/app/NavigationController.ts`:不再依赖具体 `PlanController` 类型 +- `src/controller/app/AppController.ts`:Host 实现里委托 `ensureDefaultPlan` + +**Agent 不得改动**: +- `PlanController.ensureDefaultPlan()` 方法本身 +- 其他 Controller 对 PlanController 的依赖 + +### 具体改动 +NavigationController 当前: +```ts +host.getPlanController().ensureDefaultPlan(); +``` + +改后: +```ts +host.ensureDefaultPlan(); +``` + +Host 接口加方法(删除 `getPlanController()`,加 `ensureDefaultPlan()`): +```ts +export interface NavigationControllerHost { + readonly fleetPlannerController: FleetPlannerController; + ensureDefaultPlan(): void; // 替代 getPlanController() + refreshAdbStatus(): Promise; + refreshShipLibraryStatus(): Promise; +} +``` + +AppController(Host 实现): +```ts +ensureDefaultPlan(): void { + this.planController.ensureDefaultPlan(); +} +``` + +### 验证方式 +1. `npm run build` 通过 +2. 切换到计划页:确认默认计划被选中 +3. `npm run test:architecture-boundaries` 通过 + +--- + +## 14. M8:TaskGroupHost 接口收窄 + +**状态**:⛔ 无需修复(所有方法均有真实调用) + +### grep 实测(2026-08-10 代码) +`src/controller/taskGroup/` 目录下对 `TaskGroupHost` 接口方法的调用点: + +| 方法 | 调用位置 | 状态 | +|---|---|---| +| `scheduler` | [queueLoader.ts:110,206,239,276,312,317,329,373](file:///workspace/src/controller/taskGroup/queueLoader.ts), [contextMenu.ts:76](file:///workspace/src/controller/taskGroup/contextMenu.ts#L76) | 保留 | +| `plansDir` | [TaskGroupController.ts:202](file:///workspace/src/controller/taskGroup/TaskGroupController.ts#L202) | 保留 | +| `getShipNameAliases` | [queueLoader.ts:108](file:///workspace/src/controller/taskGroup/queueLoader.ts#L108) | 保留 | +| `renderMain` | [queueLoader.ts:213,241,292,352,381](file:///workspace/src/controller/taskGroup/queueLoader.ts) | 保留 | +| `switchPage` | [queueLoader.ts:291](file:///workspace/src/controller/taskGroup/queueLoader.ts#L291), [contextMenu.ts:122](file:///workspace/src/controller/taskGroup/contextMenu.ts#L122) | 保留 | +| `importTaskPreset` | [contextMenu.ts:109](file:///workspace/src/controller/taskGroup/contextMenu.ts#L109) | 保留 | +| `getCurrentPlan` | [TaskGroupController.ts:201](file:///workspace/src/controller/taskGroup/TaskGroupController.ts#L201) | 保留 | +| `setCurrentPlan` | [contextMenu.ts:119](file:///workspace/src/controller/taskGroup/contextMenu.ts#L119) | 保留 | +| `renderPlanPreview` | [contextMenu.ts:120](file:///workspace/src/controller/taskGroup/contextMenu.ts#L120) | 保留 | +| `closePresetDetail` | [TaskGroupController.ts:210](file:///workspace/src/controller/taskGroup/TaskGroupController.ts#L210) | 保留 | +| `executePreset` | [TaskGroupController.ts:211](file:///workspace/src/controller/taskGroup/TaskGroupController.ts#L211) | 保留 | +| `getCurrentPresetInfo` | [TaskGroupController.ts:214](file:///workspace/src/controller/taskGroup/TaskGroupController.ts#L214) | 保留 | +| `pickManagedBattlePlan` | [TaskGroupController.ts:157](file:///workspace/src/controller/taskGroup/TaskGroupController.ts#L157) | 保留 | +| `openManagedPlan` | [contextMenu.ts:66](file:///workspace/src/controller/taskGroup/contextMenu.ts#L66) | 保留 | + +**结论**:14 个方法全部至少有 1 个真实调用点,无死方法可删,保持现状。 + +--- + +## 15. M9:Controller 层 localStorage 统一走 Adapter + +**状态**:✅ 已确认修复方案 + +### 修改范围 +- `src/adapter/StorageAdapter.ts`:确认 `browserStorageStore` 已封装 get/set +- `src/controller/app/ConfigController.ts`:第 316、342、455、456、457、458、661 行 +- `src/controller/app/SettingsController.ts`:第 93、97、101 行 +- `src/controller/app/AppController.ts`:第 333 行 +- `src/controller/startup/StartupController.ts`:第 54 行 +- `src/controller/startup/envAndUpdates.ts`:第 13 行 + +**Agent 不得改动**: +- `src/view/theme.ts`(已经走 Adapter) +- `browserStorageStore` 的封装本身(除非字段缺失) +- localStorage 中存储的 key 名称 + +### 具体改动 +所有 `localStorage.setItem(key, value)` → `browserStorageStore.set(key, value)` +所有 `localStorage.getItem(key)` → `browserStorageStore.get(key)` + +**Agent 执行前必须先确认**:`browserStorageStore` 的 `get/set` 方法签名是否与 `localStorage.getItem/setItem` 完全兼容。如果不兼容,需要先调整 Adapter 签名。 + +### 验证方式 +1. `npm run build` 通过 +2. 切换主题:确认刷新后主题保留 +3. 切换调试模式:确认重启后状态保留 +4. 切换更新模式:确认重启后状态保留 + +--- + +## 第四部分:待决定项(1 项,未列入修复) + +### 双 pending 状态源(架构洁癖) +- CronScheduler 和 SchedulerBinder 各维护一套 pending 标记(5 类任务各自记两边),违反单一状态所有者。 +- **当前无真实用户操作路径触发 bug**:任务成功/失败/用户删除的同步回调写得齐全;尚无独立取消自动任务入口。 +- 属于未来加功能时的风险点,不修也行。建议加「暂停自动任务」「取消本时段自动演习」等新入口时顺手合并为单一所有者。 + +--- + +## 执行顺序建议 + +1. **先修真实 BUG**(第 1-3 项):影响用户实际使用 +2. **再修功能性防护**(第 4 项 H4):避免后端运行时装依赖出错 +3. **最后做架构/规范迁移**(第 5-15 项):纯规范性,可分批 + +每项改完跑 `npm run build` 验证。涉及调度器的(第 1、2 项)额外跑 `node scripts/tests/test-scheduler-domain.mjs`。 + +--- + +## 后端协调项(不阻塞 GUI 合并) + +- B2:后端 `dock_full_destroy` 默认 `True` → `False` +- M10:后端 CORS 限制为 `localhost`/Electron 来源 +- M12:后端 `DecisiveConfig.use_quick_repair` 默认 `True` → `False` +- B4-1:后端 API 加 `/api/v1/` 版本前缀(待探讨) +- M13:后端 OCR `recognize_ship_names` 关键路径强制传 `max_threshold`(归属未定) + +## 搁置项(等代码) + +- H7、M11:等后端调度器新代码上传后再 review diff --git a/docs/teaching/00-overview.md b/docs/teaching/00-overview.md index 42dbcc3..f58d548 100644 --- a/docs/teaching/00-overview.md +++ b/docs/teaching/00-overview.md @@ -1,116 +1,198 @@ -# 00 — 全局概览 - -> 建议先读此文,再按顺序阅读后续章节。 - ---- +# 00:全局概览 + +> 本章先回答三个问题:系统运行在哪里、状态由谁拥有、一次用户操作如何穿过各层。 + +## 为什么需要分层 + +桌面自动化应用同时包含: + +- DOM、表单、动画和拖放。 +- 配置、方案、编队、调度等领域状态。 +- 文件系统、Python、ADB、更新和窗口生命周期。 +- AutoWSGR HTTP 与 WebSocket。 +- YAML/JSON 持久化和旧版本迁移。 + +如果这些能力都放进一个 Controller 或 `electron/main.ts`,常见结果是: + +- 页面刷新顺手修改业务状态。 +- 文件异常触发错误的业务 fallback。 +- 一个设置字段需要在多个对象中重复保存。 +- 单元测试必须启动 Electron、DOM 和 Python 才能运行。 +- 关闭窗口后监听器、Observer 或子进程仍然存活。 + +分层的目的不是增加目录,而是让每种状态和副作用只有一个明确所有者。 + +## 运行时全景 + +```mermaid +flowchart TB + subgraph Renderer + View["ViewDOM 与局部视觉状态"] + Controller["Controller用例编排"] + Model["Model领域状态与规则"] + Adapter["AdapterHTTP / WS / IPC / Storage"] + Shared["Shared跨层纯逻辑"] + + View -->|"用户意图"| Controller + Controller -->|"ViewObject"| View + Controller --> Model + Controller --> Adapter + Model --> Adapter + Controller --> Shared + Model --> Shared + end + + Adapter --> Preload["electron/preload.ts"] + Preload --> Main["Electron Main"] + Main --> IPC["electron/ipc"] + IPC --> Service["electron/services"] + Service --> Files["文件 / 更新 / ADB / Python"] + Adapter --> Backend["AutoWSGR HTTP + WebSocket"] + Service --> Backend +``` -## 问题出在哪里 +Renderer 有两条外部通信链路: -重构前 `main` 分支的项目能正常运行,但存在三个核心维护痛点: +1. 通过 `window.electronBridge` 请求 Electron 能力。 +2. 通过 `ApiClient` 请求 AutoWSGR 后端。 -### 巨型文件 +它们不能混成一条“万能服务”,因为权限、错误语义和生命周期不同。 -| 文件 | 重构前 | 重构后 | -|------|--------|--------| -| `src/controller/AppController.ts` | **3052 行** | 430 行 + 33 个子模块 | -| `electron/main.ts` | **1192 行** | 401 行 + 6 个子模块 | -| `src/model/Scheduler.ts` | **单文件** | 7 个文件的 `scheduler/` 子系统 | +## 当前源码边界 -一个 3000+ 行的文件意味着: -- **定位困难** — 修 bug 要全文搜索,改动可能波及几百行外的逻辑 -- **合并冲突频繁** — 多人协作时所有改动集中在同一文件 -- **心智负担** — 需要在脑中维护几十个状态变量 +| 边界 | 当前入口 | 所有权 | +|---|---|---| +| Renderer 组合根 | `src/controller/app/AppController.ts` | 创建对象并连接生命周期 | +| Controller | `src/controller/**` | 用户用例和跨对象协调 | +| View | `src/view/**` | DOM、浏览器事件和局部视觉状态 | +| Model | `src/model/**` | 配置、方案、舰队和调度状态 | +| Adapter | `src/adapter/**` | IPC、HTTP、WS、YAML、JSON 和 Storage | +| Types | `src/types/**` | 层间契约 | +| Shared | `src/shared/**` | 无 DOM、Electron、Node 副作用的纯逻辑 | +| Preload | `electron/preload.ts` | 唯一 Electron Bridge 暴露点 | +| Main IPC | `electron/ipc/**` | 通道、参数、结果和异常边界 | +| Main Service | `electron/services/**` | 主进程业务与可测试策略 | +| Main 组合根 | `electron/main.ts` | 服务装配、启动和退出顺序 | -### 隐式依赖 +## 两条数据流 -原始 `AppController` 中所有方法直接访问 `this` 的几十个字段,任何方法都可能读写任意状态,职责边界模糊。 +### 展示流 -### 数据流不清晰 +```text +Repository / Model + -> Controller + -> ViewObject + -> View.render() + -> DOM +``` -View 可能直接读 Model 状态、调用 API,Controller 同时承担渲染和业务逻辑,数据流向是"哪里方便写在哪里"。 +例如主页面由 +`src/controller/app/rendering.ts` 的 `buildMainViewObject()` 把 Scheduler、 +舰队、统计和连接状态转换成 `MainViewObject`,然后交给 `MainView.render()`。 ---- +View 不需要知道 Scheduler 如何重试,也不需要知道后端响应结构。 -## 重构后架构 +### 意图流 +```text +DOM event + -> View callback / intent + -> Controller + -> Model 或 Repository + -> 新 snapshot / ViewObject + -> View.render() ``` -╔══════════════════════════════════════════════════════╗ -║ Electron 主进程 ║ -║ main.ts ─── 窗口 + IPC 注册 ║ -║ ├── backend.ts ────── 后端子进程生命周期 ║ -║ ├── pythonEnv/ ────── Python 发现/安装/更新 ║ -║ └── emulatorDetect.ts 模拟器检测 ║ -╠══════════════════════════════════════════════════════╣ -║ Renderer Process (MVC) ║ -║ ║ -║ ┌─ View 层 ──────────────────────────────────────┐ ║ -║ │ main/ plan/ template/ config/ setup/ │ ║ -║ │ MainView PlanPreviewView ... (纯渲染,无逻辑) │ ║ -║ └────────────────────────────────────────────────┘ ║ -║ ▲ render(ViewObject) ║ -║ ┌─ Controller 层 ────────────────────────────────┐ ║ -║ │ AppController (协调者) │ ║ -║ │ ├── PlanController │ ║ -║ │ ├── TaskGroupController │ ║ -║ │ ├── TemplateController │ ║ -║ │ ├── StartupController │ ║ -║ │ ├── ConfigController │ ║ -║ │ └── SchedulerBinder │ ║ -║ └────────────────────────────────────────────────┘ ║ -║ │ 读取 Model, 拼装 VO ║ -║ ┌─ Model 层 ─────────────────────────────────────┐ ║ -║ │ ConfigModel PlanModel TemplateModel │ ║ -║ │ TaskGroupModel ApiClient MapDataLoader │ ║ -║ │ scheduler/ (Scheduler + 5 个子模块) │ ║ -║ └────────────────────────────────────────────────┘ ║ -║ ║ -║ ┌─ Types 层 ─────────────────────────────────────┐ ║ -║ │ model.ts view.ts api.ts scheduler.ts │ ║ -║ │ electronBridge.ts │ ║ -║ └────────────────────────────────────────────────┘ ║ -╠══════════════════════════════════════════════════════╣ -║ Python 后端 (uvicorn + FastAPI) ║ -╚══════════════════════════════════════════════════════╝ + +例如舰队编辑器发出 `FleetDraftEditIntent`,Controller 将意图交给 Fleet 领域 +函数,随后重新生成只读草稿快照。View 不直接修改持久化对象。 + +## 状态所有权 + +判断代码放哪,先问“谁能修改这个状态”: + +| 状态 | 权威所有者 | +|---|---| +| 调度队列、运行任务、等待重试 | `Scheduler` | +| Cron 配置和触发时钟 | `CronScheduler` | +| 普通舰队草稿 | `FleetPlannerController` + Fleet 领域对象 | +| 决战舰队草稿 | `DecisivePlanController` + `DecisiveFleetDraft` | +| 作战方案内容 | `PlanModel` | +| 后端业务配置 | `ConfigModel.current` | +| GUI 自动化配置 | `ConfigModel.currentGuiAutomation` | +| 搜索、筛选、弹窗展开 | 对应 View | +| 用户文件 | Main Repository / Service | +| 窗口和子进程 | Electron Main Service | + +同一状态出现两个可写副本,通常不是“缓存优化”,而是潜在同步 Bug。 + +## 一次设置保存如何流动 + +```mermaid +sequenceDiagram + participant User + participant View as ConfigView + participant Controller as ConfigController + participant Adapter as ConfigurationGateway + participant IPC as ConfigurationIpc + participant Service as GuiSettingsCommitService + participant Store as SecureFile/GuiConfiguration + + User->>View: 修改并点击保存 + View->>Controller: onSave + Controller->>View: 收集表单 + Controller->>Adapter: commitGuiSettings + Adapter->>IPC: preload invoke + IPC->>Service: commitAtomic + Service->>Store: 保存 YAML 与 JSON + Store-->>Service: 成功或异常 + Service-->>Controller: 提交结果 + Controller->>View: 刷新展示 ``` -## 重构后文件分布 +这个流程说明: -### Controller 层 — 33 个文件分 6 个子目录 +- View 只知道表单。 +- Controller 知道保存用例。 +- IPC 不解析业务 YAML。 +- Service 保证跨文件事务和回滚。 +- 成功前 Renderer 不应提前更新权威内存。 -``` -src/controller/ -├── app/ AppController(430) + SchedulerBinder(179) + ConfigController(226) -│ + rendering(71) + constants(26) + theme(30) -├── plan/ PlanController(215) + importExport(122) + presetFlow(121) -│ + nodeEditor(47) + rendering(109) -├── taskGroup/ TaskGroupController(149) + queueLoader(154) + contextMenu(101) -│ + addItems(78) + importExport(71) + metaLoader(59) -├── template/ TemplateController(191) + selectors(152) + wizard(113) -│ + crud(89) + useTemplate(80) -├── startup/ StartupController(89) + envAndUpdates(100) + connection(64) -└── shared/ ControllerHost(12) + DialogHelper(90) -``` +## 构建时和运行时要分开 -### Electron 主进程 — 从 1 个文件变成 4 + 6 个 +Renderer 开发源并不是 Electron 最终直接加载的全部文件: +```text +src/view/html/** -> scripts/build-view-html.js -> src/view/index.html +src/view/styles/**/*.scss -> Sass -> src/view/styles/styles.css +TypeScript -> tsc -> dist/** +AppController.js -> esbuild -> dist/renderer.bundle.js ``` -electron/ -├── main.ts(401) backend.ts(176) emulatorDetect.ts(114) preload.ts(104) -└── pythonEnv/ - ├── context.ts(33) finder.ts(90) envCheck.ts(208) - ├── installer.ts(225) updater.ts(178) utils.ts(101) index.ts(15) -``` ---- +因此: + +- 修改 HTML partial 后生成 `index.html`。 +- 修改 SCSS 后生成 `styles.css`。 +- 不手工编辑 `dist/**`。 +- 运行时仍然只有一个 HTML、一个 CSS 和一个 Renderer Bundle。 + +## 后续章节地图 + +| 需要理解的问题 | 章节 | +|---|---| +| 一个大文件应该怎么拆 | [01](01-extract-class.md) | +| 子模块怎样避免依赖整个宿主 | [02](02-host-interface.md) | +| Model 数据怎样变成页面展示 | [03](03-viewobject-flow.md) | +| Main 中的文件和进程能力放哪 | [04](04-electron-split.md) | +| View、HTML、SCSS 和共享组件怎么组织 | [05](05-view-layer.md) | +| 领域状态、纯策略和 Scheduler 怎么区分 | [06](06-model-layer.md) | +| DTO、Intent、ViewObject 为什么不能混用 | [07](07-type-system.md) | + +## 本章检查 -## 核心理念速查 +进入一个新需求前,应能回答: -| 序号 | 模式 | 一句话 | 详细文档 | -|------|------|--------|----------| -| 1 | Extract Class | 按职责边界拆,不是按行数平均分 | [01](01-extract-class.md) | -| 2 | Host 接口 | 子控制器通过最小接口与宿主通信,禁止反向依赖 | [02](02-host-interface.md) | -| 3 | ViewObject | Controller 拼装 VO → 单向传递 → View 纯渲染 | [03](03-viewobject-flow.md) | -| 4 | Context 注入 | Electron 各模块通过 init() 注入运行上下文 | [04](04-electron-split.md) | -| 5 | Facade | View 内部可拆子视图,对外保持统一 API | [05](05-view-layer.md) | -| 6 | 领域子系统 | 相关类组成 `scheduler/` 目录,通过 index barrel 导出 | [06](06-model-layer.md) | -| 7 | 类型隔离 | `types/view.ts` 和 `types/model.ts` 严格分离 | [07](07-type-system.md) | +1. 它改变的是视觉状态、领域状态还是外部资源? +2. 权威状态当前由哪个对象持有? +3. 用户意图从哪个 View 进入? +4. 外部副作用经过哪个 Adapter、IPC 或 Service? +5. 哪些消费者和持久化契约会受到影响? diff --git a/docs/teaching/01-extract-class.md b/docs/teaching/01-extract-class.md index 7e63e1b..058e842 100644 --- a/docs/teaching/01-extract-class.md +++ b/docs/teaching/01-extract-class.md @@ -1,149 +1,220 @@ -# 01 — Extract Class:按职责拆分大文件 +# 01:按职责拆分类 -> **前置阅读**:[00-overview](00-overview.md) -> **核心原则**:一个类/模块只负责一件事;当文件超过 300-400 行时,大概率需要拆分。 +> 前置阅读:[00 全局概览](00-overview.md) ---- +Extract Class 的目标不是减少单文件行数,而是把不同的状态、变化原因和生命周期 +分开。拆完后如果两个文件仍共同修改同一批字段,只是多了一层转发,边界并没有 +改善。 -## 拆分的思路 +## 先找责任,不先找行数 -不是"按行数平均分",而是**按业务职责边界切割**。判断标准: +适合提取的信号: -| 信号 | 行动 | -|------|------| -| 一组方法总是一起被调用 | 提取为独立模块 | -| 一组状态变量只被少数方法读写 | 连同方法一起提取成类 | -| 不同业务域的代码混在一起 | 拆成子控制器 | -| `import` 列表超长 | 职责大概率太多了 | +- 一组字段只被一组方法使用。 +- 一段逻辑有独立输入输出,可以不依赖宿主内部状态。 +- 某个区域有自己的资源生命周期,如事件监听器或 Observer。 +- 某个用例只需要宿主的少量能力。 +- 同一视觉行为被两个真实页面重复实现。 +- 一组规则可以单独测试,不需要启动 DOM、Electron 或后端。 ---- +不适合提取的理由: -## 案例 1:SchedulerBinder 的提取 +- “文件超过 N 行”。 +- “以后可能复用”。 +- “每个方法都应该有一个类”。 +- “先建 Manager/Factory,后面再接功能”。 -原始 `AppController` 中有一大段调度器回调绑定代码和相关状态变量。它们逻辑上是一个整体——**跟踪调度器事件、管理临时渲染状态**——和 AppController 的其他逻辑(导航、配置、任务组…)无关。 +## 案例一:AppController 只做组合 -### 提取了什么 +当前 Renderer 入口是: -```typescript -// src/controller/app/SchedulerBinder.ts - -export class SchedulerBinder { - // ── 这些状态原来散落在 AppController 的字段里 ── - private pendingExerciseTaskId: string | null = null; - private pendingBattleTaskId: string | null = null; - private pendingLootTaskId: string | null = null; - currentProgress = ''; - trackedLoot = ''; - trackedShip = ''; - wsConnected = false; - expeditionTimerText = '--:--'; - - constructor(private readonly host: SchedulerBinderHost) {} - - bindSchedulerCallbacks(): void { - this.host.scheduler.setCallbacks({ - onProgressUpdate: (_taskId, progress) => { - this.currentProgress = `${progress.current}/${progress.total}`; - this.host.renderMain(); // 通过 Host 接口回调主控制器 - }, - onTaskCompleted: (taskId, success) => { - this.currentProgress = ''; - this.trackedLoot = ''; - // ...处理定时任务完成态 - }, - }); - } -} +`src/controller/app/AppController.ts` + +它仍然持有核心对象,但细分行为由明确模块负责: + +```text +src/controller/app/ +├─ AppController.ts +├─ ConfigController.ts +├─ CurrentFleetController.ts +├─ NavigationController.ts +├─ OperationsController.ts +├─ ScheduledTaskLoader.ts +├─ SchedulerBinder.ts +├─ SchedulerRuntimeTracker.ts +├─ SettingsController.ts +└─ rendering.ts ``` -### 为什么有效 +拆分判断依据不是“AppController 太长”,而是变化原因不同: -- 这些状态(`currentProgress`、`trackedLoot`…)**只被调度器回调读写**,和 AppController 其他逻辑无关 -- 提取后,`AppController` 不再需要了解"调度器回调具体做了什么" -- 调度器逻辑有 bug?直接看 `SchedulerBinder.ts` 一个文件 +| 模块 | 独立变化原因 | +|---|---| +| `NavigationController` | 页面和标签导航 | +| `ConfigController` | 配置加载、转换和提交 | +| `SettingsController` | Python、CUDA、ADB、更新和资料库 | +| `SchedulerBinder` | Scheduler/Cron/后端事件接线 | +| `SchedulerRuntimeTracker` | 从日志派生运行状态 | +| `rendering.ts` | 状态到 `MainViewObject` 的纯转换 | ---- +`AppController` 保留对象创建、依赖连接和启动/退出生命周期。这是组合根应承担的 +责任,不能为了“更短”再把对象创建随机搬到多个全局单例。 -## 案例 2:子控制器 + 模块委托 +## 案例二:有状态核心与纯策略分开 -`PlanController` 自身只有 215 行,但它不是把逻辑写在自己体内——而是把**具体流程委托给独立的纯函数模块**: +Scheduler 的权威状态仍在: -``` -src/controller/plan/ -├── PlanController.ts (215 行,事件绑定 + 状态持有) -├── importExport.ts (122 行,方案导入/导出流程) -├── presetFlow.ts (121 行,任务预设流程) -├── nodeEditor.ts (47 行,节点编辑器保存) -└── rendering.ts (109 行,ViewObject 拼装) -``` +`src/model/scheduler/Scheduler.ts` -```typescript -// PlanController.ts — 只做委托 +它拥有运行任务、队列、等待任务、状态和子模块。可独立计算的规则被提取为: -import { importPlanFlow, exportPlanFlow } from './importExport'; -import { executePresetFlow } from './presetFlow'; +- `SchedulerTaskPolicy.ts` +- `SchedulerRepairPolicy.ts` -export class PlanController { - async importPlan(): Promise { - return importPlanFlow(this.planView, this.host, this.planSetters); - } +例如任务创建规则不需要访问 Scheduler 私有状态: - closePresetDetail(): void { - closePresetDetailFlow(this.planView, this.presetState); - } +```typescript +export function createSchedulerTask( + options: SchedulerTaskOptions, +): SchedulerTask { + const times = options.times ?? 1; + const unlimited = !Number.isFinite(times); + const normalizedTimes = unlimited ? 1 : Math.max(1, Math.trunc(times)); + return { + id: options.id, + logicalId: options.id, + remainingTimes: normalizedTimes, + totalTimes: normalizedTimes, + maxRetries: 2, + retryCount: 0, + // 其余字段来自显式输入 + }; } ``` -**模式**:控制器是"调度员",具体"干活"的是独立函数。 +这种拆分有三个收益: -好处: -- 控制器只关注"谁来做",不关注"怎么做" -- 流程函数可以独立测试,不需要实例化整个控制器 -- 同一模式在 `taskGroup/`、`template/` 中复用 +1. 状态所有者仍然唯一。 +2. 规则可以用普通输入输出测试。 +3. Scheduler 只负责何时调用规则和如何推进状态机。 ---- +反例是把 `currentTask`、`waitingTasks` 分别放进多个“Manager”,再让它们相互 +回调修改。那会产生多个可写状态源。 -## 案例 3:Scheduler 子系统拆分 +## 案例三:View Facade 与职责子 View -原始 `Scheduler.ts` 是单一大文件。重构后按职责拆成 7 个文件: +当前设置页由 `ConfigView` 对 Controller 保持统一 API,内部组合: -``` -src/model/scheduler/ -├── Scheduler.ts 主调度器,持有队列 + 消费循环 -├── TaskQueue.ts 优先级队列的增删改查 -├── CronScheduler.ts 基于系统时钟的定时触发 -├── ExpeditionTimer.ts 远征检查倒计时 -├── RepairManager.ts 泡澡修理管理 -├── StopConditionChecker.ts 停止条件判断 -└── index.ts barrel re-export +- `ConfigAutomationView` +- `ConfigRuntimeView` +- `settingSelectWidth.ts` + +这次拆分的边界是视觉责任: + +- 自动任务列表和额度摘要一起变化。 +- Python、CUDA、ADB、更新状态一起变化。 +- 下拉框宽度计算是独立纯 DOM 辅助。 + +Controller 仍只依赖 `ConfigView`,没有因为视觉拆分而获得三个新依赖。 + +这种结构是 Facade: + +```text +Controller -> ConfigView + ├─ ConfigAutomationView + ├─ ConfigRuntimeView + └─ settingSelectWidth ``` -`Scheduler` 本体通过组合持有子模块: +Facade 的价值是保持外部契约稳定,而不是把每个 DOM 元素包装成一个类。 -```typescript -// Scheduler.ts -export class Scheduler { - private _taskQueue: TaskQueue; - private expeditionTimer: ExpeditionTimer; - private stopChecker: StopConditionChecker; - // ... -} +## 案例四:共享组件必须有真实复用 + +普通舰队页和决战页原本都需要: + +- 舰船搜索和筛选。 +- 排序和批量渲染。 +- 卡片交互和拖拽。 +- 滚动位置恢复。 + +这些完整视觉行为进入: + +`src/view/plan/ShipGalleryView.ts` + +页面差异通过 `ShipGalleryViewHost` 注入。普通舰队的主选/候选规则和决战的 +level1/level2 草稿仍留在各自领域,不进入共享图库。 + +共享边界成立是因为: + +1. 已有两个真实消费者。 +2. 共享的是完整视觉行为,不是相似名称。 +3. 页面业务差异仍由 Host 隔离。 +4. 共享组件拥有完整 `dispose()` 生命周期。 + +如果只有一个调用方,或提取后充满 `if (page === ...)`,就不应创建共享组件。 + +## 案例五:源文件拆分不能改变运行结构 + +HTML 和 SCSS 也按职责拆分: + +```text +src/view/html/** # HTML 开发源 +src/view/styles/pages/config/** # 设置页 SCSS partial +src/view/styles/pages/plan/** # 方案页 SCSS partial ``` -外部导入不需要关心内部拆分细节: +构建后 Electron 仍加载: -```typescript -// 外部使用 — 导入路径没变 -import { Scheduler, CronScheduler } from '../../model/scheduler'; +```text +src/view/index.html +src/view/styles/styles.css ``` ---- +机械拆分必须保持: + +- DOM 顺序和 ID。 +- CSS 选择器、属性和加载顺序。 +- 事件绑定时机。 +- 页面行为和视觉效果。 + +因此源文件拆分和功能修改应分批进行。 -## 速查:拆分前后对比 +## 安全提取步骤 -| 区域 | 重构前 | 重构后 | 拆分依据 | -|------|--------|--------|---------| -| Controller | 1 个 3052 行文件 | 6 个子目录 33 个文件 | 按业务域(Plan/TaskGroup/Template/Startup) | -| Scheduler | 1 个大文件 | 7 个文件 | 按调度子职责(队列/定时/修理/停止条件) | -| Electron | 1 个 1192 行文件 | 4 + 6 个文件 | 按关注点(窗口/后端/Python/模拟器) | -| View | 混在一起 | 6 个子目录 16 个文件 | 按功能页面(main/plan/template/config…) | +1. 用 `rg` 找字段、方法、调用方、DOM ID、类型和测试。 +2. 写清楚待提取责任,以及仍留在宿主的责任。 +3. 确认状态唯一所有者不变。 +4. 先定义最小输入输出或 Host。 +5. 移动一条完整责任,不顺手改业务规则。 +6. 保持原公共 API,或同步修改所有消费者。 +7. 补齐监听器、Observer、定时器等释放链。 +8. 跑专项测试并检查 diff。 + +## 拆分完成的判断 + +一次有效拆分应满足: + +- 提取模块能用一句话描述责任。 +- 不需要访问宿主大部分私有字段。 +- 没有新增第二份可写业务状态。 +- 错误仍在原来的业务边界报告。 +- 外部调用方没有被迫理解内部拆分。 +- 测试能更直接地验证该责任。 + +## 验证 + +Renderer 类和 View 拆分至少执行: + +```powershell +npm run test:architecture-boundaries +npm run test:renderer-contract +npm run test:build +git diff --check +``` + +涉及 Scheduler 或 Fleet 时再执行: + +```powershell +npm run test:scheduler-domain +npm run test:fleet-domain +``` diff --git a/docs/teaching/02-host-interface.md b/docs/teaching/02-host-interface.md index 47044f2..c2589fa 100644 --- a/docs/teaching/02-host-interface.md +++ b/docs/teaching/02-host-interface.md @@ -1,151 +1,230 @@ -# 02 — Host 接口与依赖注入 +# 02:Host 接口与依赖注入 -> **前置阅读**:[01-extract-class](01-extract-class.md) -> **核心原则**:子控制器不直接引用 `AppController`,通过最小化的 Host 接口与宿主通信。 +> 前置阅读:[01 按职责拆分类](01-extract-class.md) ---- +提取子模块后,最容易出现的新问题是:子模块为了完成工作,直接依赖整个 +`AppController`、具体 Repository 或具体页面。Host 接口通过声明“我只需要这些 +能力”限制依赖面。 -## 问题场景 +## 从具体对象改为能力 -`PlanController` 需要:调度器实例、方案目录路径、触发主视图重渲染、切换页面。 +反例: -在重构前可能的做法: -- 直接 `this.scheduler`(因为在同一个类里)→ 拆分后无法用 -- 传入整个 `AppController` 引用 → 双向依赖,子控制器知道太多 - ---- +```typescript +class NavigationController { + constructor(private readonly app: AppController) {} +} +``` -## 解决方案:Host 接口 +问题不是类型名字,而是 `NavigationController` 可以访问 AppController 的全部 +状态,未来任何字段都可能成为隐式依赖。 -### 基接口 +当前实现使用局部能力接口: ```typescript -// src/controller/shared/ControllerHost.ts - -export interface ControllerHost { - readonly scheduler: Scheduler; - plansDir: string; - renderMain(): void; - switchPage(page: string): void; +export interface NavigationControllerHost { + loadFleetPlanner(): Promise; + ensureDefaultPlan(): Promise; + loadPlanManagement(): Promise; + refreshAdbStatus(): Promise; + refreshShipLibraryStatus(): Promise; } ``` -### 子控制器声明自己的 Host +`NavigationController` 只知道导航触发后要调用哪些能力,不知道能力由哪个具体 +Controller 提供。 -```typescript -// src/controller/plan/PlanController.ts +## 四种常见注入边界 -export interface PlanHost { - readonly scheduler: Scheduler; - plansDir: string; - renderMain(): void; - switchPage(page: string): void; -} +### Controller Host -export class PlanController { - constructor( - private readonly planView: PlanPreviewView, - readonly host: PlanHost, // 只依赖接口,不依赖具体类 - ) {} -} +跨 Controller 流程使用 Host。共享契约集中在: + +`src/controller/contracts.ts` + +当前包括: + +- `TaskGroupHost` +- `PlanHost` +- `StartupHost` + +只服务一个 Controller 的 Host 通常与该 Controller 放在一起,例如: + +- `NavigationControllerHost` +- `SettingsControllerHost` +- `SchedulerBinderHost` +- `ScheduledTaskLoaderHost` + +不需要把所有 Host 都塞进一个全局 `ControllerHost`。 + +### Repository 能力裁剪 + +Controller 不应依赖完整 `ElectronBridge`。当前使用 `Pick` 裁剪: + +```typescript +export type PlanManagementRepository = Pick< + FleetPlannerRepository, + | 'getPlanManagement' + | 'exportUserPlans' + | 'setPlanUnlinkedIgnored' + | 'renameUserCombatPlan' + | 'deleteUserCombatPlan' + | 'deleteUserTeamPlan' +>; ``` -### AppController 在创建时注入实现 +这样测试只需提供六个相关方法,新增无关 IPC 方法不会扩大 Controller 权限。 + +Renderer 侧这些 Gateway/Repository 主要定义在: + +`src/adapter/IpcAdapter.ts` + +### View Host + +复杂 View 通过 Host 发送明确意图,不直接获得 Model。 + +例如 `ShipGalleryViewHost` 提供: ```typescript -// src/controller/app/AppController.ts — init() - -this.planCtrl = new PlanController(this.planView, { - scheduler: this.scheduler, - plansDir: '', - renderMain: () => this.renderMain(), - switchPage: (p) => this.switchPage(p), -}); +export interface ShipGalleryViewHost { + activeSlotDescription(): string; + isExcluded(ship: ShipLibraryShip): boolean; + assignShip(ship: ShipLibraryShip): void; + getRefitFilter?(): boolean; + setRefitFilter?(enabled: boolean): void; + isInteractionEnabled?(): boolean; +} ``` ---- +图库知道如何询问展示差异和发出分配意图,但不知道普通舰队或决战草稿的内部 +结构。 -## 依赖方向 +### Main Service 依赖 + +Electron Service 使用构造参数或依赖对象接收外部能力。例如: +```typescript +export interface WindowServiceDependencies { + readonly backendPort: number; + readonly moduleDirectory: string; + createBrowserWindow(options: Electron.BrowserWindowConstructorOptions): + options: BrowserWindowConstructorOptions, + getDisplays(): Display[]; + getDisplays(): WindowDisplay[]; + isPackaged(): boolean; + resourceRoot(): string; +} + showMessageBox(options: MessageBoxOptions): void; ``` -┌─────────────────────────────────┐ -│ AppController │ -│ - 创建子控制器 │ -│ - 实现 Host 接口 │ -└──────┬──────┬──────┬────────────┘ - │ │ │ 传入 Host 对象 - ▼ ▼ ▼ - PlanCtrl TaskGroupCtrl TemplateCtrl - host:{..} host:{..} (各自的 Host) - │ │ - ▼ ▼ 纯函数委托 - importExport queueLoader - presetFlow contextMenu ``` -**依赖规则**: -- 子控制器 → Host 接口(✅ 依赖抽象) -- 子控制器 → AppController(❌ 禁止直接依赖具体类) -- 子控制器 → 子控制器(❌ 禁止直接对话,由 AppController 桥接) - ---- +Service 不应反向导入 `electron/main.ts` 的全局变量。 -## 实际案例:桥接子控制器 +## Host 方法应该长什么样 -`TaskGroupController` 需要调用 `PlanController` 的功能(如导入预设、获取当前方案)。但它**不直接引用 PlanController**,而是通过 Host 桥接: +优先使用业务能力: ```typescript -// TaskGroupController 的 Host — 比 PlanHost 更丰富 -export interface TaskGroupHost extends ControllerHost { - importTaskPreset(preset: TaskPreset, filePath: string): void; - getCurrentPlan(): PlanModel | null; - setCurrentPlan(plan: PlanModel, mapData: MapData | null): void; - renderPlanPreview(): void; - closePresetDetail(): void; - executePreset(): void; - getCurrentPresetInfo(): { preset: TaskPreset; filePath: string } | null; -} +loadPlanManagement(): Promise; +saveTeamPlan(name: string): Promise; +refreshShipLibraryStatus(): Promise; ``` +避免暴露实现细节: + ```typescript -// AppController 做中介桥接 -this.taskGroupCtrl = new TaskGroupController( - this.taskGroupModel, this.taskGroupView, this.templateModel, - this.mainView, { - scheduler: this.scheduler, - plansDir: '', - renderMain: () => this.renderMain(), - switchPage: (p) => this.switchPage(p), - // 桥接到 PlanController - importTaskPreset: (preset, fp) => this.planCtrl.importTaskPreset(preset, fp), - getCurrentPlan: () => this.planCtrl.getCurrentPlan(), - setCurrentPlan: (plan, mapData) => this.planCtrl.setCurrentPlan(plan, mapData), - renderPlanPreview: () => this.planCtrl.renderPlanPreview(), - closePresetDetail: () => this.planCtrl.closePresetDetail(), - executePreset: () => this.planCtrl.executePreset(), - getCurrentPresetInfo: () => this.planCtrl.getCurrentPresetInfo(), - }, -); +getPlanController(): PlanController; +getDocument(): Document; +getMainWindow(): BrowserWindow; +getAllPrivateState(): AppState; ``` ---- +前一种接口限制“能做什么”,后一种接口只是把具体对象藏在 getter 后面。 -## 为什么不用继承 +## 依赖方向 -| 方案 | 问题 | -|------|------| -| `PlanController extends AppController` | 紧耦合 — 子类继承全部实现细节 | -| 深继承链 | 脆弱基类 — 父类改动破坏子类 | -| Host 接口 | ✅ 扁平组合,最小知识,易测试 | +```mermaid +flowchart LR + App["AppController"] -->|"实现能力"| Host["NavigationControllerHost"] + Nav["NavigationController"] --> Host + Nav --> View["NavigationView"] -测试时只需 Mock 一个 Host 对象,不需要实例化整个 AppController: + Controller["PlanManagementController"] --> Repo["PlanManagementRepository"] + Adapter["IpcAdapter"] -->|"实现"| Repo -```typescript -const mockHost: PlanHost = { - scheduler: fakeScheduler, - plansDir: '/tmp/plans', - renderMain: vi.fn(), - switchPage: vi.fn(), -}; -const ctrl = new PlanController(fakeView, mockHost); + Page["FleetPlannerView"] --> GalleryHost["ShipGalleryViewHost"] + Gallery["ShipGalleryView"] --> GalleryHost +``` + +接口应定义在消费者附近。消费者决定自己需要什么,而不是提供者公布一个巨大 +公共对象让所有人挑选。 + +## 为什么有些方法是可选的 + +`ShipGalleryViewHost` 的拖拽、改造筛选等能力只对部分页面存在,因此使用可选 +成员。可选能力是合理的前提: + +- 缺失时有明确且安全的行为。 +- 不会掩盖配置或运行错误。 +- 不是为了兼容多个不相关业务而堆积开关。 + +Controller、页面导航、OCR 或环境异常不能通过可选 Host 静默降级。 + +## 用注入改善测试 + +`PlanManagementController` 的构造函数允许注入: + +- Repository。 +- View。 +- Dialogs。 + +测试可以提供内存对象并断言: + +- 删除前是否确认。 +- Repository 收到哪个文件 identity。 +- 失败是否展示明确错误。 +- 成功后是否重新加载。 + +这比 mock 全局 `window.electronBridge` 更精确,也更不容易污染其他测试。 + +## 常见误区 + +### 巨型 Host + +如果一个 Host 有几十个互不相关的方法,说明拆出的模块仍承担多个用例。继续 +增加方法前,应重新检查责任边界。 + +### 回调代替所有类型 + +不是每个依赖都要写成 `(arg) => result`。已有稳定领域接口时,使用 +`Pick` 更容易保持方法语义和类型一致。 + +### Host 持有 DOM + +Controller Host 不应返回 `HTMLElement` 或接收浏览器事件。DOM 所有权属于 +View,Controller 只接收业务意图。 + +### Host 复制状态 + +`getState()` + `setState()` 的万能接口通常会制造第二状态源。优先提供一个完整 +用例能力,或让领域 Model 继续拥有状态。 + +## 新增 Host 的步骤 + +1. 列出消费者实际调用的能力。 +2. 删除“可能以后用到”的成员。 +3. 把接口放在消费者所在模块或稳定共享契约中。 +4. 在组合根注入实现,不在子模块中查找全局对象。 +5. 使用 `Pick` 保留已有 Repository 契约。 +6. 写一个最小 fake 验证接口确实可独立使用。 +7. 搜索是否仍导入了被替代的具体类。 + +## 验证 + +```powershell +rg -n "Host|Repository = Pick" src/controller src/view electron +npm run test:architecture-boundaries +npm run test:main-services +npm run test:main-ipc +git diff --check ``` diff --git a/docs/teaching/03-viewobject-flow.md b/docs/teaching/03-viewobject-flow.md index 9a1d050..8650e87 100644 --- a/docs/teaching/03-viewobject-flow.md +++ b/docs/teaching/03-viewobject-flow.md @@ -1,153 +1,227 @@ -# 03 — ViewObject 单向数据流 +# 03:ViewObject 单向数据流 -> **前置阅读**:[02-host-interface](02-host-interface.md) -> **核心原则**:View 层不允许直接读取 Model,所有数据通过 Controller 拼装成 ViewObject 后单向传递。 +> 前置阅读:[02 Host 接口与依赖注入](02-host-interface.md) ---- +ViewObject,简称 VO,是 Controller 为页面准备的只读展示数据。它的价值不是给 +Model 类型换一个名字,而是阻止 View 理解业务状态机、存储格式和后端 DTO。 -## 数据流示意 +## 完整数据流 -``` -Model (业务数据) Controller (拼装) View (渲染) - │ │ │ - scheduler.status ───────► │ │ - scheduler.taskQueue ────► │ │ - trackedLoot ────────────► buildMainViewObject() │ - wsConnected ────────────► │ │ - ▼ │ - MainViewObject ──────────► render(vo) - (纯数据,无方法) (纯DOM操作) +```mermaid +flowchart LR + Repository --> Model + Model --> Controller + Controller -->|"构建 ViewObject"| View + View -->|"DOM render"| User + User -->|"event"| View + View -->|"callback / intent"| Controller + Controller --> Model ``` ---- +核心约束: -## ViewObject 接口 +- 数据向 View 单向流动。 +- 用户动作以意图返回 Controller。 +- View 不持有可写 Model 引用。 +- Controller 不读取 DOM 来补业务状态。 -```typescript -// src/types/view.ts — View 层唯一认识的数据结构 - -export interface MainViewObject { - status: AppStatus; // 'idle' | 'running' | ... - statusText: string; // "空闲" | "运行中" | ... - currentTask: TaskViewObject | null; - expeditionTimer: string; // "12:34" - taskQueue: TaskQueueItemVO[]; - wsConnected: boolean; - runningTaskId: string | null; -} - -export interface TaskQueueItemVO { - id: string; - name: string; - priorityLabel: string; // "远征" | "用户" | "日常" - remaining: number; - totalTimes: number; - progress?: string; // "2/5" - progressPercent?: number; // 0~1 - acquisitionText?: string; // "装备 3/200 | 舰船 253/500" -} -``` +## 主页面示例 -**设计要点**: -- VO 是**纯数据接口**,没有方法 -- 字段使用 View 能直接展示的格式(`priorityLabel: "远征"` 而非 `priority: 0`) -- View 层**完全不知道** `Scheduler`、`ApiClient` 的存在 +主页面展示来源包括 Scheduler、舰队、统计和 WebSocket 状态。转换函数位于: ---- +`src/controller/app/rendering.ts` -## 拼装函数 +输入是明确的 `RenderingState`: ```typescript -// src/controller/app/rendering.ts - export interface RenderingState { readonly scheduler: Scheduler; + currentFleet: CurrentFleetShipVO[]; currentProgress: string; trackedLoot: string; trackedShip: string; + dailySortieStats: DailySortieStatsSnapshot; wsConnected: boolean; expeditionTimerText: string; } +``` + +`buildMainViewObject()` 负责: + +- 把调度状态转换成中文状态文本。 +- 合并运行中、排队中和等待中的任务。 +- 计算展示进度。 +- 把 OCR 追踪结果转换成资源摘要。 +- 生成 `MainViewObject`。 -export function buildMainViewObject(state: RenderingState): MainViewObject { - const { scheduler, currentProgress, wsConnected, expeditionTimerText } = state; - const running = scheduler.currentRunningTask; - - const taskQueueVo: TaskQueueItemVO[] = []; - if (running) { - taskQueueVo.push({ - id: running.id, - name: running.name, - priorityLabel: PRIORITY_LABELS[running.priority] ?? '用户', - remaining: running.remainingTimes, - totalTimes: running.totalTimes, - progress: currentProgress || undefined, - // ... - }); - } - for (const t of scheduler.taskQueue) { - taskQueueVo.push({ /* 同上结构 */ }); - } - - return { - status: scheduler.status, - statusText: STATUS_TEXT[scheduler.status] ?? '未知', - currentTask: running ? { name: running.name, /* ... */ } : null, - expeditionTimer: expeditionTimerText, - taskQueue: taskQueueVo, - wsConnected, - runningTaskId: running?.id ?? null, - }; +随后 `MainView` 只按 VO 渲染: + +```typescript +render(vo: MainViewObject): void { + this.statusBar.render(vo); + this.taskQueueView.render(vo); + this.fleetPreviewView.render( + vo.currentFleet, + vo.currentTask !== null, + vo.dailySortieStats, + ); } ``` -**关键**:拼装函数是**纯函数**——输入 `RenderingState`,输出 `MainViewObject`。不修改任何状态,不操作 DOM。 +`MainView` 不需要访问 Scheduler,也不需要解析后端日志。 ---- +## 为什么不直接把 Scheduler 传给 View -## View 层:只接收 VO,只做渲染 +直接传 Model 会让 View 必须理解: -```typescript -// src/view/main/MainView.ts +- `currentRunningTask` 与 `taskQueue` 的合并顺序。 +- `waitingTaskList` 的 gap/retry 文案。 +- `remainingTimes`、`totalTimes` 和 `unlimited` 的关系。 +- `idle` 且队列非空时为何显示“队列已暂停”。 -export class MainView { - render(vo: MainViewObject): void { - this.statusBar.render(vo); // 委托子视图 - this.taskQueueView.render(vo); - } +这些都是业务解释,不是 DOM 渲染。把它们集中在 Controller 转换层后,页面只 +关心“显示什么”。 - appendLog(entry: LogEntryVO): void { - this.logView.appendLog(entry); - } -} +## 方案页面示例 + +方案 VO 由: + +`src/controller/plan/rendering.ts` + +构建为 `PlanPreviewViewObject`。转换层负责把: + +- `PlanModel` 节点和默认值。 +- 地图节点和边。 +- 阵型、修理模式等业务值。 +- 当前选择和终点规则。 + +转换成 View 可直接使用的数据。 + +View 不应自己调用 `PlanModel.getNodeArgs()`,因为那会把节点继承规则泄漏到 +页面层。 + +## 用户意图返回 + +只读 VO 不代表 View 没有交互。View 通过回调发送明确动作: + +```text +用户拖动舰船 + -> FleetEditorView + -> FleetDraftEditIntent + -> FleetPlannerController + -> Fleet 领域函数 + -> FleetDraftViewObject + -> FleetPlannerView ``` -View 的职责**只有两件事**: -1. 接收 VO → 渲染 DOM -2. 用户操作 → 触发回调(如 `onRemoveQueueItem`) +`FleetDraftEditIntent` 定义在 `src/types/fleetEditor.ts`,它表达: + +- 要执行什么编辑动作。 +- 目标舰位或拖拽来源。 +- 必要的规则更新值。 + +它不携带整个 Model,也不允许 View 任意改写草稿。 + +## View 可以拥有的状态 + +ViewObject 流并不要求 View 完全无状态。以下状态可以留在 View: ---- +- 当前打开的标签。 +- 搜索关键字。 +- 筛选和排序。 +- 弹窗展开状态。 +- 滚动位置。 +- 表单尚未保存的局部输入。 +- loading 和视觉动画状态。 -## 为什么禁止 View 直接读 Model +判断标准是:关闭页面或重新从业务状态渲染后,这些值是否可以安全重建或丢弃。 -| 做法 | 后果 | -|------|------| -| View 直接 `import Scheduler` | View 和 Model 紧耦合,改字段名要同时改 View | -| View 调用 `api.getStatus()` | View 包含业务逻辑,不再是纯渲染层 | -| View 通过 VO 接收数据 | ✅ Model 变了只需改拼装函数,View 无感 | +以下状态不能留在 View: ---- +- 已保存方案。 +- Scheduler 任务。 +- 普通或决战舰队权威草稿。 +- 文件来源和 identity。 +- 配置提交是否成功。 +- 迁移阶段。 -## 调用链全貌 +## 异步操作的流向 +异步加载时仍保持同一边界: + +```text +View.onRefresh + -> Controller.load() + -> View.showLoading() + -> Repository.get... + -> Controller 保存结果 + -> build...ViewObject() + -> View.render() +``` + +以 `PlanManagementController` 为例,Repository 异常由 Controller 捕获并调用 +`view.showError()`。View 不直接调用 Repository,也不会因为加载失败修改文件。 + +## 四类数据不要混用 + +| 数据 | 示例 | 用途 | +|---|---|---| +| API DTO | `TaskRequest` | GUI 与 AutoWSGR 通信 | +| IPC DTO | `ManagedBattlePlan` | Renderer 与 Main 通信 | +| Model | `PlanData`、`SchedulerTask` | 领域状态和规则 | +| ViewObject | `PlanPreviewViewObject` | 页面展示 | + +同一个概念可以在四层有不同形状。例如任务在后端请求里不需要 GUI 的 +`logicalId`,在页面 VO 里也不需要完整 `TaskRequest`。 + +## 常见反例 + +### View 导入 Model + +```typescript +// 错误:页面开始理解 PlanModel 的继承和持久化语义 +render(plan: PlanModel): void {} +``` + +应改为: + +```typescript +render(vo: PlanPreviewViewObject | null): void {} ``` -状态变化 (onTaskCompleted 等) - ↓ -SchedulerBinder.callback → host.renderMain() - ↓ -AppController.renderMain() - ↓ -buildMainViewObject(state) ← 纯函数拼装 - ↓ -mainView.render(vo) ← 纯 DOM 渲染 + +### Controller 查询 DOM + +```typescript +// 错误:Controller 通过页面找回业务状态 +const value = document.getElementById('cfg-backend-port'); +``` + +应由 View 的受控收集方法或明确 intent 返回值。 + +### VO 只是 Model 的别名 + +如果 VO 仍包含完整 `PlanData`,并让 View 自己转换文案、继承默认值和处理未知 +字段,说明转换边界没有建立。 + +### 为避免转换而使用 `any` + +`any` 只会把层间不匹配推迟到运行时。应明确增加 DTO、Model 或 VO 字段,并 +更新转换函数。 + +## 新增展示字段的步骤 + +1. 确认字段来自哪个权威状态。 +2. 在对应 VO 中增加展示所需的最小字段。 +3. 在 Controller rendering 函数中完成转换。 +4. 让 View 只消费新字段。 +5. 验证其他 VO 构造点和测试 fixture。 +6. 不把 Model 或 API 对象整体透传给 View。 + +## 验证 + +```powershell +rg -n "build.*ViewObject|ViewObject|VO" src/controller src/types/view.ts +npm run test:architecture-boundaries +npm run test:renderer-contract +npm run test:build ``` diff --git a/docs/teaching/04-electron-split.md b/docs/teaching/04-electron-split.md index d7ee85e..98ecd34 100644 --- a/docs/teaching/04-electron-split.md +++ b/docs/teaching/04-electron-split.md @@ -1,168 +1,229 @@ -# 04 — Electron 主进程拆分 +# 04:Electron Main 分层 -> **前置阅读**:[00-overview](00-overview.md) -> **核心原则**:`main.ts` 只做窗口管理 + IPC 注册,业务逻辑提取到独立模块并通过 Context 注入依赖。 +> 前置阅读:[00 全局概览](00-overview.md) ---- +Electron Main 同时能访问文件、进程、窗口和系统 API,因此最需要控制权限边界。 +当前架构不是简单地把 `main.ts` 拆成多个文件,而是区分组合、传输、用例、来源 +和格式。 -## 重构前后 +## 当前分层 -**重构前**: - -``` +```text electron/ -├── main.ts (1192 行 — 窗口 + IPC + Python + 后端 + 模拟器全在一起) -└── preload.ts +├─ main.ts # 组合根和生命周期 +├─ preload.ts # 唯一 Renderer Bridge +├─ ipc/ # 通道和参数边界 +├─ services/ # 主进程用例、策略和持久化 +└─ pythonEnv/ # Python、依赖、CUDA 和后端来源 ``` -**重构后**: +Main 内部常见职责: -``` -electron/ -├── main.ts (401 行,只保留窗口 + IPC) -├── backend.ts (176 行,后端子进程生命周期) -├── emulatorDetect.ts (114 行,模拟器检测) -├── preload.ts (104 行) -└── pythonEnv/ (Python 环境管理) - ├── index.ts (15 行,barrel re-export) - ├── context.ts (33 行,共享上下文) - ├── finder.ts (90 行,Python 查找) - ├── envCheck.ts (208 行,环境检查) - ├── installer.ts (225 行,安装) - ├── updater.ts (178 行,更新) - └── utils.ts (101 行,工具函数) +| 层 | 负责 | 不负责 | +|---|---|---| +| `main.ts` | 创建服务、注入依赖、注册 IPC、启动/退出顺序 | 文件格式和业务规则 | +| preload | 暴露白名单方法、转发 invoke/sendSync/event | 主进程业务 | +| IPC | 通道契约、输入输出和异常边界 | 持久化细节 | +| Service | 一个主进程用例或策略 | Renderer DOM | +| Repository | 目录、文件 identity、系统/用户来源 | 页面展示 | +| Codec | YAML/JSON 结构、校验、升级、未知字段 | 文件选择和窗口 | + +## main.ts 是组合根 + +`electron/main.ts` 创建具体实现并把它们连接起来,例如: + +```typescript +registerConfigurationIpc(ipcMain, { + getAppVersion: () => app.getVersion(), + backendPort: BACKEND_PORT, + configuration: guiConfigurationService, + settingsCommit: guiSettingsCommitService, + cudaEnvironment: cudaEnvironmentService, + pythonEnvironment: pythonEnvironmentService, + windows: windowService, +}); ``` ---- +这段代码表达对象关系,实际配置提交、CUDA 检测和窗口逻辑分别在 Service 中。 -## 模式:Context 注入 +组合根允许依赖具体类,因为它的责任就是选择实现。子 Service 不应反向导入 +`main.ts` 获取全局变量。 -子模块不通过 `import` 读取 `main.ts` 的全局变量。而是由 `main.ts` 在启动时调用 `init()` 注入运行上下文。 +## preload 是唯一桥 -### backend.ts +`electron/preload.ts` 通过 `contextBridge` 暴露: ```typescript -// electron/backend.ts - -export interface BackendContext { - appRoot: () => string; - resourceRoot: () => string; - BACKEND_PORT: number; - getMainWindow: () => BrowserWindow | null; -} - -let ctx: BackendContext; - -export function initBackend(context: BackendContext): void { - ctx = context; -} - -// 之后所有函数通过 ctx 访问,不依赖 main.ts 全局变量 -export async function startBackend(): Promise<{ success: boolean; message: string }> { - const pythonCmd = findPython(); - // 使用 ctx.appRoot(), ctx.BACKEND_PORT ... -} +window.electronBridge ``` -### pythonEnv/context.ts +完整契约定义在: -```typescript -// electron/pythonEnv/context.ts +`src/types/ipc.ts` -export interface PythonEnvContext { - appRoot: () => string; - sendProgress: (msg: string) => void; - getConfiguredPythonPath: () => string | null; - getTempDir: () => string; -} +Renderer Controller 和 View 不直接访问该全局对象。Adapter 使用 `Pick` 将完整 +Bridge 裁剪成不同用例需要的 Gateway/Repository。 -let ctx: PythonEnvContext; +这样可以同时限制: -export function initPythonEnv(context: PythonEnvContext): void { - ctx = context; -} +- Renderer 能调用哪些系统能力。 +- 某个 Controller 能看到哪些 IPC 方法。 +- IPC DTO 如何跨进程序列化。 -export function getCtx(): PythonEnvContext { - return ctx; // 内部各模块通过此函数获取上下文 -} -``` +## IPC 必须保持薄 -### main.ts 的启动流程 +以 `electron/ipc/ConfigurationIpc.ts` 为例,它负责: -```typescript -// electron/main.ts — 启动时注入上下文 - -import { initBackend, startBackend, stopBackend } from './backend'; -import { initPythonEnv, findPython, checkEnvironment } from './pythonEnv'; -import { detectEmulator } from './emulatorDetect'; - -app.whenReady().then(() => { - initBackend({ - appRoot: () => appRoot(), - resourceRoot: () => resourceRoot(), - BACKEND_PORT, - getMainWindow: () => mainWindow, - }); - - initPythonEnv({ - appRoot: () => appRoot(), - sendProgress: (msg) => mainWindow?.webContents.send('setup-log', msg), - getConfiguredPythonPath: () => getConfiguredPythonPath(), - getTempDir: () => app.getPath('temp'), - }); - - createWindow(); -}); +- 注册同步 getter。 +- 注册异步 handler。 +- 把请求交给配置、环境、窗口和提交服务。 + +事务保存由: + +`electron/services/GuiSettingsCommitService.ts` + +处理,而不是写在 IPC handler 中。 + +判断逻辑放错位置的信号: + +- IPC 开始解析 YAML。 +- IPC 直接拼用户目录。 +- IPC 决定迁移策略。 +- IPC 捕获异常后改走业务 fallback。 + +这些行为应进入 Codec、Repository 或 Service。 + +## 配置事务案例 + +设置页一次提交会同时影响: + +- `userData/usersettings.yaml` +- `userData/gui_settings.json` -// IPC handler — 每个只做转发 -ipcMain.handle('start-backend', () => startBackend()); -ipcMain.handle('detect-emulator', () => detectEmulator()); +`GuiSettingsCommitService.commitAtomic()` 的顺序是: + +```text +准备窗口配置 + -> 快照 usersettings.yaml + -> 保存新 YAML + -> 提交 gui_settings.json + -> JSON 失败时恢复 YAML + -> 全部成功后返回提交结果 ``` ---- +事务属于 Service,因为它协调多个存储并定义失败恢复语义。若放在 Renderer, +窗口关闭、IPC 中断或 Main 写入失败时就无法可靠回滚。 -## emulatorDetect.ts — 零依赖纯函数 +## 方案的 Codec / Repository / Service -最简单的提取案例:模拟器检测逻辑完全独立,不需要任何 Context: +作战和编队方案展示了三层分工: -```typescript -// electron/emulatorDetect.ts +```text +CombatPlanCodec + 解析、规范化、拆分、展开和序列化 + +CombatPlanRepository / TeamPlanRepository + 系统与用户目录、文件 identity 和读写 -export interface EmulatorDetectResult { - type: string; - path: string; - serial: string; - adbPath: string; -} +PlanManagementService / TeamPlanService / RuntimePlanService + 管理、保存、删除影响和运行时准备 +``` + +例如运行前展开舰队引用: -export function detectEmulator(): EmulatorDetectResult[] { /* ... */ } +```text +受管作战方案 + -> Repository 读取 + -> Codec 校验和展开独立舰队 + -> RuntimePlanService 写入临时执行方案 + -> 后端执行 ``` ---- +Codec 不弹文件对话框,Repository 不构建页面 VO,Service 不手写 YAML 字符串。 -## pythonEnv/ — barrel re-export +## 文件能力为什么不能直接暴露 -提取多个文件后,外部导入路径保持不变: +通用文件 IPC 经过: -```typescript -// electron/pythonEnv/index.ts +- `SafePathService` +- `SecureFileService` +- `AtomicFileStore` + +它们共同保证: + +- 读取只在允许的资源和 `userData` 根内。 +- 写入只进入 `userData`。 +- 拒绝路径穿越、UNC、盘符跳转和 ADS。 +- 检查符号链接或 junction 的真实目标。 +- 写入使用临时文件和原子替换。 + +“用户在对话框选过一个文件”只授权该次操作,不能顺便扩大通用文件 IPC 的根 +目录。 + +## 生命周期也是架构边界 + +启动顺序不能随意移动: -export { initPythonEnv, clearPythonCache } from './context'; -export { findPython } from './finder'; -export { checkEnvironment } from './envCheck'; -export { installPortablePython, installDependencies } from './installer'; -export { autoUpdateAutowsgr } from './updater'; +```text +单实例锁 + -> 更新恢复 + -> userData 迁移 + -> Python / 后端环境 + -> 方案和资料库 + -> IPC + -> BrowserWindow ``` -`main.ts` 中的 `import { findPython } from './pythonEnv'` 无需改动。 +退出时 Main 保存窗口状态,并停止后端和 GUI 内置 ADB。次实例必须在迁移、pip、 +后端和窗口初始化之前退出。 ---- +生命周期逻辑可以在 `main.ts` 编排,但可测试的停止、更新和单实例行为分别由 +Service 实现。 -## 要点 +## Python 环境为什么单独成域 + +`electron/pythonEnv/**` 负责: + +- 解释器来源和版本。 +- managed/external 后端模式。 +- 依赖安装和 `.env_ready`。 +- CUDA 环境。 +- AutoWSGR 唯一来源和正式运行契约。 + +`BackendService` 只在环境和契约通过后启动 Uvicorn。环境检查失败不能静默切换 +到另一个不明确来源。 + +## 新逻辑应该放哪 + +| 新需求 | 放置位置 | +|---|---| +| 新 IPC 方法 | `src/types/ipc.ts`、preload、对应 IPC | +| 文件命名和来源 | Repository | +| YAML 字段兼容 | Codec | +| 跨文件事务 | Service | +| 启动先后顺序 | `main.ts` | +| Python 查找或安装 | `electron/pythonEnv/**` | +| 后端进程控制 | Backend Service | +| 页面显示 | Renderer View/Controller | + +## 常见反例 + +- 在 `main.ts` 直接实现一个完整 CRUD 用例。 +- preload 暴露 Node `fs` 或任意路径读取。 +- IPC 同时校验参数、解析 YAML、写文件和刷新窗口。 +- Repository 返回页面文案。 +- Codec 根据 Electron 对话框选择文件。 +- 捕获 Main 异常后在 Renderer 触发另一个业务流程。 + +## 验证 + +```powershell +npm run test:main-services +npm run test:main-ipc +npm run test:migrations +npm run test:python-environment +npm run test:build +git diff --check +``` -| 规则 | 说明 | -|------|------| -| `main.ts` 只做三件事 | 创建窗口、注册 IPC、管理生命周期 | -| 子模块通过 `init()` 接收上下文 | 不直接 import main.ts 的全局变量 | -| 纯函数优先 | 如 `emulatorDetect.ts`,不需要上下文就不用 | -| barrel re-export | 外部导入路径不变,内部自由拆分 | +涉及进程、窗口、更新或安装时,还需要实际启动 Electron 并验证关闭和强制退出。 diff --git a/docs/teaching/05-view-layer.md b/docs/teaching/05-view-layer.md index 62ed0eb..a517555 100644 --- a/docs/teaching/05-view-layer.md +++ b/docs/teaching/05-view-layer.md @@ -1,143 +1,250 @@ -# 05 — View 层组织 +# 05:View 层组织 -> **前置阅读**:[03-viewobject-flow](03-viewobject-flow.md) -> **核心原则**:View 按功能页面分子目录;复杂视图用 Facade 模式对外暴露统一 API;View 层零 Model 依赖。 +> 前置阅读:[03 ViewObject 单向数据流](03-viewobject-flow.md) ---- +View 层的边界不是“所有前端代码”,而是 DOM、浏览器事件、局部视觉状态和资源 +生命周期。它不负责文件、调度、持久化或领域规则。 -## 目录结构 +## 三类开发源 -``` +```text src/view/ -├── main/ # 主页面 -│ ├── MainView.ts (65 行,Facade) -│ ├── LogView.ts (日志面板) -│ ├── TaskQueueView.ts (任务队列) -│ └── StatusBar.ts (状态栏) -├── plan/ # 方案预览 -│ ├── PlanPreviewView.ts (Facade) -│ ├── MapView.ts (地图可视化) -│ ├── NodeEditorView.ts (节点编辑器) -│ ├── FleetPresetView.ts (编队预设) -│ └── FleetEditDialog.ts (编队编辑对话框) -├── template/ # 模板库 -│ ├── TemplateWizardView.ts(向导) -│ ├── TemplateLibraryView.ts(库列表) -│ └── SelectorDialog.ts (选择器对话框) -├── config/ -│ └── ConfigView.ts (配置页) -├── setup/ -│ └── SetupWizardView.ts (首次配置向导) -├── taskGroup/ -│ └── TaskGroupView.ts (任务组面板) -└── shared/ - └── ShipAutocomplete.ts (跨视图复用的舰船自动补全) +├─ html/** # HTML partial +├─ styles/** # SCSS partial 和生成 CSS +└─ **/*.ts # View、Facade、共享视觉组件 ``` ---- +构建后 Electron 加载: -## Facade 模式:MainView +```text +src/view/index.html +src/view/styles/styles.css +dist/renderer.bundle.js +``` -`MainView` 内部由 3 个子视图组成,但 Controller 只看到一个统一入口: +HTML partial 和 SCSS partial 是开发结构,不会在运行时 fetch 或动态 include。 -```typescript -// src/view/main/MainView.ts - -export class MainView { - private logView: LogView; - private taskQueueView: TaskQueueView; - private statusBar: StatusBar; - - constructor() { - this.logView = new LogView(); - this.taskQueueView = new TaskQueueView(); - this.statusBar = new StatusBar(); - } - - // ── 渲染:Controller 只调这一个方法 ── - render(vo: MainViewObject): void { - this.statusBar.render(vo); - this.taskQueueView.render(vo); - } - - appendLog(entry: LogEntryVO): void { - this.logView.appendLog(entry); - } - - // ── 回调转发:用 setter 传递给内部子视图 ── - set onRemoveQueueItem(fn: ((taskId: string) => void) | undefined) { - this.taskQueueView.onRemoveQueueItem = fn; - } - set onMoveQueueItem(fn: ((from: number, to: number) => void) | undefined) { - this.taskQueueView.onMoveQueueItem = fn; - } -} -``` +## View 负责什么 + +- 查找和更新 DOM。 +- 绑定 DOM、window、document 事件。 +- 管理搜索、筛选、排序、展开和 loading。 +- 管理滚动、动画和 Observer。 +- 收集表单草稿。 +- 通过 callback 或 intent 上报用户动作。 +- 释放自己创建的事件、Observer 和定时资源。 + +View 不负责: + +- Scheduler 和任务重试。 +- 配置或方案持久化。 +- 文件 identity 和系统/用户来源。 +- HTTP、IPC 或 Electron Bridge。 +- 可写领域 Model。 + +该边界由 `scripts/tests/test-renderer-architecture.js` 静态检查。 + +## Facade:保持外部 API 稳定 + +Facade 对 Controller 提供稳定入口,内部组合职责子 View。 + +### MainView + +`src/view/main/MainView.ts` 内部组合: + +- `StatusBar` +- `TaskQueueView` +- `FleetPreviewView` +- `LogView` -Controller 的使用方式: +Controller 只调用: ```typescript -// AppController 中 -this.mainView.render(vo); // 不需要知道内部有几个子视图 -this.mainView.appendLog(logEntry); -this.mainView.onRemoveQueueItem = (id) => this.scheduler.removeTask(id); +this.mainView.render(vo); +this.mainView.appendLog(entry); ``` ---- +新增一个主页面局部视觉模块时,优先由 `MainView` 组合,而不是让 +`AppController` 直接管理更多 DOM 组件。 -## Facade 模式:PlanPreviewView +### ConfigView -同样的模式用在方案预览页: +`src/view/config/ConfigView.ts` 保持设置页公共 API,内部组合: -```typescript -// src/view/plan/PlanPreviewView.ts +- `ConfigAutomationView` +- `ConfigRuntimeView` +- `settingSelectWidth.ts` + +拆分后 Controller 仍面对一个 `ConfigView`,配置状态也没有转移到子 View。 + +### FleetPlannerView + +`src/view/plan/FleetPlannerView.ts` 组合: + +- `FleetEditorView` +- `FleetGalleryView` +- `TeamPlanLoaderView` + +它通过 `FleetPlannerViewHost` 发送编辑和保存意图,业务草稿仍由 Controller 和 +Fleet 领域维护。 + +## 职责子 View 和共享组件不同 + +职责子 View 只服务一个 Facade,例如 `ConfigRuntimeView`。 + +共享组件必须有多个真实消费者,例如: + +`src/view/plan/ShipGalleryView.ts` + +它同时服务普通舰队和决战页面,复用: + +- 搜索和筛选。 +- 排序。 +- 增量渲染。 +- 舰船卡片。 +- 滚动恢复。 +- 拖拽起点。 + +页面差异由 `ShipGalleryViewHost` 提供。共享组件不能持有: -export class PlanPreviewView { - private mapView: MapView; - private nodeEditor: NodeEditorView; - private fleetPresetView: FleetPresetView; +- 普通舰队主选/候选语义。 +- 决战 level1/level2 草稿。 +- 保存状态和文件 identity。 - render(vo: PlanPreviewViewObject | null): void { - // 统一渲染,内部协调三个子视图 - } +共享的是视觉行为,不是业务状态。 + +## 生命周期必须完整 + +`ShipGalleryView` 创建一组事件监听器和一个 `ResizeObserver`: + +```typescript +private readonly eventController = new AbortController(); +private readonly resizeObserver: ResizeObserver; + +dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.eventController.abort(); + this.resizeObserver.disconnect(); } ``` ---- +释放链是: + +```text +AppController.onBeforeUnload + -> FleetPlannerController.dispose() + -> FleetPlannerView.dispose() + -> ShipGalleryView.dispose() + +AppController.onBeforeUnload + -> DecisivePlanController.dispose() + -> DecisivePlanView.dispose() + -> ShipGalleryView.dispose() +``` + +创建 `window/document` 监听器、Observer、interval 或 animation frame 时,必须 +同时确定所有者和释放入口。 + +## 局部状态和业务状态 + +可以留在 View: + +- 图库搜索词。 +- 当前筛选和排序。 +- 弹窗展开。 +- 拖拽中的滚动位置。 +- 尚未保存的输入框文本。 + +必须交回 Controller/Model: + +- 舰队槽位内容。 +- 计划选中节点。 +- 已保存配置。 +- Scheduler 队列。 +- 任务组条目。 -## Facade 的价值 +局部状态不应在 `render()` 时被无条件重置,否则其他字段变化会导致滑块、选择 +或滚动位置回退。 -| 不用 Facade | 用 Facade | -|-------------|-----------| -| Controller 要知道 `LogView` / `TaskQueueView` / `StatusBar` | Controller 只知道 `MainView` | -| 新增子视图需改 Controller | 新增子视图只改 `MainView` 内部 | -| Controller import 列表膨胀 | 只 import 一个 Facade | +## HTML partial 是 DOM 契约 ---- +HTML 源位于: -## 共享组件 +`src/view/html/**` -跨多个视图复用的 UI 组件放在 `shared/`: +由 `scripts/build-view-html.js` 递归展开 include。构建脚本拒绝: +- include 逃出源目录。 +- 循环 include。 +- 生成结果与已提交 `src/view/index.html` 不一致。 + +View 使用的 DOM ID 是契约。移动 HTML 时要检查: + +```powershell +rg -n "getElementById|querySelector|data-" src/view ``` -src/view/shared/ -└── ShipAutocomplete.ts (舰船名自动补全输入框) + +`test-renderer-dom-contract.js` 检查重复 ID、View 引用缺失和例外白名单。 + +## SCSS 按所有权组织 + +```text +src/view/styles/ +├─ base/ +├─ components/ +├─ pages/ +├─ themes/ +└─ main.scss ``` -`FleetEditDialog` 和 `TemplateWizardView` 都使用它,但不重复实现。 +规则: + +- 页面特有布局留在 `pages/`。 +- 两个以上页面共享的独立视觉组件进入 `components/`。 +- 拆分 partial 时保持选择器、属性和加载顺序。 +- 不在机械拆分中同时修改视觉效果。 +- 只编辑 SCSS 源,不手工改 `styles.css`。 + +## View 可以导入什么 ---- +允许: -## 零 Model 依赖规则 +- `src/types/view.ts` +- `src/types/fleetEditor.ts` 等明确 intent 类型 +- `src/shared/**` 中纯逻辑和只读契约 +- `src/view/shared/**` 中共享 UI +- 同功能域子 View -View 文件的 import **只允许**: -- `../../types/view` — ViewObject 接口 -- `../shared/*` — 共享 UI 组件 -- 同目录的子视图 +禁止: -**禁止** import: -- `../../model/*` -- `../../types/model` -- `../../types/api` +- 有状态 `src/model/**` +- `ApiClient` +- `src/adapter/**` +- `window.electronBridge` +- 直接 `localStorage` + +当前唯一受控例外是 `src/view/theme.ts` 通过 `StorageAdapter` 保存纯 UI 偏好。 + +## 提取 View 的步骤 + +1. 确定一个完整视觉责任和 DOM 所有权。 +2. 搜索该区域全部 ID、class、事件和 CSS。 +3. 保持 Controller 对外 API 不变。 +4. 不移动业务状态。 +5. 定义最小 callback/Host。 +6. 明确创建和释放生命周期。 +7. 拆 HTML/SCSS 时保持生成结果和顺序。 +8. 回归所有共享组件消费者。 + +## 验证 + +```powershell +npm run build:html +npm run test:renderer-contract +npm run test:architecture-boundaries +npm run test:build +git diff --check +``` -这通过类型系统的分层来保障,详见 [07-type-system](07-type-system.md)。 +涉及交互时还要在 Electron 中回归点击、输入、拖放、滚动、弹窗和窗口关闭。 diff --git a/docs/teaching/06-model-layer.md b/docs/teaching/06-model-layer.md index 0c41a7f..5fc937b 100644 --- a/docs/teaching/06-model-layer.md +++ b/docs/teaching/06-model-layer.md @@ -1,206 +1,251 @@ -# 06 — Model 层组织 +# 06:Model 与领域状态 -> **前置阅读**:[01-extract-class](01-extract-class.md) -> **核心原则**:Model 层分两类 — 独立领域模型 + Scheduler 子系统。Model 不依赖 View,通过回调通知 Controller。 +> 前置阅读:[01 按职责拆分类](01-extract-class.md) ---- +Model 层保存领域状态并执行领域规则。判断一个类是否属于 Model,不看它是否 +“处理数据”,而看它是否拥有业务含义、状态不变量和转换规则。 -## 目录结构 +## 当前领域分布 -``` +```text src/model/ -├── ConfigModel.ts (91 行) 用户配置: YAML 加载/导出/局部更新 -├── PlanModel.ts 战斗方案: 解析 YAML, 节点操作 -├── TemplateModel.ts (146 行) 模板库: 内置 + 用户模板 CRUD -├── TaskGroupModel.ts (185 行) 任务组: 定义/持久化/排序 -├── ApiClient.ts HTTP + WebSocket 后端通信 -├── MapDataLoader.ts (71 行) 地图数据加载 + 缓存 -└── scheduler/ 调度器子系统 (7 个文件) - ├── Scheduler.ts 主调度器 - ├── TaskQueue.ts 优先级队列 - ├── CronScheduler.ts 定时触发 - ├── ExpeditionTimer.ts 远征倒计时 - ├── RepairManager.ts 泡澡修理 - ├── StopConditionChecker.ts 停止条件 - └── index.ts barrel re-export +├─ ConfigModel.ts +├─ PlanModel.ts +├─ TaskGroupModel.ts +├─ TemplateModel.ts +├─ MapDataLoader.ts +├─ ApiClient.ts +├─ fleet/ +├─ scheduler/ +└─ statistics/ ``` ---- +主要状态所有者: -## 独立领域模型 +| 领域 | 权威状态 | +|---|---| +| 配置 | `ConfigModel` | +| 当前作战方案 | `PlanModel` | +| 任务组 | `TaskGroupModel` | +| 模板 | `TemplateModel` | +| 普通舰队草稿 | Fleet 领域 + `FleetPlannerController` | +| 决战草稿 | `DecisiveFleetDraft` + `DecisivePlanController` | +| 调度 | `Scheduler` | +| 每日额度 | `CampaignDailyQuota`、`NormalFightDailyQuota` | +| 出征统计 | `DailySortieStats` | -每个 Model 类只负责一个数据领域,职责清晰: +Controller 可以持有 Model,但不复制 Model 的可写状态。 -| 类 | 职责 | 关键方法 | -|----|------|---------| -| `ConfigModel` | 配置数据的加载/导出/更新 | `loadFromYaml()`, `toYaml()`, `update()` | -| `PlanModel` | 方案文件解析 + 节点操作 | `parseYaml()`, `getNodeArgs()` | -| `TemplateModel` | 模板库读写 | `loadBuiltin()`, `add()`, `remove()` | -| `TaskGroupModel` | 任务组定义 + 持久化 | `load()`, `save()`, `reorder()` | -| `ApiClient` | 后端 HTTP/WebSocket 通信 | `taskStart()`, `gameAcquisition()` | -| `MapDataLoader` | 地图 JSON 加载 + 内存缓存 | `loadMapData()`, `getNodeType()` | +## ConfigModel:两个配置域 -### ConfigModel 示例 +`ConfigModel` 同时维护两个明确分开的域: ```typescript -// src/model/ConfigModel.ts +private settings: UserSettings; +private guiAutomation: GuiAutomationSettings; +``` -export class ConfigModel { - private settings: UserSettings; +- `settings` 对应 AutoWSGR `usersettings.yaml`。 +- `guiAutomation` 对应 GUI 自身自动化设置。 - get current(): UserSettings { return this.settings; } +`rawRoot` 保存 GUI 尚未建模的 YAML 字段: - loadFromYaml(yamlStr: string): void { - const parsed = yaml.load(yamlStr) as Record | null; - const base = structuredClone(DEFAULT_SETTINGS); - if (parsed?.emulator) Object.assign(base.emulator, parsed.emulator); - if (parsed?.account) Object.assign(base.account, parsed.account); - if (parsed?.daily_automation) Object.assign(base.daily_automation, parsed.daily_automation); - this.settings = base; - } +```typescript +private rawRoot: Record = {}; +``` - toYaml(): string { - return yaml.dump(this.settings, { lineWidth: -1, noRefs: true }); - } +这样读取、编辑和写回时不会静默删除后端新增或用户手写字段。 - update(partial: Partial): void { - if (partial.emulator) Object.assign(this.settings.emulator, partial.emulator); - if (partial.account) Object.assign(this.settings.account, partial.account); - if (partial.daily_automation) Object.assign(this.settings.daily_automation, partial.daily_automation); +这里的教学重点是:Model 不只保存“已知字段”,还维护 round-trip 不变量和旧 +字段迁移语义。 + +## PlanModel:继承规则属于领域 + +节点参数由默认值和节点覆盖合并: + +```typescript +getNodeArgs(nodeId: string): NodeArgs { + const defaults = this.data.node_defaults ?? {}; + const overrides = this.data.node_args?.[nodeId] ?? {}; + const args = { ...defaults, ...overrides }; + if (this.data.endpoint_nodes?.includes(nodeId)) { + args.proceed = false; } + return args; } ``` -特点:纯数据操作,不引用任何 View 或 Controller。 +这段逻辑属于 `PlanModel`,因为: ---- +- 它解释方案字段语义。 +- Controller 和 View 都需要一致结果。 +- 持久化结构变化时只应改一处。 -## Scheduler 子系统 +View 不应自行合并 `node_defaults`,Controller 也不应为每个页面复制规则。 -原始的 `Scheduler.ts` 是一个大文件,重构后拆成 7 个文件,每个负责一个调度子域。 +`PlanModel.rawRoot` 同样保留未知 YAML 字段,保存时只覆盖 GUI 管理的字段。 -### 职责分工 +## 独立纯规则放在哪里 -| 类 | 职责 | -|----|------| -| `Scheduler` | 主调度器: 持有队列、消费循环、WebSocket 连接 | -| `TaskQueue` | 优先级队列的增删改查、ID 生成 | -| `CronScheduler` | 基于系统时钟的定时触发(演习/战役/刷闪) | -| `ExpeditionTimer` | 远征检查的倒计时 + 定时触发 | -| `RepairManager` | 泡澡修理: 检查血量、送入修理、预设轮换 | -| `StopConditionChecker` | 判断任务是否满足停止条件(预飞检查 + 实时检查) | +有些规则属于一个领域,但不需要读取领域对象状态。例如: -### 组合关系 +- `SchedulerTaskPolicy.ts` +- `SchedulerRepairPolicy.ts` +- Fleet 目录中的草稿变换函数 +- `src/controller/plan/selectedNodes.ts` -`Scheduler` 通过**组合**持有子模块,不是继承: +`selectedNodes.ts` 维护路线用例规则: ```typescript -// src/model/scheduler/Scheduler.ts - -export class Scheduler { - private _taskQueue: TaskQueue; - private expeditionTimer: ExpeditionTimer; - private stopChecker: StopConditionChecker; - private repairMgr: RepairManager; - - constructor(api: ApiClient) { - this._taskQueue = new TaskQueue(); - this.expeditionTimer = new ExpeditionTimer(DEFAULT_INTERVAL, { - onTick: (sec) => this.callbacks.onExpeditionTimerTick?.(sec), - onTrigger: () => this.insertExpeditionTask(), - }); - this.stopChecker = new StopConditionChecker(api, (level, msg) => { /* ... */ }); - this.repairMgr = new RepairManager(api); +export function initialSelectedNodesForNewPlan(): string[] { + return ['0']; +} + +export function assertPlanRouteReadyForExecution( + selectedNodes: readonly string[], +): void { + if (selectedNodes.length === 1 && selectedNodes[0] === '0') { + throw new Error('出征计划只启用了起始节点,请至少开启一个路线节点'); } } ``` -### StopConditionChecker 示例 +它位于 Controller 领域辅助模块,是因为“新建/执行方案”属于应用用例边界; +`PlanModel` 仍只解释方案本身。 -从 Scheduler 中提取的独立职责——只负责判断,不负责执行: +位置判断取决于规则语义,不是所有纯函数都必须进入 `shared/`。 -```typescript -// src/model/scheduler/StopConditionChecker.ts - -export class StopConditionChecker { - trackedLootCount: number | null = null; - trackedShipCount: number | null = null; - - /** 任务执行中实时检查 */ - checkRunning(cond: StopCondition): boolean { - if (cond.loot_count_ge != null - && this.trackedLootCount != null - && this.trackedLootCount >= cond.loot_count_ge) { - return true; - } - return false; - } +## Fleet:草稿、编辑和持久化分开 - /** 预飞检查:发起任务前确认条件是否已满足 */ - async preflightCheck(cond: StopCondition, taskName: string): Promise { - const resp = await this.api.gameAcquisition(); - // ...OCR 读取出征面板数量 - } -} -``` +`src/model/fleet/**` 包含: -### ExpeditionTimer 示例 +- `FleetDraft` +- `DecisiveFleetDraft` +- `FleetDraftEditor` +- `FleetPresetIdentity` +- `FleetRuleMapper` +- `ShipMatcher` -从 Scheduler 中提取的纯定时逻辑: +普通舰队与决战舰队共享舰船资料和部分视觉行为,但草稿状态独立。 -```typescript -// src/model/scheduler/ExpeditionTimer.ts - -export class ExpeditionTimer { - start(): void { - this.timer = setInterval(() => { - this.callbacks.onTrigger(); // 触发远征检查 - }, this._intervalMs); - - this.tickTimer = setInterval(() => { - const remaining = this._intervalMs - (Date.now() - this.lastCheck); - this.callbacks.onTick?.(Math.ceil(remaining / 1000)); - }, 1000); - } +关键不变量: - stop(): void { /* 清理定时器 */ } +- 主选舰优先于候选舰。 +- candidate-only 槽位不能把第一个候选自动提升为主选。 +- 全局唯一分配优先保留主选。 +- 主选失败后再用候选重新执行全局分配。 +- 舰种只接受规定的 canonical code。 - setInterval(ms: number): void { - this._intervalMs = ms; - if (this.timer) this.start(); // 运行中自动重启 - } -} -``` +这些规则属于 Model/Fleet 领域,不应放进卡片点击事件或 Main IPC。 ---- +## Scheduler:唯一任务状态机 -## barrel re-export +`Scheduler` 组合: -外部导入不需要知道内部拆分: +- `TaskQueue` +- `RepairManager` +- `StopConditionChecker` +- `ExpeditionTimer` +- 任务和修理纯策略 -```typescript -// src/model/scheduler/index.ts -export { Scheduler } from './Scheduler'; -export { CronScheduler } from './CronScheduler'; -export { TaskPriority, type SchedulerTask, type SchedulerStatus } from '../../types/scheduler'; +它拥有: + +- 当前物理轮次。 +- 就绪队列。 +- 延迟和等待任务。 +- 系统和停止状态。 +- 重试和后续轮次推进。 + +任务身份分两层: + +```text +id 一次物理执行轮次 +logicalId 整个多轮逻辑任务 ``` -```typescript -// 外部使用 -import { Scheduler, CronScheduler, TaskPriority } from '../../model/scheduler'; +`buildFollowUpTask()` 创建下一物理轮次时生成新 `id`,但保留 `logicalId`。 + +这使取消、完成和每日额度可以针对整个逻辑任务,而不是误把每轮都当成独立用户 +任务。 + +## Model 通过事件通知,不操作页面 + +Scheduler 使用 `SchedulerCallbacks` 通知 Controller。Controller 的 +`SchedulerBinder` 再更新页面、Cron 和统计。 + +```text +Scheduler 状态变化 + -> SchedulerCallbacks + -> SchedulerBinder + -> Controller 状态 / ViewObject + -> MainView ``` ---- +Model 不调用 `document.*`,也不 import 具体 View。 -## Model 的通信方式 +## Model、Shared 和 Adapter 的区别 -Model 不 import View,通过**回调**通知 Controller: +| 位置 | 适合内容 | +|---|---| +| `src/model/**` | 有领域状态、领域不变量或领域内策略 | +| `src/shared/**` | Renderer/Main 都能用的无状态纯逻辑 | +| `src/adapter/**` | HTTP、WS、IPC、Storage、YAML/JSON 技术边界 | -```typescript -// Scheduler 通过回调通知,不直接操作 UI -scheduler.setCallbacks({ - onStatusChange: (status) => { /* Controller 处理 */ }, - onProgressUpdate: (taskId, progress) => { /* Controller 处理 */ }, - onTaskCompleted: (taskId, success) => { /* Controller 处理 */ }, -}); +例如: + +- `fleetShipTypes.ts` 在 Shared,因为 Main 和 Renderer 都需同一舰种规则。 +- `SchedulerTaskPolicy.ts` 在 Scheduler 子系统,因为规则只服务调度领域。 +- `YamlAdapter.ts` 是技术编解码边界,不拥有 Plan 业务。 + +`ApiClient` 当前位于 Model,但网络传输由 `ApiAdapter` 注入。新增后端字段时仍需 +保持 API DTO 与领域状态分开。 + +## Model 的持久化原则 + +领域 Model 负责: + +- 校验和规范化。 +- 默认值。 +- 未知字段保留。 +- 旧字段兼容。 +- 领域对象到可持久化结构的转换。 + +Main Repository/Service 负责: + +- 文件位置和来源。 +- 读写权限。 +- 原子写入。 +- 导入导出。 +- 多文件事务。 + +Model 不应自行拼 `userData` 路径。 + +## 常见反例 + +- View 持有并修改 `PlanModel.data`。 +- Controller 同时维护一份 Scheduler 队列副本。 +- Repository 决定 candidate-only 业务语义。 +- Shared 模块读取 DOM 或 Electron。 +- 一个“Manager”同时拥有配置、任务和页面状态。 +- 为了复用,把普通舰队和决战草稿合成一个可写对象。 + +## 新增领域规则的步骤 + +1. 确认规则属于哪个领域和状态所有者。 +2. 搜索所有现有实现和兼容分支。 +3. 判断规则需要状态还是可做纯输入输出。 +4. 在唯一所有者或领域策略中实现。 +5. 保持序列化未知字段和旧格式兼容。 +6. 通过 Controller 转换成 VO,不让 View 重复解释。 +7. 添加领域专项测试。 + +## 验证 + +```powershell +npm run test:scheduler-domain +npm run test:fleet-domain +npm run test:settings +npm run test:migrations +npm run test:build +git diff --check ``` diff --git a/docs/teaching/07-type-system.md b/docs/teaching/07-type-system.md index 2f6a1e3..f45ba3b 100644 --- a/docs/teaching/07-type-system.md +++ b/docs/teaching/07-type-system.md @@ -1,192 +1,264 @@ -# 07 — 类型系统分层 +# 07:类型系统分层 -> **前置阅读**:[03-viewobject-flow](03-viewobject-flow.md)、[06-model-layer](06-model-layer.md) -> **核心原则**:类型定义按层分文件 — `model.ts` 给 Model/Controller 用,`view.ts` 给 View/Controller 用,两者不互相引用。 +> 前置阅读:[03 ViewObject 单向数据流](03-viewobject-flow.md)、[06 Model 与领域状态](06-model-layer.md) ---- +TypeScript 类型不仅用于消除编译错误,还用于限制数据穿过哪些边界。API DTO、 +IPC DTO、领域状态、编辑意图和 ViewObject 即使描述同一功能,也不应合并成一个 +万能接口。 -## 目录结构 +## 当前类型目录 -``` +```text src/types/ -├── model.ts 业务领域实体 (Plan, Config, FleetPreset...) -├── view.ts ViewObject 接口 (MainViewObject, TaskQueueItemVO...) -├── api.ts 后端 API 请求/响应类型 -├── scheduler.ts 调度器公共类型 (SchedulerTask, TaskPriority...) -└── electronBridge.ts IPC 桥方法签名 +├─ api.ts +├─ fleetEditor.ts +├─ ipc.ts +├─ model.ts +├─ scheduler.ts +├─ statistics.ts +└─ view.ts ``` ---- - -## 类型流向图 - -``` - 后端 (Python) - │ - ▼ - types/api.ts ← 后端通信契约 (TaskRequest, TaskResult, WsLogMessage) - │ - ▼ - Model 层 ← types/model.ts (PlanData, UserSettings, FleetPreset...) - │ ← types/scheduler.ts (SchedulerTask, TaskPriority...) - │ - ▼ - Controller 层 ← 同时引用 model.ts + view.ts - (拼装 VO) ← 把 Model 类型转换为 View 类型 - │ - ▼ - View 层 ← types/view.ts (MainViewObject, TaskQueueItemVO...) - ← 不允许引用 model.ts 或 api.ts +`ElectronBridge` 和全部 IPC DTO 定义在 `src/types/ipc.ts`。 + +## 类型流向 + +```mermaid +flowchart LR + Backend["AutoWSGR"] --> Api["types/api.ts"] + Main["Electron Main"] --> Ipc["types/ipc.ts"] + Api --> Model["Model"] + Ipc --> Adapter["IpcAdapter"] + Adapter --> Controller + Model --> Controller + Scheduler["types/scheduler.ts"] --> Controller + Intent["types/fleetEditor.ts"] --> Controller + Controller --> ViewType["types/view.ts"] + ViewType --> View ``` ---- +Controller 是主要转换边界,因此可以同时认识领域类型和 ViewObject。View 只 +应看到展示和明确交互所需的类型。 -## 各文件职责 +## api.ts:AutoWSGR 契约 -### types/model.ts — 业务实体 +包含: -对应后端 AutoWSGR 的数据结构,Controller 和 Model 层使用: +- `ApiResponse` +- 游戏上下文和资源响应。 +- `TaskRequest` 联合类型。 +- `TaskResult` 和战斗轮次。 +- WebSocket 消息。 +- `ApiClientCallbacks` + +`TaskRequest` 是联合: ```typescript -export interface PlanData { - chapter: number; - map: number; - selected_nodes: string[]; - node_args?: Record; - fleet_presets?: FleetPreset[]; - times?: number; - stop_condition?: StopCondition; - // ... -} - -export interface UserSettings { - emulator: EmulatorConfig; - account: AccountConfig; - daily_automation: DailyAutomation; -} +export type TaskRequest = + | NormalFightReq + | EventFightReq + | CampaignReq + | ExerciseReq + | DecisiveReq; ``` -### types/view.ts — ViewObject +新增任务字段时必须先确认后端公开契约,再更新 GUI DTO、`ApiClient` 和 API +契约测试。不能用 `Record` 绕过联合类型。 -Controller 拼装后传给 View 的纯数据,View 层唯一认识的类型: +## ipc.ts:跨进程契约 -```typescript -export interface MainViewObject { - status: AppStatus; - statusText: string; // 已转换为中文 - taskQueue: TaskQueueItemVO[]; - wsConnected: boolean; - // ... -} - -export interface NodeViewObject { - id: string; - formation: string; // 已转换为中文阵型名 - nodeType: MapNodeType; - // ... -} -``` +包含: -**设计要点**:VO 字段是**展示友好**的格式,不暴露内部枚举值。 +- `ElectronBridge` +- GUI 配置提交 DTO。 +- 方案、编队、日常方案 DTO。 +- 舰船资料库 DTO。 +- 更新、CUDA、ADB 和窗口 DTO。 -### types/api.ts — 后端 API 契约 +IPC 类型必须可结构化克隆,不能携带 DOM、函数实现、Node Stream 或具体 +Service 实例。 -定义请求/响应类型,只在 Model 层和 Controller 层使用: +调用链: -```typescript -export interface TaskRequest { - type: string; - fleet_id?: number; - // ... -} - -export interface ApiResponse { - success: boolean; - data: T; - error?: string; -} +```text +src/types/ipc.ts + -> electron/preload.ts + -> electron/ipc/** + -> src/adapter/IpcAdapter.ts + -> Controller ``` -### types/scheduler.ts — 调度器公共类型 +新增 Bridge 方法时,这条链路必须同步更新。 -从 `Scheduler.ts` 中提取,供 Controller 和 View 层引用: +## model.ts:领域数据 -```typescript -export enum TaskPriority { - EXPEDITION = 0, - USER_TASK = 10, - DAILY = 20, -} - -export interface SchedulerTask { - id: string; - name: string; - type: SchedulerTaskType; - priority: TaskPriority; - remainingTimes: number; - totalTimes: number; - // ... -} - -export type SchedulerStatus = 'idle' | 'running' | 'stopping' | 'not_connected'; -``` +包含: -### types/electronBridge.ts — IPC 方法签名 +- `UserSettings` +- `GuiAutomationSettings` +- `PlanData` +- `NodeArgs` +- `FleetPreset` +- `StopCondition` +- `TaskPreset` +- 修理和模板领域类型 -定义 `window.electronBridge` 的完整接口,Renderer 进程通过此接口与主进程通信: +这些类型表达领域和持久化含义,不是页面展示结构。 -```typescript -export interface ElectronBridge { - openDirectoryDialog: (title?: string) => Promise; - startBackend: () => Promise<{ success: boolean; message: string }>; - detectEmulator: () => Promise; - getAppVersion: () => string; - getBackendPort: () => number; - // ... 40+ 个方法 -} +例如 `PlanData.node_args` 可以包含节点覆盖,但 View 不应直接读取它来决定显示 +文案。Controller 先生成 `NodeViewObject`。 + +## scheduler.ts:任务状态机契约 + +包含: + +- `TaskPriority` +- `SchedulerTaskType` +- `SchedulerTask` +- `SchedulerStatus` +- `SchedulerWaitingTask` +- 逻辑任务完成/取消原因。 +- `SchedulerCallbacks` + +`SchedulerTask` 同时包含物理 `id` 和逻辑 `logicalId`,这是调度领域不变量,不 +应塞入后端 `TaskRequest`。 + +## fleetEditor.ts:编辑意图 + +包含: + +- `FleetEditorSelection` +- `FleetEditorDragSource` +- `FleetRuleUpdate` +- `FleetDraftEditIntent` +- `FleetDraftEditResult` + +Intent 表达用户要做的动作,Model 类型表达当前状态。两者分开后,View 不需要 +拿到可写草稿对象。 + +例如: + +```text +FleetDraftViewObject 页面看到什么 +FleetDraftEditIntent 用户想改什么 +FleetDraftEditResult 领域是否接受这次修改 ``` ---- +## statistics.ts:统计快照 + +定义战果等级、掉落和 `DailySortieStatsSnapshot`。统计快照可进入 VO,但统计 +累加逻辑仍由 `DailySortieStats` 持有。 + +## view.ts:展示契约 + +包含: + +- `ConfigViewObject` +- `MainViewObject` +- `TaskQueueItemVO` +- Fleet 和 Team Plan ViewObject。 +- `PlanPreviewViewObject` +- 任务组、模板和向导 ViewObject。 + +VO 字段应是 View 能直接渲染的格式: + +- 已转换的文案。 +- 已合并的列表。 +- 明确的 loading/error 状态。 +- 只读展示 identity。 + +VO 不应把完整 Model、Repository 或 API 响应包进去。 + +## 同一概念为何需要多个类型 + +以作战任务为例: + +| 边界 | 类型关注点 | +|---|---| +| API | 后端执行需要的请求字段 | +| Scheduler | 优先级、轮次、重试、logicalId | +| Model | 方案、停止条件和舰队规则 | +| ViewObject | 名称、剩余次数、进度和等待文案 | + +如果全部合成一个 `Task`: + +- 后端会看到 GUI 私有字段。 +- View 会依赖请求内部结构。 +- 持久化兼容字段会污染运行状态。 +- 大量成员只能被标为可选,类型失去约束力。 ## 引用规则 -| 层 | 可引用 | 禁止引用 | -|----|--------|----------| -| View | `types/view.ts` | `types/model.ts`, `types/api.ts` | -| Controller | `types/view.ts`, `types/model.ts`, `types/api.ts`, `types/scheduler.ts` | — | -| Model | `types/model.ts`, `types/api.ts`, `types/scheduler.ts` | `types/view.ts` | -| Electron 主进程 | `types/electronBridge.ts` | `types/view.ts` | +| 消费方 | 主要可引用 | 不应引用 | +|---|---|---| +| View | `view.ts`、明确 Intent、纯共享 DTO | 有状态 Model、ApiClient、Bridge | +| Controller | Model、Scheduler、API、IPC、View 类型 | DOM 实现类型 | +| Model | Model、Scheduler、API 类型 | ViewObject | +| Adapter | API/IPC 契约 | 页面实现 | +| Electron Main | IPC DTO、Shared 契约 | ViewObject 和 DOM | ---- +架构测试会拒绝 Controller 中的 `HTMLElement`、`ResizeObserver` 等 DOM 实现 +类型,也会拒绝 View 导入有状态 Model、ApiClient 或 Adapter。 -## 为什么要分开 +## 新增字段的正确路径 -一个反例:如果 `PlanPreviewView` 直接 import `types/model.ts` 中的 `PlanData`—— +先确定字段属于哪条契约: -```typescript -// ❌ 反例 -import type { PlanData } from '../../types/model'; - -class PlanPreviewView { - render(plan: PlanData): void { - // View 要自己处理 plan.node_args 的格式转换 - // View 要知道 formation 数字对应什么阵型名 - // 后端改了 PlanData 结构,View 也要改 - } -} +### 只影响展示 + +```text +types/view.ts + -> Controller rendering + -> View ``` -正确做法:Controller 完成所有转换,View 只接收 `PlanPreviewViewObject`: +### 后端 API 字段 -```typescript -// ✅ 正确 -import type { PlanPreviewViewObject } from '../../types/view'; - -class PlanPreviewView { - render(vo: PlanPreviewViewObject | null): void { - // vo.mapName 已经是 "7-4" 格式 - // vo.selectedNodes[0].formation 已经是 "单纵" - // View 不需要任何转换逻辑 - } -} +```text +types/api.ts + -> ApiClient + -> 请求构建方 + -> API contract test +``` + +### Electron IPC 字段 + +```text +types/ipc.ts + -> preload + -> Main IPC / Service + -> IpcAdapter / Controller +``` + +### 领域持久化字段 + +```text +types/model.ts + -> Model parse/default/serialize + -> Controller + -> 必要时再生成 VO +``` + +不要因为一个字段最终会显示在页面上,就直接把它同时加入所有类型。 + +## 常见反例 + +- 用 `any` 或双重断言连接不兼容层。 +- 给万能接口堆几十个可选字段。 +- View 直接消费 `ApiResponse`。 +- Main IPC 返回具体 Service 对象。 +- SchedulerTask 直接作为后端 TaskRequest。 +- 为避免转换,令 VO 继承完整 Model 类型。 +- 在 Renderer 和 Main 各复制一份不同的 IPC 接口。 + +## 验证 + +```powershell +npm run build +npm run test:architecture-boundaries +npm run test:main-ipc +npm run test:api-contract +git diff --check ``` + +编译通过只能证明结构类型兼容;API、IPC、持久化和页面语义仍需对应专项测试。 diff --git a/docs/teaching/README.md b/docs/teaching/README.md index 52196f8..1aa04c0 100644 --- a/docs/teaching/README.md +++ b/docs/teaching/README.md @@ -1,21 +1,61 @@ -# 重构实践教学系列 +# 重构与架构实践教学 -> **目标读者**:编写 `main` 分支原始代码的初级工程师 -> **文档定位**:循序渐进的教学,用架构图 + 关键代码片段讲清楚"为什么改、怎么改" +本目录用 AutoWSGR-GUI 的**当前工作区代码**讲解架构设计和安全重构方法, +包括尚未提交或尚未 push 的实现。示例不是历史快照,也不使用旧文件数和旧行数 +描述当前系统。 -> **规范说明**:本目录包含历史重构案例和教学快照,不是合并规范。当前强制要求以[工程与代码规范](../engineering-standards.md)为准。 +## 文档定位 + +- 想知道“项目现在怎么组成”,阅读[架构文档](../architecture/README.md)。 +- 准备修改代码,先遵守项目根目录的 [AGENTS.md](../../AGENTS.md)。 +- 想理解“为什么这样拆、如何判断代码放哪”,阅读本教学系列。 + +可执行配置、测试和生产源码是最终事实来源。代码变化后,教学示例也必须同步 +更新,不能为了匹配文档而改回旧架构。 ## 阅读顺序 -| # | 文档 | 内容 | 建议用时 | -|---|------|------|---------| -| 0 | [全局概览](00-overview.md) | 重构前后数据对比 + 完整架构图 | 10 min | -| 1 | [Extract Class — 拆分大文件](01-extract-class.md) | 按职责边界拆分 3000 行巨型文件 | 15 min | -| 2 | [Host 接口与依赖注入](02-host-interface.md) | 用最小接口替代隐式耦合 | 15 min | -| 3 | [ViewObject 单向数据流](03-viewobject-flow.md) | Model → Controller 拼装 → View 纯渲染 | 10 min | -| 4 | [Electron 主进程拆分](04-electron-split.md) | main.ts → backend / pythonEnv / emulatorDetect | 10 min | -| 5 | [View 层组织](05-view-layer.md) | Facade 模式 + 按功能域分子目录 | 10 min | -| 6 | [Model 层组织](06-model-layer.md) | Scheduler 子系统 + 独立领域模型 | 10 min | -| 7 | [类型系统分层](07-type-system.md) | model / view / api / scheduler 类型隔离 | 10 min | - -**建议路线**:先读 00 → 01 → 02 → 03 了解核心理念,再按需阅读 04-07。 +| # | 文档 | 学习目标 | +|---|---|---| +| 00 | [全局概览](00-overview.md) | 建立进程、分层、数据流和状态所有权认知 | +| 01 | [按职责拆分类](01-extract-class.md) | 判断何时拆分,以及如何保持行为不变 | +| 02 | [Host 接口与依赖注入](02-host-interface.md) | 用最小能力接口解除具体对象耦合 | +| 03 | [ViewObject 单向数据流](03-viewobject-flow.md) | 从 Model 到 View,再把用户意图送回 Controller | +| 04 | [Electron Main 分层](04-electron-split.md) | 区分组合根、IPC、Service、Repository 和 Codec | +| 05 | [View 层组织](05-view-layer.md) | 组织 Facade、局部 View、共享组件、HTML 和 SCSS | +| 06 | [Model 与领域状态](06-model-layer.md) | 确定唯一状态所有者,拆分领域规则和调度策略 | +| 07 | [类型系统分层](07-type-system.md) | 区分 API、IPC、Model、Scheduler、Intent 和 ViewObject | + +建议第一次按 `00 -> 01 -> 02 -> 03` 阅读,再按工作范围选择 `04~07`。 + +## 学习方式 + +每章都按同一顺序使用: + +1. 先看要解决的耦合或状态问题。 +2. 打开章节列出的当前源码。 +3. 理解为什么边界放在这里。 +4. 对照反例判断哪些“拆分”只是在搬代码。 +5. 使用章节末尾命令验证依赖、构建和行为。 + +阅读代码时优先使用: + +```powershell +rg -n "目标类名|目标方法|界面文案" src electron scripts/tests +rg --files src electron scripts/tests +git status --short +``` + +不要只查看 Git HEAD。本项目允许工作区存在尚未提交的连续开发,当前文件才是 +正在生效的实现。 + +## 核心原则 + +1. 按职责和状态所有权拆分,不按行数平均拆分。 +2. Controller 编排用例,但不拥有 DOM 或底层 IPC。 +3. View 拥有 DOM 和局部视觉状态,但不拥有业务状态。 +4. Model 拥有领域状态和规则,不依赖具体 View。 +5. Renderer 外部能力经过 Adapter;Electron 能力经过 preload 和 IPC。 +6. Main 的可测试行为进入 Service,文件来源进入 Repository,格式进入 Codec。 +7. 新抽象必须有真实调用方,并减少实质重复或隔离明确边界。 +8. 拆分前后状态来源、持久化契约、交互和错误语义保持一致。 diff --git a/docs/user-guide.md b/docs/user-guide.md index ef73332..ce7b04f 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -2,51 +2,58 @@ 本文面向日常使用者,按实际界面说明当前版本已实现的功能与操作流程。 +> 对应版本:`2.0.0` 稳定版;更新频道:`latest`。 + ## 1. 页面功能总览 -- 主页 +- 作战 - 显示连接状态、当前任务、远征倒计时。 - 管理任务队列:开始执行、停止、清空。 - - 管理任务列表(任务组):新建/重命名/删除任务组,导入导出任务列表,整组加入队列。 + - 管理任务列表:加载出征计划或日常任务,新建、保存和加载列表,整组加入队列。 - 查看运行日志并按级别过滤。 - 手动执行常用操作:收取远征、收取奖励、收取建造、食堂烹饪、浴室修理。 -- 方案预览 - - 导入 YAML 方案或按地图新建方案。 - - 可视化编辑节点路线与节点参数。 - - 配置编队预设、泡澡修理阈值、任务执行参数。 - - 将方案加入队列或加入任务组。 -- 配置 +- 计划 + - 舰队规划:用舰船资料库维护主选、备选和等级限制,保存独立舰队方案。 + - 出征规划:新建或加载 YAML,关联舰队方案并编辑地图节点。 + - 决战计划(旧):保留现有决战配置的兼容入口。 + - 计划管理:检查系统/用户计划、舰队引用和需要处理的异常状态。 +- 设置 - 模拟器与 ADB 连接设置。 - Python 解释器与后端端口设置。 - 自动更新设置。 - - 每日自动任务开关。 + - 自动任务和自动强化预留策略。 - 主题、主色调与调试模式。 ## 2. 首次使用建议 -1. 打开配置页,先完成模拟器类型和 ADB 串号设置。 +1. 打开“设置 > 系统设置”,先完成模拟器类型和 ADB 地址设置。 2. 点击检测 ADB,确认状态为可连接。 3. 检查 Python 环境(默认可留空自动检测)。 -4. 点击保存配置。 -5. 回到主页,确认状态从“未连接”转为可用。 +4. 点击“保存设置”。 +5. 回到“作战”,确认右上角运行状态从“未连接”转为可用。 + +### 2.1 从旧版本升级与回退 -## 3. 新建任务配置流程(从方案预览页开始) +- 1.4.x 用户可以使用 2.0.0 安装包覆盖升级。安装器会先将旧设置、任务列表、 + 模板和用户计划保存到 + `%LOCALAPPDATA%\AutoWSGR-GUI\legacy-upgrade`;确认迁移结果前不要删除该目录。 +- Alpha 与稳定版使用不同频道。包括 `2.0.16-alpha` 在内的 Alpha 客户端不会自动 + 切换到 `latest`,需要手动运行 2.0.0 稳定版安装包。 +- 如需回退,请先退出 2.0,使用旧安装器重新安装,再从上述备份恢复旧格式数据。 + 不要让旧版直接写入或覆盖唯一的 2.0 `userData`。 -### 3.1 进入方案预览页 +## 3. 新建任务配置流程(从“计划 > 出征规划”开始) -1. 点击顶部导航中的“方案预览”。 -2. 在空白页可看到两个入口: - - 选择 yaml 文件(导入现有方案)。 - - 新建方案(按章节和地图生成新方案)。 +### 3.1 进入出征规划 + +1. 点击顶部导航中的“计划”,再选择“出征规划”。 +2. 点击“加载”可以读取已有 YAML,点击“新建”可以按章节和地图创建方案。 ### 3.2 选择方案来源 -1. 如果你已有 YAML 方案,点击“选择 yaml 文件”导入。 -2. 如果要新建,点击“新建方案”。 -3. 在弹窗中配置: - - 海域(第 1~9 章或 Ex 系列)。 - - 地图(普通章节 1~6,Ex 章节 1~12)。 -4. 点击“确定”后进入方案详情编辑区。 +1. 如果已有 YAML,点击“加载”;外部文件可在加载窗口中选择“添加本地 YAML”。 +2. 如果要新建,点击“新建”,再选择章节和地图。 +3. 完成后进入方案详情编辑区。 ### 3.3 配置方案级设置 @@ -113,39 +120,181 @@ - 索敌规则(每行一条,支持 and 组合) 3. 点击“应用”保存节点设置。 -索敌规则示例: +#### 索敌规则是做什么的 + +进入索敌画面并识别敌方舰队后,系统根据索敌规则决定: + +- 使用什么阵型战斗。 +- 撤退并结束本轮出击。 +- 在可以迂回的节点跳过本次战斗。 + +索敌规则只判断敌方舰种和数量,不判断己方舰队,也不负责选择地图路线。 +如果没有任何规则满足条件,系统使用该节点原本设置的阵型和行为。 + +地图上的“自定义规则”标记表示该节点单独保存过设置。修改阵型、夜战、前进或 +索敌规则中的任意一项,都可能出现这个标记;它不表示索敌规则一定已经填写。 + +#### 最简单的填写方法 + +每行填写一条规则,格式为: + +```text +看到什么敌人, 采取什么动作 +``` + +例如: + +```text +CV >= 1, retreat +``` + +意思是:敌方有一艘或更多航母时撤退。 + +推荐统一使用英文逗号分隔。界面也支持中文逗号、`=>` 和 `->`: + +```text +CV >= 1,撤退 +SS >= 1 => 5 +AP < 1 -> detour +``` + +空行会被忽略。 + +#### 可以填写的动作 + +| 填写内容 | 实际效果 | +|----------|----------| +| `1` | 使用单纵阵战斗 | +| `2` | 使用复纵阵战斗 | +| `3` | 使用轮型阵战斗 | +| `4` | 使用梯形阵战斗 | +| `5` | 使用单横阵战斗 | +| `retreat` 或 `撤退` | 撤退,结束本轮出击 | +| `detour` 或 `迂回` | 点击迂回,跳过当前战斗并继续走图 | + +`撤退`只结束当前这一轮出击,不等于停止整个任务队列。如果任务还有剩余执行 +次数,后续轮次仍可能继续执行。 + +`迂回`只应写在地图本身支持迂回的节点。普通节点无法执行迂回,可能导致任务 +报错。 + +#### 可以填写的判断条件 + +支持以下比较方式: + +| 写法 | 含义 | +|------|------| +| `CV > 0` | 航母数量大于 0 | +| `SS >= 1` | 潜艇数量大于等于 1 | +| `CVL == 1` | 轻母数量正好为 1 | +| `BB != 2` | 战列数量不等于 2 | +| `DD + CL >= 3` | 驱逐和轻巡总数大于等于 3 | +| `SS >= 1 and CV == 0` | 有潜艇,并且没有航母 | +| `ALL == 6` | 敌方舰船总数为 6 | + +支持的比较符为: + +```text +> >= < <= == != +``` + +多个条件只能用 `and` 连接,不支持 `or`。舰种代号必须使用下表中的大写形式。 + +#### 敌方舰种代号 + +| 代号 | 舰种 | 代号 | 舰种 | +|------|------|------|------| +| `CV` | 航母 | `CVL` | 轻母 | +| `AV` | 装母 | `BB` | 战列 | +| `BBV` | 航战 | `BC` | 战巡 | +| `CA` | 重巡 | `CAV` | 航巡 | +| `CLT` | 雷巡 | `CL` | 轻巡 | +| `BM` | 重炮 | `DD` | 驱逐 | +| `SS` | 潜艇 | `SSG` | 导潜 | +| `SC` | 炮潜 | `AP` | 补给 | +| `ASDG` | 导驱 | `AADG` | 防驱 | +| `KP` | 导巡 | `CG` | 防巡 | +| `CBG` | 大巡 | `BBG` | 导战 | +| `ALL` | 敌方舰船总数 | | | + +补给舰推荐写 `AP`,旧写法 `NAP` 也能识别。大巡必须写 `CBG`,不要写 `BG`。 +`ss_or_ssg`、中文舰种名称以及其他缩写不能用于索敌规则。 + +#### 多条规则怎么判断 + +系统从上往下判断,第一条满足条件的规则生效,后面的规则不再判断。 + +```text +CV >= 1, retreat +SS >= 1, 5 +ALL >= 0, 2 +``` + +这三条规则表示: + +1. 有航母时撤退。 +2. 没有航母但有潜艇时,使用单横阵。 +3. 其他情况使用复纵阵。 + +因此,范围越小、越重要的规则应放在上面,兜底规则应放在最后。`ALL >= 0` +永远成立,可以作为兜底条件。 + +#### 常用场景 + +只在发现补给舰时战斗,否则迂回: + +```text +AP >= 1, 4 +AP < 1, detour +``` + +发现航母就撤退: + +```text +CV >= 1, retreat +``` + +发现潜艇或导潜时使用单横阵: + +```text +SS + SSG >= 1, 5 +``` + +同时满足两个条件才使用梯形阵: ```text -DD >= 1, 4 DD >= 1 and CL >= 2, 4 ``` +#### 当前版本使用建议 + +- 想让规则决定“战斗还是迂回”时,关闭节点自身的“迂回”开关,再用 + `AP < 1, detour` 这样的规则明确指定何时迂回。 +- 如果方案设置了全局默认索敌规则,节点输入框清空后仍可能继续继承全局规则。 + 需要让该节点始终按固定阵型战斗时,可填写 `ALL >= 0, 阵型编号`,例如 + `ALL >= 0, 4`。 +- 不要使用单个等号,例如 `CV = 1`;判断相等必须写成 `CV == 1`。 +- 不要填写中文舰种名称,例如“航母 >= 1”。应写成 `CV >= 1`。 +- 条件格式不正确时,界面可能仍能保存,但任务加入队列或开始运行时会被后端 + 拒绝。遇到这种情况,先检查舰种代号、比较符和动作。 + ### 3.7 保存并投递任务 编辑完成后,你可以选择: -- 加入队列:立即进入主页队列等待执行。 +- 加入队列:立即进入“作战”页的执行队列等待执行。 - 加入任务组:保存到任务列表,便于后续重复使用。 - 保存 YAML:覆盖当前方案文件。 - 另存为:保存为新文件。 -## 4. 模板库创建与使用 - -方案预览页右侧“模板库”支持创建可复用任务模板。 +## 4. 复用计划与任务列表 -1. 点击“创建模板”进入三步向导。 -2. 第一步:选择模板类型(普通出击 / 演习 / 战役 / 决战)。 -3. 第二步:按类型填写参数。 - - 普通出击:可选方案文件(可多选)、编队、可选舰船编队。 - - 演习:演习舰队、可选舰船编队。 - - 战役:战役类型、可选舰船编队。 - - 决战:章节、level1、level2、旗舰优先级、是否启用快修。 -4. 第三步:填写模板名称、默认执行次数、停止条件(可选)。 -5. 保存后可在模板项中点击“加入列表”,加入主页任务列表。 +GUI 2.0 当前没有独立的可见模板库入口。旧模板数据仍用于兼容;日常复用应在 +“计划”页保存舰队和出征计划,再到“作战”页将计划加入任务列表并保存列表。 -## 5. 主页执行任务 +## 5. 作战页执行任务 -1. 切回主页。 +1. 切回“作战”。 2. 在任务列表中选择任务组,可进行: - 全部加入队列 - 从文件添加 @@ -154,7 +303,7 @@ DD >= 1 and CL >= 2, 4 3. 在任务队列中点击“开始执行”。 4. 运行中可点击“停止”终止当前执行。 -## 6. 配置页可配置项清单 +## 6. 设置页可配置项清单 ### 6.1 模拟器配置 @@ -180,15 +329,23 @@ DD >= 1 and CL >= 2, 4 ### 6.5 自动化设置 +- 自动强化策略预留 - 自动远征(含检查间隔) -- 自动战役(战役类型、次数) +- 自动战役(战役类型;每日固定完成 8 次) - 自动演习(演习舰队) - 自动常规出击 -- 自动决战(保留票数、模板) +- 自动决战(决战模板) - 每日自动刷战利品(地图方案、停止数量) +2.0.0 的自动强化只保存策略,不加入 Scheduler,生产路径为零后端调用,也不会 +操作舰船。“功能说明”和“尚未开放”按钮均不会发起强化请求。 + +自动决战每天最多执行一轮。模板中填写的舰队优先;模板舰队留空时,使用 +“决战计划”页当前保存的舰队和快修设置。GUI 不读取剩余票数,也不会根据 +旧版“票数保留”字段推算执行轮数。任务一旦实际结束,当天不会再次自动触发。 + ## 7. 常见建议 - 调整后端端口或 Python 路径后,建议重启 GUI 再执行任务。 -- 任务经常复用时,优先使用模板库和任务组,减少重复配置。 +- 任务经常复用时,优先保存计划和任务列表,减少重复配置。 - 节点规则建议先小范围测试,再用于高次数任务。 diff --git a/electron/backend.ts b/electron/backend.ts deleted file mode 100644 index 3597f89..0000000 --- a/electron/backend.ts +++ /dev/null @@ -1,426 +0,0 @@ -/** - * 后端服务管理(启动 / 停止 / setup.bat)。 - * 从 main.ts 提取。 - */ -import * as path from 'path'; -import * as fs from 'fs'; -import { execSync, spawn, ChildProcess } from 'child_process'; -import type { BrowserWindow } from 'electron'; -import { ensurePthFile, ensureSslCertForPython, findPython, localSitePackages } from './pythonEnv'; -import { buildResourceEnvironment, SHIP_LIBRARY_ENV, shipLibraryRoot } from './resourcePaths'; - -// ════════════════════════════════════════ -// Context — 由 main.ts 在启动时注入 -// ════════════════════════════════════════ - -export interface BackendContext { - appRoot: () => string; - resourceRoot: () => string; - BACKEND_PORT: number; - getMainWindow: () => BrowserWindow | null; -} - -let ctx: BackendContext; - -export function initBackend(context: BackendContext): void { - ctx = context; -} - -// ════════════════════════════════════════ -// 内部状态 -// ════════════════════════════════════════ - -let backendProcess: ChildProcess | null = null; - -export function getBackendProcess(): ChildProcess | null { - return backendProcess; -} - -type OcrGpuMode = 'auto' | 'cpu' | 'cuda'; - -function readGuiSettings(): Record { - try { - const settingsPath = path.join(ctx.appRoot(), 'gui_settings.json'); - if (!fs.existsSync(settingsPath)) return {}; - return JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as Record; - } catch { - return {}; - } -} - -function readBackendRepoOverrideFromSettings(): string | null { - const raw = readGuiSettings(); - const value = raw.backend_repo_path; - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function readOcrGpuModeFromSettings(): OcrGpuMode { - const raw = readGuiSettings(); - const value = raw.ocr_gpu_mode; - if (value === 'cpu' || value === 'cuda') return value; - return 'auto'; -} - -function normalizeCudaRoot(candidate: string): string { - const resolved = path.resolve(candidate.trim()); - if (isCudaRuntimeDirectory(resolved)) return resolved; - return path.basename(resolved).toLowerCase() === 'bin' ? path.dirname(resolved) : resolved; -} - -function isCudaRuntimeDirectory(candidate: string): boolean { - try { - const names = fs.readdirSync(candidate); - return names.some(name => /^cudart64.*\.dll$/i.test(name)) - && names.some(name => /^cublas64.*\.dll$/i.test(name)); - } catch { - return false; - } -} - -function readCudaPathFromSettings(): string | null { - const raw = readGuiSettings(); - const value = raw.cuda_path; - if (typeof value !== 'string' || !value.trim()) return null; - const cudaRoot = normalizeCudaRoot(value); - const binDir = path.join(cudaRoot, 'bin'); - const runtimeDir = isCudaRuntimeDirectory(cudaRoot) - ? cudaRoot - : isCudaRuntimeDirectory(binDir) - ? binDir - : null; - if (!fs.existsSync(path.join(binDir, 'nvcc.exe')) && !runtimeDir) { - console.warn(`[Backend] 忽略 cuda_path(未找到 Toolkit 或 CUDA Runtime DLL): ${cudaRoot}`); - return null; - } - return fs.existsSync(path.join(binDir, 'nvcc.exe')) ? cudaRoot : runtimeDir; -} - -/** 构造后端 CUDA 环境;手动路径优先,留空保留系统自动检测。 */ -export function buildCudaEnvironment( - baseEnv: NodeJS.ProcessEnv, - configuredCudaRoot: string | null, -): NodeJS.ProcessEnv { - if (!configuredCudaRoot) return { ...baseEnv }; - const cudaRoot = normalizeCudaRoot(configuredCudaRoot); - const isToolkit = fs.existsSync(path.join(cudaRoot, 'bin', 'nvcc.exe')); - const cudaBin = isToolkit ? path.join(cudaRoot, 'bin') : cudaRoot; - const existingPath = baseEnv.PATH || baseEnv.Path || ''; - const pathEntries = existingPath.split(path.delimiter).filter(Boolean); - const withoutDuplicate = pathEntries.filter(entry => path.resolve(entry).toLowerCase() !== path.resolve(cudaBin).toLowerCase()); - const env: NodeJS.ProcessEnv = { ...baseEnv }; - for (const key of Object.keys(env)) { - if (key.toLowerCase() === 'path') delete env[key]; - } - if (isToolkit) { - env.CUDA_PATH = cudaRoot; - env.CUDA_HOME = cudaRoot; - } - env.PATH = [cudaBin, ...withoutDuplicate].join(path.delimiter); - - let version: string | null = null; - try { - const versionJson = path.join(cudaRoot, 'version.json'); - if (fs.existsSync(versionJson)) { - const raw = JSON.parse(fs.readFileSync(versionJson, 'utf-8').replace(/^\uFEFF/, '')) as Record; - version = raw.cuda?.version ?? raw.cuda_cudart?.version ?? null; - } - } catch { /* use directory name fallback */ } - version ??= path.basename(cudaRoot).match(/v(\d+(?:\.\d+)?)/i)?.[1] ?? null; - const versionMatch = version?.match(/^(\d+)\.(\d+)/); - if (isToolkit && versionMatch) { - env[`CUDA_PATH_V${versionMatch[1]}_${versionMatch[2]}`] = cudaRoot; - } - return env; -} - -function readSaveBackendScreenshotsFromSettings(): boolean { - const raw = readGuiSettings(); - return raw.save_backend_screenshots === true; -} - -function resolveLocalBackendRepoPath(): string | null { - const fromEnv = process.env.AUTOWSGR_BACKEND_REPO?.trim(); - const fromSettings = readBackendRepoOverrideFromSettings(); - const candidate = fromEnv || fromSettings; - if (!candidate) return null; - - let resolved = path.resolve(candidate); - if (!fs.existsSync(resolved)) { - console.warn(`[Backend] 忽略 backend_repo_path(路径不存在): ${resolved}`); - return null; - } - - // 允许直接填写包目录 .../autowsgr - const looksLikePkgDir = fs.existsSync(path.join(resolved, '__init__.py')) && fs.existsSync(path.join(resolved, 'server', 'main.py')); - if (looksLikePkgDir && path.basename(resolved).toLowerCase() === 'autowsgr') { - resolved = path.dirname(resolved); - } - - if (!fs.existsSync(path.join(resolved, 'autowsgr', 'server', 'main.py'))) { - console.warn(`[Backend] 忽略 backend_repo_path(未找到 autowsgr/server/main.py): ${resolved}`); - return null; - } - - return resolved; -} - -// ════════════════════════════════════════ -// 后端服务 -// ════════════════════════════════════════ - -/** 运行 setup.bat 安装环境 */ -export function runSetupScript(): Promise<{ success: boolean; output: string }> { - return new Promise((resolve) => { - // 打包模式下 setup.bat 在 extraResources 里 - let setupPath = path.join(ctx.resourceRoot(), 'setup.bat'); - if (!fs.existsSync(setupPath)) { - setupPath = path.join(ctx.appRoot(), 'setup.bat'); - } - if (!fs.existsSync(setupPath)) { - resolve({ success: false, output: '找不到 setup.bat' }); - return; - } - - const proc = spawn('cmd.exe', ['/c', setupPath], { - cwd: ctx.appRoot(), - windowsHide: false, - stdio: 'pipe', - }); - - let output = ''; - proc.stdout?.on('data', (data: Buffer) => { - const text = data.toString(); - output += text; - ctx.getMainWindow()?.webContents.send('setup-log', text); - }); - proc.stderr?.on('data', (data: Buffer) => { - const text = data.toString(); - output += text; - ctx.getMainWindow()?.webContents.send('setup-log', text); - }); - proc.on('close', (code) => { - resolve({ success: code === 0, output: output.slice(-1000) }); - }); - proc.on('error', (err) => { - resolve({ success: false, output: err.message }); - }); - }); -} - -export async function startBackend(): Promise { - ensurePthFile(); - const pythonCmd = await findPython(); - if (!pythonCmd) { - console.error('[Backend] 找不到 Python'); - return; - } - - const certFile = await ensureSslCertForPython(pythonCmd); - if (certFile) console.log(`[Backend] TLS cert: ${certFile}`); - else console.warn('[Backend] WARNING 未检测到 TLS 根证书,HTTPS 请求可能失败'); - - const cwd = ctx.appRoot(); - const localSite = localSitePackages(); - const guiSettings = readGuiSettings(); - const backendStartupMode = guiSettings.backend_startup_mode === 'external' ? 'external' : 'managed'; - const localBackendRepo = backendStartupMode === 'external' ? resolveLocalBackendRepoPath() : null; - const ocrGpuMode = readOcrGpuModeFromSettings(); - const configuredCudaRoot = readCudaPathFromSettings(); - const saveBackendScreenshots = readSaveBackendScreenshotsFromSettings(); - - const pyLiteral = (value: string): string => value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); - - // 使用 -c 启动而非 -m uvicorn,以便: - // 1. 显式注入 site-packages 到 sys.path - // 2. 激活 setuptools 的 distutils 兼容层 (Python 3.12+ 需要) - // 3. 绕过嵌入式 Python 的 ._pth/PYTHONPATH 限制 - const bootstrapParts = [ - `import sys, os, site`, - `sp = r'${pyLiteral(localSite)}'`, - `sys.path.insert(0, sp)`, - `site.addsitedir(sp)`, // 处理 .pth 文件,激活 _distutils_hack - ...(localBackendRepo ? [`repo = r'${pyLiteral(localBackendRepo)}'`, `sys.path.insert(0, repo)`] : []), - `GUI_OCR_GPU_MODE = '${ocrGpuMode}'`, - `GUI_SAVE_IMAGES = ${saveBackendScreenshots ? 'True' : 'False'}`, - `import autowsgr`, - `print('[Bootstrap] autowsgr=' + getattr(autowsgr, '__file__', 'unknown'))`, - `print('[Bootstrap] repo_override=' + (r'${pyLiteral(localBackendRepo ?? '')}' or ''))`, - `print('[Bootstrap] ocr_gpu_mode=' + GUI_OCR_GPU_MODE)`, - `print('[Bootstrap] save_backend_screenshots=' + ('true' if GUI_SAVE_IMAGES else 'false'))`, - `from pathlib import Path`, - `import autowsgr.infra.logger as _aw_logger`, - `from autowsgr.scheduler import launcher as _aw_launcher`, - `import autowsgr.vision.ocr as _aw_ocr`, - `_orig_load_config = _aw_launcher.Launcher.load_config`, - `_orig_save_image = _aw_logger.save_image`, - `_orig_create = _aw_ocr.OCREngine.create.__func__`, - `_cuda_cache = None`, - `def _detect_cuda():`, - ` global _cuda_cache`, - ` if _cuda_cache is not None:`, - ` return _cuda_cache`, - ` try:`, - ` import torch`, - ` print('[Bootstrap] torch=' + str(getattr(torch, '__version__', 'unknown')))`, - ` print('[Bootstrap] torch_cuda_build=' + str(getattr(getattr(torch, 'version', None), 'cuda', None)))`, - ` _cuda_cache = bool(torch.cuda.is_available())`, - ` except Exception:`, - ` _cuda_cache = False`, - ` return _cuda_cache`, - `def _resolve_gpu_mode():`, - ` if GUI_OCR_GPU_MODE == 'cuda':`, - ` if not _detect_cuda():`, - ` raise RuntimeError('已强制使用 CUDA,但当前 PyTorch/驱动未检测到可用 CUDA;请检查 CUDA 路径、CUDA 版 PyTorch 与 NVIDIA 驱动')`, - ` return True`, - ` if GUI_OCR_GPU_MODE == 'cpu':`, - ` return False`, - ` return _detect_cuda()`, - `if GUI_OCR_GPU_MODE == 'cpu':`, - ` print('[Bootstrap] cuda_available=skipped(cpu mode)')`, - `else:`, - ` print('[Bootstrap] cuda_available=' + ('true' if _detect_cuda() else 'false'))`, - `def _patched_create(cls, engine='easyocr', gpu=False, mirror='tencent'):`, - ` use_gpu = gpu`, - ` if str(engine).lower() == 'easyocr':`, - ` use_gpu = _resolve_gpu_mode()`, - ` return _orig_create(cls, engine=engine, gpu=use_gpu, mirror=mirror)`, - `_aw_ocr.OCREngine.create = classmethod(_patched_create)`, - `def _patched_load_config(self):`, - ` cfg = _orig_load_config(self)`, - ` if GUI_SAVE_IMAGES:`, - ` try:`, - ` log_dir = getattr(cfg.log, 'dir', None)`, - ` if log_dir is not None:`, - ` img_dir = Path(log_dir) / 'images'`, - ` img_dir.mkdir(parents=True, exist_ok=True)`, - ` _aw_logger._image_dir = img_dir`, - ` _aw_logger.logger.info('[GUI] 截图保存目录: {}', img_dir)`, - ` except Exception as _e:`, - ` _aw_logger.logger.warning('[GUI] 截图目录初始化失败: {}', _e)`, - ` else:`, - ` _aw_logger._image_dir = None`, - ` return cfg`, - `_aw_launcher.Launcher.load_config = _patched_load_config`, - `def _patched_save_image(image, tag='screenshot', img_dir=None):`, - ` if not GUI_SAVE_IMAGES:`, - ` return None`, - ` target_dir = img_dir or getattr(_aw_logger, '_image_dir', None)`, - ` if target_dir is None:`, - ` return None`, - ` return _orig_save_image(image, tag=tag, img_dir=target_dir)`, - `_aw_logger.save_image = _patched_save_image`, - `import uvicorn`, - `uvicorn.run('autowsgr.server.main:app', host='127.0.0.1', port=${ctx.BACKEND_PORT})`, - ]; - - const bootstrap = bootstrapParts.join('\n'); - const mainWindow = ctx.getMainWindow(); - if (localBackendRepo) { - console.log(`[Backend] 使用本地后端仓库: ${localBackendRepo}`); - mainWindow?.webContents.send('backend-log', `[GUI] 使用本地后端仓库: ${localBackendRepo}`); - } else { - mainWindow?.webContents.send('backend-log', '[GUI] 未启用本地后端仓库覆盖,使用 site-packages 中的 autowsgr'); - } - mainWindow?.webContents.send('backend-log', `[GUI] OCR 加速模式: ${ocrGpuMode}`); - mainWindow?.webContents.send('backend-log', `[GUI] CUDA 路径: ${configuredCudaRoot ?? '系统自动检测'}`); - mainWindow?.webContents.send('backend-log', `[GUI] 保存识别异常截图: ${saveBackendScreenshots ? '开启' : '关闭'}`); - - // 将内置 ADB 目录加入 PATH,使后端 shutil.which('adb') 能找到 - const adbDir = path.join(ctx.appRoot(), 'adb'); - const cudaEnv = buildCudaEnvironment(process.env, configuredCudaRoot); - const backendEnv = buildResourceEnvironment(cudaEnv, ctx.resourceRoot()); - const envPath = cudaEnv.PATH || ''; - const pathWithAdb = fs.existsSync(adbDir) ? `${adbDir};${envPath}` : envPath; - console.log(`[Backend] ${SHIP_LIBRARY_ENV}=${shipLibraryRoot(ctx.resourceRoot())}`); - - // 预连接 ADB 设备(MuMu 多开实例不会自动被 ADB 发现,需要主动 connect) - try { - const cfgPath = path.join(ctx.appRoot(), 'usersettings.yaml'); - if (fs.existsSync(cfgPath)) { - const cfgText = fs.readFileSync(cfgPath, 'utf-8'); - const serialMatch = cfgText.match(/serial:\s*(\S+)/); - if (serialMatch) { - const serial = serialMatch[1]; - const adbExe = path.join(adbDir, 'adb.exe'); - const adbCmd = fs.existsSync(adbExe) ? adbExe : 'adb'; - execSync(`"${adbCmd}" connect ${serial}`, { windowsHide: true, timeout: 5000, stdio: 'pipe' }); - console.log(`[Backend] ADB connect ${serial} 完成`); - } - } - } catch (e: any) { - console.warn(`[Backend] ADB connect 失败 (非致命): ${e.message}`); - } - - backendProcess = spawn(pythonCmd, [ - '-X', 'utf8', - '-c', bootstrap, - ], { - cwd, - windowsHide: true, - stdio: 'pipe', - env: { - ...backendEnv, - PYTHONUTF8: '1', - PYTHONIOENCODING: 'utf-8', - PATH: pathWithAdb, - }, - }); - - // ANSI 颜色码 - const CYAN = '\x1b[36m'; - const RED = '\x1b[31m'; - const YELLOW = '\x1b[33m'; - const GREEN = '\x1b[32m'; - const DIM = '\x1b[2m'; - const RESET = '\x1b[0m'; - - const colorLine = (line: string): string => { - if (/\bERROR\b/i.test(line)) return `${RED}${line}${RESET}`; - if (/\bWARNING\b/i.test(line)) return `${YELLOW}${line}${RESET}`; - if (/\bINFO\b/i.test(line)) return `${GREEN}${line}${RESET}`; - if (/\bDEBUG\b/i.test(line)) return `${DIM}${line}${RESET}`; - return `${CYAN}${line}${RESET}`; - }; - - // loguru 新日志行以 "HH:mm:ss.SSS |" 开头 - const LOGURU_LINE_RE = /^\d{2}:\d{2}:\d{2}\.\d{3}\s*\|/; - let skipMultiline = false; - - const handleOutput = (data: Buffer) => { - for (const line of data.toString('utf-8').split('\n')) { - const trimmed = line.trim(); - if (!trimmed) continue; - console.log(`${CYAN}[Backend]${RESET} ${colorLine(trimmed)}`); - - const isNewEntry = LOGURU_LINE_RE.test(trimmed); - if (isNewEntry) { - // 新日志条目:判断级别,决定是否跳过后续续行 - skipMultiline = /\bDEBUG\b/i.test(trimmed); - } - // 跳过 DEBUG 级别的日志(包括其多行续行) - if (skipMultiline) continue; - // 跳过 uvicorn access log - if (/"(?:GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD)\s+\//.test(trimmed)) continue; - mainWindow?.webContents.send('backend-log', trimmed); - } - }; - backendProcess.stdout?.on('data', handleOutput); - backendProcess.stderr?.on('data', handleOutput); - backendProcess.on('error', (err) => { - console.error('[Backend] 启动失败:', err.message); - backendProcess = null; - }); - backendProcess.on('close', (code) => { - console.log(`[Backend] 进程退出, code=${code}`); - backendProcess = null; - }); -} - -export function stopBackend(): void { - if (backendProcess) { - backendProcess.kill(); - backendProcess = null; - } -} diff --git a/electron/emulatorDetect.ts b/electron/emulatorDetect.ts index 8392539..03ab73b 100644 --- a/electron/emulatorDetect.ts +++ b/electron/emulatorDetect.ts @@ -1,6 +1,5 @@ /** - * 模拟器自动检测 (Windows 注册表)。 - * 从 main.ts 提取,无外部依赖。 + * 通过 Windows 注册表检测已安装的模拟器。 */ import * as path from 'path'; import * as fs from 'fs'; @@ -13,13 +12,14 @@ export interface EmulatorDetectResult { adbPath: string; } +/** 读取注册表中的单个值。 */ export function readRegistryValue(keyPath: string, valueName: string): string | null { try { const output = execSync( `reg query "${keyPath}" /v "${valueName}"`, { encoding: 'utf-8', windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }, ); - // 输出格式: " ValueName REG_SZ Value" + // reg query 输出格式为“名称、类型、值”。 const match = output.match(new RegExp(`${valueName}\\s+REG_\\w+\\s+(.+)`)); return match ? match[1].trim() : null; } catch { @@ -27,6 +27,7 @@ export function readRegistryValue(keyPath: string, valueName: string): string | } } +/** 读取注册表下的直接子键。 */ export function readRegistrySubKeys(keyPath: string): string[] { try { const output = execSync( @@ -42,10 +43,11 @@ export function readRegistrySubKeys(keyPath: string): string[] { } } +/** 按 MuMu、雷电、蓝叠的顺序检测模拟器。 */ export function detectEmulator(): EmulatorDetectResult | null { if (process.platform !== 'win32') return null; - // ── MuMu 12 ── + // MuMu 12 // 用单次 reg query /s 递归搜索 Uninstall 下的 UninstallString, // 再从输出中筛选含 MuMu 的条目,避免逐键启动子进程。 const uninstallBase = 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'; @@ -54,11 +56,11 @@ export function detectEmulator(): EmulatorDetectResult | null { `reg query "${uninstallBase}" /s /v UninstallString`, { encoding: 'utf-8', windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }, ); - // 输出格式: 键路径行 + 空行 + " UninstallString REG_SZ value" + 空行 ... + // 输出由注册表键路径和 UninstallString 值交替组成。 for (const line of output.split('\n')) { const trimmed = line.trim(); if (trimmed.startsWith('HKEY')) { - // 键路径行: 当前实现不需要使用具体键名, 仅保留以便未来扩展或调试 + // 当前检测不需要具体键名。 continue; } if (/UninstallString/i.test(trimmed) && /MuMu/i.test(trimmed)) { @@ -80,9 +82,9 @@ export function detectEmulator(): EmulatorDetectResult | null { } } } - } catch { /* Uninstall 注册表扫描失败, 继续检测其他模拟器 */ } + } catch { /* 扫描失败时继续检测其他模拟器。 */ } - // ── 雷电模拟器 ── + // 雷电模拟器 try { const leidianSubs = readRegistrySubKeys('HKLM\\SOFTWARE\\leidian'); for (const subKey of leidianSubs) { @@ -100,9 +102,9 @@ export function detectEmulator(): EmulatorDetectResult | null { } } } - } catch { /* 未安装 */ } + } catch { /* 未安装时继续检测。 */ } - // ── 蓝叠 ── + // 蓝叠 for (const regKey of ['HKLM\\SOFTWARE\\BlueStacks_nxt_cn', 'HKLM\\SOFTWARE\\BlueStacks_nxt']) { const installDir = readRegistryValue(regKey, 'InstallDir'); if (installDir) { diff --git a/electron/ipc/BackendIpc.ts b/electron/ipc/BackendIpc.ts new file mode 100644 index 0000000..7c60356 --- /dev/null +++ b/electron/ipc/BackendIpc.ts @@ -0,0 +1,38 @@ +/** + * 连接后端进程 IPC 与 BackendService。 + */ +import type { ChildProcess } from 'child_process'; +import type { IpcRegistrar } from './IpcRegistrar'; + +export interface BackendIpcDependencies { + getBackendProcess(): ChildProcess | null; + startBackend(): Promise; + runSetupScript(): Promise; +} + +/** 注册后端安装脚本和启动 IPC。 */ +export function registerBackendIpc( + ipc: IpcRegistrar, + dependencies: BackendIpcDependencies, +): void { + ipc.handle('run-setup', async () => { + return dependencies.runSetupScript(); + }); + + ipc.handle('start-backend', async () => { + if (dependencies.getBackendProcess()) { + return { success: true, message: '后端已在运行' }; + } + try { + await dependencies.startBackend(); + return { success: true, message: '后端启动中' }; + } catch (error) { + return { + success: false, + message: error instanceof Error + ? error.message + : String(error), + }; + } + }); +} diff --git a/electron/ipc/CombatPlanIpc.ts b/electron/ipc/CombatPlanIpc.ts new file mode 100644 index 0000000..98b7a40 --- /dev/null +++ b/electron/ipc/CombatPlanIpc.ts @@ -0,0 +1,242 @@ +/** + * 连接作战计划 IPC、文件对话框和计划服务。 + */ +import type { + MessageBoxOptions, + OpenDialogOptions, + SaveDialogOptions, +} from 'electron'; +import type { PlanManagementService } from '../services/PlanManagementService'; +import type { PlanExportService } from '../services/PlanExportService'; +import type { SafePathService } from '../services/SafePathService'; +import type { PlanPresetSource } from '../services/TeamPlanCodec'; +import type { IpcRegistrar } from './IpcRegistrar'; + +export interface CombatPlanDialogAdapter { + showOpenDialog(options: OpenDialogOptions): Promise<{ + canceled: boolean; + filePaths: string[]; + }>; + showMessageBox(options: MessageBoxOptions): Promise<{ + response: number; + }>; + showSaveDialog(options: SaveDialogOptions): Promise<{ + canceled: boolean; + filePath?: string; + }>; +} + +export interface CombatPlanIpcDependencies { + dialog: CombatPlanDialogAdapter; + safePaths: SafePathService; + plans: PlanManagementService; + planExports: PlanExportService; +} + +/** 注册作战计划管理和运行时准备 IPC。 */ +export function registerCombatPlanIpc( + ipc: IpcRegistrar, + dependencies: CombatPlanIpcDependencies, +): void { + ipc.handle('get-plan-management', () => { + return dependencies.plans.get(); + }); + + ipc.handle('export-user-plans', async (_event, selections: unknown) => { + try { + const archive = await dependencies.planExports.createArchive( + selections, + ); + const selected = await dependencies.dialog.showSaveDialog({ + title: '批量导出用户配置', + defaultPath: dependencies.planExports.archiveFileName(), + filters: [{ + name: 'ZIP 压缩包', + extensions: ['zip'], + }], + }); + if (selected.canceled || !selected.filePath) { + return { success: false, canceled: true }; + } + dependencies.planExports.writeArchive(selected.filePath, archive); + return { + success: true, + path: selected.filePath, + count: archive.count, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }); + + ipc.handle('export-legacy-143-plans', async (_event, selections: unknown) => { + try { + const archive = await dependencies.planExports.createLegacy143Archive( + selections, + ); + const selected = await dependencies.dialog.showSaveDialog({ + title: '导出 1.4.3 降级计划备份', + defaultPath: 'AutoWSGR-GUI-1.4.3-plans-backup.zip', + filters: [{ name: 'ZIP 压缩包', extensions: ['zip'] }], + }); + if (selected.canceled || !selected.filePath) { + return { success: false, canceled: true }; + } + dependencies.planExports.writeArchive(selected.filePath, archive); + return { + success: true, + path: selected.filePath, + count: archive.count, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }); + + ipc.handle('import-local-combat-plan', async () => { + try { + const selected = await dependencies.dialog.showOpenDialog({ + title: '添加本地出征计划', + properties: ['openFile'], + filters: [{ + name: '出征计划 YAML', + extensions: ['yaml', 'yml'], + }], + }); + if (selected.canceled || selected.filePaths.length === 0) { + return { success: false, canceled: true }; + } + + const selectedPath = selected.filePaths[0]; + const result = dependencies.plans.importLocal( + selectedPath, + false, + ); + if (result.exists !== true) return result; + + const conflicts = Array.isArray(result.conflicts) + ? result.conflicts.filter( + (value): value is string => typeof value === 'string', + ) + : []; + const confirmation = await dependencies.dialog.showMessageBox({ + type: 'warning', + title: '覆盖用户配置', + message: '导入目标存在同名配置,是否覆盖?', + detail: conflicts.join('\n'), + buttons: ['取消', '覆盖'], + defaultId: 0, + cancelId: 0, + noLink: true, + }); + if (confirmation.response !== 1) { + return { success: false, canceled: true }; + } + return dependencies.plans.importLocal(selectedPath, true); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }); + + ipc.handle( + 'set-plan-unlinked-ignored', + ( + _event, + kind: 'battle' | 'team', + source: PlanPresetSource, + file: string, + ignored: boolean, + ) => { + return dependencies.plans.setUnlinkedIgnored( + kind, + source, + file, + ignored, + ); + }, + ); + + ipc.handle( + 'read-managed-combat-plan', + (_event, source: PlanPresetSource, file: string) => { + return dependencies.plans.readManaged(source, file); + }, + ); + + ipc.handle( + 'read-combat-plan-file', + (_event, rawPath: string) => { + try { + if (typeof rawPath !== 'string' || !rawPath.trim()) { + throw new Error('出征计划路径不能为空'); + } + const resolved = dependencies.safePaths.resolveAppPath( + rawPath, + ); + return dependencies.plans.readResolvedFile(resolved); + } catch (error) { + return { + success: false, + error: error instanceof Error + ? error.message + : String(error), + }; + } + }, + ); + + ipc.handle( + 'prepare-combat-plan-execution', + (_event, content: string, hint: string) => { + return dependencies.plans.prepareExecution(content, hint); + }, + ); + + ipc.handle( + 'save-managed-combat-plan', + ( + _event, + rawName: string, + content: string, + overwrite: boolean, + currentFile?: string, + ) => { + return dependencies.plans.saveManaged( + rawName, + content, + overwrite, + currentFile, + ); + }, + ); + + ipc.handle( + 'rename-user-combat-plan', + (_event, file: string, newName: string) => { + return dependencies.plans.renameUser(file, newName); + }, + ); + + ipc.handle( + 'delete-user-combat-plan', + (_event, file: string) => { + return dependencies.plans.deleteUserCombat(file); + }, + ); + + ipc.handle( + 'delete-user-team-plan', + (_event, file: string) => { + return dependencies.plans.deleteUserTeam(file); + }, + ); +} diff --git a/electron/ipc/ConfigurationIpc.ts b/electron/ipc/ConfigurationIpc.ts new file mode 100644 index 0000000..c454b98 --- /dev/null +++ b/electron/ipc/ConfigurationIpc.ts @@ -0,0 +1,201 @@ +/** + * 连接 GUI 配置 IPC 与配置、环境和窗口服务。 + */ +import type { CudaEnvironmentService } from '../services/CudaEnvironmentService'; +import type { + BackendStartupMode, + GuiAutomationSettings, + GuiConfigurationService, + OcrGpuMode, + UpdateMode, +} from '../services/GuiConfigurationService'; +import type { PythonEnvironmentService } from '../services/PythonEnvironmentService'; +import type { + WindowPreferences, + WindowService, +} from '../services/WindowService'; +import type { + LegacyDecisiveAutomationSettings, +} from '../../src/shared/legacyDecisiveAutomation'; +import type { + DecisivePlanSettings, +} from '../../src/shared/decisivePlan'; +import type { + GuiSettingsCommitRequest, +} from '../../src/types/ipc'; +import type { + GuiSettingsCommitService, +} from '../services/GuiSettingsCommitService'; +import type { IpcRegistrar } from './IpcRegistrar'; + +export interface ConfigurationIpcDependencies { + getAppVersion(): string; + backendPort: number; + configuration: GuiConfigurationService; + settingsCommit: GuiSettingsCommitService; + cudaEnvironment: CudaEnvironmentService; + pythonEnvironment: PythonEnvironmentService; + windows: WindowService; +} + +/** 注册同步配置 getter 和异步配置操作。 */ +export function registerConfigurationIpc( + ipc: IpcRegistrar, + dependencies: ConfigurationIpcDependencies, +): void { + const configuration = dependencies.configuration; + + ipc.on('get-app-version-sync', (event) => { + event.returnValue = dependencies.getAppVersion(); + }); + + ipc.on('get-backend-port-sync', (event) => { + event.returnValue = dependencies.backendPort; + }); + + ipc.on('get-backend-startup-mode-sync', (event) => { + event.returnValue = configuration.backendStartupMode(); + }); + + ipc.on('get-backend-repo-path-sync', (event) => { + event.returnValue = configuration.backendRepoPath(); + }); + + ipc.on('get-ocr-gpu-mode-sync', (event) => { + event.returnValue = configuration.ocrGpuMode(); + }); + + ipc.on('get-cuda-path-sync', (event) => { + event.returnValue = configuration.cudaPath(); + }); + + ipc.on('get-save-backend-screenshots-sync', (event) => { + event.returnValue = configuration.saveBackendScreenshots(); + }); + + ipc.on('get-window-preferences-sync', (event) => { + event.returnValue = dependencies.windows.getPreferences(); + }); + + ipc.handle( + 'set-window-preferences', + (_event, preferences: Partial) => { + return dependencies.windows.setPreferences(preferences); + }, + ); + + ipc.handle('get-gui-automation-settings', () => { + return configuration.automation(); + }); + + ipc.handle( + 'set-gui-automation-settings', + (_event, settings: GuiAutomationSettings) => { + return configuration.setAutomation(settings); + }, + ); + + ipc.handle( + 'commit-gui-settings', + (_event, settings: GuiSettingsCommitRequest) => ( + dependencies.settingsCommit.commitAtomic(settings) + ), + ); + + ipc.handle( + 'migrate-legacy-decisive-automation', + (_event, settings: LegacyDecisiveAutomationSettings) => { + return configuration.migrateLegacyDecisiveAutomation(settings); + }, + ); + + ipc.handle('get-decisive-plan-settings', () => { + return configuration.decisivePlan(); + }); + + ipc.handle( + 'set-decisive-plan-settings', + (_event, settings: DecisivePlanSettings) => { + return configuration.setDecisivePlan(settings); + }, + ); + + ipc.handle('set-backend-port', (_event, port: number) => { + configuration.setBackendPort(port); + }); + + ipc.handle( + 'set-backend-startup-mode', + (_event, mode: BackendStartupMode) => { + configuration.setBackendStartupMode(mode); + }, + ); + + ipc.handle( + 'set-backend-repo-path', + (_event, repoPath: string | null) => { + configuration.setBackendRepoPath(repoPath); + }, + ); + + ipc.handle( + 'set-ocr-gpu-mode', + (_event, mode: OcrGpuMode) => { + configuration.setOcrGpuMode(mode); + }, + ); + + ipc.handle( + 'set-cuda-path', + (_event, cudaPath: string | null) => { + configuration.setCudaPath(cudaPath); + }, + ); + + ipc.handle( + 'validate-cuda-path', + async (_event, cudaPath: string) => { + return dependencies.cudaEnvironment.detect(cudaPath); + }, + ); + + ipc.handle( + 'set-save-backend-screenshots', + (_event, enabled: boolean) => { + configuration.setSaveBackendScreenshots(enabled); + }, + ); + + ipc.on('get-python-path-sync', (event) => { + event.returnValue = configuration.configuredPythonPath(); + }); + + ipc.on('get-update-mode-sync', (event) => { + event.returnValue = configuration.updateMode(); + }); + + ipc.on('get-allow-test-updates-sync', (event) => { + event.returnValue = configuration.allowTestUpdates(); + }); + + ipc.handle( + 'set-update-mode', + (_event, mode: UpdateMode) => { + configuration.setUpdateMode(mode); + }, + ); + + ipc.handle( + 'set-python-path', + (_event, pythonPath: string | null) => { + configuration.setPythonPath(pythonPath); + }, + ); + + ipc.handle( + 'validate-python', + async (_event, pythonPath: string) => { + return dependencies.pythonEnvironment.validate(pythonPath); + }, + ); +} diff --git a/electron/ipc/DailyPlanIpc.ts b/electron/ipc/DailyPlanIpc.ts new file mode 100644 index 0000000..84608be --- /dev/null +++ b/electron/ipc/DailyPlanIpc.ts @@ -0,0 +1,50 @@ +/** + * 连接日常任务计划服务与渲染进程。 + */ +import type { DailyPlanService } from '../services/DailyPlanService'; +import type { GuiConfigurationService } from '../services/GuiConfigurationService'; +import type { + DecisivePlanSettings, +} from '../../src/shared/decisivePlan'; +import type { IpcRegistrar } from './IpcRegistrar'; + +export interface DailyPlanIpcDependencies { + dailyPlans: DailyPlanService; + configuration: GuiConfigurationService; +} + +/** 注册日常任务列表、读取和按章节保存决战配置的 IPC。 */ +export function registerDailyPlanIpc( + ipc: IpcRegistrar, + dependencies: DailyPlanIpcDependencies, +): void { + ipc.handle('list-daily-plans', () => { + return dependencies.dailyPlans.list(); + }); + + ipc.handle( + 'read-daily-plan', + (_event, source: 'system' | 'user', file: string) => { + return dependencies.dailyPlans.read(source, file); + }, + ); + + ipc.handle('get-daily-decisive-plan', (_event, chapter: number) => { + return dependencies.dailyPlans.decisivePlan(chapter); + }); + + ipc.handle( + 'get-system-daily-decisive-plan', + (_event, chapter: number) => { + return dependencies.dailyPlans.systemDecisivePlan(chapter); + }, + ); + + ipc.handle( + 'save-daily-decisive-plan', + (_event, settings: DecisivePlanSettings) => { + const saved = dependencies.dailyPlans.saveDecisivePlan(settings); + return dependencies.configuration.setDecisivePlan(saved); + }, + ); +} diff --git a/electron/ipc/DeviceIpc.ts b/electron/ipc/DeviceIpc.ts new file mode 100644 index 0000000..47dea2f --- /dev/null +++ b/electron/ipc/DeviceIpc.ts @@ -0,0 +1,43 @@ +/** + * 连接模拟器和 ADB 设备 IPC。 + */ +import type { AdbService } from '../services/AdbService'; +import type { IpcRegistrar } from './IpcRegistrar'; + +export interface DeviceIpcDependencies { + adb: AdbService; + detectEmulator(): unknown; +} + +/** 注册模拟器检测和 ADB 设备操作 IPC。 */ +export function registerDeviceIpc( + ipc: IpcRegistrar, + dependencies: DeviceIpcDependencies, +): void { + ipc.handle('detect-emulator', async () => { + return dependencies.detectEmulator(); + }); + + ipc.handle('check-adb-devices', async () => { + try { + return await dependencies.adb.listDevices(); + } catch (error) { + console.warn('[ADB] 设备查询失败:', error); + return []; + } + }); + + ipc.handle( + 'connect-adb-device', + async (_event, serial: string) => { + return dependencies.adb.runDeviceCommand('connect', serial); + }, + ); + + ipc.handle( + 'disconnect-adb-device', + async (_event, serial: string) => { + return dependencies.adb.runDeviceCommand('disconnect', serial); + }, + ); +} diff --git a/electron/ipc/EnvironmentIpc.ts b/electron/ipc/EnvironmentIpc.ts new file mode 100644 index 0000000..62fb75b --- /dev/null +++ b/electron/ipc/EnvironmentIpc.ts @@ -0,0 +1,23 @@ +/** + * 连接 Python 环境 IPC 与 PythonEnvironmentService。 + */ +import type { PythonEnvironmentService } from '../services/PythonEnvironmentService'; +import type { IpcRegistrar } from './IpcRegistrar'; + +/** 注册 Python 环境检查和安装 IPC。 */ +export function registerEnvironmentIpc( + ipc: IpcRegistrar, + pythonEnvironment: PythonEnvironmentService, +): void { + ipc.handle('check-environment', async () => { + return await pythonEnvironment.check(); + }); + + ipc.handle('install-deps', async () => { + return pythonEnvironment.installDependencies(); + }); + + ipc.handle('install-portable-python', async () => { + return pythonEnvironment.installPortablePython(); + }); +} diff --git a/electron/ipc/FileIpc.ts b/electron/ipc/FileIpc.ts new file mode 100644 index 0000000..a3950a9 --- /dev/null +++ b/electron/ipc/FileIpc.ts @@ -0,0 +1,140 @@ +/** + * 连接文件、路径和目录 IPC 与安全文件服务。 + */ +import * as fs from 'fs'; +import type { FileFilter } from 'electron'; +import type { CombatPlanRepository } from '../services/CombatPlanRepository'; +import type { SafePathService } from '../services/SafePathService'; +import type { SecureFileService } from '../services/SecureFileService'; +import type { IpcRegistrar } from './IpcRegistrar'; + +export interface FileDialogAdapter { + showOpenDialog(options: { + properties: Array<'openFile' | 'openDirectory'>; + title?: string; + defaultPath?: string; + filters?: FileFilter[]; + }): Promise<{ canceled: boolean; filePaths: string[] }>; + showSaveDialog(options: { + defaultPath?: string; + filters?: FileFilter[]; + }): Promise<{ + canceled: boolean; + filePath?: string; + }>; +} + +export interface FolderAdapter { + openPath(folderPath: string): Promise; +} + +export interface FileIpcDependencies { + dialog: FileDialogAdapter; + shell: FolderAdapter; + secureFiles: SecureFileService; + safePaths: SafePathService; + combatPlans: CombatPlanRepository; + appRoot(): string; + userDataRoot(): string; +} + +/** 注册文件、路径和目录相关 IPC。 */ +export function registerFileIpc( + ipc: IpcRegistrar, + dependencies: FileIpcDependencies, +): void { + ipc.handle('open-directory-dialog', async (_event, title?: string) => { + const result = await dependencies.dialog.showOpenDialog({ + properties: ['openDirectory'], + title: title || '选择文件夹', + }); + if (result.canceled || result.filePaths.length === 0) return null; + return result.filePaths[0]; + }); + + ipc.handle( + 'open-file-dialog', + async ( + _event, + filters: FileFilter[], + defaultDir?: string, + ) => { + const result = await dependencies.dialog.showOpenDialog({ + properties: ['openFile'], + defaultPath: defaultDir || undefined, + filters, + }); + if (result.canceled || result.filePaths.length === 0) { + return null; + } + const filePath = result.filePaths[0]; + const content = dependencies.secureFiles.readSelectedFile( + filePath, + ); + return { path: filePath, content }; + }, + ); + + ipc.handle( + 'save-file', + async (_event, filePath: string, content: string) => { + dependencies.secureFiles.save(filePath, content); + }, + ); + + ipc.handle( + 'save-file-dialog', + async ( + _event, + defaultName: string, + content: string, + filters: FileFilter[], + ) => { + const result = await dependencies.dialog.showSaveDialog({ + defaultPath: defaultName, + filters, + }); + if (result.canceled || !result.filePath) return null; + dependencies.secureFiles.writeSelectedFile( + result.filePath, + content, + ); + return result.filePath; + }, + ); + + ipc.handle('read-file', async (_event, filePath: string) => { + return dependencies.secureFiles.read(filePath); + }); + + ipc.handle( + 'append-file', + async (_event, filePath: string, content: string) => { + dependencies.secureFiles.append(filePath, content); + }, + ); + + ipc.handle('get-app-root', () => dependencies.appRoot()); + + ipc.handle('get-plans-dir', () => { + return dependencies.combatPlans.directory('user'); + }); + + ipc.handle('list-plan-files', () => { + return dependencies.combatPlans.listUserFiles(); + }); + + ipc.handle('get-config-dir', () => dependencies.userDataRoot()); + + ipc.handle( + 'open-folder', + async (_event, folderPath: string) => { + const resolved = dependencies.safePaths.resolveWritablePath( + folderPath, + ); + if (fs.existsSync(resolved)) { + await dependencies.shell.openPath(resolved); + } + }, + ); +} diff --git a/electron/ipc/IpcRegistrar.ts b/electron/ipc/IpcRegistrar.ts new file mode 100644 index 0000000..5583a51 --- /dev/null +++ b/electron/ipc/IpcRegistrar.ts @@ -0,0 +1,6 @@ +/** + * 定义 IPC Adapter 使用的最小注册接口。 + */ +import type { IpcMain } from 'electron'; + +export type IpcRegistrar = Pick; diff --git a/electron/ipc/MigrationConflictIpc.ts b/electron/ipc/MigrationConflictIpc.ts new file mode 100644 index 0000000..cbdf3d0 --- /dev/null +++ b/electron/ipc/MigrationConflictIpc.ts @@ -0,0 +1,19 @@ +/** + * 向 Renderer 暴露迁移冲突清单及受限处理入口。 + */ +import type { + MigrationConflictService, +} from '../services/MigrationConflictService'; +import type { IpcRegistrar } from './IpcRegistrar'; + +/** 注册迁移冲突读取和确认处理 IPC。 */ +export function registerMigrationConflictIpc( + ipc: IpcRegistrar, + conflicts: MigrationConflictService, +): void { + ipc.handle('get-migration-conflicts', () => conflicts.pending()); + ipc.handle( + 'resolve-migration-conflicts', + (_event, keepIds: unknown) => conflicts.resolve(keepIds), + ); +} diff --git a/electron/ipc/ShipLibraryIpc.ts b/electron/ipc/ShipLibraryIpc.ts new file mode 100644 index 0000000..d72a9e7 --- /dev/null +++ b/electron/ipc/ShipLibraryIpc.ts @@ -0,0 +1,37 @@ +/** + * 连接舰船资料库 IPC 与查询、更新服务。 + */ +import type { + ShipLibraryService, + ShipLibraryStatus, +} from '../services/ShipLibraryService'; +import type { ShipLibraryUpdater } from '../services/ShipLibraryUpdater'; +import type { IpcRegistrar } from './IpcRegistrar'; + +export interface ShipLibraryIpcDependencies { + library: ShipLibraryService; + updater: ShipLibraryUpdater; + getStatus?(): ShipLibraryStatus | Promise; +} + +/** 注册舰船资料库读取和更新 IPC。 */ +export function registerShipLibraryIpc( + ipc: IpcRegistrar, + dependencies: ShipLibraryIpcDependencies, +): void { + ipc.handle('get-ship-library-status', async () => { + if (dependencies.getStatus) return await dependencies.getStatus(); + return dependencies.library.getStatus(); + }); + + ipc.handle('get-ship-library-manifest', () => { + return dependencies.library.getManifest(); + }); + + ipc.handle('update-ship-library', async (_event, target?: unknown) => { + if (target === 'backend') { + return await dependencies.updater.syncBackend(); + } + return await dependencies.updater.update(); + }); +} diff --git a/electron/ipc/TeamPlanIpc.ts b/electron/ipc/TeamPlanIpc.ts new file mode 100644 index 0000000..6cc3fe8 --- /dev/null +++ b/electron/ipc/TeamPlanIpc.ts @@ -0,0 +1,59 @@ +/** + * 连接编队计划 IPC、文件对话框和编队服务。 + */ +import type { PlanPresetSource } from '../services/TeamPlanCodec'; +import type { TeamPlanRepository } from '../services/TeamPlanRepository'; +import type { TeamPlanService } from '../services/TeamPlanService'; +import type { FileDialogAdapter } from './FileIpc'; +import type { IpcRegistrar } from './IpcRegistrar'; + +export interface TeamPlanIpcDependencies { + dialog: FileDialogAdapter; + repository: TeamPlanRepository; + service: TeamPlanService; +} + +/** 注册编队保存、选择和列表 IPC。 */ +export function registerTeamPlanIpc( + ipc: IpcRegistrar, + dependencies: TeamPlanIpcDependencies, +): void { + ipc.handle( + 'save-user-team-plan', + ( + _event, + rawPlan: unknown, + overwrite: boolean, + currentFile?: string, + rawSource?: PlanPresetSource, + ) => { + return dependencies.service.save( + rawPlan, + overwrite, + currentFile, + rawSource, + ); + }, + ); + + ipc.handle('pick-user-team-plan', async () => { + const directory = dependencies.repository.directory('user'); + const result = await dependencies.dialog.showOpenDialog({ + title: '加载编队预设', + defaultPath: directory, + properties: ['openFile'], + filters: [{ + name: '编队 YAML', + extensions: ['yaml', 'yml'], + }], + }); + if (result.canceled || result.filePaths.length === 0) { + return { success: false, canceled: true }; + } + return dependencies.service.loadSelected(result.filePaths[0]); + }); + + ipc.handle('list-team-plans', () => { + return dependencies.service.list(); + }); +} diff --git a/electron/ipc/UpdaterIpc.ts b/electron/ipc/UpdaterIpc.ts new file mode 100644 index 0000000..543ba8b --- /dev/null +++ b/electron/ipc/UpdaterIpc.ts @@ -0,0 +1,301 @@ +/** + * 连接 GUI 自动更新 IPC 与 electron-updater。 + */ +import * as path from 'path'; +import { + autoUpdater, + type Logger, + type UpdateDownloadedEvent, + type UpdateFileInfo, + type UpdateInfo, +} from 'electron-updater'; +import type { IpcRegistrar } from './IpcRegistrar'; +import type { + GuiUpdateStateStore, +} from '../services/GuiUpdateStateStore'; +import { + classifyGuiUpdateCheck, + resolveGuiUpdateSelectionPolicy, + validateGuiUpdateCandidate, +} from '../services/GuiUpdatePolicy'; + +type GuiUpdateCheckResult = + | { status: 'available'; version: string } + | { status: 'up-to-date' } + | { status: 'error'; message: string }; + +export interface UpdaterContext { + sendToRenderer(channel: string, ...args: unknown[]): boolean; + getAppVersion(): string; + allowTestUpdates(): boolean; + logger: Logger; + updateStates: GuiUpdateStateStore; + chooseDownload( + version: string, + ): Promise<'now' | 'later'>; + chooseRestartTiming( + version: string, + ): Promise<'restart' | 'next-launch'>; + installDownloadedUpdate(): Promise; +} + +function updateFileName(file: UpdateFileInfo): string { + try { + const url = new URL(file.url, 'https://update.invalid'); + return path.basename(decodeURIComponent(url.pathname)); + } catch { + return path.basename(file.url); + } +} + +function downloadedFileMetadata( + info: UpdateDownloadedEvent, +): { + sha512: string; + isAdminRightsRequired: boolean; +} { + const downloadedName = path.basename(info.downloadedFile); + const file = info.files.find( + candidate => updateFileName(candidate) === downloadedName, + ) ?? info.files[0]; + return { + sha512: file?.sha512 ?? info.sha512, + isAdminRightsRequired: + file?.isAdminRightsRequired === true, + }; +} + +/** 注册 GUI 更新检查;下载和安装决定均由主进程系统弹窗控制。 */ +export function registerUpdaterIpc( + ipc: IpcRegistrar, + context: UpdaterContext, +): void { + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = false; + autoUpdater.allowDowngrade = false; + autoUpdater.logger = context.logger; + + let updatePolicy = resolveGuiUpdateSelectionPolicy( + context.getAppVersion(), + context.allowTestUpdates(), + ); + const applyUpdatePolicy = (): void => { + updatePolicy = resolveGuiUpdateSelectionPolicy( + context.getAppVersion(), + context.allowTestUpdates(), + ); + autoUpdater.setFeedURL({ + provider: 'github', + owner: updatePolicy.repository.owner, + repo: updatePolicy.repository.repo, + channel: updatePolicy.channel, + }); + autoUpdater.channel = updatePolicy.channel; + autoUpdater.allowDowngrade = false; + autoUpdater.allowPrerelease = updatePolicy.allowPrerelease; + }; + applyUpdatePolicy(); + + let approvedUpdateVersion: string | null = null; + let declinedUpdateVersion: string | null = null; + let downloadPromise: Promise | null = null; + let checkPromise: Promise | null = null; + let choosingRestartTiming = false; + + const reportError = (message: string): void => { + context.logger.error(message); + context.sendToRenderer('update-status', { + status: 'error', + message, + }); + }; + + const beginDownload = (version: string): Promise => { + if (downloadPromise) return downloadPromise; + context.logger.info( + `User approved background download for GUI v${version}`, + ); + context.sendToRenderer('update-status', { + status: 'downloading', + }); + downloadPromise = autoUpdater.downloadUpdate() + .then(() => undefined) + .catch((error: unknown) => { + const message = error instanceof Error + ? error.message + : String(error); + reportError(message); + }) + .finally(() => { + downloadPromise = null; + }); + return downloadPromise; + }; + + const offerDownload = async (version: string): Promise => { + const pending = context.updateStates.read(); + if (pending?.targetVersion === version) { + context.sendToRenderer('update-status', { + status: 'deferred', + version, + }); + return; + } + if (declinedUpdateVersion === version || downloadPromise) return; + + const choice = await context.chooseDownload(version); + if (choice === 'later') { + declinedUpdateVersion = version; + context.logger.info( + `User deferred GUI v${version} download until next launch`, + ); + return; + } + void beginDownload(version); + }; + + const offerRestart = async (version: string): Promise => { + if (choosingRestartTiming) return; + choosingRestartTiming = true; + try { + const timing = await context.chooseRestartTiming(version); + if (timing === 'restart') { + context.sendToRenderer('update-status', { + status: 'installing', + message: '正在安全停止任务并准备后台更新', + }); + await context.installDownloadedUpdate(); + return; + } + context.logger.info( + `GUI v${version} will install before next window opens`, + ); + context.sendToRenderer('update-status', { + status: 'deferred', + version, + }); + } catch (error) { + const message = error instanceof Error + ? error.message + : String(error); + reportError(`GUI 更新安装准备失败:${message}`); + } finally { + choosingRestartTiming = false; + } + }; + + autoUpdater.on('checking-for-update', () => { + context.sendToRenderer('update-status', { + status: 'checking', + }); + }); + autoUpdater.on('update-available', (info: UpdateInfo) => { + const mismatch = validateGuiUpdateCandidate( + updatePolicy, + info.version, + ); + if (mismatch) { + approvedUpdateVersion = null; + reportError(mismatch); + return; + } + approvedUpdateVersion = info.version; + context.sendToRenderer('update-status', { + status: 'available', + version: info.version, + releaseNotes: typeof info.releaseNotes === 'string' + ? info.releaseNotes + : '', + }); + }); + autoUpdater.on('update-not-available', () => { + approvedUpdateVersion = null; + context.sendToRenderer('update-status', { + status: 'up-to-date', + }); + }); + autoUpdater.on( + 'update-downloaded', + (info: UpdateDownloadedEvent) => { + const mismatch = validateGuiUpdateCandidate( + updatePolicy, + info.version, + ); + if (mismatch) { + reportError(mismatch); + return; + } + try { + const metadata = downloadedFileMetadata(info); + if (!metadata.sha512) { + throw new Error('更新元数据缺少 SHA-512 校验值'); + } + context.updateStates.saveDownloaded({ + sourceVersion: context.getAppVersion(), + targetVersion: info.version, + downloadedFile: info.downloadedFile, + sha512: metadata.sha512, + isAdminRightsRequired: + metadata.isAdminRightsRequired, + }); + context.logger.info( + `GUI v${info.version} downloaded and persisted: ` + + info.downloadedFile, + ); + context.sendToRenderer('update-status', { + status: 'downloaded', + version: info.version, + }); + void offerRestart(info.version); + } catch (error) { + const message = error instanceof Error + ? error.message + : String(error); + reportError(message); + } + }, + ); + autoUpdater.on('error', (error: Error) => { + approvedUpdateVersion = null; + reportError(error.message); + }); + + ipc.handle('check-gui-updates', async () => { + if (checkPromise) return checkPromise; + checkPromise = (async (): Promise => { + try { + applyUpdatePolicy(); + const result = await autoUpdater.checkForUpdates(); + const classified = classifyGuiUpdateCheck( + updatePolicy, + result, + ); + approvedUpdateVersion = classified.status === 'available' + ? classified.version + : null; + if ( + classified.status === 'available' + && approvedUpdateVersion + ) { + await offerDownload(approvedUpdateVersion); + } + return classified; + } catch (error) { + approvedUpdateVersion = null; + const message = error instanceof Error + ? error.message + : String(error); + reportError(message); + return { + status: 'error', + message, + }; + } + })(); + try { + return await checkPromise; + } finally { + checkPromise = null; + } + }); +} diff --git a/electron/main.ts b/electron/main.ts index 85fde38..b7a5653 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,633 +1,733 @@ /** - * Electron 主进程。 - * 负责创建窗口、注册 IPC handler。 + * 组装主进程服务、注册 IPC,并管理 Electron 生命周期。 */ -import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron'; +import { app, BrowserWindow, ipcMain, dialog, screen, shell } from 'electron'; import * as path from 'path'; -import * as fs from 'fs'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import { autoUpdater, UpdateInfo, ProgressInfo } from 'electron-updater'; import { initPythonEnv, clearPythonCache, isAllowedPythonVersion, findPython, checkEnvironment, - checkForUpdates, installDependencies, installPortablePython, - pullUpdates, + installDependencies, installPortablePython, + backendShipNamesPath, } from './pythonEnv'; import { detectEmulator } from './emulatorDetect'; -import { initBackend, getBackendProcess, startBackend, stopBackend, runSetupScript } from './backend'; - -const execAsync = promisify(exec); - -/** GUI 设置文件路径(延迟到 app ready 后才有效,先用函数) */ -function guiSettingsPath(): string { - return path.join(appRoot(), 'gui_settings.json'); +import { + initBackend, + getBackendProcess, + startBackend, + stopBackend, + runSetupScript, +} from './services/BackendService'; +import { AppPaths } from './services/AppPaths'; +import { AtomicFileStore } from './services/AtomicFileStore'; +import { GuiSettingsStore } from './services/GuiSettingsStore'; +import { SafePathService } from './services/SafePathService'; +import { SecureFileService } from './services/SecureFileService'; +import { WindowService } from './services/WindowService'; +import { SingleInstanceService } from './services/SingleInstanceService'; +import { + GuiUpdateStateStore, +} from './services/GuiUpdateStateStore'; +import { + resolveGuiUpdateSelectionPolicy, + validateGuiUpdateCandidate, +} from './services/GuiUpdatePolicy'; +import { GuiUpdateInstaller } from './services/GuiUpdateInstaller'; +import { GuiUpdaterLogger } from './services/GuiUpdaterLogger'; +import { + DEFAULT_LEGACY_MIGRATION_SELECTION, + UserDataMigrationService, + type LegacyMigrationSelection, +} from './services/UserDataMigrationService'; +import { + LegacyMigrationPrompt, +} from './services/LegacyMigrationPrompt'; +import { MigrationStateStore } from './services/MigrationStateStore'; +import { + LEGACY_PLAN_MIGRATION_STAGE, + LegacyPlanMigration, +} from './services/LegacyPlanMigration'; +import { + MigrationConflictService, +} from './services/MigrationConflictService'; +import { + emptyLegacyMigrationSummary, + mergeLegacyMigrationSummaries, +} from './services/LegacyMigrationSummary'; +import { + buildLegacyMigrationNotice, +} from './services/LegacyMigrationNotice'; +import { + TeamPlanCodec, + type UserTeamPlan, +} from './services/TeamPlanCodec'; +import { TeamPlanRepository } from './services/TeamPlanRepository'; +import { TeamPlanService } from './services/TeamPlanService'; +import { CombatPlanCodec } from './services/CombatPlanCodec'; +import { CombatPlanRepository } from './services/CombatPlanRepository'; +import { RuntimePlanService } from './services/RuntimePlanService'; +import { PlanManagementService } from './services/PlanManagementService'; +import { PlanExportService } from './services/PlanExportService'; +import { TaskPresetCodec } from '../src/shared/taskPreset'; +import { DailyPlanService } from './services/DailyPlanService'; +import { ShipLibraryService } from './services/ShipLibraryService'; +import { ShipLibraryUpdater } from './services/ShipLibraryUpdater'; +import { ShipNameSynchronizer } from './services/ShipNameSynchronizer'; +import { AdbService } from './services/AdbService'; +import { CudaEnvironmentService } from './services/CudaEnvironmentService'; +import { GuiConfigurationService } from './services/GuiConfigurationService'; +import { + GuiSettingsCommitService, +} from './services/GuiSettingsCommitService'; +import { PythonEnvironmentService } from './services/PythonEnvironmentService'; +import { registerBackendIpc } from './ipc/BackendIpc'; +import { registerCombatPlanIpc } from './ipc/CombatPlanIpc'; +import { registerConfigurationIpc } from './ipc/ConfigurationIpc'; +import { registerDailyPlanIpc } from './ipc/DailyPlanIpc'; +import { registerDeviceIpc } from './ipc/DeviceIpc'; +import { registerEnvironmentIpc } from './ipc/EnvironmentIpc'; +import { registerFileIpc } from './ipc/FileIpc'; +import { + registerMigrationConflictIpc, +} from './ipc/MigrationConflictIpc'; +import { registerShipLibraryIpc } from './ipc/ShipLibraryIpc'; +import { registerTeamPlanIpc } from './ipc/TeamPlanIpc'; +import { registerUpdaterIpc } from './ipc/UpdaterIpc'; + +/** 启动终端关闭输出管道时,不让 EPIPE 终止 GUI 主进程。 */ +function ignoreBrokenPipe(stream: NodeJS.WriteStream): void { + stream.on('error', (error: NodeJS.ErrnoException) => { + if (error.code !== 'EPIPE') throw error; + }); } -/** 读取 GUI 设置 */ -function readGuiSettings(): Record { +ignoreBrokenPipe(process.stdout); +ignoreBrokenPipe(process.stderr); + +const singleInstanceService = new SingleInstanceService(app); +const isPrimaryInstance = singleInstanceService.acquire(); + +const appPaths = new AppPaths({ + moduleDirectory: __dirname, + isPackaged: () => app.isPackaged, + getPath: name => app.getPath(name), + getResourcesPath: () => process.resourcesPath, +}); +const atomicFileStore = new AtomicFileStore(); +const guiUpdateStateStore = new GuiUpdateStateStore( + () => path.join(appPaths.userDataRoot(), '.gui-update-state.json'), + atomicFileStore, +); +const guiUpdaterLogger = new GuiUpdaterLogger( + path.join( + appPaths.isPackaged() + ? appPaths.appRoot() + : appPaths.userDataRoot(), + 'logs', + 'updater.log', + ), + path.join(appPaths.userDataRoot(), 'logs', 'updater.log'), +); +const guiUpdateInstaller = new GuiUpdateInstaller( + guiUpdateStateStore, + guiUpdaterLogger, + process.resourcesPath, +); +const migrationStateStore = new MigrationStateStore( + () => path.join(appPaths.userDataRoot(), '.migration-state.json'), + atomicFileStore, +); +const userDataMigrationService = new UserDataMigrationService( + appPaths, + atomicFileStore, + migrationStateStore, +); +const migrationConflictService = new MigrationConflictService( + appPaths, + atomicFileStore, +); +let legacyUserDataMigration = emptyLegacyMigrationSummary(); +const legacyMigrationPrompt = new LegacyMigrationPrompt({ + createWindow: options => new BrowserWindow(options), +}); +const guiSettingsStore = new GuiSettingsStore( + () => path.join(appPaths.userDataRoot(), 'gui_settings.json'), + atomicFileStore, +); +const safePathService = new SafePathService(appPaths); +const secureFileService = new SecureFileService( + safePathService, + atomicFileStore, +); +const teamPlanCodec = new TeamPlanCodec(); +const teamPlanRepository = new TeamPlanRepository( + appPaths, + atomicFileStore, + teamPlanCodec, +); +const combatPlanRepository = new CombatPlanRepository( + appPaths, + atomicFileStore, +); +const combatPlanCodec = new CombatPlanCodec( + teamPlanCodec, + teamPlanRepository, +); +const teamPlanService = new TeamPlanService( + teamPlanCodec, + teamPlanRepository, + combatPlanCodec, + combatPlanRepository, +); +const taskPresetCodec = new TaskPresetCodec(); +const dailyPlanService = new DailyPlanService( + appPaths, + atomicFileStore, + combatPlanCodec, + taskPresetCodec, +); +const runtimePlanService = new RuntimePlanService( + combatPlanCodec, + combatPlanRepository, + atomicFileStore, + { + getTempDirectory: () => app.getPath('temp'), + processId: process.pid, + }, +); +const planManagementService = new PlanManagementService( + combatPlanCodec, + combatPlanRepository, + runtimePlanService, + teamPlanRepository, + guiSettingsStore, + taskPresetCodec, +); +const planExportService = new PlanExportService( + combatPlanRepository, + teamPlanRepository, + atomicFileStore, + combatPlanCodec, +); +const shipLibraryService = new ShipLibraryService(appPaths, { + processId: process.pid, +}); +const shipNameSynchronizer = new ShipNameSynchronizer(atomicFileStore); +const adbService = new AdbService(appPaths); + +/** 关闭 GUI 管理的后端与内置 ADB server,释放安装目录中的可执行文件。 */ +async function stopRuntimeResources(): Promise { + await stopBackend(); try { - const p = guiSettingsPath(); - if (fs.existsSync(p)) { - return JSON.parse(fs.readFileSync(p, 'utf-8')); - } - } catch { /* ignore */ } - return {}; -} - -/** 写入 GUI 设置(合并) */ -function writeGuiSettings(patch: Record): void { - const cur = readGuiSettings(); - Object.assign(cur, patch); - fs.writeFileSync(guiSettingsPath(), JSON.stringify(cur, null, 2), 'utf-8'); -} - -/** 后端端口:环境变量 > gui_settings.json > 默认 8438 */ -function getBackendPort(): number { - if (process.env.AUTOWSGR_PORT) { - return parseInt(process.env.AUTOWSGR_PORT, 10); - } - const settings = readGuiSettings(); - if (typeof settings.backend_port === 'number' && settings.backend_port > 0 && settings.backend_port < 65536) { - return settings.backend_port; + const stopped = await adbService.stopServer(); + console.log( + stopped + ? '[ADB] GUI 内置 server 已停止' + : '[ADB] 未发现 GUI 内置 server,跳过停止', + ); + } catch (error) { + const message = error instanceof Error + ? error.message + : String(error); + console.warn(`[ADB] GUI 内置 server 停止失败,将继续退出: ${message}`); } - return 8438; } -const BACKEND_PORT = getBackendPort(); - -/** 用户配置的 Python 路径:gui_settings.json > null (自动检测) */ -function getConfiguredPythonPath(): string | null { - const settings = readGuiSettings(); - if (typeof settings.python_path === 'string' && settings.python_path.length > 0) { - return settings.python_path; - } - return null; -} - -function getUpdateMode(): 'auto' | 'manual' { - const settings = readGuiSettings(); - return settings.update_mode === 'manual' ? 'manual' : 'auto'; -} - -type BackendStartupMode = 'managed' | 'external'; -type OcrGpuMode = 'auto' | 'cpu' | 'cuda'; - -function getBackendStartupMode(): BackendStartupMode { - const settings = readGuiSettings(); - return settings.backend_startup_mode === 'external' ? 'external' : 'managed'; -} - -function getBackendRepoPath(): string { - const settings = readGuiSettings(); - if (typeof settings.backend_repo_path !== 'string') return ''; - return settings.backend_repo_path.trim(); -} - -function getOcrGpuMode(): OcrGpuMode { - const settings = readGuiSettings(); - const value = typeof settings.ocr_gpu_mode === 'string' ? settings.ocr_gpu_mode : ''; - if (value === 'cpu' || value === 'cuda') return value; - return 'auto'; -} - -function getCudaPath(): string { - const settings = readGuiSettings(); - if (typeof settings.cuda_path !== 'string') return ''; - return settings.cuda_path.trim(); -} - -function normalizeCudaPath(candidate: string): string { - const resolved = path.resolve(candidate.trim()); - if (findCudaRuntimeDll(resolved)) return resolved; - return path.basename(resolved).toLowerCase() === 'bin' ? path.dirname(resolved) : resolved; -} - -function findCudaRuntimeDll(directory: string): boolean { +const cudaEnvironmentService = new CudaEnvironmentService( + CudaEnvironmentService.createDependencies(findPython), +); +const guiConfigurationService = new GuiConfigurationService( + guiSettingsStore, + { + clearPythonCache, + normalizeCudaPath: candidate => ( + cudaEnvironmentService.normalizePath(candidate) + ), + environmentPort: () => process.env.AUTOWSGR_PORT, + defaultAllowTestUpdates: () => ( + resolveGuiUpdateSelectionPolicy(app.getVersion(), true).stage + === 'prerelease' + ), + }, +); +const pythonEnvironmentService = new PythonEnvironmentService( + PythonEnvironmentService.createDependencies({ + isAllowedVersion: isAllowedPythonVersion, + findPython, + checkEnvironment, + installDependencies, + installPortablePython, + }), +); +const BACKEND_PORT = guiConfigurationService.backendPort(); +const windowService = new WindowService(guiSettingsStore, { + backendPort: BACKEND_PORT, + moduleDirectory: __dirname, + createBrowserWindow: options => new BrowserWindow(options), + getDisplays: () => screen.getAllDisplays(), + getAppPath: () => app.getAppPath(), + isPackaged: () => appPaths.isPackaged(), + resourceRoot: () => appPaths.resourceRoot(), + showMessageBox: options => { + void dialog.showMessageBox(options); + }, +}); +const guiSettingsCommitService = new GuiSettingsCommitService( + guiConfigurationService, + secureFileService, + windowService, +); +singleInstanceService.setMainWindowProvider( + () => windowService.getMainWindow(), +); +let updateInProgressDialogOpen = false; + +/** 安装期间的重复启动只显示系统提示,不创建旧版主窗口。 */ +async function showUpdateInProgressDialog(): Promise { + if (updateInProgressDialogOpen) return; + updateInProgressDialogOpen = true; try { - const names = fs.readdirSync(directory); - return names.some(name => /^cudart64.*\.dll$/i.test(name)) - && names.some(name => /^cublas64.*\.dll$/i.test(name)); - } catch { - return false; + await app.whenReady(); + await dialog.showMessageBox({ + type: 'info', + title: 'AutoWSGR-GUI 正在更新', + message: '后台正在更新,请稍后', + buttons: ['确认'], + defaultId: 0, + cancelId: 0, + noLink: true, + }); + } finally { + updateInProgressDialogOpen = false; } } -function validateCudaPath(candidate: string): { valid: boolean; path: string; version: string | null; kind?: 'toolkit' | 'runtime'; error?: string } { - if (!candidate.trim()) return { valid: false, path: '', version: null, error: '路径为空' }; - const cudaRoot = normalizeCudaPath(candidate); - if (!fs.existsSync(cudaRoot)) return { valid: false, path: cudaRoot, version: null, error: '目录不存在' }; - const binDir = path.join(cudaRoot, 'bin'); - const isToolkit = fs.existsSync(path.join(binDir, 'nvcc.exe')); - const runtimeDir = findCudaRuntimeDll(cudaRoot) - ? cudaRoot - : findCudaRuntimeDll(binDir) - ? binDir - : null; - if (!isToolkit && !runtimeDir) { - return { valid: false, path: cudaRoot, version: null, error: '未找到 CUDA Toolkit(bin\\nvcc.exe)或 PyTorch CUDA Runtime DLL' }; +singleInstanceService.setDuplicateLaunchHandler(() => { + const state = guiUpdateStateStore.read(); + if ( + !state + || state.sourceVersion !== app.getVersion() + || !guiUpdateStateStore.isInstallationActive(state) + ) { + return false; } + void showUpdateInProgressDialog(); + return true; +}); +const shipLibraryUpdater = new ShipLibraryUpdater( + shipLibraryService, + { + findPython, + appRoot, + sendProgress: message => { + windowService.sendToRenderer( + 'ship-library-update-progress', + { message }, + ); + }, + compareShipNames: pythonCmd => { + return shipNameSynchronizer.compare( + backendShipNamesPath(pythonCmd), + shipLibraryService.getManifest().ships, + ); + }, + syncShipNames: pythonCmd => { + return shipNameSynchronizer.sync( + backendShipNamesPath(pythonCmd), + shipLibraryService.getManifest().ships, + ); + }, + }, +); - let version: string | null = null; - try { - const versionJson = path.join(cudaRoot, 'version.json'); - if (fs.existsSync(versionJson)) { - const raw = JSON.parse(fs.readFileSync(versionJson, 'utf-8').replace(/^\uFEFF/, '')) as Record; - version = raw.cuda?.version ?? raw.cuda_cudart?.version ?? null; - } - } catch { /* use directory name fallback */ } - version ??= path.basename(cudaRoot).match(/v\d+(?:\.\d+)?/i)?.[0] ?? null; - if (isToolkit) return { valid: true, path: cudaRoot, version, kind: 'toolkit' }; - - let runtimeVersion: string | null = null; - try { - const cudart = fs.readdirSync(runtimeDir!).find(name => /^cudart64.*\.dll$/i.test(name)); - runtimeVersion = cudart?.match(/^cudart64[_-]?(\d+)/i)?.[1] ?? null; - if (runtimeVersion?.length === 2) runtimeVersion = `${runtimeVersion[0]}.${runtimeVersion[1]}`; - else if (runtimeVersion?.length === 3) runtimeVersion = `${runtimeVersion.slice(0, 2)}.${runtimeVersion[2]}`; - } catch { /* version remains unknown */ } - return { valid: true, path: runtimeDir!, version: runtimeVersion, kind: 'runtime' }; -} - -function getSaveBackendScreenshots(): boolean { - const settings = readGuiSettings(); - return settings.save_backend_screenshots === true; -} - -let mainWindow: BrowserWindow | null = null; - -/** 是否处于打包后的生产模式 */ -function isPackaged(): boolean { - return app.isPackaged; -} - -/** - * 应用工作目录(外部可写文件:autowsgr/、usersettings.yaml 等): - * - 开发模式: 项目根目录 - * - 打包模式: exe 所在目录 - */ +/** 返回开发项目根目录或打包后的 exe 目录。 */ function appRoot(): string { - if (isPackaged()) { - return path.dirname(app.getPath('exe')); - } - return path.join(__dirname, '..', '..'); + return appPaths.appRoot(); } -/** extraResources 目录 (resource/, plans/, setup.bat) */ +/** 返回包含 resource 和 setup.bat 的 extraResources 目录。 */ function resourceRoot(): string { - if (isPackaged()) { - return process.resourcesPath; - } - return path.join(__dirname, '..', '..'); -} - -/** 将相对路径解析为绝对路径 */ -function resolveAppPath(filePath: string): string { - if (path.isAbsolute(filePath)) return filePath; - // resource/ 在打包后位于 extraResources(只读) - if (filePath.startsWith('resource')) { - return path.join(resourceRoot(), filePath); - } - // plans/ 及其他文件在 appRoot(可写,用户数据不会被覆盖安装覆盖) - return path.join(appRoot(), filePath); + return appPaths.resourceRoot(); } -/** - * 初始化用户方案目录:将 extraResources 中的默认方案 - * 复制到 appRoot/plans(不覆盖已有文件,保留用户自定义方案)。 - */ -function initUserPlansDir(): void { - const bundledDir = path.join(resourceRoot(), 'plans'); - const userDir = path.join(appRoot(), 'plans'); - if (!fs.existsSync(bundledDir)) return; - copyDirNoOverwrite(bundledDir, userDir); +/** 返回 Electron userData 根目录。 */ +function userDataRoot(): string { + return appPaths.userDataRoot(); } -/** 递归复制目录,跳过已存在的文件 */ -function copyDirNoOverwrite(src: string, dest: string): void { - if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true }); - for (const entry of fs.readdirSync(src, { withFileTypes: true })) { - const srcPath = path.join(src, entry.name); - const destPath = path.join(dest, entry.name); - if (entry.isDirectory()) { - copyDirNoOverwrite(srcPath, destPath); - } else if (!fs.existsSync(destPath)) { - fs.copyFileSync(srcPath, destPath); +// IPC 注册 + +registerFileIpc(ipcMain, { + dialog, + shell, + secureFiles: secureFileService, + safePaths: safePathService, + combatPlans: combatPlanRepository, + appRoot, + userDataRoot, +}); +registerDeviceIpc(ipcMain, { + adb: adbService, + detectEmulator, +}); +registerConfigurationIpc(ipcMain, { + getAppVersion: () => app.getVersion(), + backendPort: BACKEND_PORT, + configuration: guiConfigurationService, + settingsCommit: guiSettingsCommitService, + cudaEnvironment: cudaEnvironmentService, + pythonEnvironment: pythonEnvironmentService, + windows: windowService, +}); +registerDailyPlanIpc(ipcMain, { + dailyPlans: dailyPlanService, + configuration: guiConfigurationService, +}); +registerMigrationConflictIpc(ipcMain, migrationConflictService); +registerEnvironmentIpc(ipcMain, pythonEnvironmentService); +registerTeamPlanIpc(ipcMain, { + dialog, + repository: teamPlanRepository, + service: teamPlanService, +}); +registerCombatPlanIpc(ipcMain, { + dialog, + safePaths: safePathService, + plans: planManagementService, + planExports: planExportService, +}); +registerShipLibraryIpc(ipcMain, { + library: shipLibraryService, + updater: shipLibraryUpdater, + getStatus: async () => { + const status = shipLibraryService.getStatus(); + if ( + !status.exists + || status.error + || status.shipCount <= 0 + || status.missingAssets > 0 + ) { + return status; } - } + try { + const backend = await shipLibraryUpdater.getBackendSyncStatus(); + return { + ...status, + backendSynchronized: backend.synchronized, + backendMissingRecords: backend.missingRecords, + backendMissingAliases: backend.missingAliases, + }; + } catch (error) { + return { + ...status, + backendSynchronized: false, + backendError: error instanceof Error + ? error.message + : String(error), + }; + } + }, +}); +registerBackendIpc(ipcMain, { + getBackendProcess, + startBackend, + runSetupScript, +}); + +const legacyPlanMigration = new LegacyPlanMigration( + appPaths, + atomicFileStore, + userDataMigrationService, + migrationStateStore, + { + yamlFiles: directory => combatPlanRepository.yamlFiles(directory), + safePlanBaseName: value => combatPlanCodec.safeBaseName(value), + normalizeUserTeamPlan: raw => teamPlanCodec.normalizeLegacy(raw), + teamPlanMatches: (filePath, team) => ( + teamPlanRepository.matches(filePath, team) + ), + teamName: team => team.name, + renameTeam: (team, name) => ({ + ...structuredClone(team), + name, + }), + normalizeCombatPlanFleetPresets: ( + root, + source, + requireEmbeddedShips, + ) => combatPlanCodec.normalizeLegacyFleetPresets( + root, + source, + requireEmbeddedShips, + ), + buildTeamPlanWrites: (teams, directory) => ( + teamPlanRepository.buildWrites(teams, directory) + ), + serializeCombatPlan: (root, originalContent) => ( + combatPlanCodec.serialize(root, originalContent) + ), + isStandaloneTaskPreset: root => ( + taskPresetCodec.isStandalone(root) + ), + normalizeTaskPreset: root => taskPresetCodec.normalize(root), + }, +); + +/** 向渲染进程发送环境检查进度。 */ +function sendProgress(msg: string): void { + windowService.sendToRenderer('backend-log', msg); } -function createWindow(): BrowserWindow { - const win = new BrowserWindow({ - width: 1280, - height: 720, - minWidth: 960, - minHeight: 540, - webPreferences: { - preload: path.join(__dirname, 'preload.js'), - contextIsolation: true, - nodeIntegration: false, - }, - titleBarStyle: 'hiddenInset', - backgroundColor: '#1a1a2e', - icon: path.join(isPackaged() ? process.resourcesPath : path.join(__dirname, '..', '..'), 'resource', 'images', 'logo.png'), - }); - - const appDir = app.getAppPath(); - const htmlPath = path.join(appDir, 'src', 'view', 'index.html'); - - // 根据 BACKEND_PORT 动态注入 CSP - win.webContents.session.webRequest.onHeadersReceived((details, callback) => { - callback({ - responseHeaders: { - ...details.responseHeaders, - 'Content-Security-Policy': [ - `default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' http://localhost:${BACKEND_PORT} ws://localhost:${BACKEND_PORT}` - ], - }, - }); - }); - - win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => { - const msg = `Page load failed!\nCode: ${errorCode}\nDesc: ${errorDescription}\nURL: ${validatedURL}\nPath: ${htmlPath}`; - console.error('[Main]', msg); - if (isPackaged()) { - dialog.showMessageBox({ type: 'error', title: 'Load Error', message: msg }); +// 应用生命周期 + +if (isPrimaryInstance) initializeApplicationLifecycle(); + +function initializeApplicationLifecycle(): void { + let runtimeShutdownInProgress = false; + let runtimeShutdownComplete = false; + + /** 在任何迁移、后端初始化和主窗口创建前处理待安装更新。 */ + const handleStartupUpdate = async (): Promise => { + const pendingUpdate = guiUpdateStateStore.read(); + if (pendingUpdate) { + const updatePolicy = resolveGuiUpdateSelectionPolicy( + app.getVersion(), + guiConfigurationService.allowTestUpdates(), + ); + const mismatch = validateGuiUpdateCandidate( + updatePolicy, + pendingUpdate.targetVersion, + ); + if (mismatch) { + guiUpdaterLogger.warn( + `Discarded pending GUI update after channel change: ${mismatch}`, + ); + guiUpdateStateStore.clear(); + } } - }); - - win.loadFile(htmlPath).catch(err => { - console.error('[Main] loadFile failed:', err); - if (isPackaged()) { - dialog.showMessageBox({ type: 'error', title: 'loadFile Error', message: `${err.message}\nPath: ${htmlPath}` }); + const resolution = guiUpdateStateStore.resolveStartup( + app.getVersion(), + ); + if (resolution.action === 'continue') return false; + if (resolution.action === 'cleanup') { + const cleanupTimer = setTimeout(() => { + guiUpdateInstaller.cleanupAppliedUpdate(resolution.state); + }, 10_000); + cleanupTimer.unref(); + return false; + } + if (resolution.action === 'wait') { + guiUpdaterLogger.info( + `Blocked old GUI startup while v` + + `${resolution.state.targetVersion} is installing`, + ); + await showUpdateInProgressDialog(); + runtimeShutdownComplete = true; + app.quit(); + return true; } - }); - - mainWindow = win; - win.on('closed', () => { mainWindow = null; }); - return win; -} - -// ════════════════════════════════════════ -// IPC Handlers -// ════════════════════════════════════════ - -ipcMain.handle('open-directory-dialog', async (_event, title?: string) => { - const result = await dialog.showOpenDialog({ - properties: ['openDirectory'], - title: title || '选择文件夹', - }); - if (result.canceled || result.filePaths.length === 0) return null; - return result.filePaths[0]; -}); - -ipcMain.handle('open-file-dialog', async (_event, filters: Electron.FileFilter[], defaultDir?: string) => { - const result = await dialog.showOpenDialog({ - properties: ['openFile'], - defaultPath: defaultDir || undefined, - filters, - }); - - if (result.canceled || result.filePaths.length === 0) { - return null; - } - - const filePath = result.filePaths[0]; - const content = fs.readFileSync(filePath, 'utf-8'); - return { path: filePath, content }; -}); - -ipcMain.handle('save-file', async (_event, filePath: string, content: string) => { - const resolved = resolveAppPath(filePath); - const dir = path.dirname(resolved); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(resolved, content, 'utf-8'); -}); - -ipcMain.handle('save-file-dialog', async (_event, defaultName: string, content: string, filters: Electron.FileFilter[]) => { - const result = await dialog.showSaveDialog({ - defaultPath: defaultName, // caller can pass full path (dir + filename) - filters, - }); - if (result.canceled || !result.filePath) return null; - fs.writeFileSync(result.filePath, content, 'utf-8'); - return result.filePath; -}); - -ipcMain.handle('read-file', async (_event, filePath: string) => { - const resolved = resolveAppPath(filePath); - if (!fs.existsSync(resolved)) return ''; - return fs.readFileSync(resolved, 'utf-8'); -}); - -ipcMain.handle('append-file', async (_event, filePath: string, content: string) => { - const resolved = resolveAppPath(filePath); - const dir = path.dirname(resolved); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - fs.appendFileSync(resolved, content, 'utf-8'); -}); - - -ipcMain.handle('detect-emulator', async () => { - return detectEmulator(); -}); -ipcMain.handle('check-adb-devices', async () => { - const adbDir = path.join(appRoot(), 'adb'); - const adbExe = path.join(adbDir, 'adb.exe'); - const adbCmd = fs.existsSync(adbExe) ? adbExe : 'adb'; - try { - const { stdout } = await execAsync(`"${adbCmd}" devices`, { windowsHide: true, timeout: 5000 }); - const lines = stdout.split('\n').slice(1); // skip header - return lines - .map(l => l.trim()) - .filter(l => l.length > 0) - .map(l => { - const [serial, status] = l.split(/\s+/); - return { serial, status: status || 'unknown' }; + try { + await guiUpdateInstaller.launchPendingUpdate(); + runtimeShutdownComplete = true; + app.quit(); + return true; + } catch (error) { + const message = error instanceof Error + ? error.message + : String(error); + guiUpdaterLogger.error( + `Cannot install pending GUI update: ${message}`, + ); + await dialog.showMessageBox({ + type: 'error', + title: 'GUI 更新失败', + message: '后台更新无法启动', + detail: `${message}\n本次将继续打开当前版本。`, + buttons: ['确认'], + defaultId: 0, + cancelId: 0, + noLink: true, }); - } catch { - return []; - } -}); - -ipcMain.on('get-app-version-sync', (event) => { - event.returnValue = app.getVersion(); -}); - -ipcMain.on('get-backend-port-sync', (event) => { - event.returnValue = BACKEND_PORT; -}); - -ipcMain.on('get-backend-startup-mode-sync', (event) => { - event.returnValue = getBackendStartupMode(); -}); - -ipcMain.on('get-backend-repo-path-sync', (event) => { - event.returnValue = getBackendRepoPath(); -}); - -ipcMain.on('get-ocr-gpu-mode-sync', (event) => { - event.returnValue = getOcrGpuMode(); -}); - -ipcMain.on('get-cuda-path-sync', (event) => { - event.returnValue = getCudaPath(); -}); - -ipcMain.on('get-save-backend-screenshots-sync', (event) => { - event.returnValue = getSaveBackendScreenshots(); -}); - -ipcMain.handle('set-backend-port', (_event, port: number) => { - // 防御性校验:仅在端口为有限数值且位于合法范围时才写入设置 - if (typeof port !== 'number' || !Number.isFinite(port)) { - return; - } - const normalizedPort = Math.trunc(port); - if (normalizedPort < 1 || normalizedPort > 65535) { - return; - } - writeGuiSettings({ backend_port: normalizedPort }); -}); - -ipcMain.handle('set-backend-startup-mode', (_event, mode: BackendStartupMode) => { - const normalized = mode === 'external' ? 'external' : 'managed'; - writeGuiSettings({ backend_startup_mode: normalized }); -}); - -ipcMain.handle('set-backend-repo-path', (_event, repoPath: string | null) => { - const normalized = typeof repoPath === 'string' ? repoPath.trim() : ''; - writeGuiSettings({ backend_repo_path: normalized }); -}); - -ipcMain.handle('set-ocr-gpu-mode', (_event, mode: OcrGpuMode) => { - const normalized: OcrGpuMode = mode === 'cpu' || mode === 'cuda' ? mode : 'auto'; - writeGuiSettings({ ocr_gpu_mode: normalized }); -}); - -ipcMain.handle('set-cuda-path', (_event, cudaPath: string | null) => { - const raw = typeof cudaPath === 'string' ? cudaPath.trim() : ''; - const normalized = raw ? normalizeCudaPath(raw) : ''; - writeGuiSettings({ cuda_path: normalized }); -}); - -ipcMain.handle('validate-cuda-path', (_event, cudaPath: string) => { - return validateCudaPath(cudaPath); -}); - -ipcMain.handle('set-save-backend-screenshots', (_event, enabled: boolean) => { - writeGuiSettings({ save_backend_screenshots: enabled === true }); -}); - -ipcMain.on('get-python-path-sync', (event) => { - event.returnValue = getConfiguredPythonPath(); -}); - -ipcMain.on('get-update-mode-sync', (event) => { - event.returnValue = getUpdateMode(); -}); - -ipcMain.handle('set-update-mode', (_event, mode: 'auto' | 'manual') => { - const normalized = mode === 'manual' ? 'manual' : 'auto'; - writeGuiSettings({ update_mode: normalized }); -}); - -ipcMain.handle('set-python-path', (_event, pythonPath: string | null) => { - writeGuiSettings({ python_path: pythonPath ?? '' }); - clearPythonCache(); // 清除缓存,下次查找时使用新路径 -}); - -ipcMain.handle('validate-python', async (_event, pythonPath: string) => { - if (!pythonPath) return { valid: false, version: null, error: '路径为空' }; - if (!fs.existsSync(pythonPath)) return { valid: false, version: null, error: '文件不存在' }; - try { - const { stdout } = await execAsync(`"${pythonPath}" --version`, { windowsHide: true, timeout: 10000 }); - const version = stdout.trim(); - if (!isAllowedPythonVersion(version)) { - return { valid: false, version, error: `版本不兼容: ${version}(需要 3.12 或 3.13)` }; + return false; + } + }; + + app.whenReady().then(async () => { + if (await handleStartupUpdate()) return; + + let migrationSelection: LegacyMigrationSelection = { + ...DEFAULT_LEGACY_MIGRATION_SELECTION, + }; + if (userDataMigrationService.shouldMigrateLegacyInstallation()) { + const selected = await legacyMigrationPrompt.show(); + if (!selected) { + app.quit(); + return; + } + migrationSelection = selected; + legacyUserDataMigration = ( + userDataMigrationService.migrateLegacyUserDataFiles( + migrationSelection, + ) + ); } - return { valid: true, version }; - } catch (e) { - return { valid: false, version: null, error: `执行失败: ${e instanceof Error ? e.message : String(e)}` }; - } -}); - -ipcMain.handle('get-app-root', () => { - return appRoot(); -}); - -ipcMain.handle('resolve-app-path', (_event, filePath: string) => { - return resolveAppPath(filePath); -}); - -ipcMain.handle('get-plans-dir', () => { - return resolveAppPath('plans'); -}); - -ipcMain.handle('list-plan-files', () => { - const dir = resolveAppPath('plans'); - if (!fs.existsSync(dir)) return []; - return fs.readdirSync(dir) - .filter(f => /\.ya?ml$/i.test(f)) - .map(f => ({ name: f.replace(/\.ya?ml$/i, ''), file: f })); -}); - -ipcMain.handle('get-config-dir', () => { - return appRoot(); -}); - -ipcMain.handle('open-folder', async (_event, folderPath: string) => { - if (fs.existsSync(folderPath)) { - await shell.openPath(folderPath); - } -}); - -ipcMain.handle('check-environment', async () => { - return await checkEnvironment(); -}); - -/* - * 测试期接口(后端源码更新)已停用,逻辑保留便于回滚恢复。 -ipcMain.handle('check-updates', async () => { - return await checkForUpdates(); -}); -*/ - -ipcMain.handle('install-deps', async () => { - const pythonCmd = await findPython(); - if (!pythonCmd) return { success: false, output: '找不到 Python' }; - return installDependencies(pythonCmd); -}); - -ipcMain.handle('run-setup', async () => { - return runSetupScript(); -}); - -ipcMain.handle('install-portable-python', async () => { - return installPortablePython(); -}); - -/* - * 测试期接口(后端源码更新)已停用,逻辑保留便于回滚恢复。 -ipcMain.handle('pull-updates', async () => { - return pullUpdates(); -}); -*/ - -ipcMain.handle('start-backend', async () => { - if (getBackendProcess()) return { success: true, message: '后端已在运行' }; - await startBackend(); - return { success: true, message: '后端启动中' }; -}); - -// ════════════════════════════════════════ -// GUI 自动更新 (electron-updater) -// ════════════════════════════════════════ - -/** 初始化自动更新 */ -function initAutoUpdater(): void { - autoUpdater.autoDownload = false; - autoUpdater.autoInstallOnAppQuit = true; - autoUpdater.on('update-available', (info: UpdateInfo) => { - mainWindow?.webContents.send('update-status', { - status: 'available', - version: info.version, - releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : '', + initPythonEnv({ + appRoot, + sendProgress, + getConfiguredPythonPath: () => ( + guiConfigurationService.configuredPythonPath() + ), + getUpdateMode: () => guiConfigurationService.updateMode(), + allowTestUpdates: () => ( + guiConfigurationService.allowTestUpdates() + ), + getBackendStartupMode: () => ( + guiConfigurationService.backendStartupMode() + ), + getBackendRepoPath: () => ( + guiConfigurationService.backendRepoPath() + ), + getTempDir: () => app.getPath('temp'), }); - }); - - autoUpdater.on('update-not-available', () => { - mainWindow?.webContents.send('update-status', { status: 'up-to-date' }); - }); - - autoUpdater.on('download-progress', (progress: ProgressInfo) => { - mainWindow?.webContents.send('update-status', { - status: 'downloading', - percent: Math.round(progress.percent), - transferred: progress.transferred, - total: progress.total, + initBackend({ + appRoot, + userDataRoot, + resourceRoot, + BACKEND_PORT, + sendToRenderer: (channel, ...args) => ( + windowService.sendToRenderer(channel, ...args) + ), }); - }); - - autoUpdater.on('update-downloaded', (info: UpdateInfo) => { - mainWindow?.webContents.send('update-status', { - status: 'downloaded', - version: info.version, + combatPlanRepository.initializeUserDirectory(); + shipLibraryService.initialize(); + teamPlanRepository.initializeUserDirectory(); + const presetInventoryResult = ( + userDataMigrationService.migratePresetInventory() + ); + const legacyPlanResult = legacyPlanMigration.migrate( + migrationSelection, + ); + const legacyMigrationResult = mergeLegacyMigrationSummaries( + legacyUserDataMigration, + legacyPlanResult, + presetInventoryResult, + ); + userDataMigrationService.writeMigrationReport( + legacyMigrationResult, + ); + if ( + legacyMigrationResult.failed === 0 + && migrationStateStore.isStageComplete( + LEGACY_PLAN_MIGRATION_STAGE, + ) + ) { + userDataMigrationService.completeLegacySourceMigration(); + } + migrationConflictService.prepareAfterMigration( + legacyMigrationResult.total > 0, + ); + registerUpdaterIpc(ipcMain, { + sendToRenderer: (channel, ...args) => ( + windowService.sendToRenderer(channel, ...args) + ), + getAppVersion: () => app.getVersion(), + allowTestUpdates: () => guiConfigurationService.allowTestUpdates(), + logger: guiUpdaterLogger, + updateStates: guiUpdateStateStore, + chooseDownload: async (version) => { + const options = { + type: 'question' as const, + title: '发现 GUI 更新', + message: `发现 GUI v${version},是否现在更新?`, + detail: [ + '“现在更新”只会在后台静默下载和校验,不会关闭 GUI 或中断当前任务。', + '“稍后”本次不下载,下次打开 GUI 时仍会提示。', + ].join('\n'), + buttons: ['现在更新', '稍后'], + defaultId: 1, + cancelId: 1, + noLink: true, + }; + const mainWindow = windowService.getMainWindow(); + const result = mainWindow + ? await dialog.showMessageBox(mainWindow, options) + : await dialog.showMessageBox(options); + return result.response === 0 ? 'now' : 'later'; + }, + chooseRestartTiming: async (version) => { + const options = { + type: 'question' as const, + title: 'GUI 更新准备完成', + message: `GUI v${version} 已下载并校验完成`, + detail: [ + '“立即重启”会安全停止后端和 ADB,静默安装完成后启动新版本。', + '“下次启动”会继续当前任务,下次打开 GUI 时先完成更新再显示主窗口。', + ].join('\n'), + buttons: ['立即重启', '下次启动'], + defaultId: 1, + cancelId: 1, + noLink: true, + }; + const mainWindow = windowService.getMainWindow(); + const result = mainWindow + ? await dialog.showMessageBox(mainWindow, options) + : await dialog.showMessageBox(options); + return result.response === 0 + ? 'restart' + : 'next-launch'; + }, + installDownloadedUpdate: async () => { + await stopRuntimeResources(); + await guiUpdateInstaller.launchPendingUpdate(); + runtimeShutdownComplete = true; + app.quit(); + }, }); - }); + windowService.createWindow(); + const migrationNotice = buildLegacyMigrationNotice( + legacyMigrationResult, + ); + const mainWindow = windowService.getMainWindow(); + if (migrationNotice && mainWindow) { + void dialog.showMessageBox(mainWindow, migrationNotice); + } - autoUpdater.on('error', (err: Error) => { - mainWindow?.webContents.send('update-status', { - status: 'error', - message: err.message, + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + windowService.createWindow(); + } }); }); -} - -ipcMain.handle('check-gui-updates', async () => { - try { - const result = await autoUpdater.checkForUpdates(); - return result?.updateInfo ? { version: result.updateInfo.version } : null; - } catch { - return null; - } -}); - -ipcMain.handle('download-gui-update', async () => { - try { - await autoUpdater.downloadUpdate(); - return { success: true }; - } catch (err: any) { - return { success: false, message: err.message }; - } -}); -ipcMain.handle('install-gui-update', () => { - autoUpdater.quitAndInstall(false, true); -}); - -/** 向渲染进程发送环境检查进度 */ -function sendProgress(msg: string): void { - mainWindow?.webContents.send('backend-log', msg); -} - -// ════════════════════════════════════════ -// App Lifecycle -// ════════════════════════════════════════ - -app.whenReady().then(() => { - initPythonEnv({ - appRoot, - sendProgress, - getConfiguredPythonPath, - getUpdateMode, - getTempDir: () => app.getPath('temp'), - }); - initBackend({ - appRoot, - resourceRoot, - BACKEND_PORT, - getMainWindow: () => mainWindow, + app.on('before-quit', (event) => { + windowService.captureWindowBounds(); + windowService.persistWindowBounds(); + if (runtimeShutdownComplete) return; + event.preventDefault(); + if (runtimeShutdownInProgress) return; + + runtimeShutdownInProgress = true; + void stopRuntimeResources().then(() => { + runtimeShutdownComplete = true; + runtimeShutdownInProgress = false; + app.quit(); + }).catch(error => { + runtimeShutdownInProgress = false; + const message = error instanceof Error + ? error.message + : String(error); + console.error('[Backend] 无法安全退出:', message); + dialog.showErrorBox( + '无法安全退出', + `后端进程仍在运行,应用没有退出:${message}`, + ); + }); }); - initUserPlansDir(); - initAutoUpdater(); - createWindow(); - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); } }); -}); - -app.on('before-quit', () => { - stopBackend(); -}); - -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit(); - } -}); +} diff --git a/electron/preload.ts b/electron/preload.ts index b75d86c..5192a46 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,9 +1,27 @@ /** - * Preload 脚本 —— 通过 contextBridge 安全暴露 IPC 方法给渲染进程。 + * 通过 contextBridge 向渲染进程安全暴露 IPC 方法。 */ import { contextBridge, ipcRenderer } from 'electron'; - -contextBridge.exposeInMainWorld('electronBridge', { +import type { + LootAutomationPlan, + LootPlanSource, +} from '../src/shared/lootPlans'; +import type { + LegacyDecisiveAutomationSettings, +} from '../src/shared/legacyDecisiveAutomation'; +import type { + DecisiveAutomationSource, +} from '../src/shared/decisiveAutomation'; +import type { + DecisivePlanSettings, + ElectronBridge, + GuiSettingsCommitRequest, + GuiSettingsCommitResult, + GuiUpdateStatus, + ShipLibraryUpdateTarget, +} from '../src/types/ipc'; + +const electronBridge = { getAppVersion: () => { return ipcRenderer.sendSync('get-app-version-sync') as string; }, @@ -32,6 +50,63 @@ contextBridge.exposeInMainWorld('electronBridge', { return ipcRenderer.sendSync('get-save-backend-screenshots-sync') as boolean; }, + getWindowPreferences: () => { + return ipcRenderer.sendSync('get-window-preferences-sync') as { + defaultWidth: number; + defaultHeight: number; + rememberBounds: boolean; + }; + }, + + setWindowPreferences: (preferences: { + defaultWidth: number; + defaultHeight: number; + rememberBounds: boolean; + }) => { + return ipcRenderer.invoke('set-window-preferences', preferences); + }, + + getGuiAutomationSettings: () => { + return ipcRenderer.invoke('get-gui-automation-settings'); + }, + + setGuiAutomationSettings: (settings: { + expeditionInterval: number; + battleTimes: number; + autoDecisive: boolean; + decisiveTemplateId: DecisiveAutomationSource; + autoLoot: boolean; + lootPlanSource: LootPlanSource; + lootPlanId: string; + lootPlans: LootAutomationPlan[]; + lootStopCount: number; + }) => { + return ipcRenderer.invoke('set-gui-automation-settings', settings); + }, + + commitGuiSettings: ( + settings: GuiSettingsCommitRequest, + ): Promise => { + return ipcRenderer.invoke('commit-gui-settings', settings); + }, + + migrateLegacyDecisiveAutomation: ( + settings: LegacyDecisiveAutomationSettings, + ) => { + return ipcRenderer.invoke( + 'migrate-legacy-decisive-automation', + settings, + ); + }, + + getDecisivePlanSettings: () => { + return ipcRenderer.invoke('get-decisive-plan-settings'); + }, + + setDecisivePlanSettings: (settings: DecisivePlanSettings) => { + return ipcRenderer.invoke('set-decisive-plan-settings', settings); + }, + setBackendPort: (port: number) => { return ipcRenderer.invoke('set-backend-port', port); }, @@ -64,10 +139,172 @@ contextBridge.exposeInMainWorld('electronBridge', { return ipcRenderer.sendSync('get-update-mode-sync') as 'auto' | 'manual'; }, + getAllowTestUpdates: () => { + return ipcRenderer.sendSync('get-allow-test-updates-sync') as boolean; + }, + setUpdateMode: (mode: 'auto' | 'manual') => { return ipcRenderer.invoke('set-update-mode', mode); }, + getShipLibraryStatus: () => { + return ipcRenderer.invoke('get-ship-library-status'); + }, + + getShipLibraryManifest: () => { + return ipcRenderer.invoke('get-ship-library-manifest'); + }, + + updateShipLibrary: (target: ShipLibraryUpdateTarget = 'wiki') => { + return ipcRenderer.invoke('update-ship-library', target); + }, + + onShipLibraryUpdateProgress: (callback: (progress: { message: string }) => void) => { + ipcRenderer.on('ship-library-update-progress', (_event, progress) => callback(progress)); + }, + + saveUserTeamPlan: ( + plan: unknown, + overwrite = false, + currentFile?: string, + source: 'system' | 'user' = 'user', + ) => { + return ipcRenderer.invoke( + 'save-user-team-plan', + plan, + overwrite, + currentFile, + source, + ); + }, + + pickUserTeamPlan: () => { + return ipcRenderer.invoke('pick-user-team-plan'); + }, + + listTeamPlans: () => { + return ipcRenderer.invoke('list-team-plans'); + }, + + getPlanManagement: () => { + return ipcRenderer.invoke('get-plan-management'); + }, + + listDailyPlans: () => { + return ipcRenderer.invoke('list-daily-plans'); + }, + + readDailyPlan: ( + source: 'system' | 'user', + file: string, + ) => { + return ipcRenderer.invoke('read-daily-plan', source, file); + }, + + getDailyDecisivePlan: (chapter: number) => { + return ipcRenderer.invoke('get-daily-decisive-plan', chapter); + }, + + getSystemDailyDecisivePlan: (chapter: number) => { + return ipcRenderer.invoke( + 'get-system-daily-decisive-plan', + chapter, + ); + }, + + saveDailyDecisivePlan: (settings: DecisivePlanSettings) => { + return ipcRenderer.invoke('save-daily-decisive-plan', settings); + }, + + getMigrationConflicts: () => { + return ipcRenderer.invoke('get-migration-conflicts'); + }, + + resolveMigrationConflicts: (keepIds: string[]) => { + return ipcRenderer.invoke('resolve-migration-conflicts', keepIds); + }, + + exportUserPlans: ( + selections: Array<{ + kind: 'battle' | 'team'; + file: string; + }>, + ) => { + return ipcRenderer.invoke('export-user-plans', selections); + }, + + exportLegacy143Plans: ( + selections: Array<{ kind: 'battle' | 'team'; file: string }>, + ) => ipcRenderer.invoke('export-legacy-143-plans', selections), + + importLocalCombatPlan: () => { + return ipcRenderer.invoke('import-local-combat-plan'); + }, + + setPlanUnlinkedIgnored: ( + kind: 'battle' | 'team', + source: 'system' | 'user', + file: string, + ignored: boolean, + ) => { + return ipcRenderer.invoke( + 'set-plan-unlinked-ignored', + kind, + source, + file, + ignored, + ); + }, + + readManagedCombatPlan: ( + source: 'system' | 'user', + file: string, + ) => { + return ipcRenderer.invoke('read-managed-combat-plan', source, file); + }, + + readCombatPlanFile: (filePath: string) => { + return ipcRenderer.invoke('read-combat-plan-file', filePath); + }, + + prepareCombatPlanExecution: ( + content: string, + hint: string, + ) => { + return ipcRenderer.invoke( + 'prepare-combat-plan-execution', + content, + hint, + ); + }, + + saveManagedCombatPlan: ( + name: string, + content: string, + overwrite = false, + currentFile?: string, + ) => { + return ipcRenderer.invoke( + 'save-managed-combat-plan', + name, + content, + overwrite, + currentFile, + ); + }, + + renameUserCombatPlan: (file: string, newName: string) => { + return ipcRenderer.invoke('rename-user-combat-plan', file, newName); + }, + + deleteUserCombatPlan: (file: string) => { + return ipcRenderer.invoke('delete-user-combat-plan', file); + }, + + deleteUserTeamPlan: (file: string) => { + return ipcRenderer.invoke('delete-user-team-plan', file); + }, + openDirectoryDialog: (title?: string) => { return ipcRenderer.invoke('open-directory-dialog', title); }, @@ -100,12 +337,16 @@ contextBridge.exposeInMainWorld('electronBridge', { return ipcRenderer.invoke('check-adb-devices'); }, - getAppRoot: () => { - return ipcRenderer.invoke('get-app-root'); + connectAdbDevice: (serial: string) => { + return ipcRenderer.invoke('connect-adb-device', serial); }, - resolveAppPath: (filePath: string) => { - return ipcRenderer.invoke('resolve-app-path', filePath); + disconnectAdbDevice: (serial: string) => { + return ipcRenderer.invoke('disconnect-adb-device', serial); + }, + + getAppRoot: () => { + return ipcRenderer.invoke('get-app-root'); }, getPlansDir: () => { @@ -128,24 +369,10 @@ contextBridge.exposeInMainWorld('electronBridge', { return ipcRenderer.invoke('check-environment'); }, - /* - * 测试期接口(后端源码更新)已停用,逻辑保留便于回滚恢复。 - checkUpdates: () => { - return ipcRenderer.invoke('check-updates'); - }, - */ - installDeps: () => { return ipcRenderer.invoke('install-deps'); }, - /* - * 测试期接口(后端源码更新)已停用,逻辑保留便于回滚恢复。 - pullUpdates: () => { - return ipcRenderer.invoke('pull-updates'); - }, - */ - startBackend: () => { return ipcRenderer.invoke('start-backend'); }, @@ -158,7 +385,7 @@ contextBridge.exposeInMainWorld('electronBridge', { return ipcRenderer.invoke('install-portable-python'); }, - // ── Python 路径配置 ── + // Python 路径配置 getPythonPath: () => { return ipcRenderer.sendSync('get-python-path-sync') as string | null; }, @@ -171,20 +398,12 @@ contextBridge.exposeInMainWorld('electronBridge', { return ipcRenderer.invoke('validate-python', pythonPath); }, - // ── GUI 自动更新 ── + // GUI 自动更新 checkGuiUpdates: () => { return ipcRenderer.invoke('check-gui-updates'); }, - downloadGuiUpdate: () => { - return ipcRenderer.invoke('download-gui-update'); - }, - - installGuiUpdate: () => { - return ipcRenderer.invoke('install-gui-update'); - }, - - onUpdateStatus: (callback: (status: any) => void) => { + onUpdateStatus: (callback: (status: GuiUpdateStatus) => void) => { ipcRenderer.on('update-status', (_event, status) => callback(status)); }, @@ -195,4 +414,6 @@ contextBridge.exposeInMainWorld('electronBridge', { onSetupLog: (callback: (text: string) => void) => { ipcRenderer.on('setup-log', (_event, text: string) => callback(text)); }, -}); +} satisfies ElectronBridge; + +contextBridge.exposeInMainWorld('electronBridge', electronBridge); diff --git a/electron/pythonEnv/backendContractProbe.ts b/electron/pythonEnv/backendContractProbe.ts new file mode 100644 index 0000000..c2a0072 --- /dev/null +++ b/electron/pythonEnv/backendContractProbe.ts @@ -0,0 +1,76 @@ +/** + * 生成 AutoWSGR GUI 运行契约的隔离行为探针。 + * + * 探针只在短生命周期的 Python 检查进程中替换外部依赖,验证 + * Launcher 公共方法是否真正消费 GUI 环境变量,不修改实际后端进程。 + */ +export function buildBackendRuntimeContractProbeLines( + functionName = '_verify_gui_runtime_contract', +): string[] { + return [ + `def ${functionName}():`, + ' import os as _contract_os', + ' from types import SimpleNamespace as _ContractNamespace', + ' from unittest.mock import patch as _contract_patch', + ' from autowsgr.scheduler.launcher import Launcher as _ContractLauncher', + ' _log = _ContractNamespace(', + " dir='.',", + " level='INFO',", + ' effective_channels=[],', + ' )', + ' _ocr = _ContractNamespace(', + ' gpu=False,', + " mirror='modelscope',", + ' ship_name_match_confidence=0.0,', + ' ship_name_corrections={},', + ' ship_name_aliases={},', + ' )', + ' _config = _ContractNamespace(log=_log, ocr=_ocr)', + " _save_key = 'AUTOWSGR_SAVE_IMAGES'", + " _ocr_key = 'AUTOWSGR_OCR_GPU_MODE'", + ' _previous_save = _contract_os.environ.get(_save_key)', + ' _previous_ocr = _contract_os.environ.get(_ocr_key)', + ' try:', + ' _save_values = []', + ' def _capture_logger(*args, **kwargs):', + " _save_values.append(kwargs.get('save_images'))", + ' with _contract_patch(', + " 'autowsgr.scheduler.launcher.ConfigManager.load',", + ' return_value=_config,', + ' ), _contract_patch(', + " 'autowsgr.scheduler.launcher.setup_logger',", + ' side_effect=_capture_logger,', + ' ):', + " _contract_os.environ[_save_key] = 'true'", + ' _ContractLauncher().load_config()', + " _contract_os.environ[_save_key] = 'false'", + ' _ContractLauncher().load_config()', + ' if _save_values != [True, False]:', + " raise RuntimeError('AUTOWSGR_SAVE_IMAGES 行为不兼容')", + ' _ocr_values = []', + ' def _capture_ocr(*args, **kwargs):', + " _ocr_values.append(kwargs.get('gpu'))", + ' return object()', + ' _launcher = _ContractLauncher()', + ' _launcher.set_config(_config)', + ' with _contract_patch(', + " 'autowsgr.scheduler.launcher.EasyOCREngine.create',", + ' side_effect=_capture_ocr,', + ' ):', + " _contract_os.environ[_ocr_key] = 'cuda'", + ' _launcher.create_ocr()', + " _contract_os.environ[_ocr_key] = 'cpu'", + ' _launcher.create_ocr()', + ' if _ocr_values != [True, False]:', + " raise RuntimeError('AUTOWSGR_OCR_GPU_MODE 行为不兼容')", + ' finally:', + ' if _previous_save is None:', + ' _contract_os.environ.pop(_save_key, None)', + ' else:', + ' _contract_os.environ[_save_key] = _previous_save', + ' if _previous_ocr is None:', + ' _contract_os.environ.pop(_ocr_key, None)', + ' else:', + ' _contract_os.environ[_ocr_key] = _previous_ocr', + ]; +} diff --git a/electron/pythonEnv/backendRequirement.ts b/electron/pythonEnv/backendRequirement.ts new file mode 100644 index 0000000..81ee805 --- /dev/null +++ b/electron/pythonEnv/backendRequirement.ts @@ -0,0 +1,106 @@ +/** + * GUI 管理模式使用的 Stable/Alpha AutoWSGR 后端来源。 + * + * 打包版本从 resources/backend-distribution.json 读取不可变发行清单。 + * 开发环境使用与发行包相同的双通道固定提交。 + */ +import * as fs from 'fs'; +import * as path from 'path'; + +export type BackendDistributionId = 'alpha' | 'stable'; + +export interface BackendDistribution { + id: BackendDistributionId; + repository: string; + ref: string; + commit: string; + forceUpdateOnInstall: boolean; +} + +export interface BackendDistributionManifest { + stable: BackendDistribution; + alpha: BackendDistribution; +} + +const DEFAULT_DISTRIBUTIONS: BackendDistributionManifest = { + stable: { + id: 'stable', + repository: 'OpenWSGR/AutoWSGR', + ref: 'main', + commit: 'a5effbfc606794ec30fa8bfd2f8edd2cc15d3852', + forceUpdateOnInstall: true, + }, + alpha: { + id: 'alpha', + repository: 'ShiinaKuroko/AutoWSGR', + ref: 'ShiinaKuroko', + commit: '77f34b7b30d18f7b86cf736bdd5cf17ae35d5f78', + forceUpdateOnInstall: true, + }, +}; + +/** 校验单个后端发行项,拒绝浮动或不完整来源。 */ +function isBackendDistribution( + value: unknown, + id: BackendDistributionId, +): value is BackendDistribution { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const raw = value as Record; + return ( + raw.id === id + && typeof raw.repository === 'string' + && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(raw.repository) + && typeof raw.ref === 'string' + && /^[A-Za-z0-9._/-]+$/.test(raw.ref) + && typeof raw.commit === 'string' + && /^[0-9a-f]{40}$/.test(raw.commit) + && typeof raw.forceUpdateOnInstall === 'boolean' + ); +} + +/** 校验打包器写入的双通道清单;正式包缺失或损坏时失败关闭。 */ +function readBackendDistributions(): BackendDistributionManifest { + const resourcesPath = process.resourcesPath; + if (process.defaultApp || !resourcesPath) return DEFAULT_DISTRIBUTIONS; + const manifestPath = path.join( + resourcesPath, + 'backend-distribution.json', + ); + try { + const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + if ( + isBackendDistribution(raw.stable, 'stable') + && isBackendDistribution(raw.alpha, 'alpha') + ) { + return raw as BackendDistributionManifest; + } + } catch (error) { + throw new Error( + `后端发行清单无效: ${error instanceof Error ? error.message : String(error)}`, + ); + } + throw new Error('后端发行清单字段无效'); +} + +export const BACKEND_DISTRIBUTIONS = readBackendDistributions(); + +/** 使用与 GUI 更新通道相同的设置选择受管后端。 */ +export function resolveBackendDistribution( + allowTestUpdates: boolean, +): BackendDistribution { + return allowTestUpdates + ? BACKEND_DISTRIBUTIONS.alpha + : BACKEND_DISTRIBUTIONS.stable; +} + +/** 生成固定提交归档地址,禁止运行时跟随浮动分支。 */ +export function buildManagedAutowsgrRequirement( + distribution: BackendDistribution, +): string { + return ( + `https://github.com/${distribution.repository}/archive/` + + `${distribution.commit}.zip` + ); +} diff --git a/electron/pythonEnv/context.ts b/electron/pythonEnv/context.ts index d287ec6..0c1756d 100644 --- a/electron/pythonEnv/context.ts +++ b/electron/pythonEnv/context.ts @@ -1,6 +1,5 @@ /** - * Python 环境共享上下文与缓存状态。 - * 由 main.ts 在启动时通过 initPythonEnv() 注入。 + * 保存由 main.ts 注入的 Python 环境上下文和路径缓存。 */ export interface PythonEnvContext { @@ -8,6 +7,9 @@ export interface PythonEnvContext { sendProgress: (msg: string) => void; getConfiguredPythonPath: () => string | null; getUpdateMode: () => 'auto' | 'manual'; + allowTestUpdates: () => boolean; + getBackendStartupMode: () => 'managed' | 'external'; + getBackendRepoPath: () => string; getTempDir: () => string; } @@ -17,19 +19,17 @@ export function initPythonEnv(context: PythonEnvContext): void { ctx = context; } -/** 内部访问器:获取已注入的上下文 */ +/** 获取已注入的 Python 环境上下文。 */ export function getCtx(): PythonEnvContext { return ctx; } -// ════════════════════════════════════════ // Python 路径缓存 -// ════════════════════════════════════════ -/** 缓存的 Python 路径 (undefined = 尚未查找) */ +/** 缓存的 Python 路径,undefined 表示尚未查找。 */ let cachedPythonCmd: string | null | undefined; -/** 清除 Python 路径缓存(用户切换路径后调用) */ +/** 清除 Python 路径缓存。 */ export function clearPythonCache(): void { cachedPythonCmd = undefined; } diff --git a/electron/pythonEnv/cuda.ts b/electron/pythonEnv/cuda.ts new file mode 100644 index 0000000..9da7ba0 --- /dev/null +++ b/electron/pythonEnv/cuda.ts @@ -0,0 +1,129 @@ +/** + * 解析 CUDA 配置并构造 Python 子进程环境。 + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { + buildPythonProcessEnv, + type PythonEnvironment, +} from './environment'; + +function isRecord(value: unknown): value is Record { + return ( + typeof value === 'object' + && value !== null + && !Array.isArray(value) + ); +} + +function nestedVersion( + root: Record, + key: string, +): string | null { + const section = root[key]; + if (!isRecord(section)) return null; + const version = section.version; + return typeof version === 'string' && version.trim() + ? version.trim() + : null; +} + +/** 读取 CUDA version.json;无效或缺失时由调用方执行目录名回退。 */ +export function readCudaVersionFile(cudaRoot: string): string | null { + try { + const versionJson = path.join(cudaRoot, 'version.json'); + if (!fs.existsSync(versionJson)) return null; + const parsed: unknown = JSON.parse( + fs.readFileSync(versionJson, 'utf-8').replace(/^\uFEFF/, ''), + ); + if (!isRecord(parsed)) return null; + return nestedVersion(parsed, 'cuda') + ?? nestedVersion(parsed, 'cuda_cudart'); + } catch { + return null; + } +} + +function normalizeCudaRoot(candidate: string): string { + const resolved = path.resolve(candidate.trim()); + if (isCudaRuntimeDirectory(resolved)) return resolved; + return path.basename(resolved).toLowerCase() === 'bin' + ? path.dirname(resolved) + : resolved; +} + +function isCudaRuntimeDirectory(candidate: string): boolean { + try { + const names = fs.readdirSync(candidate); + return names.some(name => /^cudart64.*\.dll$/i.test(name)) + && names.some(name => /^cublas64.*\.dll$/i.test(name)); + } catch { + return false; + } +} + +/** 校验用户配置并返回 CUDA Toolkit 根目录或运行库目录。 */ +export function resolveConfiguredCudaRoot(value: unknown): string | null { + if (typeof value !== 'string' || !value.trim()) return null; + const cudaRoot = normalizeCudaRoot(value); + const binDir = path.join(cudaRoot, 'bin'); + const runtimeDir = isCudaRuntimeDirectory(cudaRoot) + ? cudaRoot + : isCudaRuntimeDirectory(binDir) + ? binDir + : null; + if (!fs.existsSync(path.join(binDir, 'nvcc.exe')) && !runtimeDir) { + return null; + } + return fs.existsSync(path.join(binDir, 'nvcc.exe')) + ? cudaRoot + : runtimeDir; +} + +/** 在同一个 Python 基础环境上叠加 CUDA 运行变量。 */ +export function buildCudaEnvironment( + baseEnv: NodeJS.ProcessEnv, + configuredCudaRoot: string | null, +): NodeJS.ProcessEnv { + if (!configuredCudaRoot) return { ...baseEnv }; + + const cudaRoot = normalizeCudaRoot(configuredCudaRoot); + const isToolkit = fs.existsSync(path.join(cudaRoot, 'bin', 'nvcc.exe')); + const cudaBin = isToolkit ? path.join(cudaRoot, 'bin') : cudaRoot; + const existingPath = baseEnv.PATH || baseEnv.Path || ''; + const pathEntries = existingPath.split(path.delimiter).filter(Boolean); + const withoutDuplicate = pathEntries.filter( + entry => path.resolve(entry).toLowerCase() + !== path.resolve(cudaBin).toLowerCase(), + ); + const env: NodeJS.ProcessEnv = { ...baseEnv }; + for (const key of Object.keys(env)) { + if (key.toLowerCase() === 'path') delete env[key]; + } + if (isToolkit) { + env.CUDA_PATH = cudaRoot; + env.CUDA_HOME = cudaRoot; + } + env.PATH = [cudaBin, ...withoutDuplicate].join(path.delimiter); + + let version = readCudaVersionFile(cudaRoot); + version ??= path.basename(cudaRoot).match(/v(\d+(?:\.\d+)?)/i)?.[1] + ?? null; + const versionMatch = version?.match(/^(\d+)\.(\d+)/); + if (isToolkit && versionMatch) { + env[`CUDA_PATH_V${versionMatch[1]}_${versionMatch[2]}`] = cudaRoot; + } + return env; +} + +/** 构造 CUDA 检测和后端启动共同使用的完整 Python 运行环境。 */ +export function buildBackendRuntimeEnvironment( + environment: PythonEnvironment, + configuredCudaRoot: string | null, + baseEnv: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + return buildCudaEnvironment( + buildPythonProcessEnv(environment, baseEnv), + configuredCudaRoot, + ); +} diff --git a/electron/pythonEnv/dependencies.ts b/electron/pythonEnv/dependencies.ts new file mode 100644 index 0000000..1909d3b --- /dev/null +++ b/electron/pythonEnv/dependencies.ts @@ -0,0 +1,44 @@ +/** GUI Python 运行时必须可导入的模块。 */ +export const PYTHON_DEPENDENCY_SPECS = Object.freeze([ + { + key: 'uvicorn', + importName: 'uvicorn', + packageName: 'uvicorn', + }, + { + key: 'fastapi', + importName: 'fastapi', + packageName: 'fastapi', + }, + { + key: 'scipy', + importName: 'scipy._lib', + packageName: 'scipy', + }, + { + key: 'requests', + importName: 'requests', + packageName: 'requests', + }, + { + key: 'beautifulSoup', + importName: 'bs4', + packageName: 'beautifulsoup4', + }, + { + key: 'maafw', + importName: 'maa', + packageName: 'maafw>=5.12.3,<6.0', + }, +] as const); + +/** 舰船资料库更新器不由 AutoWSGR 后端依赖间接保证的包。 */ +export const SHIP_LIBRARY_REQUIREMENTS: readonly string[] = Object.freeze([ + 'requests>=2.32.5', + 'beautifulsoup4>=4.12.0', +]); + +/** 使用 --no-deps 更新后端前必须显式安装的运行依赖。 */ +export const BACKEND_RUNTIME_REQUIREMENTS: readonly string[] = Object.freeze([ + 'maafw>=5.12.3,<6.0', +]); diff --git a/electron/pythonEnv/envCheck.ts b/electron/pythonEnv/envCheck.ts index f9b1b6e..02897ac 100644 --- a/electron/pythonEnv/envCheck.ts +++ b/electron/pythonEnv/envCheck.ts @@ -1,6 +1,5 @@ /** - * 环境校验主流程。 - * 包括 VC++ 检查、env marker 管理、依赖包验证。 + * 检查 VC++、Python、依赖包和环境就绪标记。 */ import * as path from 'path'; import * as fs from 'fs'; @@ -8,21 +7,46 @@ import { exec } from 'child_process'; import { promisify } from 'util'; import { getCtx, setCachedPythonCmd } from './context'; import { findPython } from './finder'; -import { type EnvCheckResult, ensurePthFile, localSitePackages, pipEnv, ensurePip, ensureSslCertForPython } from './utils'; +import { + type EnvCheckResult, + ensurePthFile, + localSitePackages, + pipEnv, + ensurePip, + ensureSslCertForPython, +} from './utils'; import { autoUpdateAutowsgr, type AutoUpdateDeps } from './updater'; +import { + buildPythonProcessEnv, + type PythonEnvironment, + resolvePythonEnvironment, +} from './environment'; +import { + buildBackendRuntimeContractProbeLines, +} from './backendContractProbe'; +import { PYTHON_DEPENDENCY_SPECS } from './dependencies'; +import { + buildManagedAutowsgrRequirement, + resolveBackendDistribution, +} from './backendRequirement'; const execAsync = promisify(exec); -// ════════════════════════════════════════ // VC++ Redistributable -// ════════════════════════════════════════ -/** 检查并安装 VC++ Redistributable(c10.dll 等依赖需要) */ +/** 检查并安装 VC++ Redistributable。 */ async function ensureVCRedist(): Promise { const ctx = getCtx(); - // vcruntime140.dll 存在于 system32 说明已安装 - const dllPath = path.join(process.env.SYSTEMROOT || 'C:\\Windows', 'System32', 'vcruntime140.dll'); - if (fs.existsSync(dllPath)) return; + // system32 中存在 vcruntime140.dll 即视为已安装。 + const systemRoot = process.env.SystemRoot + || process.env.SYSTEMROOT + || process.env.WINDIR; + if ( + systemRoot + && fs.existsSync(path.join(systemRoot, 'System32', 'vcruntime140.dll')) + ) { + return; + } ctx.sendProgress('Microsoft Visual C++ Redistributable is not installed, this may lead to the DLL load failure.'); const redistExe = path.join(ctx.appRoot(), 'redist', 'vc_redist.x64.exe'); @@ -40,42 +64,83 @@ async function ensureVCRedist(): Promise { } } -// ════════════════════════════════════════ // 环境就绪标记 (.env_ready) -// ════════════════════════════════════════ -/** 环境就绪标记文件路径 */ +/** 返回环境就绪标记路径。 */ export const ENV_READY_MARKER = () => path.join(getCtx().appRoot(), '.env_ready'); -/** 读取标记文件中保存的 autowsgr 版本;标记不存在或无效时返回 null */ -function readEnvMarker(): { pythonCmd: string; pythonVersion: string; autowsgrVersion: string } | null { +interface EnvironmentMarker { + pythonCmd: string; + pythonVersion: string; + autowsgrVersion: string; + environmentIdentity: string; + backendRequirement: string | null; + environment: PythonEnvironment; +} + +/** 返回当前 GUI 通道固定的 managed 后端来源。 */ +function managedBackendRequirement(): string { + return buildManagedAutowsgrRequirement( + resolveBackendDistribution(getCtx().allowTestUpdates()), + ); +} + +/** 读取环境标记;当前模式、解释器或仓库变化时返回 null。 */ +function readEnvMarker(): EnvironmentMarker | null { const ctx = getCtx(); try { const data = JSON.parse(fs.readFileSync(ENV_READY_MARKER(), 'utf-8')); - if (data && data.pythonCmd && data.autowsgrVersion) { - // 确保记录的 python 路径仍然存在 + if ( + data + && data.pythonCmd + && data.autowsgrVersion + && data.environmentIdentity + ) { + // 标记中的 Python 路径必须仍然存在。 if (!fs.existsSync(data.pythonCmd)) return null; - // 若用户切换了 Python 路径,旧标记自动失效 + // Python 路径变化后旧标记失效。 const configured = ctx.getConfiguredPythonPath(); if (configured && configured !== data.pythonCmd) return null; - return data; + const environment = resolvePythonEnvironment(data.pythonCmd); + if (environment.identity !== data.environmentIdentity) return null; + if ( + environment.startupMode === 'managed' + && data.backendRequirement !== managedBackendRequirement() + ) { + return null; + } + return { ...data, environment }; } - } catch { /* ignore */ } + } catch { /* 标记缺失或损坏时重新检查。 */ } return null; } -/** 写入环境就绪标记 */ -function writeEnvMarker(pythonCmd: string, pythonVersion: string, autowsgrVersion: string): void { +/** 写入环境就绪标记。 */ +function writeEnvMarker( + environment: PythonEnvironment, + pythonVersion: string, + autowsgrVersion: string, +): void { try { - fs.writeFileSync(ENV_READY_MARKER(), JSON.stringify({ pythonCmd, pythonVersion, autowsgrVersion }), 'utf-8'); - } catch { /* ignore */ } + fs.writeFileSync( + ENV_READY_MARKER(), + JSON.stringify({ + pythonCmd: environment.pythonCmd, + pythonVersion, + autowsgrVersion, + environmentIdentity: environment.identity, + backendRequirement: environment.startupMode === 'managed' + ? managedBackendRequirement() + : null, + }), + 'utf-8', + ); + } catch { /* 标记写入失败不阻断启动。 */ } } -// ════════════════════════════════════════ // autowsgr 更新桥接 -// ════════════════════════════════════════ -/** 构建 autoUpdateAutowsgr 所需的依赖对象 */ +/** 构造 autoUpdateAutowsgr 的依赖对象。 */ function buildAutoUpdateDeps(): AutoUpdateDeps { const ctx = getCtx(); return { @@ -85,108 +150,201 @@ function buildAutoUpdateDeps(): AutoUpdateDeps { localSitePackages, pipEnv, ensurePip, + backendRequirement: managedBackendRequirement, }; } -function shouldAutoUpdate(): boolean { +function shouldAutoUpdate(environment: PythonEnvironment): boolean { const ctx = getCtx(); - return ctx.getUpdateMode() !== 'manual'; + return environment.startupMode === 'managed' + && ctx.getUpdateMode() !== 'manual'; +} + +/** 发行包在安装器清除环境标记后必须重新安装一次指定后端。 */ +function shouldForceManagedBackendInstall( + environment: PythonEnvironment, +): boolean { + return ( + environment.startupMode === 'managed' + && resolveBackendDistribution( + getCtx().allowTestUpdates(), + ).forceUpdateOnInstall + ); +} + +function autoUpdateSkipMessage(environment: PythonEnvironment): string { + return environment.startupMode === 'external' + ? '本地后端调试模式:跳过 autowsgr 自动更新检查' + : '手动更新模式:跳过 autowsgr 自动更新检查'; } type CoreDepProbeResult = { uvicorn: boolean; fastapi: boolean; scipy: boolean; + requests: boolean; + beautifulSoup: boolean; + maafw: boolean; autowsgr: string | null; + backendRuntimeContract: boolean; }; -/** - * 检查核心依赖可导入性。 - * 这里额外检查 scipy._lib,避免仅校验入口包导致“检查通过但运行时报错”。 - */ -async function probeCoreDependencies(pythonCmd: string): Promise { +/** 检查核心依赖及 scipy._lib 是否可导入。 */ +async function probeCoreDependencies( + environment: PythonEnvironment, +): Promise { const ctx = getCtx(); - const spFwd = localSitePackages().replace(/\\/g, '/'); + const { backendRoot, pythonCmd, useLocalSite } = environment; + const expectedRoot = backendRoot || environment.localSite; + const pythonPath = (value: string): string => value + .replace(/\\/g, '/') + .replace(/'/g, "\\'"); const checkScript = path.join(ctx.getTempDir(), 'autowsgr_depcheck.py'); - fs.writeFileSync(checkScript, [ + const scriptLines = [ 'import json, sys, site', - `sp = '${spFwd}'`, - 'sys.path.insert(0, sp)', - 'site.addsitedir(sp)', + ...(useLocalSite + ? [ + `sp = '${pythonPath(localSitePackages())}'`, + 'sys.path.insert(0, sp)', + 'site.addsitedir(sp)', + ] + : []), + ...(backendRoot + ? [ + `repo = '${pythonPath(backendRoot)}'`, + 'sys.path.insert(0, repo)', + ] + : []), 'r = {}', - "checks = [('uvicorn', 'uvicorn'), ('fastapi', 'fastapi'), ('scipy', 'scipy._lib')]", + `checks = ${JSON.stringify( + PYTHON_DEPENDENCY_SPECS.map( + dependency => [dependency.key, dependency.importName], + ), + )}`, 'for key, mod in checks:', ' try:', ' __import__(mod); r[key] = True', ' except Exception:', ' r[key] = False', 'try:', - ' import autowsgr; r["autowsgr"] = autowsgr.__version__', + ' import autowsgr', + ' r["autowsgr"] = getattr(autowsgr, "__version__", "source")', + ' r["autowsgr_path"] = autowsgr.__file__', 'except Exception:', ' r["autowsgr"] = None', + ' r["autowsgr_path"] = None', + ...buildBackendRuntimeContractProbeLines(), + 'try:', + ' _verify_gui_runtime_contract()', + ' r["backend_runtime_contract"] = True', + 'except Exception:', + ' r["backend_runtime_contract"] = False', 'print(json.dumps(r))', - ].join('\n'), 'utf-8'); + ]; + fs.writeFileSync(checkScript, scriptLines.join('\n'), 'utf-8'); try { const { stdout: depOut } = await execAsync( `"${pythonCmd}" "${checkScript}"`, - { windowsHide: true, timeout: 30000 }, + { + windowsHide: true, + timeout: 30000, + env: buildPythonProcessEnv(environment), + }, ); const depResult = JSON.parse(depOut.trim()); + const autowsgrPath = typeof depResult.autowsgr_path === 'string' + ? path.resolve(depResult.autowsgr_path) + : ''; + const relativePath = autowsgrPath + ? path.relative(path.resolve(expectedRoot), autowsgrPath) + : ''; + const usesExpectedAutowsgr = autowsgrPath !== '' + && relativePath !== '..' + && !relativePath.startsWith(`..${path.sep}`) + && !path.isAbsolute(relativePath); + if (depResult.autowsgr != null && !usesExpectedAutowsgr) { + ctx.sendProgress( + `WARNING 忽略来源不正确的 autowsgr: ${autowsgrPath}`, + ); + } return { uvicorn: Boolean(depResult.uvicorn), fastapi: Boolean(depResult.fastapi), scipy: Boolean(depResult.scipy), - autowsgr: depResult.autowsgr == null ? null : String(depResult.autowsgr), + requests: Boolean(depResult.requests), + beautifulSoup: Boolean(depResult.beautifulSoup), + maafw: Boolean(depResult.maafw), + autowsgr: depResult.autowsgr == null || !usesExpectedAutowsgr + ? null + : String(depResult.autowsgr), + backendRuntimeContract: ( + usesExpectedAutowsgr + && depResult.backend_runtime_contract === true + ), }; } catch { return null; } finally { - try { fs.unlinkSync(checkScript); } catch { /* ignore */ } + try { fs.unlinkSync(checkScript); } catch { /* 忽略清理失败。 */ } } } -// ════════════════════════════════════════ // 环境检查主流程 -// ════════════════════════════════════════ -/** 检查 Python 环境和所需包 */ +function environmentSourceMessage( + environment: PythonEnvironment, +): string { + return `运行环境来源: 后端 ${environment.startupMode}, Python ${environment.pythonSource} (${environment.pythonCmd})`; +} + +/** 检查 Python 环境和所需包。 */ export async function checkEnvironment(): Promise { const ctx = getCtx(); ctx.sendProgress('正在检查运行环境…'); await ensureVCRedist(); - // ── 快速路径: 如果标记文件存在且有效,跳过重量级依赖检查 ── + // 有效标记可跳过重量级依赖检查。 const marker = readEnvMarker(); if (marker) { setCachedPythonCmd(marker.pythonCmd); + ctx.sendProgress(environmentSourceMessage(marker.environment)); const certFile = await ensureSslCertForPython(marker.pythonCmd); if (certFile) ctx.sendProgress(`TLS 证书已就绪: ${certFile}`); else ctx.sendProgress('WARNING 未检测到 TLS 根证书,后续联网操作可能失败'); - const markerProbe = await probeCoreDependencies(marker.pythonCmd); + const markerProbe = await probeCoreDependencies(marker.environment); const markerBrokenDeps: string[] = []; if (!markerProbe) { markerBrokenDeps.push('dep-check'); } else { - if (!markerProbe.uvicorn) markerBrokenDeps.push('uvicorn'); - if (!markerProbe.fastapi) markerBrokenDeps.push('fastapi'); - if (!markerProbe.scipy) markerBrokenDeps.push('scipy'); + for (const dependency of PYTHON_DEPENDENCY_SPECS) { + if (!markerProbe[dependency.key]) { + markerBrokenDeps.push(dependency.packageName); + } + } if (markerProbe.autowsgr == null) markerBrokenDeps.push('autowsgr'); + if (!markerProbe.backendRuntimeContract) { + markerBrokenDeps.push('autowsgr-runtime-contract'); + } } if (markerBrokenDeps.length === 0) { - // 每次启动检查并自动更新 autowsgr(可由更新模式关闭) + // 自动模式下每次启动检查 autowsgr 更新。 const markerAutowsgrVersion = markerProbe?.autowsgr ?? marker.autowsgrVersion; let finalVer = markerAutowsgrVersion; - if (shouldAutoUpdate()) { + if (shouldAutoUpdate(marker.environment)) { const updatedVer = await autoUpdateAutowsgr(marker.pythonCmd, buildAutoUpdateDeps()); finalVer = updatedVer ?? markerAutowsgrVersion; if (updatedVer && updatedVer !== markerAutowsgrVersion) { - writeEnvMarker(marker.pythonCmd, marker.pythonVersion, finalVer); + writeEnvMarker( + marker.environment, + marker.pythonVersion, + finalVer, + ); } } else { - ctx.sendProgress('手动更新模式:跳过 autowsgr 自动更新检查'); + ctx.sendProgress(autoUpdateSkipMessage(marker.environment)); } ctx.sendProgress(`环境就绪 (${marker.pythonVersion}, autowsgr ${finalVer}) ✓`); return { @@ -198,18 +356,34 @@ export async function checkEnvironment(): Promise { } ctx.sendProgress(`检测到依赖异常 (${markerBrokenDeps.join(', ')}),重新执行完整检查…`); - try { fs.unlinkSync(ENV_READY_MARKER()); } catch { /* ignore */ } + try { fs.unlinkSync(ENV_READY_MARKER()); } catch { /* 忽略清理失败。 */ } } - // ── 完整检查路径 ── + // 标记无效时执行完整检查。 ctx.sendProgress('正在检查 Python 环境…'); - ensurePthFile(); const pythonCmd = await findPython(); if (!pythonCmd) { ctx.sendProgress('WARNING 未找到兼容的 Python(需要 3.12 或 3.13)'); return { pythonCmd: null, pythonVersion: null, missingPackages: [], allReady: false }; } + let environment: PythonEnvironment; + try { + environment = resolvePythonEnvironment(pythonCmd); + } catch (error) { + ctx.sendProgress( + `ERROR ${error instanceof Error ? error.message : String(error)}`, + ); + return { + pythonCmd, + pythonVersion: null, + missingPackages: ['autowsgr'], + allReady: false, + }; + } + ctx.sendProgress(environmentSourceMessage(environment)); + if (environment.useLocalSite) ensurePthFile(); + const certFile = await ensureSslCertForPython(pythonCmd); if (certFile) ctx.sendProgress(`TLS 证书已就绪: ${certFile}`); else ctx.sendProgress('WARNING 未检测到 TLS 根证书,后续联网操作可能失败'); @@ -219,24 +393,24 @@ export async function checkEnvironment(): Promise { const { stdout } = await execAsync(`"${pythonCmd}" --version`, { windowsHide: true }); pythonVersion = stdout.trim(); ctx.sendProgress(`${pythonVersion} ✓`); - } catch { /* ignore */ } + } catch { /* 版本读取失败时保留空值。 */ } ctx.sendProgress('正在检查依赖包…'); const missingPackages: string[] = []; let autowsgrVersion = ''; try { - const depResult = await probeCoreDependencies(pythonCmd); + const depResult = await probeCoreDependencies(environment); if (!depResult) { throw new Error('依赖探测失败'); } - for (const pkg of ['uvicorn', 'fastapi', 'scipy'] as const) { - if (depResult[pkg]) { - ctx.sendProgress(` ${pkg} \u2713`); + for (const dependency of PYTHON_DEPENDENCY_SPECS) { + if (depResult[dependency.key]) { + ctx.sendProgress(` ${dependency.packageName} \u2713`); } else { - missingPackages.push(pkg); - ctx.sendProgress(` ${pkg} \u2717`); + missingPackages.push(dependency.packageName); + ctx.sendProgress(` ${dependency.packageName} \u2717`); } } @@ -248,16 +422,50 @@ export async function checkEnvironment(): Promise { missingPackages.push('autowsgr'); ctx.sendProgress(` autowsgr \u2717`); } + if (depResult.backendRuntimeContract) { + ctx.sendProgress(' AutoWSGR GUI 运行契约 ✓'); + } else { + missingPackages.push('autowsgr-runtime-contract'); + ctx.sendProgress( + ' AutoWSGR GUI 运行契约 ✗ 请更新后端版本', + ); + } } catch { - missingPackages.push('uvicorn', 'fastapi', 'scipy', 'autowsgr'); + missingPackages.push( + ...PYTHON_DEPENDENCY_SPECS.map( + dependency => dependency.packageName, + ), + 'autowsgr', + ); ctx.sendProgress(' 依赖检查失败'); } + const forceBackendInstall = shouldForceManagedBackendInstall(environment); const allReady = missingPackages.length === 0; + if (!allReady && forceBackendInstall) { + ctx.sendProgress('覆盖安装后正在增量更新后端及依赖…'); + const updatedVer = await autoUpdateAutowsgr( + pythonCmd, + buildAutoUpdateDeps(), + true, + ); + if (updatedVer) { + writeEnvMarker(environment, pythonVersion || '', updatedVer); + ctx.sendProgress(`环境增量更新完成 (autowsgr ${updatedVer}) ✓`); + return { + pythonCmd, + pythonVersion, + missingPackages: [], + allReady: true, + }; + } + ctx.sendProgress('WARNING 增量更新未完成,将尝试修复缺失依赖'); + } + if (allReady) { ctx.sendProgress('依赖检查通过 ✓'); - // 检查 ADB 可用性 + // 检查 ADB 可用性。 const adbDir = path.join(ctx.appRoot(), 'adb'); const builtinAdb = path.join(adbDir, 'adb.exe'); if (fs.existsSync(builtinAdb)) { @@ -266,15 +474,28 @@ export async function checkEnvironment(): Promise { ctx.sendProgress('ADB (内置) ✗ 将使用模拟器自带 ADB'); } - // 检查并自动更新 autowsgr(可由更新模式关闭) + // 自动模式下检查并更新 autowsgr。 let finalVer = autowsgrVersion; - if (shouldAutoUpdate()) { - const updatedVer = await autoUpdateAutowsgr(pythonCmd, buildAutoUpdateDeps()); + if (shouldAutoUpdate(environment) || forceBackendInstall) { + const updatedVer = await autoUpdateAutowsgr( + pythonCmd, + buildAutoUpdateDeps(), + forceBackendInstall, + ); finalVer = updatedVer || autowsgrVersion; + if (forceBackendInstall && !updatedVer) { + ctx.sendProgress('WARNING 当前通道后端强制更新未完成,下次启动将重试'); + return { + pythonCmd, + pythonVersion, + missingPackages: [], + allReady: true, + }; + } } else { - ctx.sendProgress('手动更新模式:跳过 autowsgr 自动更新检查'); + ctx.sendProgress(autoUpdateSkipMessage(environment)); } - writeEnvMarker(pythonCmd, pythonVersion || '', finalVer); + writeEnvMarker(environment, pythonVersion || '', finalVer); } return { diff --git a/electron/pythonEnv/environment.ts b/electron/pythonEnv/environment.ts new file mode 100644 index 0000000..ca62794 --- /dev/null +++ b/electron/pythonEnv/environment.ts @@ -0,0 +1,153 @@ +/** + * 统一描述 Python 安装、检查和启动环境。 + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { getCtx } from './context'; +import { isLocalPython, localSitePackages } from './utils'; + +export type BackendStartupMode = 'managed' | 'external'; +export type PythonSource = 'configured' | 'bundled' | 'system'; + +/** 安装、检查和启动共同使用的 Python 环境。 */ +export interface PythonEnvironment { + startupMode: BackendStartupMode; + pythonSource: PythonSource; + pythonCmd: string; + backendRoot: string | null; + localSite: string; + useLocalSite: boolean; + installTarget: string | null; + identity: string; +} + +function normalizeIdentityPath(value: string | null): string | null { + if (!value) return null; + const normalized = path.resolve(value); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} + +function isSamePath(left: string, right: string): boolean { + return normalizeIdentityPath(left) === normalizeIdentityPath(right); +} + +/** 解析 external 模式的仓库根目录;managed 模式返回 null。 */ +export function resolveExternalBackendRoot(): string | null { + const ctx = getCtx(); + if (ctx.getBackendStartupMode() !== 'external') return null; + + const configured = process.env.AUTOWSGR_BACKEND_REPO?.trim() + || ctx.getBackendRepoPath().trim(); + if (!configured) { + throw new Error('external 模式未配置 AutoWSGR 本地仓库路径'); + } + + let root = path.resolve(configured); + if (!fs.existsSync(root)) { + throw new Error(`external 后端仓库路径不存在: ${root}`); + } + + const isPackageDirectory = path.basename(root).toLowerCase() === 'autowsgr' + && fs.existsSync(path.join(root, '__init__.py')) + && fs.existsSync(path.join(root, 'server', 'main.py')); + if (isPackageDirectory) root = path.dirname(root); + + if (!fs.existsSync(path.join(root, 'autowsgr', 'server', 'main.py'))) { + throw new Error( + `external 后端仓库无效,未找到 autowsgr/server/main.py: ${root}`, + ); + } + return root; +} + +/** 根据当前设置生成唯一的 Python 环境描述。 */ +export function resolvePythonEnvironment( + pythonCmd: string, +): PythonEnvironment { + const ctx = getCtx(); + const startupMode = ctx.getBackendStartupMode(); + const backendRoot = resolveExternalBackendRoot(); + const localSite = localSitePackages(); + const useLocalSite = startupMode === 'managed' || isLocalPython(pythonCmd); + const installTarget = useLocalSite ? localSite : null; + const configuredPython = ctx.getConfiguredPythonPath(); + const bundledPython = path.join(ctx.appRoot(), 'python', 'python.exe'); + const pythonSource: PythonSource = ( + configuredPython && isSamePath(pythonCmd, configuredPython) + ) + ? 'configured' + : isSamePath(pythonCmd, bundledPython) + ? 'bundled' + : 'system'; + const identity = JSON.stringify({ + startupMode, + pythonCmd: normalizeIdentityPath(pythonCmd), + backendRoot: normalizeIdentityPath(backendRoot), + installTarget: normalizeIdentityPath(installTarget), + }); + + return { + startupMode, + pythonSource, + pythonCmd, + backendRoot, + localSite, + useLocalSite, + installTarget, + identity, + }; +} + +/** 返回当前 managed 或 external 后端实际使用的舰名库路径。 */ +export function backendShipNamesPath(pythonCmd: string): string { + const environment = resolvePythonEnvironment(pythonCmd); + const backendRoot = environment.backendRoot ?? environment.localSite; + return path.join( + backendRoot, + 'autowsgr', + 'data', + 'shipnames.yaml', + ); +} + +/** 构造子进程环境,并隔离外部解释器与 managed 包目录。 */ +export function buildPythonProcessEnv( + environment: PythonEnvironment, + baseEnv: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const result: NodeJS.ProcessEnv = { ...baseEnv }; + if (environment.useLocalSite) { + const existing = result.PYTHONPATH || ''; + const entries = existing.split(path.delimiter).filter(Boolean); + if (!entries.some(entry => isSamePath(entry, environment.localSite))) { + entries.unshift(environment.localSite); + } + result.PYTHONPATH = entries.join(path.delimiter); + result.PYTHONUSERBASE = path.join(getCtx().appRoot(), 'python'); + return result; + } + + const entries = (result.PYTHONPATH || '') + .split(path.delimiter) + .filter(entry => entry && !isSamePath(entry, environment.localSite)); + if (entries.length > 0) result.PYTHONPATH = entries.join(path.delimiter); + else delete result.PYTHONPATH; + + const managedUserBase = path.join(getCtx().appRoot(), 'python'); + if ( + result.PYTHONUSERBASE + && isSamePath(result.PYTHONUSERBASE, managedUserBase) + ) { + delete result.PYTHONUSERBASE; + } + return result; +} + +/** 返回 pip 安装目标参数;外部解释器使用自身环境时返回空数组。 */ +export function installTargetArgs( + environment: PythonEnvironment, +): string[] { + return environment.installTarget + ? ['--target', environment.installTarget] + : []; +} diff --git a/electron/pythonEnv/finder.ts b/electron/pythonEnv/finder.ts index 4265e6c..8d8efc0 100644 --- a/electron/pythonEnv/finder.ts +++ b/electron/pythonEnv/finder.ts @@ -1,16 +1,15 @@ /** - * Python 可执行文件查找逻辑。 - * 优先级: 用户配置 > 本地便携版 > 系统全局。 + * 按用户配置、便携版、系统全局的顺序查找 Python。 */ import * as path from 'path'; import * as fs from 'fs'; -import { exec, execSync } from 'child_process'; +import { exec } from 'child_process'; import { promisify } from 'util'; import { getCtx, getCachedPythonCmd, setCachedPythonCmd } from './context'; const execAsync = promisify(exec); -/** 检查 Python 版本是否为 3.12.x 或 3.13.x */ +/** 判断 Python 版本是否为 3.12 或 3.13。 */ export function isAllowedPythonVersion(versionOutput: string): boolean { const m = versionOutput.match(/(\d+)\.(\d+)/); if (!m) return false; @@ -19,14 +18,14 @@ export function isAllowedPythonVersion(versionOutput: string): boolean { return major === 3 && (minor === 12 || minor === 13); } -/** 查找可用的 Python 可执行文件 (用户配置 > 本地便携版 > 系统, 仅接受 3.12/3.13, 结果会缓存) */ +/** 异步查找并缓存兼容的 Python 可执行文件。 */ export async function findPython(): Promise { if (getCachedPythonCmd() !== undefined) return getCachedPythonCmd()!; const ctx = getCtx(); let found: string | null = null; - // 最高优先级:用户在配置页指定的 Python 路径 + // 优先使用配置页指定的 Python。 const configured = ctx.getConfiguredPythonPath(); if (configured && fs.existsSync(configured)) { try { @@ -41,26 +40,24 @@ export async function findPython(): Promise { ctx.sendProgress('WARNING 用户配置的 Python 路径不存在,回退自动检测'); } - // 优先使用本地便携版 Python + // 其次使用本地便携版 Python。 const localPython = path.join(ctx.appRoot(), 'python', 'python.exe'); - if (fs.existsSync(localPython)) { + if (!found && fs.existsSync(localPython)) { try { const { stdout, stderr } = await execAsync(`"${localPython}" --version`, { windowsHide: true }); const verStr = stdout || stderr; if (isAllowedPythonVersion(verStr)) found = localPython; else ctx.sendProgress(`WARNING 本地 Python 版本不兼容: ${verStr.trim()}(需要 3.12 或 3.13)`); - } catch { /* local Python broken */ } + } catch { /* 本地 Python 不可用时继续查找。 */ } } - if (!found && !configured) { // 仅在本地 Python 不可用且无用户配置时回退系统 Python - // 回退到系统全局 Python - // 注意: 必须解析出真实的 .exe 绝对路径,因为 pyenv 等工具使用 .bat shim, - // 而 Node.js spawn() 不经过 shell,无法执行 .bat 文件。 + if (!found) { // 用户配置和本地 Python 均不可用时回退系统 Python + // 最后回退到系统 Python;需解析真实 exe,避免 spawn 无法执行 .bat shim。 for (const cmd of ['python', 'python3']) { try { const { stdout: verOut, stderr: verErr } = await execAsync(`${cmd} --version`, { windowsHide: true }); if (!isAllowedPythonVersion(verOut || verErr)) continue; - // 通过 Python 自身获取真实可执行文件路径 (解决 pyenv/.bat shim 问题) + // 通过 Python 自身解析真实可执行文件。 const { stdout } = await execAsync( `${cmd} -c "import sys; print(sys.executable)"`, { windowsHide: true }, @@ -68,54 +65,10 @@ export async function findPython(): Promise { const resolved = stdout.trim(); found = (resolved && fs.existsSync(resolved)) ? resolved : cmd; break; - } catch { /* continue */ } + } catch { /* 当前命令不可用时继续查找。 */ } } } setCachedPythonCmd(found); return found; } - -/** 同步检查 Python 版本是否被允许 (shell 重定向以同时捕获 stdout/stderr) */ -function isAllowedPythonVersionSync(pythonCmd: string): boolean { - try { - const output = execSync( - `"${pythonCmd}" --version 2>&1`, - { encoding: 'utf-8', windowsHide: true, shell: 'cmd.exe' }, - ); - return isAllowedPythonVersion(output); - } catch { - return false; - } -} - -/** 同步查找 Python (用于非 async 上下文) */ -export function findPythonSync(): string | null { - if (getCachedPythonCmd() !== undefined) return getCachedPythonCmd()!; - const ctx = getCtx(); - // 最高优先级:用户配置的 Python 路径 - const configured = ctx.getConfiguredPythonPath(); - if (configured && fs.existsSync(configured) && isAllowedPythonVersionSync(configured)) { - setCachedPythonCmd(configured); - return configured; - } - const localPython = path.join(ctx.appRoot(), 'python', 'python.exe'); - if (fs.existsSync(localPython) && isAllowedPythonVersionSync(localPython)) { - setCachedPythonCmd(localPython); - return localPython; - } - for (const cmd of ['python', 'python3']) { - try { - if (!isAllowedPythonVersionSync(cmd)) continue; - // 解析真实路径 (pyenv/.bat shim 兼容) - const resolved = execSync( - `${cmd} -c "import sys; print(sys.executable)"`, - { windowsHide: true, encoding: 'utf-8' }, - ).trim(); - const result = (resolved && fs.existsSync(resolved)) ? resolved : cmd; - setCachedPythonCmd(result); - return result; - } catch { /* continue */ } - } - return null; -} diff --git a/electron/pythonEnv/index.ts b/electron/pythonEnv/index.ts index 16c53e7..7ed78e5 100644 --- a/electron/pythonEnv/index.ts +++ b/electron/pythonEnv/index.ts @@ -1,21 +1,53 @@ /** - * Barrel re-exports — 保持外部导入路径 './pythonEnv' 不变。 + * 汇总 Python 环境模块的公共导出。 */ -// context export { type PythonEnvContext, initPythonEnv, clearPythonCache } from './context'; -// finder -export { isAllowedPythonVersion, findPython, findPythonSync } from './finder'; +export { isAllowedPythonVersion, findPython } from './finder'; -// utils -export { type EnvCheckResult, sysPathInsert, ensurePthFile, pipEnv, localSitePackages, ensurePip, ensureSslCertForPython } from './utils'; +export { + type EnvCheckResult, + ensurePthFile, + pipEnv, + localSitePackages, + ensurePip, + ensureSslCertForPython, + isLocalPython, +} from './utils'; + +export { + type BackendStartupMode, + type PythonSource, + type PythonEnvironment, + resolveExternalBackendRoot, + resolvePythonEnvironment, + backendShipNamesPath, + buildPythonProcessEnv, + installTargetArgs, +} from './environment'; + +export { + readCudaVersionFile, + resolveConfiguredCudaRoot, + buildCudaEnvironment, + buildBackendRuntimeEnvironment, +} from './cuda'; -// envCheck export { checkEnvironment } from './envCheck'; -// installer -export { installPortablePython, checkForUpdates, installDependencies, pullUpdates } from './installer'; +export { + type DependencyInstallPlan, + installPortablePython, + checkForUpdates, + buildDependencyInstallPlan, + installDependencies, +} from './installer'; + +export { + BACKEND_DISTRIBUTIONS, + buildManagedAutowsgrRequirement, + resolveBackendDistribution, +} from './backendRequirement'; -// updater (DI 接口,供外部直接调用时使用) export { type AutoUpdateDeps, autoUpdateAutowsgr } from './updater'; diff --git a/electron/pythonEnv/installer.ts b/electron/pythonEnv/installer.ts index be1514c..1e583ce 100644 --- a/electron/pythonEnv/installer.ts +++ b/electron/pythonEnv/installer.ts @@ -7,19 +7,28 @@ import { exec, spawn } from 'child_process'; import { promisify } from 'util'; import { getCtx, setCachedPythonCmd } from './context'; import { findPython } from './finder'; -import { ensurePthFile, localSitePackages, pipEnv, ensurePip, ensureSslCertForPython } from './utils'; +import { ensurePthFile, pipEnv, ensurePip, ensureSslCertForPython } from './utils'; import { ENV_READY_MARKER } from './envCheck'; +import { + buildPythonProcessEnv, + installTargetArgs, + type PythonEnvironment, + resolvePythonEnvironment, +} from './environment'; +import { + BACKEND_RUNTIME_REQUIREMENTS, + SHIP_LIBRARY_REQUIREMENTS, +} from './dependencies'; +import { + buildManagedAutowsgrRequirement, + resolveBackendDistribution, +} from './backendRequirement'; const execAsync = promisify(exec); -/** PyPI 2.2.2 尚未包含 2026-07-30 活动支持,临时固定到上游已合入提交。 */ -const AUTOWSGR_REQUIREMENT = 'https://github.com/OpenWSGR/AutoWSGR/archive/a38252d3.zip'; - -// ════════════════════════════════════════ // 便携版 Python 安装 -// ════════════════════════════════════════ -/** 安装/初始化便携版 Python(已随应用打包,仅需确保 pip 就绪) */ +/** 安装或初始化便携版 Python。 */ export async function installPortablePython(): Promise<{ success: boolean }> { const ctx = getCtx(); setCachedPythonCmd(undefined); // 安装后需重新检测 @@ -27,30 +36,30 @@ export async function installPortablePython(): Promise<{ success: boolean }> { const pythonExe = path.join(pythonDir, 'python.exe'); if (!fs.existsSync(pythonExe)) { - // 兜底: 如果打包产物缺失 python,尝试在线下载 + // 打包产物缺失时尝试在线下载。 ctx.sendProgress('WARNING 未找到内置 Python,尝试在线下载…'); return downloadPortablePython(); } - // 确保 ._pth 配置正确 + // 确保 ._pth 配置正确。 ensurePthFile(); - // 检查 pip 是否可用 + // 检查 pip 是否可用。 try { await execAsync(`"${pythonExe}" -m pip --version`, { windowsHide: true, timeout: 15000 }); const certFile = await ensureSslCertForPython(pythonExe); if (certFile) ctx.sendProgress(`TLS 证书已就绪: ${certFile}`); ctx.sendProgress('内置 Python + pip 就绪 ✓'); return { success: true }; - } catch { /* pip not available, install it */ } + } catch { /* pip 不可用时继续安装。 */ } - // pip 缺失则安装 + // pip 缺失时执行安装。 ctx.sendProgress('正在安装 pip…'); const getPipPath = path.join(ctx.getTempDir(), 'get-pip.py'); try { await execAsync(`curl -sSL -o "${getPipPath}" "https://bootstrap.pypa.io/get-pip.py"`, { windowsHide: true, timeout: 60000 }); await execAsync(`"${pythonExe}" "${getPipPath}"`, { windowsHide: true, timeout: 120000 }); - try { fs.unlinkSync(getPipPath); } catch { /* ignore */ } + try { fs.unlinkSync(getPipPath); } catch { /* 忽略清理失败。 */ } const certFile = await ensureSslCertForPython(pythonExe); if (certFile) ctx.sendProgress(`TLS 证书已就绪: ${certFile}`); else ctx.sendProgress('WARNING 未检测到 TLS 根证书,后续联网操作可能失败'); @@ -62,7 +71,7 @@ export async function installPortablePython(): Promise<{ success: boolean }> { } } -/** 兜底: 在线下载便携版 Python(仅在内置 Python 缺失时使用) */ +/** 在内置 Python 缺失时在线下载便携版。 */ async function downloadPortablePython(): Promise<{ success: boolean }> { const ctx = getCtx(); const pythonDir = path.join(ctx.appRoot(), 'python'); @@ -94,7 +103,7 @@ async function downloadPortablePython(): Promise<{ success: boolean }> { ensurePthFile(); - // 安装 pip + // 安装 pip。 ctx.sendProgress('正在安装 pip…'); const getPipPath = path.join(ctx.getTempDir(), 'get-pip.py'); try { @@ -108,16 +117,14 @@ async function downloadPortablePython(): Promise<{ success: boolean }> { return { success: false }; } - try { fs.unlinkSync(zipPath); } catch { /* ignore */ } - try { fs.unlinkSync(getPipPath); } catch { /* ignore */ } + try { fs.unlinkSync(zipPath); } catch { /* 忽略清理失败。 */ } + try { fs.unlinkSync(getPipPath); } catch { /* 忽略清理失败。 */ } ctx.sendProgress(`Python ${version} 便携版安装完成 ✓`); return { success: true }; } -// ════════════════════════════════════════ // 更新检查 -// ════════════════════════════════════════ interface UpdateCheckResult { gitAvailable: boolean; @@ -127,7 +134,7 @@ interface UpdateCheckResult { remoteUrl: string; } -/** 检查 autowsgr 包是否有可用更新 (对比本地已安装版本与 PyPI 最新版) */ +/** 比较本地与 PyPI 的 autowsgr 版本。 */ export async function checkForUpdates(): Promise { const result: UpdateCheckResult = { gitAvailable: false, @@ -140,17 +147,17 @@ export async function checkForUpdates(): Promise { const pythonCmd = await findPython(); if (!pythonCmd) return result; - result.gitAvailable = true; // reuse field: means "can check updates" + result.gitAvailable = true; // 复用字段表示可检查更新。 try { - // 获取已安装版本 + // 获取已安装版本。 const { stdout: localVer } = await execAsync( `"${pythonCmd}" -c "import autowsgr; print(autowsgr.__version__)"`, { windowsHide: true, env: pipEnv() }, ); - result.currentBranch = localVer.trim(); // reuse field: current version + result.currentBranch = localVer.trim(); // 复用字段保存当前版本。 - // 获取 PyPI 最新版本 + // 获取 PyPI 最新版本。 const { stdout: pipOut } = await execAsync( `"${pythonCmd}" -m pip index versions autowsgr`, { windowsHide: true, timeout: 15000, env: pipEnv() }, @@ -160,40 +167,98 @@ export async function checkForUpdates(): Promise { const latestVer = m[1].replace(/,$/,''); result.hasUpdates = latestVer !== result.currentBranch; } - } catch { /* ignore */ } + } catch { /* 检查失败时返回默认结果。 */ } return result; } -// ════════════════════════════════════════ // 依赖安装与更新 -// ════════════════════════════════════════ -/** 自动安装依赖 (pip install autowsgr),始终安装到项目目录,不动全局 */ +export interface DependencyInstallPlan { + buildArgs: string[]; + toolArgs: string[]; + backendArgs: string[]; +} + +/** 生成与运行环境一致的 pip 参数,供回归测试直接验证。 */ +export function buildDependencyInstallPlan( + environment: PythonEnvironment, + backendRequirement: string, +): DependencyInstallPlan { + const targetArgs = installTargetArgs(environment); + return { + buildArgs: [ + '-m', 'pip', 'install', + '--upgrade', + ...targetArgs, + 'setuptools', + 'hatchling', + 'hatch-vcs', + ], + toolArgs: [ + '-m', 'pip', 'install', + '--upgrade', + ...targetArgs, + ...SHIP_LIBRARY_REQUIREMENTS, + ...BACKEND_RUNTIME_REQUIREMENTS, + ], + backendArgs: [ + '-m', 'pip', 'install', + '--upgrade', + '--no-build-isolation', + ...targetArgs, + backendRequirement, + ], + }; +} + +/** 自动安装依赖;安装目标由统一 Python 环境描述决定。 */ export async function installDependencies(pythonCmd: string): Promise<{ success: boolean; output: string }> { const ctx = getCtx(); - // 安装后环境变化,清除标记以便下次重新检查 - try { fs.unlinkSync(ENV_READY_MARKER()); } catch { /* ignore */ } + let environment: PythonEnvironment; + try { + environment = resolvePythonEnvironment(pythonCmd); + } catch (error) { + return { + success: false, + output: error instanceof Error ? error.message : String(error), + }; + } + const backendRequirement = environment.backendRoot + ?? buildManagedAutowsgrRequirement( + resolveBackendDistribution(ctx.allowTestUpdates()), + ); + const installPlan = buildDependencyInstallPlan( + environment, + backendRequirement, + ); + + // 安装后清除环境标记,触发下次完整检查。 + try { fs.unlinkSync(ENV_READY_MARKER()); } catch { /* 忽略清理失败。 */ } const certFile = await ensureSslCertForPython(pythonCmd); if (certFile) ctx.sendProgress(`TLS 证书已就绪: ${certFile}`); else ctx.sendProgress('WARNING 未检测到 TLS 根证书,后续联网操作可能失败'); - // 确保 pip 可用 + // 确保 pip 可用。 if (!(await ensurePip(pythonCmd))) { return { success: false, output: 'pip 安装失败,无法安装依赖' }; } const cwd = ctx.appRoot(); - const targetDir = localSitePackages(); - if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true }); + if ( + environment.installTarget + && !fs.existsSync(environment.installTarget) + ) { + fs.mkdirSync(environment.installTarget, { recursive: true }); + } const runPip = (args: string[]): Promise<{ code: number; output: string }> => new Promise((resolve) => { const proc = spawn(pythonCmd, args, { cwd, windowsHide: true, stdio: 'pipe', - env: pipEnv(), + env: buildPythonProcessEnv(environment), }); let output = ''; @@ -212,89 +277,25 @@ export async function installDependencies(pythonCmd: string): Promise<{ success: }); ctx.sendProgress('正在安装后端构建依赖…'); - const buildDeps = await runPip([ - '-m', 'pip', 'install', - '--upgrade', - '--target', targetDir, - 'setuptools', - 'hatchling', - 'hatch-vcs', - ]); + const buildDeps = await runPip(installPlan.buildArgs); if (buildDeps.code !== 0) { ctx.sendProgress('ERROR 后端构建依赖安装失败'); return { success: false, output: buildDeps.output.slice(-500) }; } - ctx.sendProgress('正在安装后端依赖到项目目录…'); - const install = await runPip([ - '-m', 'pip', 'install', - '--upgrade', - '--no-build-isolation', - '--target', targetDir, - AUTOWSGR_REQUIREMENT, - ]); + ctx.sendProgress('正在安装工具与后端运行依赖…'); + const toolDeps = await runPip(installPlan.toolArgs); + if (toolDeps.code !== 0) { + ctx.sendProgress('ERROR 舰船资料库更新依赖安装失败'); + return { success: false, output: toolDeps.output.slice(-500) }; + } + + const installLocation = environment.installTarget + ? 'GUI 项目目录' + : '当前 Python 环境'; + ctx.sendProgress(`正在安装后端依赖到${installLocation}…`); + const install = await runPip(installPlan.backendArgs); if (install.code === 0) ctx.sendProgress('后端依赖安装完成 ✓'); else ctx.sendProgress('ERROR 依赖安装失败'); return { success: install.code === 0, output: install.output.slice(-500) }; } - -/** 更新 autowsgr 包(仅升级 autowsgr 本体,不级联重装所有依赖) */ -export async function pullUpdates(): Promise<{ success: boolean; output: string }> { - const ctx = getCtx(); - // 更新后清除环境标记 - try { fs.unlinkSync(ENV_READY_MARKER()); } catch { /* ignore */ } - const pythonCmd = await findPython(); - if (!pythonCmd) return { success: false, output: '找不到 Python' }; - - const certFile = await ensureSslCertForPython(pythonCmd); - if (certFile) ctx.sendProgress(`TLS 证书已就绪: ${certFile}`); - else ctx.sendProgress('WARNING 未检测到 TLS 根证书,后续联网操作可能失败'); - - try { - await execAsync( - `"${pythonCmd}" -m pip install --upgrade --target "${localSitePackages()}" hatchling hatch-vcs`, - { cwd: ctx.appRoot(), windowsHide: true, timeout: 120000, env: pipEnv() }, - ); - } catch (e) { - const output = e instanceof Error ? e.message : String(e); - return { success: false, output: `活动热修复构建依赖安装失败: ${output}` }; - } - - return new Promise((resolve) => { - const targetDir = localSitePackages(); - if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true }); - - // 先删除旧版 autowsgr,再重新安装(不带 --upgrade 避免级联更新依赖) - try { - for (const entry of fs.readdirSync(targetDir)) { - if (entry === 'autowsgr' || entry.startsWith('autowsgr-')) { - fs.rmSync(path.join(targetDir, entry), { recursive: true, force: true }); - } - } - } catch { /* ignore cleanup errors */ } - - const proc = spawn(pythonCmd, [ - '-m', 'pip', 'install', - '--target', targetDir, - '--no-build-isolation', - '--no-deps', - '-i', 'https://pypi.tuna.tsinghua.edu.cn/simple', - '--trusted-host', 'pypi.tuna.tsinghua.edu.cn', - AUTOWSGR_REQUIREMENT, - ], { - cwd: ctx.appRoot(), - windowsHide: true, - stdio: 'pipe', - env: pipEnv(), - }); - let output = ''; - proc.stdout?.on('data', (d: Buffer) => { output += d.toString(); }); - proc.stderr?.on('data', (d: Buffer) => { output += d.toString(); }); - proc.on('close', (code) => { - resolve({ success: code === 0, output: output.slice(-500) }); - }); - proc.on('error', (err) => { - resolve({ success: false, output: err.message }); - }); - }); -} diff --git a/electron/pythonEnv/updater.ts b/electron/pythonEnv/updater.ts index f80b620..3161a14 100644 --- a/electron/pythonEnv/updater.ts +++ b/electron/pythonEnv/updater.ts @@ -1,16 +1,21 @@ /** - * autowsgr 自动更新逻辑。 - * 使用依赖注入,不直接依赖 context 模块。 + * 通过依赖注入检查并更新 autowsgr。 */ import * as path from 'path'; import * as fs from 'fs'; import { exec, spawn } from 'child_process'; import { promisify } from 'util'; +import { + buildBackendRuntimeContractProbeLines, +} from './backendContractProbe'; +import { + BACKEND_RUNTIME_REQUIREMENTS, + PYTHON_DEPENDENCY_SPECS, + SHIP_LIBRARY_REQUIREMENTS, +} from './dependencies'; const execAsync = promisify(exec); - -/** PyPI 2.2.2 尚未包含 2026-07-30 活动支持,临时固定到上游已合入提交。 */ -const AUTOWSGR_REQUIREMENT = 'https://github.com/OpenWSGR/AutoWSGR/archive/a38252d3.zip'; +const BACKEND_BUILD_REQUIREMENTS = ['hatchling', 'hatch-vcs']; export interface AutoUpdateDeps { sendProgress: (msg: string) => void; @@ -19,14 +24,253 @@ export interface AutoUpdateDeps { localSitePackages: () => string; pipEnv: () => NodeJS.ProcessEnv; ensurePip: (pythonCmd: string) => Promise; + backendRequirement: () => string; +} + +/** 生成依赖安装参数,调用方只传入检查后确认需要处理的包。 */ +export function buildBackendRuntimeInstallArgs( + targetDir: string, + requirements: readonly string[] = BACKEND_RUNTIME_REQUIREMENTS, +): string[] { + return [ + '-m', 'pip', 'install', + '--upgrade', + '--target', targetDir, + '--no-deps', + '-i', 'https://pypi.tuna.tsinghua.edu.cn/simple', + '--trusted-host', 'pypi.tuna.tsinghua.edu.cn', + ...requirements, + ]; +} + +/** 元数据探测失败时才使用 pip 自身的完整依赖解析。 */ +function buildRequirementFallbackArgs( + targetDir: string, + requirements: readonly string[], +): string[] { + return [ + '-m', 'pip', 'install', + '--upgrade', + '--upgrade-strategy', 'only-if-needed', + '--target', targetDir, + '-i', 'https://pypi.tuna.tsinghua.edu.cn/simple', + '--trusted-host', 'pypi.tuna.tsinghua.edu.cn', + ...requirements, + ]; +} + +/** 生成依赖版本探测脚本,递归检查传递依赖和 extras。 */ +export function buildRequirementProbeScript( + targetDir: string, + requirements: readonly string[], + includeBackendRequirements = false, +): string { + return [ + 'import json, sys', + 'from importlib import metadata', + 'from pip._vendor.packaging.requirements import Requirement', + `sys.path.insert(0, ${JSON.stringify(targetDir)})`, + `roots = ${JSON.stringify(requirements)}`, + ...(includeBackendRequirements + ? [ + 'try:', + ' roots.extend(metadata.distribution("autowsgr").requires or [])', + 'except metadata.PackageNotFoundError:', + ' pass', + ] + : []), + 'unsatisfied = []', + 'visited = set()', + 'def applies(requirement, active_extras):', + ' if requirement.marker is None:', + ' return True', + ' environments = [{"extra": ""}]', + ' environments.extend({"extra": extra} for extra in active_extras)', + ' return any(requirement.marker.evaluate(env) for env in environments)', + 'def install_text(requirement):', + ' extras = ""', + ' if requirement.extras:', + ' extras = "[" + ",".join(sorted(requirement.extras)) + "]"', + ' if requirement.url:', + ' return f"{requirement.name}{extras} @ {requirement.url}"', + ' return f"{requirement.name}{extras}{requirement.specifier}"', + 'pending = [(raw, set()) for raw in roots]', + 'while pending:', + ' raw, active_extras = pending.pop()', + ' requirement = Requirement(raw)', + ' if not applies(requirement, active_extras):', + ' continue', + ' key = (', + ' requirement.name.lower(),', + ' str(requirement.specifier),', + ' requirement.url or "",', + ' tuple(sorted(requirement.extras)),', + ' )', + ' if key in visited:', + ' continue', + ' visited.add(key)', + ' try:', + ' installed = metadata.distribution(requirement.name)', + ' except metadata.PackageNotFoundError:', + ' unsatisfied.append(install_text(requirement))', + ' continue', + ' if requirement.specifier and not requirement.specifier.contains(', + ' installed.version, prereleases=True', + ' ):', + ' unsatisfied.append(install_text(requirement))', + ' continue', + ' for child in installed.requires or []:', + ' pending.append((child, set(requirement.extras)))', + 'print(json.dumps(list(dict.fromkeys(unsatisfied))))', + ].join('\n'); } -/** 检查 autowsgr 是否有 PyPI 更新,有则自动升级;返回最终的已安装版本 */ -export async function autoUpdateAutowsgr(pythonCmd: string, deps: AutoUpdateDeps): Promise { +/** 返回真正缺失或版本不兼容的依赖,探测失败时返回 null。 */ +async function findUnsatisfiedRequirements( + pythonCmd: string, + deps: AutoUpdateDeps, + requirements: readonly string[], + includeBackendRequirements = false, +): Promise { + const scriptPath = path.join( + deps.getTempDir(), + `autowsgr_requirement_probe_${Date.now()}.py`, + ); try { - deps.sendProgress('正在检查 autowsgr 更新…'); + fs.writeFileSync( + scriptPath, + buildRequirementProbeScript( + deps.localSitePackages(), + requirements, + includeBackendRequirements, + ), + 'utf-8', + ); + const { stdout } = await execAsync( + `"${pythonCmd}" "${scriptPath}"`, + { windowsHide: true, timeout: 30000, env: deps.pipEnv() }, + ); + const result: unknown = JSON.parse(stdout.trim()); + return Array.isArray(result) + ? result.filter((item): item is string => typeof item === 'string') + : null; + } catch { + return null; + } finally { + try { fs.unlinkSync(scriptPath); } catch { /* 忽略清理失败。 */ } + } +} - // 单次 Python 调用: 获取本地版本 + PyPI 最新版本 +/** 执行 pip 并将输出转发到安装日志。 */ +async function runPip( + pythonCmd: string, + args: string[], + deps: AutoUpdateDeps, +): Promise { + return new Promise((resolve) => { + const proc = spawn(pythonCmd, args, { + cwd: deps.appRoot(), + windowsHide: true, + stdio: 'pipe', + env: deps.pipEnv(), + }); + proc.stdout?.on('data', (data: Buffer) => { + for (const line of data.toString().split('\n')) { + if (line.trim()) deps.sendProgress(line.trim()); + } + }); + proc.stderr?.on('data', (data: Buffer) => { + for (const line of data.toString().split('\n')) { + if (line.trim()) deps.sendProgress(line.trim()); + } + }); + proc.on('close', code => resolve(code ?? 1)); + proc.on('error', () => resolve(1)); + }); +} + +type RequirementEnsureResult = 'ready' | 'probe-failed' | 'install-failed'; + +/** 分轮补齐依赖,每轮只安装探测器确认不满足的包。 */ +async function ensureRequirements( + pythonCmd: string, + deps: AutoUpdateDeps, + requirements: readonly string[], + includeBackendRequirements = false, +): Promise { + const targetDir = deps.localSitePackages(); + for (let round = 0; round < 16; round += 1) { + const missing = await findUnsatisfiedRequirements( + pythonCmd, + deps, + requirements, + includeBackendRequirements, + ); + if (missing === null) return 'probe-failed'; + if (missing.length === 0) return 'ready'; + + deps.sendProgress(`正在安装必要依赖: ${missing.join(', ')}`); + const code = await runPip( + pythonCmd, + buildBackendRuntimeInstallArgs(targetDir, missing), + deps, + ); + if (code !== 0) return 'install-failed'; + } + return 'install-failed'; +} + +/** 生成 managed 后端更新参数,确保自动更新不会改装 PyPI 裸包。 */ +export function buildManagedAutowsgrUpdateArgs( + targetDir: string, + backendRequirement: string, + forceInstall = false, +): string[] { + return [ + '-m', 'pip', 'install', + '--upgrade', + ...(forceInstall ? ['--force-reinstall'] : []), + '--target', targetDir, + '--no-build-isolation', + '--no-deps', + '-i', 'https://pypi.tuna.tsinghua.edu.cn/simple', + '--trusted-host', 'pypi.tuna.tsinghua.edu.cn', + backendRequirement, + ]; +} + +/** 依赖元数据无法读取时,回退给 pip 做一次完整兼容性解析。 */ +function buildManagedDependencyRepairArgs( + targetDir: string, + backendRequirement: string, +): string[] { + return [ + '-m', 'pip', 'install', + '--upgrade', + '--upgrade-strategy', 'only-if-needed', + '--target', targetDir, + '--no-build-isolation', + '-i', 'https://pypi.tuna.tsinghua.edu.cn/simple', + '--trusted-host', 'pypi.tuna.tsinghua.edu.cn', + backendRequirement, + ]; +} + +/** 确保 managed 环境使用 GUI 明确支持的后端版本。 */ +export async function autoUpdateAutowsgr( + pythonCmd: string, + deps: AutoUpdateDeps, + forceInstall = false, +): Promise { + try { + const backendRequirement = deps.backendRequirement(); + deps.sendProgress( + forceInstall + ? '正在强制更新当前通道 autowsgr…' + : '正在检查 autowsgr 更新…', + ); + + // 单次 Python 调用检查本地版本、活动资源和 GUI 运行契约。 const spFwd = deps.localSitePackages().replace(/\\/g, '\\\\'); const checkScript = [ 'import json, sys', @@ -41,11 +285,12 @@ export async function autoUpdateAutowsgr(pythonCmd: string, deps: AutoUpdateDeps 'except:', ' result["local"] = None', ' result["event20260730"] = False', + ...buildBackendRuntimeContractProbeLines(), 'try:', - ' import urllib.request', - ' data = json.loads(urllib.request.urlopen("https://pypi.org/pypi/autowsgr/json", timeout=10).read())', - ' result["latest"] = data["info"]["version"]', - 'except: result["latest"] = None', + ' _verify_gui_runtime_contract()', + ' result["runtime_contract"] = True', + 'except Exception:', + ' result["runtime_contract"] = False', 'print(json.dumps(result))', ].join('\n'); @@ -56,92 +301,112 @@ export async function autoUpdateAutowsgr(pythonCmd: string, deps: AutoUpdateDeps `"${pythonCmd}" "${scriptPath}"`, { windowsHide: true, timeout: 20000, env: deps.pipEnv() }, ); - try { fs.unlinkSync(scriptPath); } catch { /* ignore */ } + try { fs.unlinkSync(scriptPath); } catch { /* 忽略清理失败。 */ } const info = JSON.parse(stdout.trim()); const localVer: string | null = info.local; - const latestVer: string | null = info.latest; const supportsLatestEvent = info.event20260730 === true; + const supportsRuntimeContract = info.runtime_contract === true; - if (!latestVer) { - deps.sendProgress('autowsgr 更新检查跳过(无法获取最新版本信息)'); + if ( + !forceInstall + && + localVer + && supportsLatestEvent + && supportsRuntimeContract + ) { + deps.sendProgress(`autowsgr ${localVer} 与 GUI 运行契约兼容 ✓`); return localVer; } - if (localVer === latestVer && supportsLatestEvent) { - deps.sendProgress(`autowsgr ${localVer} 已是最新版 ✓`); - return localVer; - } - - // 有版本更新,或当前 PyPI 版本缺少最新活动热修复。 - // 仅当 PyPI 已无更高版本时使用固定提交;未来正式版本发布后优先回到 PyPI。 - const needsEventHotfix = !supportsLatestEvent && localVer === latestVer; - if (!supportsLatestEvent) { - deps.sendProgress('当前 autowsgr 缺少 20260730 活动支持,正在安装上游活动热修复…'); - } else { - deps.sendProgress(`发现 autowsgr 更新: ${localVer ?? '未安装'} → ${latestVer},正在自动升级…`); - } - const installRequirement = needsEventHotfix ? AUTOWSGR_REQUIREMENT : 'autowsgr'; + const incompatibilities = [ + ...(!localVer ? ['未安装'] : []), + ...(!supportsLatestEvent ? ['缺少 20260730 活动资源'] : []), + ...(!supportsRuntimeContract ? ['缺少 GUI 运行契约'] : []), + ]; + deps.sendProgress( + forceInstall + ? '正在重新安装当前通道指定的后端…' + : `当前 autowsgr ${incompatibilities.join('、')},正在安装 GUI 兼容版本…`, + ); + const failureVersion = forceInstall ? null : localVer; const targetDir = deps.localSitePackages(); if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true }); - // 确保 pip 可用 + // 确保 pip 可用。 if (!(await deps.ensurePip(pythonCmd))) { deps.sendProgress('WARNING pip 不可用,autowsgr 升级跳过'); - return localVer; + return failureVersion; } - const buildDepsCode = await new Promise((resolve) => { - const proc = spawn(pythonCmd, [ - '-m', 'pip', 'install', - '--upgrade', - '--target', targetDir, - 'hatchling', - 'hatch-vcs', - ], { - cwd: deps.appRoot(), - windowsHide: true, - stdio: 'pipe', - env: deps.pipEnv(), - }); - proc.stdout?.on('data', (d: Buffer) => { for (const l of d.toString().split('\n')) { if (l.trim()) deps.sendProgress(l.trim()); } }); - proc.stderr?.on('data', (d: Buffer) => { for (const l of d.toString().split('\n')) { if (l.trim()) deps.sendProgress(l.trim()); } }); - proc.on('close', (code) => resolve(code ?? 1)); - proc.on('error', () => resolve(1)); - }); - if (buildDepsCode !== 0) { - deps.sendProgress('WARNING 活动热修复构建依赖安装失败'); - return localVer; + deps.sendProgress('正在检查后端构建依赖…'); + const buildRequirementState = await ensureRequirements( + pythonCmd, + deps, + BACKEND_BUILD_REQUIREMENTS, + ); + if (buildRequirementState === 'probe-failed') { + const buildDepsCode = await runPip( + pythonCmd, + buildRequirementFallbackArgs(targetDir, BACKEND_BUILD_REQUIREMENTS), + deps, + ); + if (buildDepsCode !== 0) { + deps.sendProgress('WARNING GUI 兼容后端构建依赖安装失败'); + return failureVersion; + } + } else if (buildRequirementState === 'install-failed') { + deps.sendProgress('WARNING GUI 兼容后端构建依赖安装失败'); + return failureVersion; } + deps.sendProgress('后端构建依赖已满足版本要求 ✓'); - const exitCode = await new Promise((resolve) => { - const proc = spawn(pythonCmd, [ - '-m', 'pip', 'install', - '--upgrade', - '--target', targetDir, - '--no-build-isolation', - '--no-deps', - '-i', 'https://pypi.tuna.tsinghua.edu.cn/simple', - '--trusted-host', 'pypi.tuna.tsinghua.edu.cn', - installRequirement, - ], { - cwd: deps.appRoot(), - windowsHide: true, - stdio: 'pipe', - env: deps.pipEnv(), - }); - proc.stdout?.on('data', (d: Buffer) => { for (const l of d.toString().split('\n')) { if (l.trim()) deps.sendProgress(l.trim()); } }); - proc.stderr?.on('data', (d: Buffer) => { for (const l of d.toString().split('\n')) { if (l.trim()) deps.sendProgress(l.trim()); } }); - proc.on('close', (code) => resolve(code ?? 1)); - proc.on('error', () => resolve(1)); - }); + const exitCode = await runPip( + pythonCmd, + buildManagedAutowsgrUpdateArgs( + targetDir, + backendRequirement, + forceInstall, + ), + deps, + ); if (exitCode !== 0) { deps.sendProgress('WARNING autowsgr 升级失败,使用当前版本继续'); - return localVer; + return failureVersion; + } + + deps.sendProgress('正在核对后端依赖版本…'); + const runtimeRoots = [ + ...SHIP_LIBRARY_REQUIREMENTS, + ...BACKEND_RUNTIME_REQUIREMENTS, + ]; + const runtimeRequirementState = await ensureRequirements( + pythonCmd, + deps, + runtimeRoots, + true, + ); + if (runtimeRequirementState === 'probe-failed') { + const runtimeDepsCode = await runPip( + pythonCmd, + buildManagedDependencyRepairArgs( + targetDir, + backendRequirement, + ), + deps, + ); + if (runtimeDepsCode !== 0) { + deps.sendProgress('WARNING 后端运行依赖安装失败'); + return failureVersion; + } + } else if (runtimeRequirementState === 'install-failed') { + deps.sendProgress('WARNING 后端运行依赖安装失败'); + return failureVersion; } + deps.sendProgress('后端运行依赖已满足版本要求 ✓'); - // 升级后:单次 Python 调用验证版本 + 关键依赖 + // 升级后一次性验证版本和关键依赖。 const postScript = path.join(deps.getTempDir(), 'autowsgr_post_upgrade.py'); fs.writeFileSync(postScript, [ 'import json, sys, site', @@ -155,9 +420,20 @@ export async function autoUpdateAutowsgr(pythonCmd: string, deps: AutoUpdateDeps ' root = Path(autowsgr.__file__).resolve().parent', ' r["event20260730"] = (root / "data" / "map" / "event" / "20260730").is_dir()', 'except: pass', - "for m in ['fastapi', 'uvicorn']:", - ' try: __import__(m)', - ' except Exception: r["missing"].append(m)', + ...buildBackendRuntimeContractProbeLines(), + 'try:', + ' _verify_gui_runtime_contract()', + ' r["runtime_contract"] = True', + 'except Exception:', + ' r["runtime_contract"] = False', + `checks = ${JSON.stringify( + PYTHON_DEPENDENCY_SPECS.map( + dependency => [dependency.importName, dependency.packageName], + ), + )}`, + 'for mod, package in checks:', + ' try: __import__(mod)', + ' except Exception: r["missing"].append(package)', 'print(json.dumps(r))', ].join('\n'), 'utf-8'); @@ -166,15 +442,20 @@ export async function autoUpdateAutowsgr(pythonCmd: string, deps: AutoUpdateDeps `"${pythonCmd}" "${postScript}"`, { windowsHide: true, timeout: 15000, env: deps.pipEnv() }, ); - try { fs.unlinkSync(postScript); } catch { /* ignore */ } + try { fs.unlinkSync(postScript); } catch { /* 忽略清理失败。 */ } const postResult = JSON.parse(postOut.trim()); const actualVer: string = postResult.version; const missing: string[] = postResult.missing; const eventReady = postResult.event20260730 === true; + const runtimeContractReady = postResult.runtime_contract === true; - if (needsEventHotfix && !eventReady) { - deps.sendProgress('WARNING 活动热修复安装后仍未检测到 20260730 资源'); - return localVer; + if (!eventReady) { + deps.sendProgress('WARNING GUI 兼容后端安装后仍缺少 20260730 活动资源'); + return failureVersion; + } + if (!runtimeContractReady) { + deps.sendProgress('WARNING GUI 兼容后端安装后仍不支持运行契约'); + return failureVersion; } if (missing.length > 0) { @@ -223,27 +504,19 @@ export async function autoUpdateAutowsgr(pythonCmd: string, deps: AutoUpdateDeps } if (actualVer !== 'unknown') { - const expectedVersion = needsEventHotfix ? localVer : latestVer; - const msg = actualVer === expectedVersion - ? needsEventHotfix - ? `autowsgr ${actualVer} 活动热修复已安装 ✓` - : `autowsgr 已升级至 ${latestVer} ✓` - : `autowsgr 已升级至 ${actualVer}(期望 ${expectedVersion})`; - deps.sendProgress(msg); + deps.sendProgress( + `autowsgr ${actualVer} GUI 兼容版本已安装 ✓`, + ); return actualVer; } } catch { - try { fs.unlinkSync(postScript); } catch { /* ignore */ } + try { fs.unlinkSync(postScript); } catch { /* 忽略清理失败。 */ } } - if (needsEventHotfix) { - deps.sendProgress(`autowsgr ${localVer} 活动热修复已安装 ✓`); - return localVer; - } - deps.sendProgress(`autowsgr 已升级至 ${latestVer} ✓`); - return latestVer; + deps.sendProgress('WARNING 无法验证 GUI 兼容后端安装结果'); + return failureVersion; } catch { - deps.sendProgress('autowsgr 更新检查跳过(网络不可用或超时)'); + deps.sendProgress('autowsgr 更新检查跳过(环境不可用或检查超时)'); return null; } } diff --git a/electron/pythonEnv/utils.ts b/electron/pythonEnv/utils.ts index 5cb0eb0..f4f13f5 100644 --- a/electron/pythonEnv/utils.ts +++ b/electron/pythonEnv/utils.ts @@ -18,9 +18,7 @@ const CERT_ENV_KEYS: Array = [ const certFileCache = new Map(); -// ════════════════════════════════════════ // 共享接口 -// ════════════════════════════════════════ export interface EnvCheckResult { pythonCmd: string | null; @@ -29,25 +27,14 @@ export interface EnvCheckResult { allReady: boolean; } -// ════════════════════════════════════════ // 路径工具 -// ════════════════════════════════════════ -/** 项目本地包目录 */ +/** 返回项目本地包目录。 */ export function localSitePackages(): string { return path.join(getCtx().appRoot(), 'python', 'site-packages'); } -/** 生成在 Python 命令前插入 site-packages 路径的前缀代码 */ -export function sysPathInsert(): string { - // 使用 sys.path.insert 而非 PYTHONPATH 环境变量,因为: - // 1. 嵌入式 Python 的 ._pth 会完全忽略 PYTHONPATH - // 2. 避免 Windows 环境变量传递的各种边界问题 - const sp = localSitePackages().replace(/\\/g, '\\\\'); - return `import sys; sys.path.insert(0, r'${sp}'); `; -} - -/** pip 命令的公共环境变量:确保项目目录的包优先于全局 */ +/** 构造优先使用项目包目录的 pip 环境。 */ export function pipEnv(): NodeJS.ProcessEnv { const localSite = localSitePackages(); const existing = process.env.PYTHONPATH || ''; @@ -130,19 +117,16 @@ async function probePythonCertFile(pythonCmd: string): Promise { return certFile; } } catch { - // ignore probe failures; caller will continue without TLS env override + // 探测失败时由调用方继续使用原环境。 } finally { - try { fs.unlinkSync(scriptPath); } catch { /* ignore */ } + try { fs.unlinkSync(scriptPath); } catch { /* 忽略清理失败。 */ } } certFileCache.set(pythonCmd, null); return null; } -/** - * 为给定 Python 解释器补齐 TLS 证书环境变量。 - * 优先沿用用户已配置的证书路径,否则自动探测 Python 默认/Certifi 证书。 - */ +/** 沿用或探测证书,并补齐 Python TLS 环境变量。 */ export async function ensureSslCertForPython(pythonCmd: string): Promise { const existing = pickExistingCertFromEnv(); const certFile = existing || await probePythonCertFile(pythonCmd); @@ -156,16 +140,20 @@ export async function ensureSslCertForPython(pythonCmd: string): Promise { const ctx = getCtx(); try { await execAsync(`"${pythonCmd}" -m pip --version`, { windowsHide: true, timeout: 15000 }); return true; - } catch { /* pip not available */ } + } catch { /* pip 不可用时继续安装。 */ } if (isLocalPython(pythonCmd)) ensurePthFile(); @@ -209,12 +195,12 @@ export async function ensurePip(pythonCmd: string): Promise { try { await execAsync(`curl -sSL -o "${getPipPath}" "https://bootstrap.pypa.io/get-pip.py"`, { windowsHide: true, timeout: 60000 }); await execAsync(`"${pythonCmd}" "${getPipPath}"`, { windowsHide: true, timeout: 120000 }); - try { fs.unlinkSync(getPipPath); } catch { /* ignore */ } + try { fs.unlinkSync(getPipPath); } catch { /* 忽略清理失败。 */ } ctx.sendProgress('pip 安装完成 ✓'); return true; } catch { ctx.sendProgress('ERROR pip 安装失败'); - try { fs.unlinkSync(getPipPath); } catch { /* ignore */ } + try { fs.unlinkSync(getPipPath); } catch { /* 忽略清理失败。 */ } return false; } } diff --git a/electron/services/AdbService.ts b/electron/services/AdbService.ts new file mode 100644 index 0000000..e173eb9 --- /dev/null +++ b/electron/services/AdbService.ts @@ -0,0 +1,204 @@ +/** + * 管理 ADB 设备发现、连接和断开。 + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { AppPaths } from './AppPaths'; + +const execFileAsync = promisify(execFile); + +export interface AdbDevice { + serial: string; + status: string; +} + +export interface AdbCommandResult { + stdout: unknown; + stderr: unknown; +} + +export interface AdbCommandOptions { + windowsHide: true; + timeout: number; + encoding: 'utf8'; +} + +export interface AdbServiceDependencies { + execute( + executable: string, + args: string[], + options: AdbCommandOptions, + ): Promise; +} + +export interface AdbDeviceCommandResult { + success: boolean; + serial: string; + status: string; + message: string; +} + +/** 执行 ADB 设备查询和连接状态确认。 */ +export class AdbService { + constructor( + private readonly appPaths: AppPaths, + private readonly dependencies: AdbServiceDependencies = { + execute: async (executable, args, options) => { + const result = await execFileAsync(executable, args, options); + return { + stdout: result.stdout, + stderr: result.stderr, + }; + }, + }, + ) {} + + private bundledExecutable(): string { + return path.join( + this.appPaths.appRoot(), + 'adb', + 'adb.exe', + ); + } + + /** 返回内置 ADB;不存在时继续使用系统命令。 */ + executable(): string { + const bundledAdb = this.bundledExecutable(); + return fs.existsSync(bundledAdb) ? bundledAdb : 'adb'; + } + + /** 仅停止由 GUI 目录内置 adb.exe 启动的 server。 */ + async stopServer(): Promise { + const bundledAdb = this.bundledExecutable(); + if (!fs.existsSync(bundledAdb)) return false; + + const escapedPath = bundledAdb.replace(/'/g, "''"); + const processQuery = [ + `$target = [IO.Path]::GetFullPath('${escapedPath}')`, + '$running = Get-CimInstance Win32_Process' + + ' -Filter "Name = \'adb.exe\'"' + + ' -ErrorAction SilentlyContinue' + + ' | Where-Object {' + + ' $_.ExecutablePath' + + ' -and [IO.Path]::GetFullPath($_.ExecutablePath) -eq $target' + + ' }', + "if ($running) { [Console]::Out.Write('1') }", + ].join('; '); + const { stdout } = await this.dependencies.execute( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', processQuery], + { + windowsHide: true, + timeout: 5000, + encoding: 'utf8', + }, + ); + if (String(stdout).trim() !== '1') return false; + + await this.dependencies.execute( + bundledAdb, + ['kill-server'], + { + windowsHide: true, + timeout: 5000, + encoding: 'utf8', + }, + ); + return true; + } + + /** 读取并解析 adb devices 的当前设备列表。 */ + async listDevices(): Promise { + const { stdout } = await this.dependencies.execute( + this.executable(), + ['devices'], + { + windowsHide: true, + timeout: 5000, + encoding: 'utf8', + }, + ); + return String(stdout) + .split(/\r?\n/) + .slice(1) + .map(line => line.trim()) + .filter(Boolean) + .map(line => { + const [serial, status] = line.split(/\s+/); + return { + serial, + status: status || 'unknown', + }; + }); + } + + /** 执行 connect 或 disconnect,并通过设备列表确认结果。 */ + async runDeviceCommand( + command: 'connect' | 'disconnect', + rawSerial: string, + ): Promise { + const serial = String(rawSerial ?? '').trim(); + if (!serial || !/^[A-Za-z0-9._:[\]-]+$/.test(serial)) { + return { + success: false, + serial, + status: 'invalid', + message: 'ADB 地址格式不正确', + }; + } + + try { + const { stdout, stderr } = await this.dependencies.execute( + this.executable(), + [command, serial], + { + windowsHide: true, + timeout: 10000, + encoding: 'utf8', + }, + ); + const devices = await this.listDevices(); + const status = devices.find( + device => device.serial === serial, + )?.status; + const success = command === 'connect' + ? status === 'device' + : status === undefined; + return { + success, + serial, + status: status ?? 'disconnected', + message: [stdout, stderr] + .map(value => String(value).trim()) + .filter(Boolean) + .join('\n') + || ( + success + ? '操作成功' + : '操作后设备状态未达到预期' + ), + }; + } catch (error) { + const details = error as { + message?: string; + stdout?: string; + stderr?: string; + }; + return { + success: false, + serial, + status: 'error', + message: [ + details.stderr, + details.stdout, + details.message, + ] + .map(value => String(value ?? '').trim()) + .find(Boolean) + || 'ADB 命令执行失败', + }; + } + } +} diff --git a/electron/services/AppPaths.ts b/electron/services/AppPaths.ts new file mode 100644 index 0000000..f6dfdce --- /dev/null +++ b/electron/services/AppPaths.ts @@ -0,0 +1,85 @@ +/** + * 集中提供应用、资源和用户数据目录。 + */ +import * as path from 'path'; + +/** Electron 路径能力的最小依赖,便于主进程注入和独立测试。 */ +export interface AppPathsDependencies { + readonly moduleDirectory: string; + isPackaged(): boolean; + getPath(name: 'exe' | 'userData'): string; + getResourcesPath(): string; +} + +/** 集中计算主进程使用的应用、资源和用户数据路径。 */ +export class AppPaths { + constructor(private readonly dependencies: AppPathsDependencies) {} + + /** 是否处于打包后的生产模式。 */ + isPackaged(): boolean { + return this.dependencies.isPackaged(); + } + + /** 返回开发项目根目录或打包后的可执行文件目录。 */ + appRoot(): string { + if (this.isPackaged()) { + return path.dirname(this.dependencies.getPath('exe')); + } + return path.join(this.dependencies.moduleDirectory, '..', '..'); + } + + /** extraResources 根目录。 */ + resourceRoot(): string { + if (this.isPackaged()) { + return this.dependencies.getResourcesPath(); + } + return path.join(this.dependencies.moduleDirectory, '..', '..'); + } + + /** Electron 管理的用户数据根目录。 */ + userDataRoot(): string { + return this.dependencies.getPath('userData'); + } + + /** 内置只读作战计划目录。 */ + systemBattlePlansDir(): string { + return path.join( + this.resourceRoot(), + 'resource', + 'system_battle_plans', + ); + } + + /** GUI 管理的用户作战计划目录。 */ + userBattlePlansDir(): string { + return path.join(this.userDataRoot(), 'user_battle_plans'); + } + + /** 内置只读日常任务计划目录。 */ + systemDailyPlansDir(): string { + return path.join( + this.resourceRoot(), + 'resource', + 'system_daily_plans', + ); + } + + /** GUI 管理的用户日常任务计划目录。 */ + userDailyPlansDir(): string { + return path.join(this.userDataRoot(), 'user_daily_plans'); + } + + /** 内置只读编队计划目录。 */ + systemTeamPlansDir(): string { + return path.join( + this.resourceRoot(), + 'resource', + 'system_team_plans', + ); + } + + /** GUI 管理的用户编队计划目录。 */ + userTeamPlansDir(): string { + return path.join(this.userDataRoot(), 'user_team_plans'); + } +} diff --git a/electron/services/AtomicFileStore.ts b/electron/services/AtomicFileStore.ts new file mode 100644 index 0000000..56a8a3f --- /dev/null +++ b/electron/services/AtomicFileStore.ts @@ -0,0 +1,61 @@ +/** + * 通过同目录临时文件和原子替换完成持久化写入。 + */ +import * as fs from 'fs'; + +const WINDOWS_RETRY_DELAYS_MS = [20, 50, 100]; +const WINDOWS_TRANSIENT_CODES = new Set(['EACCES', 'EBUSY', 'EPERM']); + +/** 为需要失败回滚的持久化模块提供统一写入能力。 */ +export class AtomicFileStore { + /** 把文本或二进制内容写入目标文件,并在失败时保留原文件。 */ + write(filePath: string, content: string | Uint8Array): void { + const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`; + try { + this.retryWindowsFileLock(() => { + if (typeof content === 'string') { + fs.writeFileSync(temporary, content, 'utf-8'); + } else { + fs.writeFileSync(temporary, content); + } + }); + this.retryWindowsFileLock(() => { + fs.renameSync(temporary, filePath); + }); + } catch (error) { + try { + fs.rmSync(temporary, { force: true }); + } catch { + // 清理失败不能覆盖最初的替换错误。 + } + throw error; + } + } + + /** Windows 文件扫描或短暂占用时,等待后重试文件操作。 */ + private retryWindowsFileLock(operation: () => void): void { + for (let attempt = 0; ; attempt += 1) { + try { + operation(); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + const delay = WINDOWS_RETRY_DELAYS_MS[attempt]; + if ( + process.platform !== 'win32' + || !code + || !WINDOWS_TRANSIENT_CODES.has(code) + || delay === undefined + ) { + throw error; + } + Atomics.wait( + new Int32Array(new SharedArrayBuffer(4)), + 0, + 0, + delay, + ); + } + } + } +} diff --git a/electron/services/BackendRuntimeContract.ts b/electron/services/BackendRuntimeContract.ts new file mode 100644 index 0000000..4b3bb7a --- /dev/null +++ b/electron/services/BackendRuntimeContract.ts @@ -0,0 +1,122 @@ +/** + * 定义 GUI 与 AutoWSGR 后端之间的正式运行契约。 + * + * GUI 只通过环境变量控制 OCR 加速和截图保存,不修改后端类、 + * 函数或日志模块的私有成员。启动前会检查当前后端是否支持这些 + * 环境变量,并确认实际导入的源码来自当前环境声明的唯一来源。 + */ +import type { PythonEnvironment } from '../pythonEnv'; +import { + buildBackendRuntimeContractProbeLines, +} from '../pythonEnv/backendContractProbe'; + +export type BackendOcrGpuMode = 'auto' | 'cpu' | 'cuda'; +export type ResolvedBackendOcrGpuMode = Exclude< + BackendOcrGpuMode, + 'auto' +>; + +export interface BackendRuntimeSettings { + ocrGpuMode: ResolvedBackendOcrGpuMode; + saveImages: boolean; +} + +export const BACKEND_RUNTIME_CONTRACT = 'gui-runtime-env-v1'; + +/** 将 GUI 模式与 CUDA 探测结果转换为后端支持的明确模式。 */ +export function selectBackendOcrGpuMode( + requestedMode: BackendOcrGpuMode, + cudaAvailable: boolean, +): ResolvedBackendOcrGpuMode { + if (requestedMode === 'cpu') return 'cpu'; + if (cudaAvailable) return 'cuda'; + if (requestedMode === 'auto') return 'cpu'; + throw new Error('已强制使用 CUDA,但未检测到可用 CUDA'); +} + +function pythonLiteral(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'"); +} + +function pythonPathSetup(environment: PythonEnvironment): string[] { + return [ + 'import sys, os, site', + ...(environment.useLocalSite + ? [ + `sp = r'${pythonLiteral(environment.localSite)}'`, + 'sys.path.insert(0, sp)', + 'site.addsitedir(sp)', + ] + : []), + ...(environment.backendRoot + ? [ + `repo = r'${pythonLiteral(environment.backendRoot)}'`, + 'sys.path.insert(0, repo)', + ] + : []), + ]; +} + +function sourceVerification(environment: PythonEnvironment): string[] { + const expectedRoot = environment.backendRoot + ?? environment.localSite; + return [ + 'from pathlib import Path', + 'import autowsgr', + `_expected_root = Path(r'${pythonLiteral(expectedRoot)}').resolve()`, + '_autowsgr_file = Path(autowsgr.__file__).resolve()', + 'if not _autowsgr_file.is_relative_to(_expected_root):', + " raise RuntimeError('GUI 后端来源错误: ' + str(_autowsgr_file))", + ]; +} + +/** 将 GUI 运行设置写入后端已经公开支持的环境变量。 */ +export function applyBackendRuntimeSettings( + baseEnv: NodeJS.ProcessEnv, + settings: BackendRuntimeSettings, +): NodeJS.ProcessEnv { + return { + ...baseEnv, + AUTOWSGR_OCR_GPU_MODE: settings.ocrGpuMode, + AUTOWSGR_SAVE_IMAGES: settings.saveImages ? 'true' : 'false', + }; +} + +/** 生成启动前能力探测脚本;不满足契约时由 Python 明确报错。 */ +export function buildBackendCapabilityProbe( + environment: PythonEnvironment, +): string { + return [ + ...pythonPathSetup(environment), + ...sourceVerification(environment), + ...buildBackendRuntimeContractProbeLines(), + '_verify_gui_runtime_contract()', + 'from autowsgr.server.main import app as _backend_app', + 'import inspect as _inspect', + 'if not callable(_backend_app):', + " raise RuntimeError('AutoWSGR server app 不可调用')", + '_app_call = getattr(_backend_app, "__call__", None)', + 'if not (_inspect.iscoroutinefunction(_backend_app) or _inspect.iscoroutinefunction(_app_call)):', + " raise RuntimeError('AutoWSGR server app 不是 ASGI 应用')", + `print('[Contract] ${BACKEND_RUNTIME_CONTRACT}')`, + "print('[Contract] autowsgr=' + str(_autowsgr_file))", + ].join('\n'); +} + +/** 生成通过能力检查后使用的最小后端启动脚本。 */ +export function buildBackendBootstrap( + environment: PythonEnvironment, + port: number, +): string { + return [ + ...pythonPathSetup(environment), + ...sourceVerification(environment), + "print('[Bootstrap] autowsgr=' + str(_autowsgr_file))", + "print('[Bootstrap] ocr_gpu_mode=' + os.environ.get('AUTOWSGR_OCR_GPU_MODE', 'cpu'))", + "print('[Bootstrap] save_backend_screenshots=' + os.environ.get('AUTOWSGR_SAVE_IMAGES', 'false'))", + 'import uvicorn', + `uvicorn.run('autowsgr.server.main:app', host='127.0.0.1', port=${port})`, + ].join('\n'); +} diff --git a/electron/services/BackendService.ts b/electron/services/BackendService.ts new file mode 100644 index 0000000..704d22f --- /dev/null +++ b/electron/services/BackendService.ts @@ -0,0 +1,474 @@ +/** + * 管理 Python 后端的环境、进程和日志。 + */ +import * as path from 'path'; +import * as fs from 'fs'; +import { + execFile, + execSync, + spawn, + ChildProcess, +} from 'child_process'; +import { + buildBackendRuntimeEnvironment, + ensurePthFile, + ensureSslCertForPython, + findPython, + resolveConfiguredCudaRoot, + resolvePythonEnvironment, +} from '../pythonEnv'; +import { + applyBackendRuntimeSettings, + buildBackendBootstrap, + buildBackendCapabilityProbe, + selectBackendOcrGpuMode, + type BackendOcrGpuMode, + type ResolvedBackendOcrGpuMode, +} from './BackendRuntimeContract'; +import { shutdownBackendProcess } from './BackendShutdownService'; +import { + buildResourceEnvironment, + SHIP_LIBRARY_ENV, + shipLibraryRoot, +} from '../resourcePaths'; + +export { buildBackendRuntimeEnvironment } from '../pythonEnv'; + +export interface BackendContext { + appRoot: () => string; + userDataRoot: () => string; + resourceRoot: () => string; + BACKEND_PORT: number; + sendToRenderer: (channel: string, ...args: unknown[]) => boolean; +} + +let ctx: BackendContext; +let backendProcess: ChildProcess | null = null; +let backendStopPromise: Promise | null = null; + +/** 注入 Electron 运行时能力。 */ +export function initBackend(context: BackendContext): void { + ctx = context; +} + +/** 返回当前后端进程;未启动或已退出时返回 null。 */ +export function getBackendProcess(): ChildProcess | null { + return backendProcess; +} + +function readGuiSettings(): Record { + try { + const settingsPath = path.join( + ctx.userDataRoot(), + 'gui_settings.json', + ); + if (!fs.existsSync(settingsPath)) return {}; + return JSON.parse( + fs.readFileSync(settingsPath, 'utf-8'), + ) as Record; + } catch { + return {}; + } +} + +function readOcrGpuModeFromSettings(): BackendOcrGpuMode { + const value = readGuiSettings().ocr_gpu_mode; + if (value === 'cpu' || value === 'cuda') return value; + return 'auto'; +} + +function readCudaPathFromSettings(): string | null { + const value = readGuiSettings().cuda_path; + const cudaRoot = resolveConfiguredCudaRoot(value); + if ( + typeof value === 'string' + && value.trim() + && !cudaRoot + ) { + console.warn( + `[Backend] 忽略 cuda_path(未找到 Toolkit 或 CUDA Runtime DLL): ${path.resolve(value.trim())}`, + ); + } + return cudaRoot; +} + +function readSaveBackendScreenshotsFromSettings(): boolean { + return readGuiSettings().save_backend_screenshots === true; +} + +/** 使用后端实际运行环境解析 OCR 的自动/强制 CUDA 模式。 */ +function resolveBackendOcrGpuMode( + pythonCommand: string, + requestedMode: BackendOcrGpuMode, + cwd: string, + processEnv: NodeJS.ProcessEnv, +): Promise { + if (requestedMode === 'cpu') return Promise.resolve('cpu'); + + const probe = [ + 'import json', + 'try:', + ' import torch', + ' result = {', + ' "available": bool(torch.cuda.is_available()),', + ' "torch_version": str(torch.__version__),', + ' "cuda_version": getattr(torch.version, "cuda", None),', + ' }', + 'except Exception as exc:', + ' result = {"available": False, "error": str(exc)}', + 'print(json.dumps(result, ensure_ascii=False))', + ].join('\n'); + + return new Promise((resolve, reject) => { + execFile( + pythonCommand, + ['-X', 'utf8', '-c', probe], + { + cwd, + windowsHide: true, + timeout: 20000, + encoding: 'utf8', + env: processEnv, + }, + (error, stdout, stderr) => { + let detail = String(stderr || error?.message || '').trim(); + let available = false; + if (!error) { + try { + const output = String(stdout) + .trim() + .split(/\r?\n/) + .filter(Boolean) + .at(-1); + const result = JSON.parse(output || '{}') as { + available?: boolean; + torch_version?: string; + cuda_version?: string | null; + error?: string; + }; + available = result.available === true; + detail = result.error + ?? `PyTorch ${result.torch_version ?? 'unknown'}, CUDA ${ + result.cuda_version ?? '不可用' + }`; + } catch (parseError) { + detail = parseError instanceof Error + ? parseError.message + : String(parseError); + } + } + + try { + resolve(selectBackendOcrGpuMode(requestedMode, available)); + } catch { + reject(new Error( + `已强制使用 CUDA,但后端运行环境未检测到可用 CUDA:${detail || '无检测结果'}`, + )); + return; + } + if (!available) { + console.warn( + `[Backend] 未检测到可用 CUDA,OCR 自动切换为 CPU: ${detail || '无检测结果'}`, + ); + } + }, + ); + }); +} + +/** 启动前验证后端来源、服务入口和正式运行设置契约。 */ +function verifyBackendRuntimeContract( + pythonCommand: string, + environment: ReturnType, + cwd: string, + processEnv: NodeJS.ProcessEnv, +): Promise { + const probe = buildBackendCapabilityProbe(environment); + return new Promise((resolve, reject) => { + execFile( + pythonCommand, + ['-X', 'utf8', '-c', probe], + { + cwd, + windowsHide: true, + timeout: 30000, + encoding: 'utf8', + env: processEnv, + }, + (error, stdout, stderr) => { + if (!error) { + const message = String(stdout).trim(); + if (message) console.log(`[Backend] ${message}`); + resolve(); + return; + } + const detail = String(stderr || stdout || error.message) + .trim() + .slice(-2000); + reject(new Error( + `后端版本或能力不兼容,已阻止启动:${detail || error.message}`, + )); + }, + ); + }); +} + +/** 运行 setup.bat 安装环境。 */ +export function runSetupScript(): Promise<{ + success: boolean; + output: string; +}> { + return new Promise((resolve) => { + let setupPath = path.join(ctx.resourceRoot(), 'setup.bat'); + if (!fs.existsSync(setupPath)) { + setupPath = path.join(ctx.appRoot(), 'setup.bat'); + } + if (!fs.existsSync(setupPath)) { + resolve({ success: false, output: '找不到 setup.bat' }); + return; + } + + const proc = spawn('cmd.exe', ['/c', setupPath], { + cwd: ctx.appRoot(), + windowsHide: false, + stdio: 'pipe', + }); + + let output = ''; + proc.stdout?.on('data', (data: Buffer) => { + const text = data.toString(); + output += text; + ctx.sendToRenderer('setup-log', text); + }); + proc.stderr?.on('data', (data: Buffer) => { + const text = data.toString(); + output += text; + ctx.sendToRenderer('setup-log', text); + }); + proc.on('close', (code) => { + resolve({ success: code === 0, output: output.slice(-1000) }); + }); + proc.on('error', (error) => { + resolve({ success: false, output: error.message }); + }); + }); +} + +/** 使用当前唯一 Python 环境启动 AutoWSGR 后端。 */ +export async function startBackend(): Promise { + const pythonCmd = await findPython(); + if (!pythonCmd) { + throw new Error('找不到兼容的 Python(需要 3.12 或 3.13)'); + } + const environment = resolvePythonEnvironment(pythonCmd); + if (environment.useLocalSite) ensurePthFile(); + + const certFile = await ensureSslCertForPython(pythonCmd); + if (certFile) console.log(`[Backend] TLS cert: ${certFile}`); + else { + console.warn( + '[Backend] WARNING 未检测到 TLS 根证书,HTTPS 请求可能失败', + ); + } + + const cwd = ctx.appRoot(); + const localBackendRepo = environment.backendRoot; + const requestedOcrGpuMode = readOcrGpuModeFromSettings(); + const configuredCudaRoot = readCudaPathFromSettings(); + const saveBackendScreenshots = readSaveBackendScreenshotsFromSettings(); + const bootstrap = buildBackendBootstrap( + environment, + ctx.BACKEND_PORT, + ); + if (localBackendRepo) { + console.log(`[Backend] 使用本地后端仓库: ${localBackendRepo}`); + ctx.sendToRenderer( + 'backend-log', + `[GUI] 使用本地后端仓库: ${localBackendRepo}`, + ); + } else { + ctx.sendToRenderer( + 'backend-log', + '[GUI] 未启用本地后端仓库覆盖,使用 site-packages 中的 autowsgr', + ); + } + ctx.sendToRenderer( + 'backend-log', + `[GUI] CUDA 路径: ${configuredCudaRoot ?? '系统自动检测'}`, + ); + ctx.sendToRenderer( + 'backend-log', + `[GUI] 保存识别异常截图: ${saveBackendScreenshots ? '开启' : '关闭'}`, + ); + + const adbDir = path.join(ctx.appRoot(), 'adb'); + const cudaEnv = buildBackendRuntimeEnvironment( + environment, + configuredCudaRoot, + ); + const envPath = cudaEnv.PATH || ''; + const pathWithAdb = fs.existsSync(adbDir) + ? `${adbDir}${path.delimiter}${envPath}` + : envPath; + const runtimeEnv = { + ...cudaEnv, + PYTHONUTF8: '1', + PYTHONIOENCODING: 'utf-8', + PATH: pathWithAdb, + }; + const resolvedOcrGpuMode = await resolveBackendOcrGpuMode( + pythonCmd, + requestedOcrGpuMode, + cwd, + runtimeEnv, + ); + ctx.sendToRenderer( + 'backend-log', + `[GUI] OCR 加速模式: ${requestedOcrGpuMode}${ + requestedOcrGpuMode === 'auto' + ? ` -> ${resolvedOcrGpuMode}` + : '' + }`, + ); + const resourceEnv = buildResourceEnvironment( + runtimeEnv, + ctx.resourceRoot(), + ); + console.log( + `[Backend] ${SHIP_LIBRARY_ENV}=${shipLibraryRoot(ctx.resourceRoot())}`, + ); + const backendEnv = applyBackendRuntimeSettings( + resourceEnv, + { + ocrGpuMode: resolvedOcrGpuMode, + saveImages: saveBackendScreenshots, + }, + ); + + await verifyBackendRuntimeContract( + pythonCmd, + environment, + cwd, + backendEnv, + ); + ctx.sendToRenderer( + 'backend-log', + '[GUI] 后端运行契约验证通过', + ); + + // MuMu 多开实例不会自动被 ADB 发现,因此启动前主动连接。 + try { + const cfgPath = path.join(ctx.userDataRoot(), 'usersettings.yaml'); + if (fs.existsSync(cfgPath)) { + const cfgText = fs.readFileSync(cfgPath, 'utf-8'); + const serialMatch = cfgText.match(/serial:\s*(\S+)/); + if (serialMatch) { + const serial = serialMatch[1]; + const adbExe = path.join(adbDir, 'adb.exe'); + const adbCmd = fs.existsSync(adbExe) ? adbExe : 'adb'; + execSync(`"${adbCmd}" connect ${serial}`, { + windowsHide: true, + timeout: 5000, + stdio: 'pipe', + }); + console.log(`[Backend] ADB connect ${serial} 完成`); + } + } + } catch (error: unknown) { + const message = error instanceof Error + ? error.message + : String(error); + console.warn(`[Backend] ADB connect 失败 (非致命): ${message}`); + } + + const spawnedProcess = spawn( + pythonCmd, + ['-X', 'utf8', '-c', bootstrap], + { + cwd, + windowsHide: true, + stdio: 'pipe', + env: backendEnv, + }, + ); + backendProcess = spawnedProcess; + + const CYAN = '\x1b[36m'; + const RED = '\x1b[31m'; + const YELLOW = '\x1b[33m'; + const GREEN = '\x1b[32m'; + const DIM = '\x1b[2m'; + const RESET = '\x1b[0m'; + + const colorLine = (line: string): string => { + if (/\bERROR\b/i.test(line)) return `${RED}${line}${RESET}`; + if (/\bWARNING\b/i.test(line)) return `${YELLOW}${line}${RESET}`; + if (/\bINFO\b/i.test(line)) return `${GREEN}${line}${RESET}`; + if (/\bDEBUG\b/i.test(line)) return `${DIM}${line}${RESET}`; + return `${CYAN}${line}${RESET}`; + }; + + const LOGURU_LINE_RE = /^\d{2}:\d{2}:\d{2}\.\d{3}\s*\|/; + let skipMultiline = false; + + const handleOutput = (data: Buffer) => { + for (const line of data.toString('utf-8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + console.log(`${CYAN}[Backend]${RESET} ${colorLine(trimmed)}`); + + const isNewEntry = LOGURU_LINE_RE.test(trimmed); + if (isNewEntry) { + skipMultiline = /\bDEBUG\b/i.test(trimmed); + } + if (skipMultiline) continue; + if ( + /"(?:GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD)\s+\//.test(trimmed) + ) { + continue; + } + ctx.sendToRenderer('backend-log', trimmed); + } + }; + spawnedProcess.stdout?.on('data', handleOutput); + spawnedProcess.stderr?.on('data', handleOutput); + spawnedProcess.on('error', (error) => { + console.error('[Backend] 启动失败:', error.message); + if (backendProcess === spawnedProcess) backendProcess = null; + }); + spawnedProcess.on('close', (code) => { + console.log(`[Backend] 进程退出, code=${code}`); + if (backendProcess === spawnedProcess) backendProcess = null; + }); +} + +/** 停止后端任务和完整进程树,并等待操作系统确认进程退出。 */ +export async function stopBackend(): Promise { + if (backendStopPromise) return backendStopPromise; + const activeProcess = backendProcess; + if (!activeProcess) return; + if ( + activeProcess.exitCode !== null + || activeProcess.signalCode !== null + ) { + if (backendProcess === activeProcess) backendProcess = null; + return; + } + + backendStopPromise = shutdownBackendProcess( + activeProcess, + { backendPort: ctx.BACKEND_PORT }, + ).finally(() => { + if ( + backendProcess === activeProcess + && ( + activeProcess.exitCode !== null + || activeProcess.signalCode !== null + ) + ) { + backendProcess = null; + } + backendStopPromise = null; + }); + return backendStopPromise; +} diff --git a/electron/services/BackendShutdownService.ts b/electron/services/BackendShutdownService.ts new file mode 100644 index 0000000..520b689 --- /dev/null +++ b/electron/services/BackendShutdownService.ts @@ -0,0 +1,241 @@ +/** + * 后端进程安全关闭流程。 + * + * 1. 调用后端系统停止接口,让运行中的任务完成清理。 + * 2. 终止后端服务进程;Windows 同时处理完整进程树。 + * 3. 等待进程 close,确认操作系统已经释放进程资源。 + * 4. 超时后才强制终止,并再次等待 close。 + * + * 更新安装和应用退出共用这一流程,任何阶段无法确认进程退出时都会 + * 抛出错误,调用方不得继续安装更新。 + */ +import { execFile } from 'child_process'; +import type { ChildProcess } from 'child_process'; +import * as http from 'http'; +import { platform as nodePlatform } from 'process'; + +export interface BackendShutdownOptions { + backendPort: number; + systemStopTimeoutMs?: number; + terminateTimeoutMs?: number; + forceTerminateTimeoutMs?: number; +} + +export interface BackendShutdownDependencies { + platform?: NodeJS.Platform; + requestSystemStop?: (port: number, timeoutMs: number) => Promise; + terminateProcessTree?: ( + process: ChildProcess, + force: boolean, + platform: NodeJS.Platform, + ) => Promise; + waitForProcessClose?: ( + process: ChildProcess, + timeoutMs: number, + ) => Promise; + warn?: (message: string) => void; +} + +const DEFAULT_SYSTEM_STOP_TIMEOUT_MS = 35000; +const DEFAULT_TERMINATE_TIMEOUT_MS = 5000; +const DEFAULT_FORCE_TERMINATE_TIMEOUT_MS = 5000; + +function hasExited(process: ChildProcess): boolean { + return process.exitCode !== null || process.signalCode !== null; +} + +function executeFile( + command: string, + args: string[], +): Promise { + return new Promise((resolve, reject) => { + execFile( + command, + args, + { + windowsHide: true, + timeout: 10000, + encoding: 'utf8', + }, + (error, _stdout, _stderr) => { + if (!error) { + resolve(); + return; + } + const exitCode = typeof error.code === 'number' + || typeof error.code === 'string' + ? error.code + : 'unknown'; + reject(new Error(`${command} 执行失败,退出码 ${exitCode}`)); + }, + ); + }); +} + +/** 调用后端正式停止接口并验证返回结果。 */ +export function requestBackendSystemStop( + port: number, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + const request = http.request( + { + hostname: '127.0.0.1', + port, + path: '/api/system/stop', + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Length': '0', + }, + }, + response => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', chunk => { + body += chunk; + }); + response.on('end', () => { + const statusCode = response.statusCode ?? 0; + if (statusCode < 200 || statusCode >= 300) { + reject(new Error( + `后端停止接口返回 HTTP ${statusCode}: ${body.trim()}`, + )); + return; + } + try { + const result = JSON.parse(body) as { + success?: boolean; + message?: string; + error?: string; + }; + if (result.success !== true) { + reject(new Error( + result.error + || result.message + || '后端拒绝停止当前任务', + )); + return; + } + resolve(); + } catch (error) { + reject(new Error( + `后端停止接口返回无效 JSON: ${ + error instanceof Error ? error.message : String(error) + }`, + )); + } + }); + }, + ); + request.setTimeout(timeoutMs, () => { + request.destroy(new Error(`后端任务停止等待超过 ${timeoutMs}ms`)); + }); + request.on('error', reject); + request.end(); + }); +} + +/** 等待进程真正退出;返回 false 表示超时。 */ +export function waitForProcessClose( + process: ChildProcess, + timeoutMs: number, +): Promise { + if (hasExited(process)) return Promise.resolve(true); + return new Promise(resolve => { + const finish = (closed: boolean): void => { + clearTimeout(timer); + process.removeListener('close', onClose); + resolve(closed); + }; + const onClose = (): void => finish(true); + const timer = setTimeout(() => finish(hasExited(process)), timeoutMs); + process.once('close', onClose); + }); +} + +/** 终止服务进程;Windows 使用 /T 保证完整进程树被处理。 */ +export async function terminateBackendProcessTree( + process: ChildProcess, + force: boolean, + platform: NodeJS.Platform, +): Promise { + if (hasExited(process)) return; + if (platform === 'win32') { + if (!process.pid) throw new Error('后端进程没有可用 PID'); + const args = ['/PID', String(process.pid), '/T']; + if (force) args.push('/F'); + await executeFile('taskkill.exe', args); + return; + } + + const signal: NodeJS.Signals = force ? 'SIGKILL' : 'SIGTERM'; + if (!process.kill(signal) && !hasExited(process)) { + throw new Error(`无法向后端进程发送 ${signal}`); + } +} + +/** 执行完整关闭流程;无法确认退出时抛错并阻止更新安装。 */ +export async function shutdownBackendProcess( + process: ChildProcess, + options: BackendShutdownOptions, + dependencies: BackendShutdownDependencies = {}, +): Promise { + if (hasExited(process)) return; + + const platform = dependencies.platform ?? nodePlatform; + const requestSystemStop = dependencies.requestSystemStop + ?? requestBackendSystemStop; + const terminateProcessTree = dependencies.terminateProcessTree + ?? terminateBackendProcessTree; + const waitForClose = dependencies.waitForProcessClose + ?? waitForProcessClose; + const warn = dependencies.warn ?? (message => console.warn(message)); + + try { + await requestSystemStop( + options.backendPort, + options.systemStopTimeoutMs ?? DEFAULT_SYSTEM_STOP_TIMEOUT_MS, + ); + } catch (error) { + warn( + `[Backend] 优雅停止任务失败,将继续关闭进程:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + if (hasExited(process)) return; + + try { + await terminateProcessTree(process, false, platform); + } catch (error) { + warn( + `[Backend] 普通终止失败,将等待后强制终止:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + const terminated = await waitForClose( + process, + options.terminateTimeoutMs ?? DEFAULT_TERMINATE_TIMEOUT_MS, + ); + if (terminated) return; + + let forceError: unknown = null; + try { + await terminateProcessTree(process, true, platform); + } catch (error) { + forceError = error; + } + const forced = await waitForClose( + process, + options.forceTerminateTimeoutMs + ?? DEFAULT_FORCE_TERMINATE_TIMEOUT_MS, + ); + if (forced) return; + + const detail = forceError instanceof Error + ? `:${forceError.message}` + : ''; + throw new Error(`无法确认后端进程树已经退出${detail}`); +} diff --git a/electron/services/CombatPlanCodec.ts b/electron/services/CombatPlanCodec.ts new file mode 100644 index 0000000..3d1fa66 --- /dev/null +++ b/electron/services/CombatPlanCodec.ts @@ -0,0 +1,233 @@ +/** + * 校验、拆分、展开并序列化出征计划。 + */ +import { + normalizeLegacyNodeDecisionFields, +} from '../../src/shared/nodeDecision'; +import { + parseYaml, + serializePlanYaml, +} from '../../src/shared/yamlSerializer'; +import { + type PlanPresetSource, + TeamPlanCodec, + type UserTeamPlan, +} from './TeamPlanCodec'; +import { TeamPlanRepository } from './TeamPlanRepository'; + +export interface SplitCombatPlan { + mapRoot: Record; + teams: UserTeamPlan[]; +} + +/** 负责出征计划的拆分、展开、序列化和名称清理。 */ +export class CombatPlanCodec { + constructor( + private readonly teamCodec: TeamPlanCodec, + private readonly teamRepository: TeamPlanRepository, + ) {} + + /** 判断未知值是否为可读取的普通对象。 */ + isPlainObject(value: unknown): value is Record { + return Boolean(value) + && typeof value === 'object' + && !Array.isArray(value); + } + + /** 解析 YAML,并使用调用方指定的既有错误文本校验根对象。 */ + parseRoot( + content: string, + invalidRootMessage: string, + ): Record { + const parsed = parseYaml(content); + if (!this.isPlainObject(parsed)) { + throw new Error(invalidRootMessage); + } + return parsed; + } + + /** 序列化计划并保留原文件开头的注释。 */ + serialize( + root: Record, + originalContent = '', + ): string { + const leadingComments: string[] = []; + for (const line of originalContent.split(/\r?\n/)) { + const trimmed = line.trimStart(); + if (trimmed.startsWith('#') || trimmed === '') { + leadingComments.push(line); + continue; + } + break; + } + const prefix = leadingComments.some( + line => line.trimStart().startsWith('#'), + ) + ? `${leadingComments.join('\n').replace(/\s+$/, '')}\n` + : ''; + return `${prefix}${serializePlanYaml(root)}`; + } + + /** 把内嵌舰队拆成独立编队,并在地图中只保留名称引用。 */ + normalizeFleetPresets( + root: Record, + source: PlanPresetSource, + requireEmbeddedShips: boolean, + allowMissingReferences = false, + ): SplitCombatPlan { + this.requireMapCoordinates(root); + const normalizedRoot = structuredClone(root); + if (this.isPlainObject(normalizedRoot.node_args)) { + for (const [nodeId, node] of Object.entries(normalizedRoot.node_args)) { + if (!this.isPlainObject(node)) continue; + normalizedRoot.node_args[nodeId] = + normalizeLegacyNodeDecisionFields(node); + } + } + if (normalizedRoot.fleet_presets === undefined) { + return { + mapRoot: normalizedRoot, + teams: [], + }; + } + if (!Array.isArray(normalizedRoot.fleet_presets)) { + throw new Error('fleet_presets 必须是列表'); + } + + const names = new Set(); + const teams: UserTeamPlan[] = []; + const references = normalizedRoot.fleet_presets.map((rawPreset, index) => { + if (!this.isPlainObject(rawPreset)) { + throw new Error(`fleet_presets[${index}] 必须是对象`); + } + const name = typeof rawPreset.name === 'string' + ? rawPreset.name.trim() + : ''; + if (!name) { + throw new Error(`fleet_presets[${index}].name 不能为空`); + } + if (names.has(name)) { + throw new Error(`fleet_presets 中存在重复舰队名称:${name}`); + } + names.add(name); + + if (Array.isArray(rawPreset.ships)) { + teams.push(this.teamCodec.normalize({ + name, + ships: rawPreset.ships, + })); + } else { + if (requireEmbeddedShips) { + throw new Error(`旧计划中的舰队「${name}」缺少 ships`); + } + if ( + !allowMissingReferences + && !this.teamRepository.find(name, source) + ) { + throw new Error(`找不到舰队「${name}」的独立配置`); + } + } + return { name }; + }); + + return { + mapRoot: { + ...normalizedRoot, + fleet_presets: references, + }, + teams, + }; + } + + /** 拆分旧计划,并让其中缺少校验模式的内嵌舰队默认使用弱校验。 */ + normalizeLegacyFleetPresets( + root: Record, + source: PlanPresetSource, + requireEmbeddedShips: boolean, + ): SplitCombatPlan { + const split = this.normalizeFleetPresets( + root, + source, + requireEmbeddedShips, + ); + return { + ...split, + teams: split.teams.map(team => this.teamCodec.normalizeLegacy(team)), + }; + } + + /** 为后端执行展开舰队引用,并保留引用对象的未知字段。 */ + expandRoot( + root: Record, + source: PlanPresetSource, + ): Record { + this.requireMapCoordinates(root); + if (root.fleet_presets === undefined) { + return structuredClone(root); + } + if (!Array.isArray(root.fleet_presets)) { + throw new Error('fleet_presets 必须是列表'); + } + + const listedTeams = this.teamRepository.list().plans; + const presets = root.fleet_presets.map((rawPreset, index) => { + if (!this.isPlainObject(rawPreset)) { + throw new Error(`fleet_presets[${index}] 必须是对象`); + } + const name = typeof rawPreset.name === 'string' + ? rawPreset.name.trim() + : ''; + if (!name) { + throw new Error(`fleet_presets[${index}].name 不能为空`); + } + + const userOverride = listedTeams.find(team => ( + team.name === name && team.source === 'user' + )) ?? null; + const sameSourceTeam = listedTeams.find(team => ( + team.name === name && team.source === source + )) ?? null; + const embeddedTeam = Array.isArray(rawPreset.ships) + ? this.teamCodec.normalize({ + name, + ships: rawPreset.ships, + }) + : null; + const team = userOverride + ?? sameSourceTeam + ?? embeddedTeam + ?? this.teamRepository.find(name, source, listedTeams); + if (!team) { + throw new Error(`地图引用的舰队「${name}」不存在`); + } + return { + ...structuredClone(rawPreset), + name, + ships: structuredClone(team.ships), + }; + }); + + return { + ...structuredClone(root), + fleet_presets: presets, + }; + } + + /** 清理出征计划名称,生成既有 bettle- 文件名前的名称部分。 */ + safeBaseName(value: string): string { + return value + .trim() + .replace(/\.ya?ml$/i, '') + .replace(/^bettle-/i, '') + .replace(/[<>:"/\\|?*\x00-\x1f]/g, '_') + .replace(/[. ]+$/g, '') + .slice(0, 100); + } + + /** 校验后端执行计划必须包含的地图坐标。 */ + private requireMapCoordinates(root: Record): void { + if (!('chapter' in root) || !('map' in root)) { + throw new Error('出征计划必须包含 chapter 和 map'); + } + } +} diff --git a/electron/services/CombatPlanRepository.ts b/electron/services/CombatPlanRepository.ts new file mode 100644 index 0000000..0f6b24f --- /dev/null +++ b/electron/services/CombatPlanRepository.ts @@ -0,0 +1,118 @@ +/** + * 管理系统和用户出征计划文件。 + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { AppPaths } from './AppPaths'; +import { AtomicFileStore } from './AtomicFileStore'; +import { type PlanPresetSource } from './TeamPlanCodec'; + +/** 负责受管出征计划的路径、读取、写入、重命名和删除。 */ +export class CombatPlanRepository { + constructor( + private readonly appPaths: AppPaths, + private readonly atomicFiles: AtomicFileStore, + ) {} + + /** 初始化用户出征计划目录。 */ + initializeUserDirectory(): void { + fs.mkdirSync(this.appPaths.userBattlePlansDir(), { recursive: true }); + } + + /** 返回指定来源的权威出征计划目录。 */ + directory(source: PlanPresetSource): string { + return source === 'system' + ? this.appPaths.systemBattlePlansDir() + : this.appPaths.userBattlePlansDir(); + } + + /** 列出用户计划文件,保持原有过滤、映射和文件系统顺序。 */ + listUserFiles(): { name: string; file: string }[] { + const directory = this.directory('user'); + if (!fs.existsSync(directory)) return []; + return fs.readdirSync(directory) + .filter(file => /\.ya?ml$/i.test(file)) + .map(file => ({ + name: file.replace(/\.ya?ml$/i, ''), + file, + })); + } + + /** 返回经过文件名边界校验的受管计划路径。 */ + safeManagedPath( + source: PlanPresetSource, + file: string, + ): string | null { + if ( + (source !== 'system' && source !== 'user') + || path.basename(file) !== file + || !/\.ya?ml$/i.test(file) + ) { + return null; + } + return path.join(this.directory(source), file); + } + + /** 返回经过文件名边界校验的用户计划路径。 */ + safeUserPath(file: string): string | null { + return this.safeManagedPath('user', file); + } + + /** 判断绝对路径是否为系统或用户受管计划。 */ + managedFromPath( + filePath: string, + ): { source: PlanPresetSource; file: string } | null { + const resolved = path.resolve(filePath); + for (const source of ['system', 'user'] as const) { + const directory = path.resolve(this.directory(source)); + if ( + path.dirname(resolved).toLowerCase() === directory.toLowerCase() + && /\.ya?ml$/i.test(path.basename(resolved)) + ) { + return { + source, + file: path.basename(resolved), + }; + } + } + return null; + } + + /** 列出目录中的 YAML 文件并保持既有排序规则。 */ + yamlFiles(directory: string): string[] { + if (!fs.existsSync(directory)) return []; + return fs.readdirSync(directory) + .filter(file => /\.ya?ml$/i.test(file)) + .sort((left, right) => left.localeCompare(right, 'zh-CN')); + } + + /** 读取一份出征计划原始文本。 */ + read(filePath: string): string { + return fs.readFileSync(filePath, 'utf-8'); + } + + /** 原子写入一份出征计划文本。 */ + write(filePath: string, content: string): void { + this.atomicFiles.write(filePath, content); + } + + /** 返回路径是否已存在。 */ + exists(filePath: string): boolean { + return fs.existsSync(filePath); + } + + /** 返回文件最后修改时间的毫秒值。 */ + modifiedAt(filePath: string): number { + return fs.statSync(filePath).mtimeMs; + } + + /** 使用既有文件系统语义重命名计划。 */ + rename(source: string, target: string): void { + fs.renameSync(source, target); + } + + /** 删除已有出征计划文件。 */ + remove(filePath: string): void { + fs.unlinkSync(filePath); + } +} diff --git a/electron/services/CudaEnvironmentService.ts b/electron/services/CudaEnvironmentService.ts new file mode 100644 index 0000000..cf1caf6 --- /dev/null +++ b/electron/services/CudaEnvironmentService.ts @@ -0,0 +1,300 @@ +/** + * 校验 CUDA 路径并检测 PyTorch 的 CUDA 能力。 + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { + buildBackendRuntimeEnvironment, + readCudaVersionFile, + resolvePythonEnvironment, +} from '../pythonEnv'; + +const execFileAsync = promisify(execFile); + +export interface CudaValidationResult { + valid: boolean; + path: string; + version: string | null; + kind?: 'toolkit' | 'runtime'; + torchVersion?: string | null; + device?: string | null; + error?: string; +} + +export interface CudaCommandOptions { + windowsHide: true; + timeout: 20000; + encoding: 'utf8'; + env: NodeJS.ProcessEnv; +} + +export interface CudaEnvironmentDependencies { + findPython(): Promise; + buildRuntimeEnvironment( + pythonCommand: string, + configuredCudaRoot: string | null, + ): NodeJS.ProcessEnv; + execute( + executable: string, + args: string[], + options: CudaCommandOptions, + ): Promise<{ stdout: unknown }>; +} + +interface CudaDetectionPayload { + available: boolean; + torchVersion: string | null; + cudaVersion: string | null; + device: string | null; + error: string | null; +} + +function isRecord(value: unknown): value is Record { + return ( + typeof value === 'object' + && value !== null + && !Array.isArray(value) + ); +} + +function optionalText(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function parseCudaDetectionPayload(value: unknown): CudaDetectionPayload { + if (!isRecord(value)) { + throw new Error('Python 返回的检测结果不是对象'); + } + return { + available: value.available === true, + torchVersion: optionalText(value.torch_version), + cudaVersion: optionalText(value.cuda_version), + device: optionalText(value.device), + error: optionalText(value.error), + }; +} + +/** 校验 CUDA 目录并检测当前 PyTorch 的实际 CUDA 能力。 */ +export class CudaEnvironmentService { + constructor( + private readonly dependencies: CudaEnvironmentDependencies, + ) {} + + /** 使用生产子进程执行器创建服务依赖。 */ + static createDependencies( + findPython: () => Promise, + ): CudaEnvironmentDependencies { + return { + findPython, + buildRuntimeEnvironment: (pythonCommand, configuredCudaRoot) => ( + buildBackendRuntimeEnvironment( + resolvePythonEnvironment(pythonCommand), + configuredCudaRoot, + ) + ), + execute: async (executable, args, options) => { + const result = await execFileAsync( + executable, + args, + options, + ); + return { stdout: result.stdout }; + }, + }; + } + + /** 将配置输入归一化为 Toolkit 根目录或 Runtime 目录。 */ + normalizePath(candidate: string): string { + const resolved = path.resolve(candidate.trim()); + if (this.findRuntimeDlls(resolved)) return resolved; + return path.basename(resolved).toLowerCase() === 'bin' + ? path.dirname(resolved) + : resolved; + } + + /** 只校验本地 CUDA 路径并读取可用版本信息。 */ + validatePath(candidate: string): CudaValidationResult { + if (!candidate.trim()) { + return { + valid: false, + path: '', + version: null, + error: '路径为空', + }; + } + const cudaRoot = this.normalizePath(candidate); + if (!fs.existsSync(cudaRoot)) { + return { + valid: false, + path: cudaRoot, + version: null, + error: '目录不存在', + }; + } + const binDirectory = path.join(cudaRoot, 'bin'); + const isToolkit = fs.existsSync( + path.join(binDirectory, 'nvcc.exe'), + ); + const runtimeDirectory = this.findRuntimeDlls(cudaRoot) + ? cudaRoot + : this.findRuntimeDlls(binDirectory) + ? binDirectory + : null; + if (!isToolkit && !runtimeDirectory) { + return { + valid: false, + path: cudaRoot, + version: null, + error: '未找到 CUDA Toolkit(bin\\nvcc.exe)或 PyTorch CUDA Runtime DLL', + }; + } + + let version = readCudaVersionFile(cudaRoot); + version ??= path.basename(cudaRoot) + .match(/v\d+(?:\.\d+)?/i)?.[0] + ?? null; + if (isToolkit) { + return { + valid: true, + path: cudaRoot, + version, + kind: 'toolkit', + }; + } + + let runtimeVersion: string | null = null; + try { + const cudart = fs.readdirSync(runtimeDirectory!) + .find(name => /^cudart64.*\.dll$/i.test(name)); + runtimeVersion = cudart + ?.match(/^cudart64[_-]?(\d+)/i)?.[1] + ?? null; + if (runtimeVersion?.length === 2) { + runtimeVersion = `${runtimeVersion[0]}.${runtimeVersion[1]}`; + } else if (runtimeVersion?.length === 3) { + runtimeVersion = `${ + runtimeVersion.slice(0, 2) + }.${runtimeVersion[2]}`; + } + } catch { + // 无法解析时保留未知版本。 + } + return { + valid: true, + path: runtimeDirectory!, + version: runtimeVersion, + kind: 'runtime', + }; + } + + /** 使用后端实际采用的 Python 检测 PyTorch、CUDA 和显卡。 */ + async detect(candidate: string): Promise { + const rawPath = candidate.trim(); + const pathResult = rawPath + ? this.validatePath(rawPath) + : null; + if (pathResult && !pathResult.valid) return pathResult; + + const pythonCommand = await this.dependencies.findPython(); + if (!pythonCommand) { + return { + valid: false, + path: pathResult?.path ?? '', + version: pathResult?.version ?? null, + kind: pathResult?.kind, + error: '未找到可用的 Python 3.12 或 3.13', + }; + } + + const script = [ + 'import json', + 'try:', + ' import torch', + ' available = bool(torch.cuda.is_available())', + ' result = {', + ' "available": available,', + ' "torch_version": str(torch.__version__),', + ' "cuda_version": getattr(torch.version, "cuda", None),', + ' "device": torch.cuda.get_device_name(0) if available else None,', + ' }', + 'except Exception as exc:', + ' result = {"available": False, "error": str(exc)}', + 'print(json.dumps(result, ensure_ascii=False))', + ].join('\n'); + + try { + const { stdout } = await this.dependencies.execute( + pythonCommand, + ['-c', script], + { + windowsHide: true, + timeout: 20000, + encoding: 'utf8', + env: this.dependencies.buildRuntimeEnvironment( + pythonCommand, + pathResult?.path ?? null, + ), + }, + ); + const output = String(stdout) + .trim() + .split(/\r?\n/) + .filter(Boolean) + .at(-1); + if (!output) throw new Error('Python 未返回检测结果'); + const parsed: unknown = JSON.parse(output); + const detected = parseCudaDetectionPayload(parsed); + const version = detected.cudaVersion + ?? pathResult?.version + ?? null; + if (!detected.available) { + return { + valid: false, + path: pathResult?.path ?? '', + version, + kind: pathResult?.kind, + torchVersion: detected.torchVersion, + device: null, + error: detected.error + ? `PyTorch 检测失败:${detected.error}` + : `PyTorch ${ + detected.torchVersion ?? '' + } 未检测到可用 CUDA` + .replace(/\s+/g, ' ') + .trim(), + }; + } + return { + valid: true, + path: pathResult?.path ?? '', + version, + kind: pathResult?.kind, + torchVersion: detected.torchVersion, + device: detected.device, + }; + } catch (error) { + return { + valid: false, + path: pathResult?.path ?? '', + version: pathResult?.version ?? null, + kind: pathResult?.kind, + error: `硬件检测失败:${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + } + + /** 检查目录中是否同时包含必要 CUDA Runtime DLL。 */ + private findRuntimeDlls(directory: string): boolean { + try { + const names = fs.readdirSync(directory); + return names.some(name => /^cudart64.*\.dll$/i.test(name)) + && names.some(name => /^cublas64.*\.dll$/i.test(name)); + } catch { + return false; + } + } +} diff --git a/electron/services/DailyPlanService.ts b/electron/services/DailyPlanService.ts new file mode 100644 index 0000000..426e451 --- /dev/null +++ b/electron/services/DailyPlanService.ts @@ -0,0 +1,326 @@ +/** + * 管理演习、战役和决战三类独立日常任务 YAML。 + * + * 系统计划位于 resource/system_daily_plans,只读。 + * 用户计划位于 Electron userData/user_daily_plans,可覆盖同名系统计划。 + * 本服务只接受 exercise、campaign、decisive,避免它们再次混入普通出征计划。 + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { AppPaths } from './AppPaths'; +import { AtomicFileStore } from './AtomicFileStore'; +import { CombatPlanCodec } from './CombatPlanCodec'; +import { + TaskPresetCodec, + type TaskPresetDocument, +} from '../../src/shared/taskPreset'; +import type { + DecisivePlanSettings, +} from '../../src/shared/decisivePlan'; + +export type DailyPlanSource = 'system' | 'user'; +export type DailyPlanType = 'exercise' | 'campaign' | 'decisive'; + +export interface ManagedDailyPlan { + source: DailyPlanSource; + file: string; + name: string; + taskType: DailyPlanType; + times: number; + fleetId?: number; + campaignName?: string; + chapter?: number; + useQuickRepair?: boolean; +} + +export interface DailyPlanReadError { + source: DailyPlanSource; + file: string; + message: string; +} + +export interface DailyPlanListResult { + plans: ManagedDailyPlan[]; + errors: DailyPlanReadError[]; +} + +const DAILY_PLAN_PREFIXES: Readonly> = { + exercise: 'exercise-', + campaign: 'campaign-', + decisive: 'decisive-', +}; + +const DAILY_PLAN_ORDER: Readonly> = { + exercise: 0, + campaign: 1, + decisive: 2, +}; + +/** 提供日常任务计划的受管路径、读取、列表和决战章节保存能力。 */ +export class DailyPlanService { + constructor( + private readonly appPaths: AppPaths, + private readonly atomicFiles: AtomicFileStore, + private readonly combatCodec: CombatPlanCodec, + private readonly taskPresetCodec: TaskPresetCodec, + ) {} + + /** 列出日常任务;用户同名文件覆盖系统文件。 */ + list(): DailyPlanListResult { + const plans = new Map(); + const errors: DailyPlanReadError[] = []; + + for (const source of ['system', 'user'] as const) { + for (const file of this.yamlFiles(this.directory(source))) { + try { + const plan = this.readPlan(source, file); + plans.set(file.toLocaleLowerCase(), plan); + } catch (error) { + errors.push({ + source, + file, + message: error instanceof Error + ? error.message + : String(error), + }); + } + } + } + + return { + plans: [...plans.values()].sort((left, right) => ( + DAILY_PLAN_ORDER[left.taskType] + - DAILY_PLAN_ORDER[right.taskType] + || left.name.localeCompare(right.name, 'zh-CN') + )), + errors, + }; + } + + /** 读取一份受管日常任务,并返回任务列表执行所需的 YAML。 */ + read( + source: DailyPlanSource, + file: string, + ): Record { + try { + const filePath = this.safeManagedPath(source, file); + if (!filePath || !fs.existsSync(filePath)) { + throw new Error('日常任务计划不存在'); + } + const content = fs.readFileSync(filePath, 'utf-8'); + this.parseDailyPlan(content, file); + return { + success: true, + kind: 'daily', + path: filePath, + sourcePath: filePath, + content, + source, + file, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** 读取指定章节的用户决战配置;没有用户副本时回退到系统配置。 */ + decisivePlan(chapter: number): DecisivePlanSettings { + const normalizedChapter = this.decisiveChapter(chapter); + const file = this.decisiveFile(normalizedChapter); + const userPath = this.safeManagedPath('user', file); + const source: DailyPlanSource = ( + userPath && fs.existsSync(userPath) + ) + ? 'user' + : 'system'; + return this.readDecisiveSettings(source, file); + } + + /** 读取 GUI 2.0 提供的指定章节系统默认决战配置。 */ + systemDecisivePlan(chapter: number): DecisivePlanSettings { + const normalizedChapter = this.decisiveChapter(chapter); + return this.readDecisiveSettings( + 'system', + this.decisiveFile(normalizedChapter), + ); + } + + /** 保存一章用户决战配置,其他章节文件保持不变。 */ + saveDecisivePlan( + settings: DecisivePlanSettings, + ): DecisivePlanSettings { + const chapter = this.decisiveChapter(settings.chapter); + const root = this.taskPresetCodec.normalize({ + task_type: 'decisive', + chapter, + times: 1, + level1: this.shipNames(settings.level1, '主选队列'), + level2: this.shipNames(settings.level2, '备选队列'), + use_quick_repair: settings.useQuickRepair === true, + }); + const directory = this.directory('user'); + fs.mkdirSync(directory, { recursive: true }); + this.atomicFiles.write( + path.join(directory, this.decisiveFile(chapter)), + this.combatCodec.serialize(root), + ); + return this.settingsFromDecisive(root); + } + + /** 返回系统或用户日常任务目录。 */ + directory(source: DailyPlanSource): string { + return source === 'system' + ? this.appPaths.systemDailyPlansDir() + : this.appPaths.userDailyPlansDir(); + } + + /** 校验文件名并返回受管目录内路径。 */ + safeManagedPath( + source: DailyPlanSource, + file: string, + ): string | null { + if ( + (source !== 'system' && source !== 'user') + || path.basename(file) !== file + || !/^(exercise|campaign|decisive)-.+\.ya?ml$/i.test(file) + ) { + return null; + } + return path.join(this.directory(source), file); + } + + private readPlan( + source: DailyPlanSource, + file: string, + ): ManagedDailyPlan { + const filePath = this.safeManagedPath(source, file); + if (!filePath) throw new Error('日常任务文件名不符合规则'); + const preset = this.parseDailyPlan( + fs.readFileSync(filePath, 'utf-8'), + file, + ); + const taskType = preset.task_type as DailyPlanType; + return { + source, + file, + name: this.displayName(file, taskType), + taskType, + times: typeof preset.times === 'number' ? preset.times : 1, + fleetId: typeof preset.fleet_id === 'number' + ? preset.fleet_id + : undefined, + campaignName: typeof preset.campaign_name === 'string' + ? preset.campaign_name + : undefined, + chapter: typeof preset.chapter === 'number' + ? preset.chapter + : undefined, + useQuickRepair: typeof preset.use_quick_repair === 'boolean' + ? preset.use_quick_repair + : true, + }; + } + + private parseDailyPlan( + content: string, + file: string, + ): TaskPresetDocument { + const root = this.combatCodec.parseRoot( + content, + '日常任务计划根节点必须是对象', + ); + const preset = this.taskPresetCodec.normalize(root); + if (!this.isDailyPlanType(preset.task_type)) { + throw new Error(`不支持的日常任务类型:${preset.task_type}`); + } + const prefix = DAILY_PLAN_PREFIXES[preset.task_type]; + if (!file.toLocaleLowerCase().startsWith(prefix)) { + throw new Error(`文件名必须以 ${prefix} 开头`); + } + if (preset.task_type === 'decisive') { + this.decisiveChapter(preset.chapter); + } + return preset; + } + + private readDecisiveSettings( + source: DailyPlanSource, + file: string, + ): DecisivePlanSettings { + const filePath = this.safeManagedPath(source, file); + if (!filePath || !fs.existsSync(filePath)) { + throw new Error(`第 ${this.chapterFromFile(file)} 章决战配置不存在`); + } + const preset = this.parseDailyPlan( + fs.readFileSync(filePath, 'utf-8'), + file, + ); + if (preset.task_type !== 'decisive') { + throw new Error('目标文件不是决战配置'); + } + return this.settingsFromDecisive(preset); + } + + private settingsFromDecisive( + preset: TaskPresetDocument, + ): DecisivePlanSettings { + return { + chapter: this.decisiveChapter(preset.chapter), + useQuickRepair: preset.use_quick_repair !== false, + level1: this.shipNames(preset.level1, '主选队列'), + level2: this.shipNames(preset.level2, '备选队列'), + }; + } + + private isDailyPlanType(value: string): value is DailyPlanType { + return ( + value === 'exercise' + || value === 'campaign' + || value === 'decisive' + ); + } + + private decisiveChapter(value: unknown): number { + const chapter = Number(value); + if (!Number.isInteger(chapter) || chapter < 1 || chapter > 6) { + throw new Error('决战章节必须是 1 到 6'); + } + return chapter; + } + + private shipNames(value: unknown, label: string): string[] { + if (!Array.isArray(value)) { + throw new Error(`${label}必须是舰名列表`); + } + return value.map((item, index) => { + if (typeof item !== 'string' || !item.trim()) { + throw new Error(`${label}第 ${index + 1} 项不能为空`); + } + return item.trim(); + }); + } + + private decisiveFile(chapter: number): string { + return `decisive-决战第${chapter}章.yaml`; + } + + private chapterFromFile(file: string): string { + return /第(\d+)章/.exec(file)?.[1] ?? '?'; + } + + private displayName(file: string, taskType: DailyPlanType): string { + return file + .replace(new RegExp(`^${DAILY_PLAN_PREFIXES[taskType]}`, 'i'), '') + .replace(/\.ya?ml$/i, ''); + } + + private yamlFiles(directory: string): string[] { + if (!fs.existsSync(directory)) return []; + return fs.readdirSync(directory) + .filter(file => /\.ya?ml$/i.test(file)) + .sort((left, right) => left.localeCompare(right, 'zh-CN')); + } +} diff --git a/electron/services/GuiConfigurationService.ts b/electron/services/GuiConfigurationService.ts new file mode 100644 index 0000000..cca5331 --- /dev/null +++ b/electron/services/GuiConfigurationService.ts @@ -0,0 +1,694 @@ +/** + * 读取、归一化并保存 GUI 业务设置。 + */ +import { GuiSettingsStore } from './GuiSettingsStore'; +import { + DEFAULT_LOOT_PLAN_ID, + DEFAULT_LOOT_PLANS, + INTERIM_LOOT_PLAN_IDS, + findLootAutomationPlan, + lootPlanIdFromIndex, + normalizeLootAutomationPlans, + type LootAutomationPlan, + type LootPlanSource, +} from '../../src/shared/lootPlans'; +import type { + LegacyDecisiveAutomationSettings, +} from '../../src/shared/legacyDecisiveAutomation'; +import { + normalizeDecisiveAutomationSource, + type DecisiveAutomationSource, +} from '../../src/shared/decisiveAutomation'; +import { DAILY_CAMPAIGN_TIMES } from '../../src/shared/campaign'; +import { + DEFAULT_DECISIVE_PLAN_SETTINGS, + type DecisivePlanSettings, +} from '../../src/shared/decisivePlan'; +import type { + GuiSettingsCommitRequest, +} from '../../src/types/ipc'; + +export type BackendStartupMode = 'managed' | 'external'; +export type OcrGpuMode = 'auto' | 'cpu' | 'cuda'; +export type UpdateMode = 'auto' | 'manual'; + +export interface GuiAutomationSettings { + expeditionInterval: number; + /** 兼容持久化结构,自动战役运行时固定为 8。 */ + battleTimes: number; + autoDecisive: boolean; + decisiveTemplateId: DecisiveAutomationSource; + autoLoot: boolean; + lootPlanSource: LootPlanSource; + lootPlanId: string; + lootPlans: LootAutomationPlan[]; + lootStopCount: number; +} + +export interface GuiConfigurationDependencies { + clearPythonCache(): void; + normalizeCudaPath(candidate: string): string; + environmentPort?(): string | undefined; + defaultAllowTestUpdates?(): boolean; +} + +/** 只接受有限数字或非空数字字符串,避免把 null/false 当成 0。 */ +function finiteNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +/** 解释 GUI 设置字段并执行原有归一化和迁移规则。 */ +export class GuiConfigurationService { + constructor( + private readonly store: GuiSettingsStore, + private readonly dependencies: GuiConfigurationDependencies, + ) {} + + /** 返回启动时使用的后端端口。 */ + backendPort(): number { + const environmentPort = this.dependencies.environmentPort?.(); + if (environmentPort) { + return parseInt(environmentPort, 10); + } + const settings = this.store.read(); + if ( + typeof settings.backend_port === 'number' + && settings.backend_port > 0 + && settings.backend_port < 65536 + ) { + return settings.backend_port; + } + return 8438; + } + + /** 仅在端口是合法有限整数时写入。 */ + setBackendPort(port: number): void { + if (typeof port !== 'number' || !Number.isFinite(port)) return; + const normalized = Math.trunc(port); + if (normalized < 1 || normalized > 65535) return; + this.store.write({ backend_port: normalized }); + } + + /** 返回用户指定的 Python 路径;空值表示自动检测。 */ + configuredPythonPath(): string | null { + const settings = this.store.read(); + if ( + typeof settings.python_path === 'string' + && settings.python_path.length > 0 + ) { + return settings.python_path; + } + return null; + } + + /** 保存 Python 路径并清除 Python 发现缓存。 */ + setPythonPath(pythonPath: string | null): void { + this.store.write({ python_path: pythonPath ?? '' }); + this.dependencies.clearPythonCache(); + } + + /** 返回 autowsgr 更新模式。 */ + updateMode(): UpdateMode { + return this.store.read().update_mode === 'manual' + ? 'manual' + : 'auto'; + } + + /** 保存归一化后的 autowsgr 更新模式。 */ + setUpdateMode(mode: UpdateMode): void { + this.store.write({ + update_mode: mode === 'manual' ? 'manual' : 'auto', + }); + } + + /** 是否使用个人 GUI 与后端组成的 Alpha 更新通道。 */ + allowTestUpdates(): boolean { + const settings = this.store.read(); + if (typeof settings.allow_test_updates === 'boolean') { + return settings.allow_test_updates; + } + return this.dependencies.defaultAllowTestUpdates?.() === true; + } + + /** 返回 managed 或 external 后端启动模式。 */ + backendStartupMode(): BackendStartupMode { + return this.store.read().backend_startup_mode === 'external' + ? 'external' + : 'managed'; + } + + /** 保存归一化后的后端启动模式。 */ + setBackendStartupMode(mode: BackendStartupMode): void { + this.store.write({ + backend_startup_mode: mode === 'external' + ? 'external' + : 'managed', + }); + } + + /** 返回去除首尾空白的 external 后端仓库路径。 */ + backendRepoPath(): string { + const value = this.store.read().backend_repo_path; + return typeof value === 'string' ? value.trim() : ''; + } + + /** 保存去除首尾空白的 external 后端仓库路径。 */ + setBackendRepoPath(repoPath: string | null): void { + this.store.write({ + backend_repo_path: typeof repoPath === 'string' + ? repoPath.trim() + : '', + }); + } + + /** 返回 OCR GPU 模式。 */ + ocrGpuMode(): OcrGpuMode { + const value = this.store.read().ocr_gpu_mode; + return value === 'cpu' || value === 'cuda' ? value : 'auto'; + } + + /** 保存归一化后的 OCR GPU 模式。 */ + setOcrGpuMode(mode: OcrGpuMode): void { + this.store.write({ + ocr_gpu_mode: mode === 'cpu' || mode === 'cuda' + ? mode + : 'auto', + }); + } + + /** 返回去除首尾空白的 CUDA 配置路径。 */ + cudaPath(): string { + const value = this.store.read().cuda_path; + return typeof value === 'string' ? value.trim() : ''; + } + + /** 保存空路径或统一归一化后的 CUDA 路径。 */ + setCudaPath(cudaPath: string | null): void { + const raw = typeof cudaPath === 'string' + ? cudaPath.trim() + : ''; + this.store.write({ + cuda_path: raw + ? this.dependencies.normalizeCudaPath(raw) + : '', + }); + } + + /** 返回是否保存后端异常截图。 */ + saveBackendScreenshots(): boolean { + return this.store.read().save_backend_screenshots === true; + } + + /** 保存后端异常截图开关。 */ + setSaveBackendScreenshots(enabled: boolean): void { + this.store.write({ + save_backend_screenshots: enabled === true, + }); + } + + /** 读取已有的 GUI 自动化字段,不为缺失字段补默认值。 */ + automation(): { + exists: boolean; + settings: Partial; + } { + const raw = this.store.read().automation; + const hasAutomation = !!raw + && typeof raw === 'object' + && !Array.isArray(raw); + const value = hasAutomation + ? raw as Record + : {}; + const settings: Partial = {}; + const legacyDecisive = this.legacyDecisiveAutomation().settings; + let rewritten: Record | null = null; + const expeditionInterval = finiteNumber(value.expeditionInterval); + if (expeditionInterval !== null) { + settings.expeditionInterval = expeditionInterval; + } + const battleTimes = finiteNumber(value.battleTimes); + if (battleTimes !== null) { + settings.battleTimes = DAILY_CAMPAIGN_TIMES; + if (battleTimes !== DAILY_CAMPAIGN_TIMES) { + rewritten = { + ...(rewritten ?? value), + battleTimes: DAILY_CAMPAIGN_TIMES, + }; + } + } + if (typeof value.autoDecisive === 'boolean') { + settings.autoDecisive = value.autoDecisive; + } else if (typeof legacyDecisive.autoDecisive === 'boolean') { + settings.autoDecisive = legacyDecisive.autoDecisive; + rewritten = { + ...(rewritten ?? value), + autoDecisive: legacyDecisive.autoDecisive, + }; + } + const storedDecisiveSource = ( + typeof value.decisiveTemplateId === 'string' + ? value.decisiveTemplateId.trim() + : legacyDecisive.templateId + ); + if (storedDecisiveSource) { + const normalizedSource = normalizeDecisiveAutomationSource( + storedDecisiveSource, + ); + settings.decisiveTemplateId = normalizedSource; + if (storedDecisiveSource !== normalizedSource) { + rewritten = { + ...(rewritten ?? value), + decisiveTemplateId: normalizedSource, + }; + } + } + if (typeof value.autoLoot === 'boolean') { + settings.autoLoot = value.autoLoot; + } + const hasStoredLootPlans = Array.isArray(value.lootPlans); + const lootPlans = normalizeLootAutomationPlans(value.lootPlans); + if (hasStoredLootPlans) { + settings.lootPlans = lootPlans; + if (JSON.stringify(value.lootPlans) !== JSON.stringify(lootPlans)) { + rewritten = { + ...(rewritten ?? value), + lootPlans, + }; + } + } + const lootPlanSource: LootPlanSource = ( + value.lootPlanSource === 'user' ? 'user' : 'system' + ); + if (typeof value.lootPlanId === 'string') { + const selected = findLootAutomationPlan( + lootPlans, + lootPlanSource, + value.lootPlanId, + ); + if (selected) { + settings.lootPlanSource = selected.source; + settings.lootPlanId = selected.file; + if ( + selected.source !== value.lootPlanSource + || selected.file !== value.lootPlanId + ) { + rewritten = { + ...(rewritten ?? value), + lootPlanSource: selected.source, + lootPlanId: selected.file, + }; + } + } else { + settings.autoLoot = false; + rewritten = { + ...(rewritten ?? value), + autoLoot: false, + }; + } + } else if ( + Object.prototype.hasOwnProperty.call(value, 'lootPlanIndex') + ) { + const resolved = lootPlanIdFromIndex( + value.lootPlanIndex, + INTERIM_LOOT_PLAN_IDS, + ); + rewritten = { ...(rewritten ?? value) }; + delete rewritten.lootPlanIndex; + if (resolved) { + settings.lootPlanSource = 'system'; + settings.lootPlanId = resolved; + rewritten.lootPlanSource = 'system'; + rewritten.lootPlanId = resolved; + } else { + settings.autoLoot = false; + rewritten.autoLoot = false; + } + } else if ( + Object.prototype.hasOwnProperty.call(value, 'lootPlanId') + ) { + settings.autoLoot = false; + rewritten = { + ...(rewritten ?? value), + autoLoot: false, + }; + } + const lootStopCount = finiteNumber(value.lootStopCount); + if (lootStopCount !== null) { + settings.lootStopCount = lootStopCount; + } + if (rewritten) { + this.store.write({ automation: rewritten }); + } + return { + exists: hasAutomation || Object.keys(settings).length > 0, + settings, + }; + } + + /** 归一化并保存 GUI 自动化字段。 */ + setAutomation( + settings: GuiAutomationSettings, + ): GuiAutomationSettings { + const normalized = this.normalizeAutomation(settings); + this.store.write({ + automation: this.mergeAutomationSettings(normalized), + }); + return normalized; + } + + /** + * 归一化设置页的全部 GUI 设置并合并为一次原子 JSON 写入。 + * usersettings.yaml 由 GuiSettingsCommitService 负责写入和失败恢复。 + */ + commitSettings( + settings: GuiSettingsCommitRequest, + additionalPatch: Record, + ): GuiAutomationSettings { + if ( + typeof settings.backendPort !== 'number' + || !Number.isFinite(settings.backendPort) + || settings.backendPort < 1 + || settings.backendPort > 65535 + ) { + throw new Error('后端端口必须是 1 到 65535 的整数'); + } + if ( + settings.backendStartupMode === 'external' + && !settings.backendRepoPath?.trim() + ) { + throw new Error('使用本地后端时必须配置仓库路径'); + } + const normalizedAutomation = this.normalizeAutomation( + settings.automation, + ); + const rawCudaPath = typeof settings.cudaPath === 'string' + ? settings.cudaPath.trim() + : ''; + const patch: Record = { + ...additionalPatch, + update_mode: settings.updateMode === 'manual' ? 'manual' : 'auto', + allow_test_updates: settings.allowTestUpdates === true, + backend_port: Math.trunc(settings.backendPort), + backend_startup_mode: settings.backendStartupMode === 'external' + ? 'external' + : 'managed', + backend_repo_path: typeof settings.backendRepoPath === 'string' + ? settings.backendRepoPath.trim() + : '', + ocr_gpu_mode: ( + settings.ocrGpuMode === 'cpu' + || settings.ocrGpuMode === 'cuda' + ) + ? settings.ocrGpuMode + : 'auto', + cuda_path: rawCudaPath + ? this.dependencies.normalizeCudaPath(rawCudaPath) + : '', + save_backend_screenshots: + settings.saveBackendScreenshots === true, + python_path: settings.pythonPath ?? '', + automation: this.mergeAutomationSettings(normalizedAutomation), + }; + this.dependencies.clearPythonCache(); + this.store.write(patch); + return normalizedAutomation; + } + + /** 归一化 GUI 自动化设置,但不执行持久化。 */ + private normalizeAutomation( + settings: GuiAutomationSettings, + ): GuiAutomationSettings { + const lootPlans = normalizeLootAutomationPlans(settings?.lootPlans); + const requestedSource: LootPlanSource = ( + settings?.lootPlanSource === 'user' ? 'user' : 'system' + ); + const selected = findLootAutomationPlan( + lootPlans, + requestedSource, + settings?.lootPlanId, + ); + const fallback = lootPlans[0] ?? DEFAULT_LOOT_PLANS[0]; + const decisiveTemplateId = normalizeDecisiveAutomationSource( + settings?.decisiveTemplateId, + ); + const normalized: GuiAutomationSettings = { + expeditionInterval: Math.max( + 1, + Math.min( + 120, + Math.trunc(Number(settings?.expeditionInterval) || 15), + ), + ), + battleTimes: DAILY_CAMPAIGN_TIMES, + autoDecisive: settings?.autoDecisive === true, + decisiveTemplateId, + autoLoot: settings?.autoLoot === true && selected !== null, + lootPlanSource: selected?.source ?? fallback?.source ?? 'system', + lootPlanId: selected?.file ?? fallback?.file ?? DEFAULT_LOOT_PLAN_ID, + lootPlans, + lootStopCount: Math.max( + 1, + Math.min( + 50, + Math.trunc(Number(settings?.lootStopCount) || 50), + ), + ), + }; + return normalized; + } + + /** 保留 automation 中尚未建模的字段并覆盖已归一化字段。 */ + private mergeAutomationSettings( + normalized: GuiAutomationSettings, + ): Record { + const raw = this.store.read().automation; + const output: Record = ( + raw && typeof raw === 'object' && !Array.isArray(raw) + ? { ...raw as Record, ...normalized } + : { ...normalized } + ); + delete output.lootPlanIndex; + return output; + } + + /** 读取已经保留到 GUI JSON 的旧版决战自动化原值。 */ + legacyDecisiveAutomation(): { + exists: boolean; + settings: LegacyDecisiveAutomationSettings; + } { + const raw = this.store.read().legacy_decisive_automation; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { exists: false, settings: {} }; + } + const value = raw as Record; + const settings: LegacyDecisiveAutomationSettings = {}; + if (typeof value.auto_decisive === 'boolean') { + settings.autoDecisive = value.auto_decisive; + } + if ( + typeof value.decisive_ticket_reserve === 'number' + && Number.isFinite(value.decisive_ticket_reserve) + ) { + settings.ticketReserve = value.decisive_ticket_reserve; + } + if ( + typeof value.decisive_template_id === 'string' + && value.decisive_template_id.length > 0 + ) { + settings.templateId = value.decisive_template_id; + } + return { exists: true, settings }; + } + + /** + * 原样归档旧版决战设置;开关和模板由 automation() 升级。 + * 写入后立即回读并逐字段校验,失败时由调用方保留 YAML 原字段。 + */ + migrateLegacyDecisiveAutomation( + settings: LegacyDecisiveAutomationSettings, + ): LegacyDecisiveAutomationSettings { + const fields = [ + 'autoDecisive', + 'ticketReserve', + 'templateId', + ] as const; + const supplied = fields.filter(field => ( + Object.prototype.hasOwnProperty.call(settings, field) + )); + if (supplied.length === 0) { + throw new Error('没有可迁移的旧版决战配置'); + } + if ( + supplied.includes('autoDecisive') + && typeof settings.autoDecisive !== 'boolean' + ) { + throw new Error('auto_decisive 必须是布尔值'); + } + if ( + supplied.includes('ticketReserve') + && ( + typeof settings.ticketReserve !== 'number' + || !Number.isFinite(settings.ticketReserve) + ) + ) { + throw new Error('decisive_ticket_reserve 必须是有限数字'); + } + if ( + supplied.includes('templateId') + && ( + typeof settings.templateId !== 'string' + || settings.templateId.length === 0 + ) + ) { + throw new Error('decisive_template_id 必须是非空文字'); + } + + const current = this.store.read().legacy_decisive_automation; + const output = current + && typeof current === 'object' + && !Array.isArray(current) + ? { ...current as Record } + : {}; + if (supplied.includes('autoDecisive')) { + output.auto_decisive = settings.autoDecisive; + } + if (supplied.includes('ticketReserve')) { + output.decisive_ticket_reserve = settings.ticketReserve; + } + if (supplied.includes('templateId')) { + output.decisive_template_id = settings.templateId; + } + this.store.write({ legacy_decisive_automation: output }); + + const verified = this.legacyDecisiveAutomation(); + for (const field of supplied) { + if ( + !verified.exists + || verified.settings[field] !== settings[field] + ) { + throw new Error(`旧版决战配置回读校验失败: ${field}`); + } + } + return verified.settings; + } + + /** 读取决战计划,并在发现旧字段时原地迁移。 */ + decisivePlan(): DecisivePlanSettings { + const rawPlan = this.store.read().decisive_plan; + const normalized = this.normalizeDecisivePlan(rawPlan); + if ( + rawPlan + && typeof rawPlan === 'object' + && !Array.isArray(rawPlan) + && ( + Object.prototype.hasOwnProperty.call(rawPlan, 'level3') + || ( + Array.isArray( + (rawPlan as Record).level1, + ) + && ( + (rawPlan as Record) + .level1 as unknown[] + ).length > 6 + ) + ) + ) { + this.writeDecisivePlan(normalized); + } + return normalized; + } + + /** 归一化并保存决战计划。 */ + setDecisivePlan( + settings: DecisivePlanSettings, + ): DecisivePlanSettings { + const normalized = this.normalizeDecisivePlan(settings); + this.writeDecisivePlan(normalized); + return normalized; + } + + /** 归一化决战章节、修理设置和两级舰船列表。 */ + private normalizeDecisivePlan( + value: unknown, + ): DecisivePlanSettings { + const raw = value + && typeof value === 'object' + && !Array.isArray(value) + ? value as Record + : {}; + const chapter = Math.trunc(Number(raw.chapter)); + const requestedMainShips = this.normalizeDecisiveShips( + raw.level1, + DEFAULT_DECISIVE_PLAN_SETTINGS.level1, + ); + const mainShips = requestedMainShips.slice(0, 6); + const requestedBackupShips = this.normalizeDecisiveShips( + raw.level2, + DEFAULT_DECISIVE_PLAN_SETTINGS.level2, + ); + const legacyLevel3 = Array.isArray(raw.level3) + ? this.normalizeDecisiveShips(raw.level3, []) + : []; + const backupShips: string[] = []; + for ( + const name of [ + ...requestedMainShips.slice(6), + ...requestedBackupShips, + ...legacyLevel3, + ] + ) { + if ( + !mainShips.includes(name) + && !backupShips.includes(name) + ) { + backupShips.push(name); + } + } + return { + chapter: Number.isFinite(chapter) + ? Math.max(1, Math.min(6, chapter)) + : DEFAULT_DECISIVE_PLAN_SETTINGS.chapter, + useQuickRepair: typeof raw.use_quick_repair === 'boolean' + ? raw.use_quick_repair + : typeof raw.useQuickRepair === 'boolean' + ? raw.useQuickRepair + : DEFAULT_DECISIVE_PLAN_SETTINGS.useQuickRepair, + level1: mainShips, + level2: backupShips, + }; + } + + /** 清理舰船名、长度和重复项。 */ + private normalizeDecisiveShips( + value: unknown, + fallback: string[], + ): string[] { + if (!Array.isArray(value)) return [...fallback]; + return value + .filter(item => typeof item === 'string') + .map(item => item.trim()) + .filter((item, index, values) => ( + item.length > 0 + && item.length <= 80 + && values.indexOf(item) === index + )); + } + + /** 使用兼容的 snake_case 结构写回决战计划。 */ + private writeDecisivePlan(settings: DecisivePlanSettings): void { + this.store.write({ + decisive_plan: { + chapter: settings.chapter, + use_quick_repair: settings.useQuickRepair, + level1: settings.level1, + level2: settings.level2, + }, + }); + } +} diff --git a/electron/services/GuiSettingsCommitService.ts b/electron/services/GuiSettingsCommitService.ts new file mode 100644 index 0000000..f4f5662 --- /dev/null +++ b/electron/services/GuiSettingsCommitService.ts @@ -0,0 +1,78 @@ +/** + * 跨 usersettings.yaml 和 gui_settings.json 提交设置。 + */ +import type { + GuiSettingsCommitRequest, + GuiSettingsCommitResult, +} from '../../src/types/ipc'; +import type { GuiConfigurationService } from './GuiConfigurationService'; +import type { SecureFileService } from './SecureFileService'; +import type { WindowService } from './WindowService'; + +/** 维护设置页跨文件提交与失败恢复的不变量。 */ +export class GuiSettingsCommitService { + constructor( + private readonly configuration: Pick< + GuiConfigurationService, + 'commitSettings' + >, + private readonly secureFiles: Pick< + SecureFileService, + 'snapshot' | 'save' | 'restore' + >, + private readonly windows: Pick< + WindowService, + 'preparePreferences' + >, + ) {} + + commitAtomic( + settings: GuiSettingsCommitRequest, + ): GuiSettingsCommitResult { + if ( + !settings + || typeof settings !== 'object' + || typeof settings.usersettingsYaml !== 'string' + ) { + throw new Error('设置提交内容无效'); + } + const preparedWindow = this.windows.preparePreferences( + settings.windowPreferences, + ); + const yamlSnapshot = this.secureFiles.snapshot( + 'usersettings.yaml', + ); + this.secureFiles.save( + 'usersettings.yaml', + settings.usersettingsYaml, + ); + try { + const automation = this.configuration.commitSettings( + settings, + preparedWindow.settingsPatch, + ); + return { + automation, + windowPreferences: preparedWindow.preferences, + }; + } catch (error) { + try { + this.secureFiles.restore( + 'usersettings.yaml', + yamlSnapshot, + ); + } catch (rollbackError) { + throw new Error( + `设置提交失败,且 usersettings.yaml 恢复失败: ${ + rollbackError instanceof Error + ? rollbackError.message + : String(rollbackError) + };原始错误: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + throw error; + } + } +} diff --git a/electron/services/GuiSettingsStore.ts b/electron/services/GuiSettingsStore.ts new file mode 100644 index 0000000..9a3b116 --- /dev/null +++ b/electron/services/GuiSettingsStore.ts @@ -0,0 +1,62 @@ +/** + * 读取并浅合并写入 gui_settings.json。 + */ +import * as fs from 'fs'; +import { AtomicFileStore } from './AtomicFileStore'; + +function isRecord(value: unknown): value is Record { + return ( + typeof value === 'object' + && value !== null + && !Array.isArray(value) + ); +} + +/** 管理唯一 GUI JSON 设置文件的读取和浅合并写入。 */ +export class GuiSettingsStore { + constructor( + private readonly getFilePath: () => string, + private readonly atomicFiles: AtomicFileStore, + ) {} + + /** 返回当前 GUI 设置文件路径。 */ + filePath(): string { + return this.getFilePath(); + } + + /** 读取设置;不存在或无法解析时保持原有空对象回退。 */ + read(): Record { + try { + return this.readCurrent(); + } catch { + // 文件缺失或无效时返回空设置。 + return {}; + } + } + + /** 浅合并 patch 后覆盖写回唯一设置文件。 */ + write(patch: Record): void { + const current = this.readCurrent(); + Object.assign(current, patch); + this.atomicFiles.write( + this.filePath(), + JSON.stringify(current, null, 2), + ); + } + + /** + * 写入路径必须区分“不存在”和“已有但损坏”,避免用局部 patch + * 覆盖无法解析的完整配置。 + */ + private readCurrent(): Record { + const filePath = this.filePath(); + if (!fs.existsSync(filePath)) return {}; + const parsed: unknown = JSON.parse( + fs.readFileSync(filePath, 'utf-8'), + ); + if (!isRecord(parsed)) { + throw new Error('gui_settings.json 根节点必须是对象'); + } + return parsed; + } +} diff --git a/electron/services/GuiUpdateInstaller.ts b/electron/services/GuiUpdateInstaller.ts new file mode 100644 index 0000000..0d0d489 --- /dev/null +++ b/electron/services/GuiUpdateInstaller.ts @@ -0,0 +1,195 @@ +/** + * 复核已下载安装包,并以静默模式启动 NSIS 更新。 + */ +import { spawn } from 'child_process'; +import { createHash } from 'crypto'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Logger } from 'electron-updater'; +import { + GuiUpdateStateStore, + type GuiUpdateState, +} from './GuiUpdateStateStore'; + +interface GuiUpdateInstallerDependencies { + fileExists(filePath: string): boolean; + hashSha512(filePath: string): Promise; + launch(command: string, args: string[]): Promise; + updaterCacheRoot: string; +} + +function defaultUpdaterCacheRoot(): string { + const localAppData = process.env.LOCALAPPDATA + ?? path.join(os.homedir(), 'AppData', 'Local'); + return path.join(localAppData, 'wsgrgui-updater'); +} + +function hashSha512(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = createHash('sha512'); + const input = fs.createReadStream(filePath); + input.on('error', reject); + hash.on('error', reject); + input.on('data', chunk => hash.update(chunk)); + input.on('end', () => { + resolve(hash.digest('base64')); + }); + }); +} + +function launchDetached( + command: string, + args: string[], +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + detached: true, + windowsHide: true, + stdio: 'ignore', + }); + child.once('error', reject); + child.once('spawn', () => { + const pid = child.pid; + child.unref(); + if (!pid) { + reject(new Error('安装器进程没有返回 PID')); + return; + } + resolve(pid); + }); + }); +} + +/** 只负责 updater 缓存中的安装包,不处理应用或用户数据目录。 */ +export class GuiUpdateInstaller { + constructor( + private readonly states: GuiUpdateStateStore, + private readonly logger: Logger, + private readonly resourcesPath: string, + private readonly dependencies: GuiUpdateInstallerDependencies = { + fileExists: filePath => fs.existsSync(filePath), + hashSha512, + launch: launchDetached, + updaterCacheRoot: defaultUpdaterCacheRoot(), + }, + ) {} + + /** 校验并启动安装器;安装完成后由 NSIS 启动新版本。 */ + async launchPendingUpdate(): Promise { + const state = this.states.read(); + if (!state) { + throw new Error('没有可安装的 GUI 更新'); + } + try { + await this.verifyInstaller(state); + } catch (error) { + this.states.clear(); + this.cleanupPendingFiles(state); + throw error; + } + + this.states.markInstalling(); + try { + const args = ['--updated', '/S', '--force-run']; + const pid = await this.launchInstaller(state, args); + const installing = this.states.saveInstallerPid(pid); + this.logger.info( + `GUI update installer started: pid=${pid}, ` + + `version=${state.targetVersion}`, + ); + return installing; + } catch (error) { + this.states.restoreDownloaded(); + throw error; + } + } + + /** 新版本确认启动后,只删除 updater 的 pending 临时目录。 */ + cleanupAppliedUpdate(state: GuiUpdateState): boolean { + return this.cleanupPendingFiles(state); + } + + private async verifyInstaller( + state: GuiUpdateState, + ): Promise { + if (!this.dependencies.fileExists(state.downloadedFile)) { + throw new Error('已下载的 GUI 安装包不存在,需要重新下载'); + } + const actual = await this.dependencies.hashSha512( + state.downloadedFile, + ); + if (actual !== state.sha512) { + throw new Error('GUI 安装包校验失败,需要重新下载'); + } + } + + private async launchInstaller( + state: GuiUpdateState, + installerArgs: string[], + ): Promise { + const elevate = path.join(this.resourcesPath, 'elevate.exe'); + if ( + state.isAdminRightsRequired + && this.dependencies.fileExists(elevate) + ) { + return this.dependencies.launch( + elevate, + [state.downloadedFile, ...installerArgs], + ); + } + try { + return await this.dependencies.launch( + state.downloadedFile, + installerArgs, + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + (code === 'EACCES' || code === 'UNKNOWN') + && this.dependencies.fileExists(elevate) + ) { + return this.dependencies.launch( + elevate, + [state.downloadedFile, ...installerArgs], + ); + } + throw error; + } + } + + private cleanupPendingFiles(state: GuiUpdateState): boolean { + const pendingDirectory = path.dirname( + path.resolve(state.downloadedFile), + ); + const expectedPendingDirectory = path.join( + path.resolve(this.dependencies.updaterCacheRoot), + 'pending', + ); + if ( + pendingDirectory.toLowerCase() + !== expectedPendingDirectory.toLowerCase() + ) { + this.logger.warn( + `Skip GUI update cleanup outside pending directory: ` + + pendingDirectory, + ); + return false; + } + try { + fs.rmSync(pendingDirectory, { + recursive: true, + force: true, + }); + this.logger.info( + `Cleaned GUI update pending directory: ${pendingDirectory}`, + ); + return true; + } catch (error) { + this.logger.warn( + `Cannot clean GUI update pending directory: ${String(error)}`, + ); + return false; + } + } +} diff --git a/electron/services/GuiUpdatePolicy.ts b/electron/services/GuiUpdatePolicy.ts new file mode 100644 index 0000000..0afc354 --- /dev/null +++ b/electron/services/GuiUpdatePolicy.ts @@ -0,0 +1,174 @@ +/** + * GUI 版本和更新频道规则。 + * + * 稳定版使用 X.Y.Z 和 latest 频道。 + * Alpha 版使用 X.Y.Z-alpha 或 X.Y.Z-alpha.N 和 alpha 频道。 + * 预发布版使用 X.Y.Z-beta.N 和 beta 频道。 + * 开发版使用 X.Y.Z-dev 或 X.Y.Z-dev.N 和 dev 频道。 + * + * 发布端和客户端必须使用同一规则。客户端还会验证服务端返回的候选 + * 版本,防止 electron-updater 在频道清单缺失时回退到其他频道。 + */ + +export type GuiReleaseChannel = 'latest' | 'alpha' | 'beta' | 'dev'; +export type GuiReleaseStage = 'stable' | 'prerelease' | 'development'; + +export interface GuiReleasePolicy { + channel: GuiReleaseChannel; + stage: GuiReleaseStage; + allowPrerelease: boolean; +} + +export interface GuiUpdateRepository { + readonly owner: string; + readonly repo: string; +} + +export interface GuiUpdateSelectionPolicy extends GuiReleasePolicy { + acceptedChannels: readonly GuiReleaseChannel[]; + repository: GuiUpdateRepository; +} + +export type GuiUpdateCheckResult = + | { status: 'available'; version: string } + | { status: 'up-to-date' } + | { status: 'error'; message: string }; + +export interface UpdaterCheckResultLike { + isUpdateAvailable: boolean; + updateInfo?: { + version?: string; + }; +} + +const STABLE_VERSION = /^\d+\.\d+\.\d+$/; +const ALPHA_VERSION = /^\d+\.\d+\.\d+-alpha(?:\.\d+)?$/; +const BETA_VERSION = /^\d+\.\d+\.\d+-beta\.\d+$/; +const DEVELOPMENT_VERSION = /^\d+\.\d+\.\d+-dev(?:\.\d+)?$/; +const STABLE_UPDATE_REPOSITORY: GuiUpdateRepository = { + owner: 'yltx', + repo: 'AutoWSGR-GUI', +}; +const TEST_UPDATE_REPOSITORY: GuiUpdateRepository = { + owner: 'ShiinaKuroko', + repo: 'AutoWSGR-GUI', +}; + +/** 严格解析 GUI 版本,拒绝没有明确频道的版本后缀。 */ +export function resolveGuiReleasePolicy(version: string): GuiReleasePolicy { + const normalized = version.trim(); + if (STABLE_VERSION.test(normalized)) { + return { + channel: 'latest', + stage: 'stable', + allowPrerelease: false, + }; + } + if (ALPHA_VERSION.test(normalized)) { + return { + channel: 'alpha', + stage: 'prerelease', + allowPrerelease: true, + }; + } + if (BETA_VERSION.test(normalized)) { + return { + channel: 'beta', + stage: 'prerelease', + allowPrerelease: true, + }; + } + if (DEVELOPMENT_VERSION.test(normalized)) { + return { + channel: 'dev', + stage: 'development', + allowPrerelease: true, + }; + } + throw new Error( + `GUI 版本 ${version} 不符合规范;只允许 X.Y.Z、` + + 'X.Y.Z-alpha、X.Y.Z-alpha.N、X.Y.Z-beta.N、' + + 'X.Y.Z-dev 或 X.Y.Z-dev.N', + ); +} + +/** 根据用户偏好选择 stable/alpha 候选集合;beta/dev 保持构建自身频道。 */ +export function resolveGuiUpdateSelectionPolicy( + version: string, + allowTestUpdates: boolean, +): GuiUpdateSelectionPolicy { + const releasePolicy = resolveGuiReleasePolicy(version); + if ( + releasePolicy.channel === 'beta' + || releasePolicy.channel === 'dev' + ) { + return { + ...releasePolicy, + acceptedChannels: [releasePolicy.channel], + repository: TEST_UPDATE_REPOSITORY, + }; + } + if (allowTestUpdates) { + return { + channel: 'alpha', + stage: releasePolicy.stage, + allowPrerelease: true, + acceptedChannels: ['latest', 'alpha'], + repository: TEST_UPDATE_REPOSITORY, + }; + } + return { + channel: 'latest', + stage: releasePolicy.stage, + allowPrerelease: false, + acceptedChannels: ['latest'], + repository: STABLE_UPDATE_REPOSITORY, + }; +} + +/** 返回候选版本不属于当前频道时的明确错误。 */ +export function validateGuiUpdateCandidate( + currentPolicy: GuiReleasePolicy & { + acceptedChannels?: readonly GuiReleaseChannel[]; + }, + candidateVersion: string, +): string | null { + let candidatePolicy: GuiReleasePolicy; + try { + candidatePolicy = resolveGuiReleasePolicy(candidateVersion); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + const acceptedChannels = currentPolicy.acceptedChannels + ?? [currentPolicy.channel]; + if (acceptedChannels.includes(candidatePolicy.channel)) return null; + return `更新版本 ${candidateVersion} 属于 ${candidatePolicy.channel} 频道,` + + `当前客户端只允许 ${acceptedChannels.join('、')} 频道`; +} + +/** 将 electron-updater 的结果转换为可靠的三态结果。 */ +export function classifyGuiUpdateCheck( + currentPolicy: GuiReleasePolicy, + result: UpdaterCheckResultLike | null, +): GuiUpdateCheckResult { + if (result === null) { + return { + status: 'error', + message: '当前运行环境未启用 GUI 自动更新', + }; + } + if (!result.isUpdateAvailable) { + return { status: 'up-to-date' }; + } + + const version = result.updateInfo?.version?.trim(); + if (!version) { + return { + status: 'error', + message: '更新服务返回了无效的版本信息', + }; + } + const mismatch = validateGuiUpdateCandidate(currentPolicy, version); + if (mismatch) return { status: 'error', message: mismatch }; + return { status: 'available', version }; +} diff --git a/electron/services/GuiUpdateStateStore.ts b/electron/services/GuiUpdateStateStore.ts new file mode 100644 index 0000000..e5181e8 --- /dev/null +++ b/electron/services/GuiUpdateStateStore.ts @@ -0,0 +1,218 @@ +/** + * 持久化 GUI 更新下载和安装状态,供应用重启前后的启动门禁使用。 + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { AtomicFileStore } from './AtomicFileStore'; + +const INSTALLING_STATE_TIMEOUT_MS = 30 * 60 * 1000; +const INSTALLER_EXIT_GRACE_MS = 5 * 60 * 1000; + +export interface GuiUpdateState { + status: 'downloaded' | 'installing'; + sourceVersion: string; + targetVersion: string; + downloadedFile: string; + sha512: string; + isAdminRightsRequired: boolean; + downloadedAt: string; + installingAt?: string; + installerPid?: number; +} + +export type GuiUpdateStartupAction = + | { action: 'continue' } + | { action: 'cleanup'; state: GuiUpdateState } + | { action: 'install'; state: GuiUpdateState } + | { action: 'wait'; state: GuiUpdateState }; + +interface GuiUpdateStateStoreDependencies { + now(): number; + isProcessRunning(pid: number): boolean; +} + +function defaultProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +/** 管理用户数据目录中的单个 GUI 更新状态文件。 */ +export class GuiUpdateStateStore { + constructor( + private readonly filePath: () => string, + private readonly atomicFiles: AtomicFileStore, + private readonly dependencies: GuiUpdateStateStoreDependencies = { + now: () => Date.now(), + isProcessRunning: defaultProcessRunning, + }, + ) {} + + /** 读取并校验状态;损坏或不完整的内容不会阻塞 GUI 启动。 */ + read(): GuiUpdateState | null { + const target = this.filePath(); + if (!fs.existsSync(target)) return null; + try { + const value = JSON.parse(fs.readFileSync(target, 'utf-8')); + return this.normalize(value); + } catch { + return null; + } + } + + /** 记录 electron-updater 已下载并校验完成的安装包。 */ + saveDownloaded(input: { + sourceVersion: string; + targetVersion: string; + downloadedFile: string; + sha512: string; + isAdminRightsRequired?: boolean; + }): GuiUpdateState { + const state: GuiUpdateState = { + status: 'downloaded', + sourceVersion: input.sourceVersion, + targetVersion: input.targetVersion, + downloadedFile: path.resolve(input.downloadedFile), + sha512: input.sha512, + isAdminRightsRequired: + input.isAdminRightsRequired === true, + downloadedAt: new Date(this.dependencies.now()).toISOString(), + }; + this.write(state); + return state; + } + + /** 在启动安装器前写入安装锁,避免重复启动进入旧版 GUI。 */ + markInstalling(): GuiUpdateState { + const current = this.requireState(); + const state: GuiUpdateState = { + ...current, + status: 'installing', + installingAt: new Date(this.dependencies.now()).toISOString(), + installerPid: undefined, + }; + this.write(state); + return state; + } + + /** 安装器成功创建后保存 PID,供后续重复启动判断。 */ + saveInstallerPid(pid: number): GuiUpdateState { + const current = this.requireState(); + const state: GuiUpdateState = { + ...current, + status: 'installing', + installerPid: pid, + }; + this.write(state); + return state; + } + + /** 安装器启动失败时恢复为待安装状态。 */ + restoreDownloaded(): GuiUpdateState { + const current = this.requireState(); + const state: GuiUpdateState = { + ...current, + status: 'downloaded', + installingAt: undefined, + installerPid: undefined, + }; + this.write(state); + return state; + } + + /** 根据当前应用版本和安装器进程决定启动前应执行的动作。 */ + resolveStartup(currentVersion: string): GuiUpdateStartupAction { + const state = this.read(); + if (!state) return { action: 'continue' }; + + if (currentVersion !== state.sourceVersion) { + this.clear(); + return { action: 'cleanup', state }; + } + if (state.status === 'downloaded') { + return { action: 'install', state }; + } + if (this.isInstallationActive(state)) { + return { action: 'wait', state }; + } + + return { + action: 'install', + state: this.restoreDownloaded(), + }; + } + + /** 判断旧版本对应的安装器是否仍处于有效安装时间内。 */ + isInstallationActive(state: GuiUpdateState): boolean { + if (state.status !== 'installing') return false; + const startedAt = Date.parse(state.installingAt ?? ''); + if ( + !Number.isFinite(startedAt) + || this.dependencies.now() - startedAt + > INSTALLING_STATE_TIMEOUT_MS + ) { + return false; + } + return state.installerPid === undefined + || this.dependencies.isProcessRunning(state.installerPid) + || this.dependencies.now() - startedAt + <= INSTALLER_EXIT_GRACE_MS; + } + + clear(): void { + fs.rmSync(this.filePath(), { force: true }); + } + + private requireState(): GuiUpdateState { + const state = this.read(); + if (!state) { + throw new Error('没有可用的 GUI 待安装更新'); + } + return state; + } + + private write(state: GuiUpdateState): void { + const target = this.filePath(); + fs.mkdirSync(path.dirname(target), { recursive: true }); + this.atomicFiles.write( + target, + `${JSON.stringify(state, null, 2)}\n`, + ); + } + + private normalize(value: unknown): GuiUpdateState | null { + if (!value || typeof value !== 'object') return null; + const state = value as Partial; + if ( + (state.status !== 'downloaded' + && state.status !== 'installing') + || typeof state.sourceVersion !== 'string' + || typeof state.targetVersion !== 'string' + || typeof state.downloadedFile !== 'string' + || typeof state.sha512 !== 'string' + || typeof state.downloadedAt !== 'string' + ) { + return null; + } + return { + status: state.status, + sourceVersion: state.sourceVersion, + targetVersion: state.targetVersion, + downloadedFile: path.resolve(state.downloadedFile), + sha512: state.sha512, + isAdminRightsRequired: + state.isAdminRightsRequired === true, + downloadedAt: state.downloadedAt, + installingAt: typeof state.installingAt === 'string' + ? state.installingAt + : undefined, + installerPid: Number.isInteger(state.installerPid) + && Number(state.installerPid) > 0 + ? Number(state.installerPid) + : undefined, + }; + } +} diff --git a/electron/services/GuiUpdaterLogger.ts b/electron/services/GuiUpdaterLogger.ts new file mode 100644 index 0000000..819f160 --- /dev/null +++ b/electron/services/GuiUpdaterLogger.ts @@ -0,0 +1,96 @@ +/** + * 把 electron-updater 的诊断信息写入独立、限长的文件日志。 + */ +import * as fs from 'fs'; +import * as path from 'path'; +import type { Logger } from 'electron-updater'; + +const MAX_LOG_SIZE = 2 * 1024 * 1024; + +/** 优先写入安装目录,权限不足时回退到用户数据目录。 */ +export class GuiUpdaterLogger implements Logger { + private activeLogPath: string | null = null; + + constructor( + private readonly preferredLogPath: string, + private readonly fallbackLogPath: string, + ) {} + + info(message?: unknown): void { + this.append('INFO', message); + } + + warn(message?: unknown): void { + this.append('WARN', message); + } + + error(message?: unknown): void { + this.append('ERROR', message); + } + + debug(message: string): void { + this.append('DEBUG', message); + } + + logPath(): string { + return this.resolveLogPath(); + } + + private append(level: string, value: unknown): void { + try { + const logPath = this.resolveLogPath(); + this.rotateIfNeeded(logPath); + const message = value instanceof Error + ? value.stack ?? value.message + : typeof value === 'string' + ? value + : JSON.stringify(value); + fs.appendFileSync( + logPath, + `${new Date().toISOString()} [${level}] ${message}\n`, + 'utf-8', + ); + } catch { + // 更新日志写入失败不能阻断更新或 GUI 启动。 + } + } + + private resolveLogPath(): string { + if (this.activeLogPath) return this.activeLogPath; + for (const candidate of [ + this.preferredLogPath, + this.fallbackLogPath, + ]) { + try { + fs.mkdirSync(path.dirname(candidate), { recursive: true }); + fs.closeSync(fs.openSync(candidate, 'a')); + this.activeLogPath = candidate; + return candidate; + } catch { + // 继续尝试用户数据目录。 + } + } + this.activeLogPath = this.fallbackLogPath; + return this.activeLogPath; + } + + private rotateIfNeeded(logPath: string): void { + try { + if ( + !fs.existsSync(logPath) + || fs.statSync(logPath).size < MAX_LOG_SIZE + ) { + return; + } + const parsed = path.parse(logPath); + const previous = path.join( + parsed.dir, + `${parsed.name}.1${parsed.ext}`, + ); + fs.rmSync(previous, { force: true }); + fs.renameSync(logPath, previous); + } catch { + // 文件正被扫描时继续追加,下一条日志再尝试轮转。 + } + } +} diff --git a/electron/services/LegacyMigrationNotice.ts b/electron/services/LegacyMigrationNotice.ts new file mode 100644 index 0000000..01e3c97 --- /dev/null +++ b/electron/services/LegacyMigrationNotice.ts @@ -0,0 +1,55 @@ +/** + * 构造旧版数据迁移完成后的用户提示。 + */ +import * as path from 'path'; +import type { LegacyMigrationSummary } from './LegacyMigrationSummary'; + +/** Electron 提示框所需的迁移结果文案。 */ +export interface LegacyMigrationNotice { + type: 'info' | 'warning'; + title: string; + message: string; + detail: string; + buttons: string[]; +} + +/** 本次没有实际迁移项时不重复打扰用户。 */ +export function buildLegacyMigrationNotice( + summary: LegacyMigrationSummary, +): LegacyMigrationNotice | null { + if (!summary.detected || summary.total === 0) return null; + + const failedNames = summary.failedFiles + .slice(0, 10) + .map(file => path.basename(file)); + const hiddenFailureCount = Math.max( + 0, + summary.failedFiles.length - failedNames.length, + ); + const failureDetail = failedNames.length > 0 + ? [ + '', + '失败文件:', + ...failedNames.map(file => `- ${file}`), + ...(hiddenFailureCount > 0 + ? [`- 另有 ${hiddenFailureCount} 个文件`] + : []), + ] + : []; + + return { + type: summary.failed > 0 ? 'warning' : 'info', + title: '旧版数据迁移完成', + message: [ + `当前已迁移旧版数据:${summary.total} 项`, + `成功:${summary.succeeded} 项`, + `失败:${summary.failed} 项`, + ].join('\n'), + detail: [ + '旧版原始文件均已保留。', + '迁移失败的文件仍位于旧版本原始目录,下次启动时会继续尝试。', + ...failureDetail, + ].join('\n'), + buttons: ['确定'], + }; +} diff --git a/electron/services/LegacyMigrationPrompt.ts b/electron/services/LegacyMigrationPrompt.ts new file mode 100644 index 0000000..e92ae54 --- /dev/null +++ b/electron/services/LegacyMigrationPrompt.ts @@ -0,0 +1,176 @@ +/** + * 在首次启动时收集旧配置迁移类别。 + */ +import type { + BrowserWindow, + BrowserWindowConstructorOptions, +} from 'electron'; +import type { + LegacyMigrationSelection, +} from './UserDataMigrationService'; + +export interface LegacyMigrationPromptDependencies { + createWindow(options: BrowserWindowConstructorOptions): BrowserWindow; +} + +/** 只有明确提交选择时返回结果,直接关闭窗口返回 null。 */ +export class LegacyMigrationPrompt { + constructor( + private readonly dependencies: LegacyMigrationPromptDependencies, + ) {} + + show(): Promise { + return new Promise(resolve => { + const window = this.dependencies.createWindow({ + width: 500, + height: 410, + resizable: false, + maximizable: false, + minimizable: false, + show: false, + autoHideMenuBar: true, + title: '旧配置迁移', + backgroundColor: '#f5f8fc', + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + let settled = false; + const finish = (result: LegacyMigrationSelection | null): void => { + if (settled) return; + settled = true; + resolve(result); + if (!window.isDestroyed()) window.close(); + }; + + window.webContents.on('will-navigate', (event, target) => { + let url: URL; + try { + url = new URL(target); + } catch { + event.preventDefault(); + return; + } + if ( + url.protocol !== 'legacy-migration:' + || url.hostname !== 'decision' + ) { + event.preventDefault(); + return; + } + event.preventDefault(); + finish({ + dailyPlans: url.searchParams.get('daily') === '1', + taskQueue: url.searchParams.get('queue') === '1', + taskYamls: url.searchParams.get('tasks') === '1', + }); + }); + window.once('closed', () => finish(null)); + window.once('ready-to-show', () => window.show()); + void window.loadURL(this.pageUrl()).catch(() => finish(null)); + }); + } + + private pageUrl(): string { + const html = ` + + + + + 旧配置迁移 + + + + 发现旧版配置 + 请选择要迁移的数据。关闭此窗口不会记录决定,下次启动仍会询问。 + 设置文件会自动迁移,不需要勾选。 + + + + 日常任务 YAML演习、战役和决战任务配置 + + + + 任务队列任务组及队列依赖的自定义模板 + + + + 任务 YAML作战计划及其引用的编队 YAML + + + + 不迁移 + 迁移所选 + + + +`; + return `data:text/html;charset=UTF-8,${encodeURIComponent(html)}`; + } +} diff --git a/electron/services/LegacyMigrationSummary.ts b/electron/services/LegacyMigrationSummary.ts new file mode 100644 index 0000000..3660925 --- /dev/null +++ b/electron/services/LegacyMigrationSummary.ts @@ -0,0 +1,44 @@ +/** + * 汇总一次旧版数据迁移的执行结果。 + */ + +/** 仅统计本次实际处理的旧版配置项。 */ +export interface LegacyMigrationSummary { + detected: boolean; + total: number; + succeeded: number; + failed: number; + failedFiles: string[]; +} + +/** 创建不含迁移项的初始结果。 */ +export function emptyLegacyMigrationSummary( + detected = false, +): LegacyMigrationSummary { + return { + detected, + total: 0, + succeeded: 0, + failed: 0, + failedFiles: [], + }; +} + +/** 合并设置、任务组和计划等多个迁移阶段的结果。 */ +export function mergeLegacyMigrationSummaries( + ...summaries: LegacyMigrationSummary[] +): LegacyMigrationSummary { + return summaries.reduce( + (merged, summary) => ({ + detected: merged.detected || summary.detected, + total: merged.total + summary.total, + succeeded: merged.succeeded + summary.succeeded, + failed: merged.failed + summary.failed, + failedFiles: [ + ...merged.failedFiles, + ...summary.failedFiles, + ], + }), + emptyLegacyMigrationSummary(), + ); +} diff --git a/electron/services/LegacyPlanMigration.ts b/electron/services/LegacyPlanMigration.ts new file mode 100644 index 0000000..67abd9a --- /dev/null +++ b/electron/services/LegacyPlanMigration.ts @@ -0,0 +1,1102 @@ +/** + * 幂等迁移旧作战计划和对应任务组引用。 + */ +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import { parseYaml } from '../../src/shared/yamlSerializer'; +import { AppPaths } from './AppPaths'; +import { AtomicFileStore } from './AtomicFileStore'; +import { + emptyLegacyMigrationSummary, + type LegacyMigrationSummary, +} from './LegacyMigrationSummary'; +import { + DEFAULT_LEGACY_MIGRATION_SELECTION, + PRESET_INVENTORY_MIGRATION_STAGE, + UserDataMigrationService, + type LegacyMigrationSelection, + type LegacyPlanReferenceTarget, +} from './UserDataMigrationService'; +import { MigrationStateStore } from './MigrationStateStore'; + +/** v7 将演习、战役和决战从普通出征计划迁移到独立日常目录。 */ +const LEGACY_PLAN_MIGRATION_VERSION = 7; + +/** v7 使用独立完成键,避免共享版本号越过失败的前置迁移。 */ +export const LEGACY_PLAN_MIGRATION_STAGE = ( + 'migration:v7:legacy-plans:complete' +); + +type DailyPlanType = 'exercise' | 'campaign' | 'decisive'; + +/** 编队 Codec 为迁移生成的单个原子写入。 */ +export interface LegacyTeamWrite { + name: string; + file: string; + path: string; + content: string; +} + +/** 由现有计划 Codec 注入的迁移领域规则。 */ +export interface LegacyPlanMigrationDependencies { + yamlFiles(directory: string): string[]; + safePlanBaseName(value: string): string; + normalizeUserTeamPlan(raw: unknown): TTeam; + teamPlanMatches(filePath: string, team: TTeam): boolean; + teamName?(team: TTeam): string; + renameTeam?(team: TTeam, name: string): TTeam; + normalizeCombatPlanFleetPresets( + root: Record, + source: 'user', + requireEmbeddedShips: boolean, + ): { + mapRoot: Record; + teams: TTeam[]; + }; + buildTeamPlanWrites( + teams: TTeam[], + directory: string, + ): LegacyTeamWrite[]; + serializeCombatPlan( + root: Record, + originalContent: string, + ): string; + isStandaloneTaskPreset?( + root: Record, + ): boolean; + normalizeTaskPreset?( + root: Record, + ): Record; +} + +/** 编排旧计划迁移,不在本服务中重复任何计划格式规则。 */ +export class LegacyPlanMigration { + constructor( + private readonly appPaths: AppPaths, + private readonly atomicFiles: AtomicFileStore, + private readonly userDataMigration: UserDataMigrationService, + private readonly migrationState: MigrationStateStore, + private readonly dependencies: LegacyPlanMigrationDependencies, + ) {} + + /** 按用户选择扫描旧安装中的有效 YAML。 */ + migrate( + selection: LegacyMigrationSelection = ( + DEFAULT_LEGACY_MIGRATION_SELECTION + ), + ): LegacyMigrationSummary { + const legacyDetected = ( + this.userDataMigration.shouldMigrateLegacyInstallation() + ); + const misplacedDailyPlans = this.misplacedDailyPlanFiles(); + const detected = legacyDetected || misplacedDailyPlans.length > 0; + const summary = emptyLegacyMigrationSummary(detected); + if ( + !this.migrationState.isStageComplete( + PRESET_INVENTORY_MIGRATION_STAGE, + ) + ) { + return summary; + } + if ( + legacyDetected + && !this.userDataMigration.isLegacyConfigurationMigrationComplete() + ) { + return summary; + } + if ( + this.migrationState.isStageComplete( + LEGACY_PLAN_MIGRATION_STAGE, + ) + && !legacyDetected + ) { + return summary; + } + if (!detected) { + this.migrationState.completeStage( + LEGACY_PLAN_MIGRATION_STAGE, + LEGACY_PLAN_MIGRATION_VERSION, + ); + return summary; + } + + const state = this.migrationState.read(); + const completed = new Set(state.completed); + const fileMap = new Map(); + const decisiveFailed = legacyDetected && selection.dailyPlans + ? this.migrateLegacyDecisiveSettings(completed, summary) + : false; + const teamsFailed = legacyDetected && selection.taskYamls + ? this.migrateLegacyTeams(completed, summary) + : false; + const plansFailed = legacyDetected + ? this.migrateLegacyPlans( + completed, + fileMap, + summary, + selection, + ) + : false; + const misplacedFailed = this.migrateMisplacedDailyPlans( + misplacedDailyPlans, + completed, + fileMap, + summary, + ); + const additionalFailed = legacyDetected + ? this.migrateAdditionalYaml( + completed, + fileMap, + summary, + selection, + ) + : false; + + if (selection.taskQueue) { + this.userDataMigration.migrateLegacyTaskGroupPlanPaths(fileMap); + } + const failed = ( + decisiveFailed + || teamsFailed + || plansFailed + || misplacedFailed + || additionalFailed + ); + this.migrationState.mergeCompleted(completed); + if (!failed) { + this.migrationState.completeStage( + LEGACY_PLAN_MIGRATION_STAGE, + LEGACY_PLAN_MIGRATION_VERSION, + ); + } + return summary; + } + + /** 先迁移旧版独立编队,供引用型旧计划继续使用。 */ + private migrateLegacyTeams( + completed: Set, + summary: LegacyMigrationSummary, + ): boolean { + const targetDirectory = this.appPaths.userTeamPlansDir(); + let failed = false; + for ( + const legacyDirectory of this.legacyDirectories( + 'user_team_plans', + targetDirectory, + ) + ) { + for (const source of this.legacyYamlFiles(legacyDirectory)) { + const content = fs.readFileSync(source, 'utf-8'); + const key = this.migrationKey('team', source, content); + if (completed.has(key)) continue; + summary.total += 1; + try { + const team = this.dependencies.normalizeUserTeamPlan( + parseYaml(content), + ); + const resolved = this.resolveTeamWrite( + team, + targetDirectory, + ); + if (!fs.existsSync(resolved.write.path)) { + this.atomicFiles.write( + resolved.write.path, + resolved.write.content, + ); + } + completed.add(key); + summary.succeeded += 1; + } catch (error) { + failed = true; + summary.failed += 1; + summary.failedFiles.push(source); + console.error(`[Migration] ${source} failed:`, error); + } + } + } + return failed; + } + + /** 升级旧计划并写入 GUI 管理的用户计划目录。 */ + private migrateLegacyPlans( + completed: Set, + fileMap: Map, + summary: LegacyMigrationSummary, + selection: LegacyMigrationSelection, + ): boolean { + const targetDirectory = this.appPaths.userBattlePlansDir(); + let failed = false; + for ( + const legacyDirectory of [ + ...this.legacyDirectories('plans', targetDirectory), + ...this.legacyDirectories( + 'user_battle_plans', + targetDirectory, + ), + ] + ) { + for (const source of this.legacyYamlFiles(legacyDirectory)) { + if (!selection.dailyPlans && !selection.taskYamls) continue; + const file = path.basename(source); + const content = fs.readFileSync(source, 'utf-8'); + const key = this.migrationKey('plan', source, content); + if (completed.has(key)) { + this.registerFileMapping( + fileMap, + source, + this.completedPlanTarget(completed, key, file), + ); + continue; + } + try { + const parsed = parseYaml(content); + if (!this.isPlainObject(parsed)) { + throw new Error('旧计划根节点必须是对象'); + } + const standalone = ( + this.dependencies.isStandaloneTaskPreset?.(parsed) === true + ); + if ( + !this.shouldMigratePlan(parsed, standalone, selection) + ) { + continue; + } + summary.total += 1; + const target = standalone + ? this.migrateTaskPreset(file, content, parsed) + : this.migrateCombatPlan( + file, + content, + this.dependencies.normalizeCombatPlanFleetPresets( + parsed, + 'user', + false, + ), + ); + this.registerFileMapping(fileMap, source, target); + this.rememberPlanTarget(completed, key, target); + completed.add(key); + summary.succeeded += 1; + } catch (error) { + failed = true; + summary.failed += 1; + summary.failedFiles.push(source); + console.error(`[Migration] ${source} failed:`, error); + } + } + } + return failed; + } + + /** 把已被旧升级器放入普通计划目录的日常任务重新归类。 */ + private migrateMisplacedDailyPlans( + sources: string[], + completed: Set, + fileMap: Map, + summary: LegacyMigrationSummary, + ): boolean { + let failed = false; + for (const source of sources) { + const file = path.basename(source); + const content = fs.readFileSync(source, 'utf-8'); + const key = this.migrationKey('plan', source, content); + if (completed.has(key)) { + this.registerFileMapping( + fileMap, + source, + this.completedPlanTarget(completed, key, file), + ); + continue; + } + summary.total += 1; + try { + const parsed = parseYaml(content); + if (!this.isPlainObject(parsed)) { + throw new Error('旧日常任务根节点必须是对象'); + } + const target = this.migrateTaskPreset(file, content, parsed); + if (target.kind !== 'daily') { + throw new Error('目标文件不是日常任务'); + } + this.registerFileMapping(fileMap, source, target); + this.rememberPlanTarget(completed, key, target); + completed.add(key); + summary.succeeded += 1; + } catch (error) { + failed = true; + summary.failed += 1; + summary.failedFiles.push(source); + console.error(`[Migration] ${source} failed:`, error); + } + } + return failed; + } + + /** 把旧决战页面最后保存的配置写入对应章节日常 YAML。 */ + private migrateLegacyDecisiveSettings( + completed: Set, + summary: LegacyMigrationSummary, + ): boolean { + const source = path.join(this.appPaths.appRoot(), 'gui_settings.json'); + if (!fs.existsSync(source)) return false; + const content = fs.readFileSync(source, 'utf-8'); + let root: unknown; + try { + root = JSON.parse(content); + } catch { + return false; + } + if (!this.isPlainObject(root) || !this.isPlainObject(root.decisive_plan)) { + return false; + } + + const decisive = root.decisive_plan; + const decisiveContent = JSON.stringify(decisive); + const key = this.migrationKey('plan', source, decisiveContent); + if (completed.has(key)) return false; + summary.total += 1; + try { + const level1 = Array.isArray(decisive.level1) + ? decisive.level1 + : []; + const level2 = Array.isArray(decisive.level2) + ? decisive.level2 + : []; + const level3 = Array.isArray(decisive.level3) + ? decisive.level3 + : []; + const chapter = Number(decisive.chapter); + const preset = { + task_type: 'decisive', + chapter, + times: 1, + use_quick_repair: typeof decisive.use_quick_repair === 'boolean' + ? decisive.use_quick_repair + : decisive.useQuickRepair, + level1: level1.slice(0, 6), + level2: [ + ...level1.slice(6), + ...level2, + ...level3, + ], + }; + this.migrateTaskPreset( + `决战第${chapter}章.yaml`, + '', + preset, + ); + completed.add(key); + summary.succeeded += 1; + return false; + } catch (error) { + summary.failed += 1; + summary.failedFiles.push(source); + console.error(`[Migration] ${source} failed:`, error); + return true; + } + } + + /** 递归扫描旧安装其余目录,只迁移能明确识别的计划 YAML。 */ + private migrateAdditionalYaml( + completed: Set, + fileMap: Map, + summary: LegacyMigrationSummary, + selection: LegacyMigrationSelection, + ): boolean { + let failed = false; + for (const source of this.recursiveLegacyYamlFiles()) { + if (this.isHandledLegacyYaml(source)) continue; + const content = fs.readFileSync(source, 'utf-8'); + let parsed: unknown; + try { + parsed = parseYaml(content); + } catch { + // 非计划目录中的普通 YAML 不属于用户迁移失败。 + continue; + } + if (!this.isPlainObject(parsed)) continue; + + const isTeam = ( + typeof parsed.name === 'string' + && Array.isArray(parsed.ships) + ); + const isPreset = ( + this.dependencies.isStandaloneTaskPreset?.(parsed) === true + ); + const isPlan = 'chapter' in parsed && 'map' in parsed; + if (!isTeam && !isPreset && !isPlan) continue; + if ( + isTeam + ? !selection.taskYamls + : !this.shouldMigratePlan(parsed, isPreset, selection) + ) { + continue; + } + + const kind = isTeam ? 'team' : 'plan'; + const key = this.migrationKey(kind, source, content); + const file = path.basename(source); + if (completed.has(key)) { + if (!isTeam) { + this.registerFileMapping( + fileMap, + source, + this.completedPlanTarget(completed, key, file), + ); + } + continue; + } + + summary.total += 1; + try { + if (isTeam) { + this.migrateAdditionalTeam(parsed); + } else { + const target = this.migrateAdditionalPlan( + file, + content, + parsed, + isPreset, + ); + this.registerFileMapping(fileMap, source, target); + this.rememberPlanTarget(completed, key, target); + } + completed.add(key); + summary.succeeded += 1; + } catch (error) { + failed = true; + summary.failed += 1; + summary.failedFiles.push(source); + console.error(`[Migration] ${source} failed:`, error); + } + } + return failed; + } + + /** 将独立日常 YAML 与普通任务 YAML 映射到各自勾选项。 */ + private shouldMigratePlan( + raw: Record, + standalone: boolean, + selection: LegacyMigrationSelection, + ): boolean { + if (!standalone) return selection.taskYamls; + return this.isDailyTaskType(raw.task_type) + ? selection.dailyPlans + : selection.taskYamls; + } + + private migrateAdditionalTeam(raw: Record): void { + const team = this.dependencies.normalizeUserTeamPlan(raw); + const resolved = this.resolveTeamWrite( + team, + this.appPaths.userTeamPlansDir(), + ); + if (!fs.existsSync(resolved.write.path)) { + this.atomicFiles.write( + resolved.write.path, + resolved.write.content, + ); + } + } + + private migrateAdditionalPlan( + file: string, + content: string, + parsed: Record, + standalone: boolean, + ): LegacyPlanReferenceTarget { + if (standalone) { + return this.migrateTaskPreset(file, content, parsed); + } + return this.migrateCombatPlan( + file, + content, + this.dependencies.normalizeCombatPlanFleetPresets( + parsed, + 'user', + false, + ), + ); + } + + private migrateCombatPlan( + file: string, + content: string, + split: { + mapRoot: Record; + teams: TTeam[]; + }, + ): LegacyPlanReferenceTarget { + return { + kind: 'plan', + file: this.writeMigratedPlan( + file, + content, + split.mapRoot, + split.teams, + ), + }; + } + + /** 按预设类型选择普通计划目录或日常任务目录。 */ + private migrateTaskPreset( + file: string, + content: string, + parsed: Record, + ): LegacyPlanReferenceTarget { + const normalized = this.dependencies.normalizeTaskPreset?.(parsed); + if (!normalized) { + throw new Error('缺少任务预设迁移规则'); + } + const taskType = normalized.task_type; + if (!this.isDailyTaskType(taskType)) { + return { + kind: 'plan', + file: this.writeMigratedPlan( + file, + content, + normalized, + [], + ), + }; + } + const dailyPreset: Record & { + task_type: DailyPlanType; + } = { + ...normalized, + task_type: taskType, + }; + if ( + dailyPreset.task_type === 'campaign' + && typeof dailyPreset.campaign_name === 'string' + && dailyPreset.campaign_name.startsWith('普通') + ) { + dailyPreset.campaign_name = dailyPreset.campaign_name.replace( + /^普通/, + '简单', + ); + } + return { + kind: 'daily', + taskType, + file: this.writeMigratedDailyPlan(file, content, dailyPreset), + }; + } + + private writeMigratedDailyPlan( + file: string, + content: string, + preset: Record & { task_type: DailyPlanType }, + ): string { + const serialized = this.dependencies.serializeCombatPlan( + preset, + content, + ); + const target = this.resolveDailyPlanTarget( + this.migratedDailyPlanFileName(file, preset), + serialized, + ); + if (!target.matches) { + fs.mkdirSync(this.appPaths.userDailyPlansDir(), { recursive: true }); + this.atomicFiles.write(target.path, serialized); + } + return target.file; + } + + private resolveDailyPlanTarget( + defaultFile: string, + content: string, + ): { file: string; path: string; matches: boolean } { + for (let suffix = 0; ; suffix += 1) { + const file = this.legacyPlanFileName(defaultFile, suffix); + const target = path.join(this.appPaths.userDailyPlansDir(), file); + if (!fs.existsSync(target)) { + return { file, path: target, matches: false }; + } + if (fs.readFileSync(target, 'utf-8') === content) { + return { file, path: target, matches: true }; + } + } + } + + private migratedDailyPlanFileName( + file: string, + preset: Record & { task_type: DailyPlanType }, + ): string { + if (preset.task_type === 'decisive') { + const chapter = Number(preset.chapter); + if (!Number.isInteger(chapter) || chapter < 1 || chapter > 6) { + throw new Error('决战章节必须是 1 到 6'); + } + return `decisive-决战第${chapter}章.yaml`; + } + if (preset.task_type === 'exercise') { + return `exercise-队伍${String(preset.fleet_id)}演习.yaml`; + } + const campaignName = typeof preset.campaign_name === 'string' + ? preset.campaign_name.replace(/^简单/, '普通') + : this.dependencies.safePlanBaseName(file); + return `campaign-${campaignName}.yaml`; + } + + private isDailyTaskType(value: unknown): value is DailyPlanType { + return ( + value === 'exercise' + || value === 'campaign' + || value === 'decisive' + ); + } + + private writeMigratedPlan( + file: string, + content: string, + mapRoot: Record, + teams: TTeam[], + ): string { + const defaultTargetFile = this.migratedUserPlanFileName(file); + const originalPlan = this.dependencies.serializeCombatPlan( + mapRoot, + content, + ); + const existingOriginal = this.matchingPlanTarget( + defaultTargetFile, + originalPlan, + ); + if (existingOriginal) { + this.writeMissingOriginalTeams(teams); + return existingOriginal.file; + } + + const resolved = this.resolvePlanTeams(mapRoot, teams); + const serializedPlan = this.dependencies.serializeCombatPlan( + resolved.mapRoot, + content, + ); + const planTarget = this.resolvePlanTarget( + defaultTargetFile, + serializedPlan, + ); + const createdTeams: string[] = []; + try { + for (const team of resolved.writes) { + if (!fs.existsSync(team.path)) { + this.atomicFiles.write(team.path, team.content); + createdTeams.push(team.path); + } + } + if (!planTarget.matches) { + this.atomicFiles.write(planTarget.path, serializedPlan); + } + } catch (error) { + for (const teamPath of createdTeams) { + try { + fs.rmSync(teamPath, { force: true }); + } catch { + // 清理失败不能覆盖最初的迁移错误。 + } + } + throw error; + } + return planTarget.file; + } + + /** 已有同内容计划表示曾迁移过,只补缺失编队而不覆盖现有编队。 */ + private writeMissingOriginalTeams(teams: TTeam[]): void { + const writes = this.dependencies.buildTeamPlanWrites( + teams, + this.appPaths.userTeamPlansDir(), + ); + for (const write of writes) { + if (!fs.existsSync(write.path)) { + this.atomicFiles.write(write.path, write.content); + } + } + } + + private matchingPlanTarget( + defaultFile: string, + content: string, + ): { file: string; path: string } | null { + for (let suffix = 0; ; suffix += 1) { + const file = this.legacyPlanFileName(defaultFile, suffix); + const target = path.join( + this.appPaths.userBattlePlansDir(), + file, + ); + if (!fs.existsSync(target)) return null; + if (fs.readFileSync(target, 'utf-8') === content) { + return { file, path: target }; + } + } + } + + private resolvePlanTarget( + defaultFile: string, + content: string, + ): { file: string; path: string; matches: boolean } { + for (let suffix = 0; ; suffix += 1) { + const file = this.legacyPlanFileName(defaultFile, suffix); + const target = path.join( + this.appPaths.userBattlePlansDir(), + file, + ); + if (!fs.existsSync(target)) { + return { file, path: target, matches: false }; + } + if (fs.readFileSync(target, 'utf-8') === content) { + return { file, path: target, matches: true }; + } + } + } + + private legacyPlanFileName(defaultFile: string, suffix: number): string { + if (suffix === 0) return defaultFile; + const extension = path.extname(defaultFile); + const base = defaultFile.slice(0, -extension.length); + const label = suffix === 1 + ? '(旧版)' + : `(旧版 ${suffix})`; + return `${base}${label}${extension}`; + } + + private resolvePlanTeams( + mapRoot: Record, + teams: TTeam[], + ): { + mapRoot: Record; + teams: TTeam[]; + writes: LegacyTeamWrite[]; + } { + const resolvedRoot = structuredClone(mapRoot); + const resolvedTeams: TTeam[] = []; + const writes: LegacyTeamWrite[] = []; + const reservedPaths = new Set(); + for (const team of teams) { + const resolved = this.resolveTeamWrite( + team, + this.appPaths.userTeamPlansDir(), + reservedPaths, + ); + const oldName = this.dependencies.teamName?.(team); + const newName = this.dependencies.teamName?.(resolved.team); + if (oldName && newName && oldName !== newName) { + this.replaceTeamReference(resolvedRoot, oldName, newName); + } + reservedPaths.add(this.pathKey(resolved.write.path)); + resolvedTeams.push(resolved.team); + writes.push(resolved.write); + } + return { + mapRoot: resolvedRoot, + teams: resolvedTeams, + writes, + }; + } + + private resolveTeamWrite( + team: TTeam, + directory: string, + reservedPaths = new Set(), + ): { team: TTeam; write: LegacyTeamWrite } { + const originalName = this.dependencies.teamName?.(team); + let candidate = team; + let suffix = 1; + for (;;) { + const [write] = this.dependencies.buildTeamPlanWrites( + [candidate], + directory, + ); + if (!write) throw new Error('旧舰队未生成迁移文件'); + const reserved = reservedPaths.has(this.pathKey(write.path)); + const conflicts = ( + fs.existsSync(write.path) + && !this.dependencies.teamPlanMatches(write.path, candidate) + ); + if (!reserved && !conflicts) { + return { team: candidate, write }; + } + if ( + !originalName + || !this.dependencies.renameTeam + ) { + throw new Error(`迁移目标已存在,未覆盖:${write.path}`); + } + const name = suffix === 1 + ? `${originalName}(旧版)` + : `${originalName}(旧版 ${suffix})`; + candidate = this.dependencies.renameTeam(team, name); + suffix += 1; + } + } + + private replaceTeamReference( + mapRoot: Record, + oldName: string, + newName: string, + ): void { + if (!Array.isArray(mapRoot.fleet_presets)) return; + mapRoot.fleet_presets = mapRoot.fleet_presets.map(preset => ( + this.isPlainObject(preset) && preset.name === oldName + ? { ...preset, name: newName } + : preset + )); + } + + /** 查找旧版升级后仍滞留在普通计划目录的三类日常任务。 */ + private misplacedDailyPlanFiles(): string[] { + const directory = this.appPaths.userBattlePlansDir(); + return this.dependencies.yamlFiles(directory).flatMap(file => { + const source = path.join(directory, file); + try { + const parsed = parseYaml(fs.readFileSync(source, 'utf-8')); + return ( + this.isPlainObject(parsed) + && this.dependencies.isStandaloneTaskPreset?.(parsed) === true + && this.isDailyTaskType(parsed.task_type) + ) + ? [source] + : []; + } catch { + return []; + } + }); + } + + private recursiveLegacyYamlFiles(): string[] { + const files: string[] = []; + this.collectYamlFiles(this.appPaths.appRoot(), files); + return files.sort((left, right) => left.localeCompare(right)); + } + + private legacyYamlFiles(directory: string): string[] { + const files: string[] = []; + this.collectYamlFiles(directory, files); + return files.sort((left, right) => left.localeCompare(right)); + } + + private collectYamlFiles(directory: string, files: string[]): void { + if (!fs.existsSync(directory) || this.isExcludedDirectory(directory)) { + return; + } + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (entry.isSymbolicLink()) continue; + const target = path.join(directory, entry.name); + if (entry.isDirectory()) { + this.collectYamlFiles(target, files); + } else if ( + entry.isFile() + && /\.ya?ml$/i.test(entry.name) + ) { + files.push(target); + } + } + } + + private isExcludedDirectory(directory: string): boolean { + const name = path.basename(directory).toLowerCase(); + if (new Set([ + '.git', + '.venv', + 'venv', + 'node_modules', + 'dist', + 'dist-electron', + ]).has(name)) { + return true; + } + return [ + this.appPaths.userBattlePlansDir(), + this.appPaths.userDailyPlansDir(), + this.appPaths.userTeamPlansDir(), + this.appPaths.systemBattlePlansDir(), + this.appPaths.systemDailyPlansDir(), + this.appPaths.systemTeamPlansDir(), + ].some(excluded => this.pathWithin(directory, excluded)); + } + + private isHandledLegacyYaml(source: string): boolean { + const handledDirectories = [ + ...this.legacyDirectories( + 'plans', + this.appPaths.userBattlePlansDir(), + ), + ...this.legacyDirectories( + 'user_battle_plans', + this.appPaths.userBattlePlansDir(), + ), + ...this.legacyDirectories( + 'user_team_plans', + this.appPaths.userTeamPlansDir(), + ), + ]; + return handledDirectories.some(directory => ( + this.pathWithin(source, directory) + )); + } + + private pathWithin(candidate: string, root: string): boolean { + const relative = path.relative( + path.resolve(root), + path.resolve(candidate), + ); + return relative === '' + || ( + relative !== '..' + && !relative.startsWith(`..${path.sep}`) + && !path.isAbsolute(relative) + ); + } + + private migrationKey( + kind: 'plan' | 'team', + source: string, + content: string, + ): string { + const hash = crypto + .createHash('sha256') + .update(content) + .digest('hex'); + return `${kind}-v7:${this.pathKey(source)}:${hash}`; + } + + private rememberPlanTarget( + completed: Set, + migrationKey: string, + target: LegacyPlanReferenceTarget, + ): void { + const prefix = this.planTargetMarkerPrefix(migrationKey); + for (const value of completed) { + if (value.startsWith(prefix)) completed.delete(value); + } + completed.add(`${prefix}${encodeURIComponent(target.file)}`); + } + + private completedPlanTarget( + completed: Set, + migrationKey: string, + sourceFile: string, + ): LegacyPlanReferenceTarget { + const prefix = this.planTargetMarkerPrefix(migrationKey); + const marker = [...completed].find(value => value.startsWith(prefix)); + if (marker) { + try { + const targetFile = decodeURIComponent(marker.slice(prefix.length)); + if ( + targetFile + && !/[\\/]/.test(targetFile) + && /\.ya?ml$/i.test(targetFile) + ) { + return this.referenceTargetFromFile(targetFile); + } + } catch { + // 无效输出记录回退到旧版本默认目标文件名。 + } + } + return { + kind: 'plan', + file: this.migratedUserPlanFileName(sourceFile), + }; + } + + private planTargetMarkerPrefix(migrationKey: string): string { + const hash = crypto + .createHash('sha256') + .update(migrationKey) + .digest('hex'); + return `plan-output-v7:${hash}:`; + } + + private registerFileMapping( + fileMap: Map, + source: string, + target: LegacyPlanReferenceTarget, + ): void { + const relative = path.relative(this.appPaths.appRoot(), source); + for (const value of [source, relative, path.basename(source)]) { + const key = this.planReferenceKey(value); + if (key && !fileMap.has(key)) fileMap.set(key, target); + } + } + + private referenceTargetFromFile( + file: string, + ): LegacyPlanReferenceTarget { + const match = /^(exercise|campaign|decisive)-/.exec( + file.toLowerCase(), + ); + return match + ? { + kind: 'daily', + file, + taskType: match[1] as DailyPlanType, + } + : { kind: 'plan', file }; + } + + private planReferenceKey(value: string): string { + const normalized = value + .trim() + .replace(/\\/g, '/') + .replace(/^\.\/+/, ''); + return process.platform === 'win32' + ? normalized.toLowerCase() + : normalized; + } + + /** 返回去重后的旧目录,并排除当前 userData 目标目录。 */ + private legacyDirectories( + directoryName: 'plans' | 'user_battle_plans' | 'user_team_plans', + targetDirectory: string, + ): string[] { + const candidates = directoryName === 'plans' + ? [ + path.join(this.appPaths.appRoot(), 'plans'), + path.join(this.appPaths.userDataRoot(), 'plans'), + ] + : [ + path.join( + this.appPaths.appRoot(), + 'resource', + directoryName, + ), + path.join( + this.appPaths.resourceRoot(), + 'resource', + directoryName, + ), + ]; + const targetKey = this.pathKey(targetDirectory); + const unique = new Map(); + for (const candidate of candidates) { + const key = this.pathKey(candidate); + if (key !== targetKey && !unique.has(key)) { + unique.set(key, candidate); + } + } + return [...unique.values()]; + } + + private pathKey(value: string): string { + const resolved = path.resolve(value); + return process.platform === 'win32' + ? resolved.toLowerCase() + : resolved; + } + + private migratedUserPlanFileName(file: string): string { + const baseName = this.dependencies.safePlanBaseName(file); + if (!baseName) { + throw new Error(`旧计划文件名不合法: ${file}`); + } + return `bettle-${baseName}.yaml`; + } + + private isPlainObject( + value: unknown, + ): value is Record { + return Boolean(value) + && typeof value === 'object' + && !Array.isArray(value); + } +} diff --git a/electron/services/MigrationConflictService.ts b/electron/services/MigrationConflictService.ts new file mode 100644 index 0000000..558b21d --- /dev/null +++ b/electron/services/MigrationConflictService.ts @@ -0,0 +1,623 @@ +/** + * 管理旧配置迁移后需要用户确认的 YAML 冲突。 + * + * 处理流程: + * 1. 迁移结束后扫描系统与用户受管目录。 + * 2. YAML 先解析为结构化数据,再比较内容是否完全一致。 + * 3. 同名异内容和迁移生成的“旧版”副本也作为冲突原因记录。 + * 4. 待处理清单写入 userData,关闭窗口不会丢失确认任务。 + * 5. Renderer 只获得冲突 ID、文件名和原因,不获得绝对路径。 + * 6. 用户提交的是保留 ID;其余文件才进入删除候选。 + * 7. 删除前再次校验文件名、文件类型和内容哈希。 + * 8. 内容已变化的文件不会删除,并留到下次继续处理。 + * 9. 与系统预设完全相同的文件删除前会重写任务列表和自动胖次引用。 + * 10. 同名异内容没有安全替代项,只按用户明确选择删除。 + */ +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import { isDeepStrictEqual } from 'util'; +import type { + MigrationConflictItem, + MigrationConflictKind, + MigrationConflictListResult, + MigrationConflictReason, + MigrationConflictResolutionResult, +} from '../../src/shared/migrationConflicts'; +import { parseYaml } from '../../src/shared/yamlSerializer'; +import { AppPaths } from './AppPaths'; +import { AtomicFileStore } from './AtomicFileStore'; + +const MIGRATION_CONFLICT_STATE_VERSION = 1; + +interface StoredMigrationConflict extends MigrationConflictItem { + contentHash: string; + systemReplacement?: string; +} + +interface MigrationConflictState { + version: number; + status: 'pending' | 'resolved'; + conflicts: StoredMigrationConflict[]; + acceptedIds: string[]; +} + +interface ParsedYamlFile { + file: string; + value: unknown; +} + +/** 检测、持久化并安全处理迁移产生的用户 YAML 冲突。 */ +export class MigrationConflictService { + constructor( + private readonly appPaths: AppPaths, + private readonly atomicFiles: AtomicFileStore, + ) {} + + /** + * 应用升级到该功能后至少扫描一次;本次实际发生迁移时强制重扫。 + */ + prepareAfterMigration(forceScan: boolean): void { + const current = this.readState(); + if (current && !forceScan) return; + const acceptedIds = current?.acceptedIds ?? []; + const accepted = new Set(acceptedIds); + const conflicts = this.detectConflicts().filter(conflict => ( + !accepted.has(conflict.id) + )); + this.writeState({ + version: MIGRATION_CONFLICT_STATE_VERSION, + status: conflicts.length > 0 ? 'pending' : 'resolved', + conflicts, + acceptedIds, + }); + } + + /** 返回仍存在且内容未变化的待处理文件。 */ + pending(): MigrationConflictListResult { + const state = this.readState(); + if (!state || state.status !== 'pending') { + return { pending: false, conflicts: [] }; + } + const conflicts = state.conflicts.filter(conflict => ( + this.conflictFileStillMatches(conflict) + )); + if (conflicts.length !== state.conflicts.length) { + this.writeState({ + ...state, + status: conflicts.length > 0 ? 'pending' : 'resolved', + conflicts, + }); + } + return { + pending: conflicts.length > 0, + conflicts: conflicts.map(conflict => this.publicConflict(conflict)), + }; + } + + /** + * 保留 keepIds 中的文件,删除其余待处理文件。 + * + * 删除目标始终由持久化冲突记录反查,Renderer 不能传入路径。 + */ + resolve(keepIds: unknown): MigrationConflictResolutionResult { + const state = this.readState(); + if (!state || state.status !== 'pending') { + return { + success: true, + kept: 0, + deleted: 0, + errors: [], + remaining: [], + }; + } + if (!Array.isArray(keepIds) || keepIds.some(id => ( + typeof id !== 'string' + ))) { + throw new Error('保留清单格式无效'); + } + const knownIds = new Set(state.conflicts.map(conflict => conflict.id)); + const keep = new Set(keepIds as string[]); + if ([...keep].some(id => !knownIds.has(id))) { + throw new Error('保留清单包含未知冲突项'); + } + + const kept = state.conflicts.filter(conflict => keep.has(conflict.id)); + const requestedDeletes = state.conflicts.filter(conflict => ( + !keep.has(conflict.id) + )); + const validDeletes: StoredMigrationConflict[] = []; + const remaining: StoredMigrationConflict[] = []; + const errors: string[] = []; + + for (const conflict of requestedDeletes) { + if (this.conflictFileStillMatches(conflict)) { + validDeletes.push(conflict); + } else { + remaining.push(conflict); + errors.push(`${conflict.file} 已变化,未执行删除`); + } + } + + try { + this.rewriteTaskGroupReferences(validDeletes); + this.rewriteAutomationReferences(validDeletes); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + errors.push(`配置引用更新失败:${message}`); + remaining.push(...validDeletes); + validDeletes.length = 0; + } + + let deleted = 0; + for (const conflict of validDeletes) { + try { + fs.rmSync(this.userFilePath(conflict.kind, conflict.file)); + deleted += 1; + } catch (error) { + remaining.push(conflict); + const message = error instanceof Error ? error.message : String(error); + errors.push(`${conflict.file} 删除失败:${message}`); + } + } + + this.writeState({ + version: MIGRATION_CONFLICT_STATE_VERSION, + status: remaining.length > 0 ? 'pending' : 'resolved', + conflicts: remaining, + acceptedIds: [ + ...new Set([ + ...state.acceptedIds, + ...kept.map(conflict => conflict.id), + ]), + ].sort(), + }); + return { + success: errors.length === 0, + kept: kept.length, + deleted, + errors, + remaining: remaining.map(conflict => this.publicConflict(conflict)), + }; + } + + /** 比较普通计划和日常任务两个受管目录。 */ + private detectConflicts(): StoredMigrationConflict[] { + const conflicts = [ + ...this.detectDirectoryConflicts( + 'battle', + this.appPaths.userBattlePlansDir(), + this.appPaths.systemBattlePlansDir(), + ), + ...this.detectDirectoryConflicts( + 'daily', + this.appPaths.userDailyPlansDir(), + this.appPaths.systemDailyPlansDir(), + ), + ]; + return conflicts.sort((left, right) => ( + left.kind.localeCompare(right.kind) + || left.file.localeCompare(right.file, 'zh-CN') + )); + } + + private detectDirectoryConflicts( + kind: MigrationConflictKind, + userDirectory: string, + systemDirectory: string, + ): StoredMigrationConflict[] { + const systemFiles = this.parsedYamlFiles(systemDirectory); + const systemByName = new Map(systemFiles.map(file => [ + file.file.toLocaleLowerCase(), + file, + ])); + const userFiles = this.parsedYamlFiles(userDirectory); + const userNames = new Set(userFiles.map(file => ( + file.file.toLocaleLowerCase() + ))); + const output: StoredMigrationConflict[] = []; + + for (const userFile of userFiles) { + const reasons: MigrationConflictReason[] = []; + const exactSystem = systemFiles.find(systemFile => ( + isDeepStrictEqual(userFile.value, systemFile.value) + )); + if (exactSystem) { + reasons.push({ + reasonCode: 'same_as_system_preset', + reason: `与系统预设「${this.displayName(exactSystem.file)}」内容完全相同`, + relatedFile: exactSystem.file, + }); + } else { + const sameName = systemByName.get(userFile.file.toLocaleLowerCase()); + if (sameName) { + reasons.push({ + reasonCode: 'same_name_as_system_preset', + reason: `与系统预设「${this.displayName(sameName.file)}」文件名相同,但配置内容不同`, + relatedFile: sameName.file, + }); + } + } + + const originalFile = this.originalFileForLegacyCopy(userFile.file); + if ( + originalFile + && userNames.has(originalFile.toLocaleLowerCase()) + ) { + reasons.push({ + reasonCode: 'legacy_copy_name_conflict', + reason: `迁移时因名称冲突保存为旧版副本;现有文件为「${this.displayName(originalFile)}」`, + relatedFile: originalFile, + }); + } + if (reasons.length === 0) continue; + + const content = fs.readFileSync( + path.join(userDirectory, userFile.file), + 'utf-8', + ); + const contentHash = this.contentHash(content); + const id = this.contentHash([ + kind, + userFile.file, + contentHash, + ...reasons.map(reason => reason.reasonCode), + ].join('\n')); + output.push({ + id, + kind, + file: userFile.file, + name: this.displayName(userFile.file), + reasons, + contentHash, + systemReplacement: exactSystem?.file, + }); + } + return output; + } + + private parsedYamlFiles(directory: string): ParsedYamlFile[] { + if (!fs.existsSync(directory)) return []; + return fs.readdirSync(directory, { withFileTypes: true }) + .filter(entry => ( + entry.isFile() + && this.isManagedYamlFile(entry.name) + )) + .flatMap(entry => { + try { + return [{ + file: entry.name, + value: parseYaml( + fs.readFileSync(path.join(directory, entry.name), 'utf-8'), + ), + }]; + } catch { + return []; + } + }); + } + + private conflictFileStillMatches( + conflict: StoredMigrationConflict, + ): boolean { + if (!this.isManagedYamlFile(conflict.file)) return false; + const target = this.userFilePath(conflict.kind, conflict.file); + try { + if (!fs.existsSync(target)) return false; + const stat = fs.lstatSync(target); + if (!stat.isFile() || stat.isSymbolicLink()) return false; + return this.contentHash(fs.readFileSync(target, 'utf-8')) + === conflict.contentHash; + } catch { + return false; + } + } + + /** 删除完全相同副本时,把任务组身份切换到对应系统预设。 */ + private rewriteTaskGroupReferences( + conflicts: StoredMigrationConflict[], + ): void { + const replacements = new Map(); + for (const conflict of conflicts) { + if (!conflict.systemReplacement) continue; + replacements.set( + `${conflict.kind}:${conflict.file.toLocaleLowerCase()}`, + conflict.systemReplacement, + ); + } + if (replacements.size === 0) return; + + const target = path.join( + this.appPaths.userDataRoot(), + 'task_groups.json', + ); + if (!fs.existsSync(target)) return; + const root = JSON.parse( + fs.readFileSync(target, 'utf-8'), + ) as Record; + if (!Array.isArray(root.groups)) return; + let changed = false; + const groups = root.groups.map(group => { + if (!this.isPlainObject(group) || !Array.isArray(group.items)) { + return group; + } + return { + ...group, + items: group.items.map(item => { + if (!this.isPlainObject(item)) return item; + const managedFile = typeof item.managedFile === 'string' + ? item.managedFile + : ''; + const battleReplacement = item.managedSource === 'user' + ? replacements.get( + `battle:${managedFile.toLocaleLowerCase()}`, + ) + : undefined; + if (battleReplacement) { + changed = true; + return { + ...item, + managedSource: 'system', + managedFile: battleReplacement, + }; + } + const dailyFile = typeof item.dailyFile === 'string' + ? item.dailyFile + : ''; + const dailyReplacement = item.dailySource === 'user' + ? replacements.get( + `daily:${dailyFile.toLocaleLowerCase()}`, + ) + : undefined; + if (!dailyReplacement) return item; + changed = true; + return { + ...item, + dailySource: 'system', + dailyFile: dailyReplacement, + }; + }), + }; + }); + if (changed) { + this.atomicFiles.write( + target, + JSON.stringify({ ...root, groups }, null, 2), + ); + } + } + + /** 删除完全相同副本时,同步切换自动胖次候选和当前选择。 */ + private rewriteAutomationReferences( + conflicts: StoredMigrationConflict[], + ): void { + const replacements = new Map(); + for (const conflict of conflicts) { + if (conflict.kind !== 'battle' || !conflict.systemReplacement) { + continue; + } + replacements.set( + conflict.file.toLocaleLowerCase(), + conflict.systemReplacement, + ); + } + if (replacements.size === 0) return; + + const target = path.join( + this.appPaths.userDataRoot(), + 'gui_settings.json', + ); + if (!fs.existsSync(target)) return; + const root = JSON.parse(fs.readFileSync(target, 'utf-8')) as unknown; + if (!this.isPlainObject(root)) return; + const automation = root.automation; + if (!this.isPlainObject(automation)) return; + + let changed = false; + let lootPlans = automation.lootPlans; + if (Array.isArray(lootPlans)) { + const seen = new Set(); + lootPlans = lootPlans.map(item => { + if ( + !this.isPlainObject(item) + || item.source !== 'user' + || typeof item.file !== 'string' + ) { + return item; + } + const replacement = replacements.get( + item.file.toLocaleLowerCase(), + ); + if (!replacement) return item; + changed = true; + return { + ...item, + source: 'system', + file: replacement, + }; + }).filter(item => { + if ( + !this.isPlainObject(item) + || (item.source !== 'system' && item.source !== 'user') + || typeof item.file !== 'string' + ) { + return true; + } + const key = `${item.source}:${item.file.toLocaleLowerCase()}`; + if (seen.has(key)) { + changed = true; + return false; + } + seen.add(key); + return true; + }); + } + + const selectedFile = typeof automation.lootPlanId === 'string' + ? automation.lootPlanId + : ''; + const selectedReplacement = automation.lootPlanSource === 'user' + ? replacements.get(selectedFile.toLocaleLowerCase()) + : undefined; + if (selectedReplacement) changed = true; + if (!changed) return; + + this.atomicFiles.write( + target, + JSON.stringify({ + ...root, + automation: { + ...automation, + lootPlans, + ...(selectedReplacement + ? { + lootPlanSource: 'system', + lootPlanId: selectedReplacement, + } + : {}), + }, + }, null, 2), + ); + } + + private readState(): MigrationConflictState | null { + const target = this.statePath(); + if (!fs.existsSync(target)) return null; + try { + const parsed = JSON.parse(fs.readFileSync(target, 'utf-8')) as unknown; + if ( + !this.isPlainObject(parsed) + || parsed.version !== MIGRATION_CONFLICT_STATE_VERSION + || (parsed.status !== 'pending' && parsed.status !== 'resolved') + || !Array.isArray(parsed.conflicts) + || ( + parsed.acceptedIds !== undefined + && ( + !Array.isArray(parsed.acceptedIds) + || parsed.acceptedIds.some(id => typeof id !== 'string') + ) + ) + ) { + return null; + } + const conflicts = parsed.conflicts.filter( + (value): value is StoredMigrationConflict => ( + this.isStoredConflict(value) + ), + ); + return { + version: MIGRATION_CONFLICT_STATE_VERSION, + status: parsed.status, + conflicts, + acceptedIds: Array.isArray(parsed.acceptedIds) + ? parsed.acceptedIds as string[] + : [], + }; + } catch { + return null; + } + } + + private writeState(state: MigrationConflictState): void { + fs.mkdirSync(this.appPaths.userDataRoot(), { recursive: true }); + this.atomicFiles.write( + this.statePath(), + JSON.stringify(state, null, 2), + ); + } + + private statePath(): string { + return path.join( + this.appPaths.userDataRoot(), + '.migration-conflicts.json', + ); + } + + private userFilePath( + kind: MigrationConflictKind, + file: string, + ): string { + if (!this.isManagedYamlFile(file)) { + throw new Error('冲突文件名无效'); + } + const directory = kind === 'battle' + ? this.appPaths.userBattlePlansDir() + : this.appPaths.userDailyPlansDir(); + return path.join(directory, file); + } + + private isStoredConflict( + value: unknown, + ): value is StoredMigrationConflict { + if (!this.isPlainObject(value)) return false; + return ( + typeof value.id === 'string' + && (value.kind === 'battle' || value.kind === 'daily') + && typeof value.file === 'string' + && this.isManagedYamlFile(value.file) + && typeof value.name === 'string' + && Array.isArray(value.reasons) + && value.reasons.every(reason => ( + this.isPlainObject(reason) + && typeof reason.reasonCode === 'string' + && typeof reason.reason === 'string' + )) + && typeof value.contentHash === 'string' + && ( + value.systemReplacement === undefined + || ( + typeof value.systemReplacement === 'string' + && this.isManagedYamlFile(value.systemReplacement) + ) + ) + ); + } + + private publicConflict( + conflict: StoredMigrationConflict, + ): MigrationConflictItem { + return { + id: conflict.id, + kind: conflict.kind, + file: conflict.file, + name: conflict.name, + reasons: conflict.reasons.map(reason => ({ ...reason })), + }; + } + + private originalFileForLegacyCopy(file: string): string | null { + const extension = path.extname(file); + const base = file.slice(0, -extension.length); + const original = base.replace(/(旧版(?: \d+)?)$/, ''); + return original === base ? null : `${original}${extension}`; + } + + private displayName(file: string): string { + return file + .replace(/\.ya?ml$/i, '') + .replace(/^(?:bettle|exercise|campaign|decisive)-/i, ''); + } + + private isManagedYamlFile(file: string): boolean { + return ( + file.length > 0 + && file.length <= 255 + && !/[\\/\x00-\x1f]/.test(file) + && /\.ya?ml$/i.test(file) + ); + } + + private contentHash(content: string): string { + return crypto.createHash('sha256').update(content).digest('hex'); + } + + private isPlainObject( + value: unknown, + ): value is Record { + return ( + typeof value === 'object' + && value !== null + && !Array.isArray(value) + ); + } +} diff --git a/electron/services/MigrationStateStore.ts b/electron/services/MigrationStateStore.ts new file mode 100644 index 0000000..5b2d641 --- /dev/null +++ b/electron/services/MigrationStateStore.ts @@ -0,0 +1,72 @@ +/** + * 持久化迁移完成项和最高版本号。 + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { AtomicFileStore } from './AtomicFileStore'; + +/** 当前用户数据迁移状态格式。 */ +export interface MigrationState { + version: number; + completed: string[]; +} + +/** 独占管理 .migration-state.json 的解析、合并和原子写入。 */ +export class MigrationStateStore { + constructor( + private readonly getFilePath: () => string, + private readonly atomicFiles: AtomicFileStore, + ) {} + + /** 返回迁移状态文件路径。 */ + filePath(): string { + return this.getFilePath(); + } + + /** 读取当前迁移状态;无效文件按未迁移处理。 */ + read(): MigrationState { + try { + const raw = JSON.parse( + fs.readFileSync(this.filePath(), 'utf-8'), + ) as Partial; + return { + version: typeof raw.version === 'number' ? raw.version : 0, + completed: Array.isArray(raw.completed) + ? raw.completed.filter(value => typeof value === 'string') + : [], + }; + } catch { + return { version: 0, completed: [] }; + } + } + + /** 原子写入完整迁移状态。 */ + write(state: MigrationState): void { + fs.mkdirSync(path.dirname(this.filePath()), { recursive: true }); + this.atomicFiles.write( + this.filePath(), + JSON.stringify(state, null, 2), + ); + } + + /** 判断独立迁移阶段是否已经完整成功。 */ + isStageComplete(stage: string): boolean { + return this.read().completed.includes(stage); + } + + /** 合并单个阶段完成键,并保留旧迁移记录和较高版本。 */ + completeStage(stage: string, version: number): void { + this.mergeCompleted([stage], version); + } + + /** 合并一组完成键,供批量迁移在本轮结束后一次提交。 */ + mergeCompleted(stages: Iterable, version = 0): void { + const state = this.read(); + const completed = new Set(state.completed); + for (const stage of stages) completed.add(stage); + this.write({ + version: Math.max(state.version, version), + completed: [...completed].sort(), + }); + } +} diff --git a/electron/services/PlanExportService.ts b/electron/services/PlanExportService.ts new file mode 100644 index 0000000..dce6115 --- /dev/null +++ b/electron/services/PlanExportService.ts @@ -0,0 +1,234 @@ +/** + * 把用户勾选的出征计划和舰队方案导出为 ZIP。 + * Renderer 只提交计划类型和文件名,主进程始终从受管用户目录重新定位文件。 + */ +import * as fs from 'fs'; +import * as path from 'path'; +import JSZip from 'jszip'; +import type { CombatPlanRepository } from './CombatPlanRepository'; +import type { TeamPlanRepository } from './TeamPlanRepository'; +import type { CombatPlanCodec } from './CombatPlanCodec'; +import { AtomicFileStore } from './AtomicFileStore'; + +export interface UserPlanExportSelection { + kind: 'battle' | 'team'; + file: string; +} + +export interface UserPlanArchive { + content: Buffer; + count: number; +} + +/** 用户计划 ZIP 的两个固定目录名。 */ +const ARCHIVE_DIRECTORIES = { + battle: 'user_battle_plans', + team: 'user_team_plans', +} as const; + +/** 负责用户计划导出的输入校验、路径约束和 ZIP 生成。 */ +export class PlanExportService { + constructor( + private readonly combatRepository: CombatPlanRepository, + private readonly teamRepository: TeamPlanRepository, + private readonly atomicFiles: AtomicFileStore, + private readonly combatCodec?: CombatPlanCodec, + ) {} + + /** 使用本地日期生成导出文件名。 */ + archiveFileName(now = new Date()): string { + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}-plans.zip`; + } + + /** 生成供 1.4.3 恢复的计划包,同时保留未经转换的 2.0 原文件。 */ + async createLegacy143Archive( + rawSelections: unknown, + ): Promise { + if (!this.combatCodec) { + throw new Error('当前环境未启用 1.4.3 兼容导出'); + } + const selections = this.normalizeSelections(rawSelections).filter( + selection => selection.kind === 'battle', + ); + if (selections.length === 0) { + throw new Error('请至少选择一个用户出征计划'); + } + const zip = new JSZip(); + zip.folder('plans'); + zip.folder('original_2.0/user_battle_plans'); + selections.forEach(selection => { + const filePath = this.resolveUserPlan(selection); + const originalContent = fs.readFileSync(filePath, 'utf-8'); + const root = this.combatCodec!.parseRoot( + originalContent, + `无效的出征计划:${selection.file}`, + ); + const expanded = this.combatCodec!.expandRoot(root, 'user'); + const legacy = this.toLegacy143Plan(expanded); + const stats = fs.statSync(filePath); + zip.file( + `plans/${selection.file}`, + this.combatCodec!.serialize(legacy, originalContent), + { date: stats.mtime }, + ); + zip.file( + `original_2.0/user_battle_plans/${selection.file}`, + originalContent, + { date: stats.mtime }, + ); + }); + zip.file( + '恢复说明.txt', + [ + 'AutoWSGR-GUI 2.0.1 → 1.4.3 用户计划备份', + '', + '1. 安装 1.4.3 后退出程序。', + '2. 将 plans 目录内的 YAML 复制到 1.4.3 安装目录的 plans。', + '3. original_2.0 保存未经转换的 2.0 原文件,仅用于完整备份。', + '4. 其他设置、模板、任务组和运行环境不在本备份范围内。', + '', + ].join('\r\n'), + ); + return { + content: await zip.generateAsync({ + type: 'nodebuffer', + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + platform: 'DOS', + }), + count: selections.length, + }; + } + + /** 校验用户选择并生成包含固定分类目录的 ZIP。 */ + async createArchive(rawSelections: unknown): Promise { + const selections = this.normalizeSelections(rawSelections); + const zip = new JSZip(); + zip.folder(ARCHIVE_DIRECTORIES.battle); + zip.folder(ARCHIVE_DIRECTORIES.team); + + selections.forEach(selection => { + const filePath = this.resolveUserPlan(selection); + const stats = fs.statSync(filePath); + zip.file( + `${ARCHIVE_DIRECTORIES[selection.kind]}/${selection.file}`, + fs.readFileSync(filePath), + { date: stats.mtime }, + ); + }); + + return { + content: await zip.generateAsync({ + type: 'nodebuffer', + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + platform: 'DOS', + }), + count: selections.length, + }; + } + + /** 将已生成的 ZIP 写到系统保存对话框返回的位置。 */ + writeArchive(filePath: string, archive: UserPlanArchive): void { + this.atomicFiles.write(filePath, archive.content); + } + + /** Renderer 输入必须是去重后的用户计划文件清单。 */ + private normalizeSelections(rawSelections: unknown): UserPlanExportSelection[] { + if (!Array.isArray(rawSelections) || rawSelections.length === 0) { + throw new Error('请至少选择一个用户配置'); + } + if (rawSelections.length > 1000) { + throw new Error('单次最多导出 1000 个用户配置'); + } + + const selections: UserPlanExportSelection[] = []; + const keys = new Set(); + rawSelections.forEach(value => { + if ( + typeof value !== 'object' + || value === null + || !('kind' in value) + || !('file' in value) + ) { + throw new Error('导出配置清单格式不正确'); + } + const { kind, file } = value as Record; + if ( + (kind !== 'battle' && kind !== 'team') + || typeof file !== 'string' + || path.basename(file) !== file + || !/\.ya?ml$/i.test(file) + ) { + throw new Error('导出配置包含非法文件名'); + } + const key = `${kind}:${file.toLocaleLowerCase('en-US')}`; + if (keys.has(key)) return; + keys.add(key); + selections.push({ kind, file }); + }); + return selections; + } + + /** 只允许读取对应用户计划目录内真实存在的普通文件。 */ + private resolveUserPlan(selection: UserPlanExportSelection): string { + const directory = selection.kind === 'battle' + ? this.combatRepository.directory('user') + : this.teamRepository.directory('user'); + const candidate = path.join(directory, selection.file); + if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) { + throw new Error(`用户配置不存在:${selection.file}`); + } + + const canonicalDirectory = fs.realpathSync.native(directory); + const canonicalCandidate = fs.realpathSync.native(candidate); + const relative = path.relative(canonicalDirectory, canonicalCandidate); + if ( + relative === '' + || relative === '..' + || relative.startsWith(`..${path.sep}`) + || path.isAbsolute(relative) + ) { + throw new Error(`用户配置超出允许目录:${selection.file}`); + } + return canonicalCandidate; + } + + /** 把 2.0 独立舰队和候选规则降为 1.4.3 可读取的内嵌结构。 */ + private toLegacy143Plan( + root: Record, + ): Record { + const output = structuredClone(root); + if (!Array.isArray(output.fleet_presets)) return output; + output.fleet_presets = output.fleet_presets.map(rawPreset => { + if (!this.combatCodec!.isPlainObject(rawPreset)) return rawPreset; + const ships = Array.isArray(rawPreset.ships) + ? rawPreset.ships.map(slot => this.toLegacy143Slot(slot)) + : rawPreset.ships; + return { ...rawPreset, ships }; + }); + return output; + } + + private toLegacy143Slot(slot: unknown): unknown { + if (!this.combatCodec!.isPlainObject(slot)) return slot; + const candidates = Array.isArray(slot.candidates) + ? slot.candidates + : []; + const priority = candidates + .filter(candidate => this.combatCodec!.isPlainObject(candidate)) + .map(candidate => candidate.name) + .filter((name): name is string => ( + typeof name === 'string' && name.trim().length > 0 + )); + const output = { ...slot }; + delete output.candidates; + delete output.search_name; + delete output.relaxed; + if (priority.length > 0) output.priority = priority; + return output; + } +} diff --git a/electron/services/PlanManagementService.ts b/electron/services/PlanManagementService.ts new file mode 100644 index 0000000..c4f8517 --- /dev/null +++ b/electron/services/PlanManagementService.ts @@ -0,0 +1,889 @@ +/** + * 编排作战计划的查询、保存和删除。 + */ +import * as path from 'path'; +import { CombatPlanCodec } from './CombatPlanCodec'; +import { CombatPlanRepository } from './CombatPlanRepository'; +import { GuiSettingsStore } from './GuiSettingsStore'; +import { RuntimePlanService } from './RuntimePlanService'; +import { + type PlanFileReadError, + type PlanPresetSource, + TEAM_FILE_PATTERN, +} from './TeamPlanCodec'; +import { TeamPlanRepository } from './TeamPlanRepository'; +import { + TaskPresetCodec, + type TaskPresetType, +} from '../../src/shared/taskPreset'; + +export type ManagedBattleResult = 'D' | 'C' | 'B' | 'A' | 'S' | 'SS'; + +export interface ManagedBattlePlanFleetSummary { + name: string; + source: PlanPresetSource | 'deleted'; + primaryCount: number; + backupCount: number; +} + +export interface ManagedBattlePlanSummary { + kind: 'battle' | 'preset'; + file: string; + name: string; + source: PlanPresetSource; + modifiedAt: number; + chapter: number | string; + map: number | string; + times: number; + gap: number; + fleetId: number; + repairMode: number | number[]; + result: ManagedBattleResult | null; + lootCountGe: number; + shipCountGe: number; + fleetCount: number; + nodeCount: number; + fleets: ManagedBattlePlanFleetSummary[]; + taskType?: TaskPresetType; + campaignName?: string; +} + +export interface PlanManagementResult { + bindings: Array<{ + planFile: string; + planName: string; + source: PlanPresetSource; + teamName: string | null; + }>; + battlePlans: ManagedBattlePlanSummary[]; + teamPlans: Array<{ + file: string; + name: string; + source: PlanPresetSource; + }>; + errors: PlanFileReadError[]; + ignoredUnlinkedPlans: string[]; +} + +interface SaveManagedOptions { + importEmbeddedTeams?: boolean; +} + +/** 编排管理页查询、计划读取、保存、重命名和删除。 */ +export class PlanManagementService { + constructor( + private readonly combatCodec: CombatPlanCodec, + private readonly combatRepository: CombatPlanRepository, + private readonly runtimePlans: RuntimePlanService, + private readonly teamRepository: TeamPlanRepository, + private readonly settings: GuiSettingsStore, + private readonly taskPresetCodec: TaskPresetCodec, + ) {} + + /** 读取管理页所需的出征计划、舰队方案及名称关联。 */ + get(): PlanManagementResult { + const bindings: PlanManagementResult['bindings'] = []; + const battlePlans: ManagedBattlePlanSummary[] = []; + const errors: PlanFileReadError[] = []; + const listedTeams = this.teamRepository.list(); + errors.push(...listedTeams.errors); + const sources: Array<{ + directory: string; + source: PlanPresetSource; + }> = [ + { + directory: this.combatRepository.directory('system'), + source: 'system', + }, + { + directory: this.combatRepository.directory('user'), + source: 'user', + }, + ]; + + sources.forEach(({ directory, source }) => { + this.combatRepository.yamlFiles(directory).forEach((file) => { + try { + const planPath = path.join(directory, file); + const root = this.combatCodec.parseRoot( + this.combatRepository.read(planPath), + '无效的方案文件', + ); + const rootName = typeof root.name === 'string' + ? root.name.trim() + : ''; + const fileName = file.replace(/\.ya?ml$/i, ''); + const standardName = fileName.match(/^bettle-(.+)$/i)?.[1]; + const planName = standardName || rootName || fileName; + if (this.taskPresetCodec.isStandalone(root)) { + const preset = this.taskPresetCodec.normalize(root); + if (this.isDailyTaskType(preset.task_type)) return; + const times = Number(preset.times); + const gap = Number(preset.gap); + const fleetId = Number(preset.fleet_id); + battlePlans.push({ + kind: 'preset', + file, + name: planName, + source, + modifiedAt: this.combatRepository.modifiedAt(planPath), + chapter: ( + typeof preset.chapter === 'number' + || typeof preset.chapter === 'string' + ) + ? preset.chapter + : '-', + map: '-', + times: Number.isFinite(times) && times > 0 ? times : 1, + gap: Number.isFinite(gap) && gap >= 0 ? gap : 0, + fleetId: Number.isFinite(fleetId) && fleetId > 0 + ? fleetId + : 1, + repairMode: 1, + result: null, + lootCountGe: -1, + shipCountGe: -1, + fleetCount: 0, + nodeCount: 0, + fleets: [], + taskType: preset.task_type, + campaignName: typeof preset.campaign_name === 'string' + ? preset.campaign_name + : undefined, + }); + bindings.push({ + planFile: file, + planName, + source, + teamName: null, + }); + return; + } + const presets = Array.isArray(root.fleet_presets) + ? root.fleet_presets + : []; + const selectedNodes = Array.isArray(root.selected_nodes) + ? root.selected_nodes + : []; + const times = Number(root.times); + const gap = Number(root.gap); + const fleetId = Number(root.fleet_id); + const repairModeValue = Number(root.repair_mode); + const repairModeList = Array.isArray(root.repair_mode) + ? root.repair_mode + .map(value => Number(value)) + .filter(value => Number.isFinite(value)) + : []; + const normalizedResult = typeof root.result === 'string' + ? root.result.trim().toUpperCase() + : ''; + const result = ( + ['D', 'C', 'B', 'A', 'S', 'SS'] as ManagedBattleResult[] + ).includes(normalizedResult as ManagedBattleResult) + ? normalizedResult as ManagedBattleResult + : null; + const stopCondition = this.combatCodec.isPlainObject( + root.stop_condition, + ) + ? root.stop_condition + : {}; + const lootCountGe = Number(stopCondition.loot_count_ge); + const shipCountGe = Number(stopCondition.ship_count_ge); + const fleets = presets.flatMap((preset, index) => { + if (!this.combatCodec.isPlainObject(preset)) return []; + const name = ( + typeof preset.name === 'string' && preset.name.trim() + ) + ? preset.name.trim() + : `编队 ${index + 1}`; + const userOverride = listedTeams.plans.find(team => ( + team.name === name && team.source === 'user' + )) ?? null; + const sameSourceTeam = listedTeams.plans.find(team => ( + team.name === name && team.source === source + )) ?? null; + const embeddedShips = Array.isArray(preset.ships) + ? preset.ships + : null; + const matchingPlan = userOverride + ?? sameSourceTeam + ?? (embeddedShips + ? null + : this.teamRepository.find( + name, + source, + listedTeams.plans, + )); + const ships = matchingPlan?.ships ?? embeddedShips ?? []; + return [{ + name, + source: ( + matchingPlan?.source ?? 'deleted' + ) as ManagedBattlePlanFleetSummary['source'], + primaryCount: ships.filter(ship => ( + (typeof ship === 'string' && Boolean(ship.trim())) + || ( + this.combatCodec.isPlainObject(ship) + && typeof ship.name === 'string' + && Boolean(ship.name.trim()) + ) + )).length, + backupCount: ships.reduce((count, ship) => ( + count + ( + this.combatCodec.isPlainObject(ship) + && Array.isArray(ship.candidates) + ? ship.candidates.length + : 0 + ) + ), 0), + }]; + }); + battlePlans.push({ + kind: 'battle', + file, + name: planName, + source, + modifiedAt: this.combatRepository.modifiedAt(planPath), + chapter: ( + typeof root.chapter === 'number' + || typeof root.chapter === 'string' + ) + ? root.chapter + : '?', + map: ( + typeof root.map === 'number' + || typeof root.map === 'string' + ) + ? root.map + : '?', + times: Number.isFinite(times) && times > 0 ? times : 1, + gap: Number.isFinite(gap) && gap >= 0 ? gap : 0, + fleetId: Number.isFinite(fleetId) && fleetId > 0 + ? fleetId + : 1, + repairMode: repairModeList.length > 0 + ? repairModeList + : Number.isFinite(repairModeValue) + ? repairModeValue + : 1, + result, + lootCountGe: Number.isFinite(lootCountGe) && lootCountGe > 0 + ? lootCountGe + : -1, + shipCountGe: Number.isFinite(shipCountGe) && shipCountGe > 0 + ? shipCountGe + : -1, + fleetCount: fleets.length, + nodeCount: selectedNodes.length, + fleets, + }); + if (presets.length === 0) { + bindings.push({ + planFile: file, + planName, + source, + teamName: null, + }); + return; + } + presets.forEach((preset) => { + const teamName = this.combatCodec.isPlainObject(preset) + && typeof preset.name === 'string' + ? preset.name.trim() || null + : null; + bindings.push({ + planFile: file, + planName, + source, + teamName, + }); + }); + } catch (error) { + const message = error instanceof Error + ? error.message + : String(error); + errors.push({ + file, + source, + kind: 'battle', + message, + }); + } + }); + }); + + const teamPlans = listedTeams.plans.map(plan => ({ + file: plan.file ?? '', + name: plan.name, + source: plan.source ?? 'user', + })); + return { + bindings, + battlePlans, + teamPlans, + errors, + ignoredUnlinkedPlans: this.getIgnoredUnlinkedPlans(), + }; + } + + /** 更新一条管理页未关联计划的忽略状态。 */ + setUnlinkedIgnored( + kind: 'battle' | 'team', + source: PlanPresetSource, + file: string, + ignored: boolean, + ): string[] { + const key = this.ignoredUnlinkedPlanKey(kind, source, file); + if (!key) return this.getIgnoredUnlinkedPlans(); + const values = new Set(this.getIgnoredUnlinkedPlans()); + if (ignored === true) values.add(key); + else values.delete(key); + return this.writeIgnoredUnlinkedPlans(values); + } + + /** 读取一份受管计划,并返回后端执行所需的展开文件。 */ + readManaged( + source: PlanPresetSource, + file: string, + ): Record { + try { + const sourcePath = this.combatRepository.safeManagedPath(source, file); + if (!sourcePath || !this.combatRepository.exists(sourcePath)) { + throw new Error('出征计划不存在'); + } + const originalContent = this.combatRepository.read(sourcePath); + const root = this.combatCodec.parseRoot( + originalContent, + '无效的方案文件', + ); + if (this.taskPresetCodec.isStandalone(root)) { + const preset = this.taskPresetCodec.normalize(root); + if (this.isDailyTaskType(preset.task_type)) { + throw new Error('该配置属于日常任务,请从“加载日常任务”使用'); + } + return { + success: true, + kind: 'preset', + path: sourcePath, + sourcePath, + content: originalContent, + source, + }; + } + const missingTeamNames = this.missingTeamNames(root, source); + const prepared = this.runtimePlans.prepare(source, file); + return { + success: true, + kind: 'battle', + path: prepared.sourcePath, + sourcePath: prepared.sourcePath, + runtimePath: prepared.runtimePath, + content: prepared.content, + source, + missingTeamNames, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** 读取已经过应用路径边界校验的计划文件。 */ + readResolvedFile(resolved: string): Record { + try { + const managed = this.combatRepository.managedFromPath(resolved); + if (managed) { + return this.readManaged(managed.source, managed.file); + } + if (!this.combatRepository.exists(resolved)) { + throw new Error('出征计划不存在'); + } + return { + success: true, + path: resolved, + sourcePath: resolved, + content: this.combatRepository.read(resolved), + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** 把编辑器内容校验并写成后端执行用临时文件。 */ + prepareExecution( + content: string, + hint: string, + ): Record { + try { + if (typeof content !== 'string') { + throw new Error('出征计划内容不合法'); + } + return { + success: true, + path: this.runtimePlans.write( + content, + typeof hint === 'string' ? hint : 'plan', + ), + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** 升级用户选择的本地 YAML,并写入用户受管计划目录。 */ + importLocal( + selectedPath: string, + overwrite = false, + ): Record { + try { + if ( + typeof selectedPath !== 'string' + || !path.isAbsolute(selectedPath) + || !/\.ya?ml$/i.test(path.basename(selectedPath)) + ) { + throw new Error('本地出征计划路径不合法'); + } + if (!this.combatRepository.exists(selectedPath)) { + throw new Error('本地出征计划不存在'); + } + return this.saveManaged( + path.basename(selectedPath), + this.combatRepository.read(selectedPath), + overwrite, + undefined, + { importEmbeddedTeams: true }, + ); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** 保存、覆盖或重命名一份受管出征计划。 */ + saveManaged( + rawName: string, + content: string, + overwrite: boolean, + currentFile?: string, + options: SaveManagedOptions = {}, + ): Record { + try { + const name = typeof rawName === 'string' + ? this.combatCodec.safeBaseName(rawName) + : ''; + if (!name) throw new Error('请先填写预设名称'); + if (typeof content !== 'string') { + throw new Error('出征计划内容不合法'); + } + const parsed = this.combatCodec.parseRoot( + content, + '出征计划根节点必须是对象', + ); + + if (this.taskPresetCodec.isStandalone(parsed)) { + const preset = this.taskPresetCodec.normalize(parsed); + if (this.isDailyTaskType(preset.task_type)) { + throw new Error( + '演习、战役和决战配置请使用“加载日常任务”管理', + ); + } + return this.saveManagedTaskPreset( + name, + content, + preset, + overwrite, + currentFile, + ); + } + const split = this.combatCodec.normalizeFleetPresets( + parsed, + 'user', + false, + !options.importEmbeddedTeams, + ); + const file = `bettle-${name}.yaml`; + const target = this.combatRepository.safeUserPath(file); + if (!target) throw new Error('出征计划名称不合法'); + this.combatRepository.initializeUserDirectory(); + + let currentPath: string | null = null; + if (currentFile !== undefined) { + currentPath = this.combatRepository.safeUserPath(currentFile); + if (!currentPath) { + throw new Error('当前出征计划文件名不符合规则'); + } + } + const updatesCurrentFile = currentPath !== null + && path.resolve(currentPath).toLowerCase() + === path.resolve(target).toLowerCase(); + const mapConflict = ( + this.combatRepository.exists(target) + && !updatesCurrentFile + ); + const teamDirectory = this.teamRepository.directory('user'); + const missingTeamNames = options.importEmbeddedTeams + ? [] + : this.missingTeamNames(parsed, 'user'); + const missingTeams = new Set(missingTeamNames); + const parsedPresets = Array.isArray(parsed.fleet_presets) + ? parsed.fleet_presets + : null; + const normalizedPresets = Array.isArray(split.mapRoot.fleet_presets) + ? split.mapRoot.fleet_presets + : null; + const mapRoot = ( + missingTeams.size > 0 + && parsedPresets + && normalizedPresets + ) + ? { + ...split.mapRoot, + fleet_presets: normalizedPresets.map( + (reference, index) => { + const embedded = parsedPresets[index]; + const embeddedName = this.combatCodec.isPlainObject(embedded) + && typeof embedded.name === 'string' + ? embedded.name.trim() + : ''; + return ( + missingTeams.has(embeddedName) + && this.combatCodec.isPlainObject(embedded) + && Array.isArray(embedded.ships) + ) + ? structuredClone(embedded) + : reference; + }, + ), + } + : split.mapRoot; + const teamWrites = options.importEmbeddedTeams + ? this.teamRepository.buildWrites( + split.teams, + teamDirectory, + ).map((item, index) => ({ + ...item, + unchanged: this.teamRepository.matches( + item.path, + split.teams[index], + ), + })) + : []; + const conflicts = [ + ...(mapConflict ? [`地图:${file}`] : []), + ...teamWrites + .filter(item => ( + this.teamRepository.exists(item.path) && !item.unchanged + )) + .map(item => `舰队:${item.name}`), + ]; + if (conflicts.length > 0 && overwrite !== true) { + return { + success: false, + exists: true, + file, + source: 'user', + conflicts, + error: '存在同名配置', + }; + } + + this.teamRepository.initializeUserDirectory(); + for (const item of teamWrites) { + if (!item.unchanged) { + this.teamRepository.write(item.path, item.content); + } + } + this.combatRepository.write( + target, + this.combatCodec.serialize(mapRoot, content), + ); + if ( + currentPath + && !updatesCurrentFile + && this.combatRepository.exists(currentPath) + ) { + this.combatRepository.remove(currentPath); + } + + if (currentFile && currentFile !== file) { + this.moveIgnoredKey('battle', 'user', currentFile, file); + } + return { + success: true, + kind: 'battle', + file, + path: target, + source: 'user', + teamFiles: teamWrites.map(item => item.file), + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** 保存独立任务预设,不把它错误展开成地图计划。 */ + private saveManagedTaskPreset( + name: string, + content: string, + parsed: Record, + overwrite: boolean, + currentFile: string | undefined, + ): Record { + const preset = this.taskPresetCodec.normalize(parsed); + const file = `bettle-${name}.yaml`; + const target = this.combatRepository.safeUserPath(file); + if (!target) throw new Error('任务预设名称不合法'); + this.combatRepository.initializeUserDirectory(); + + const currentPath = currentFile === undefined + ? null + : this.combatRepository.safeUserPath(currentFile); + if (currentFile !== undefined && !currentPath) { + throw new Error('当前任务预设文件名不符合规则'); + } + const updatesCurrentFile = currentPath !== null + && path.resolve(currentPath).toLowerCase() + === path.resolve(target).toLowerCase(); + if ( + this.combatRepository.exists(target) + && !updatesCurrentFile + && overwrite !== true + ) { + return { + success: false, + exists: true, + kind: 'preset', + file, + source: 'user', + conflicts: [`任务预设:${file}`], + error: '存在同名配置', + }; + } + + this.combatRepository.write( + target, + this.combatCodec.serialize(preset, content), + ); + if ( + currentPath + && !updatesCurrentFile + && this.combatRepository.exists(currentPath) + ) { + this.combatRepository.remove(currentPath); + } + if (currentFile && currentFile !== file) { + this.moveIgnoredKey('battle', 'user', currentFile, file); + } + return { + success: true, + kind: 'preset', + file, + path: target, + source: 'user', + teamFiles: [], + }; + } + + /** 重命名一份用户出征计划。 */ + renameUser( + file: string, + newName: string, + ): Record { + const source = this.combatRepository.safeUserPath(file); + const safeName = this.combatCodec.safeBaseName(newName); + if ( + !source + || !this.combatRepository.exists(source) + || !safeName + ) { + return { success: false, error: '出征计划名称或文件不合法' }; + } + const targetFile = `bettle-${safeName}.yaml`; + const target = this.combatRepository.safeUserPath(targetFile); + if (!target) { + return { success: false, error: '出征计划名称不合法' }; + } + if ( + source.toLowerCase() !== target.toLowerCase() + && this.combatRepository.exists(target) + ) { + return { success: false, error: '同名出征计划已存在' }; + } + try { + this.combatRepository.rename(source, target); + this.moveIgnoredKey('battle', 'user', file, targetFile); + return { success: true, file: targetFile }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** 删除一份用户出征计划。 */ + deleteUserCombat(file: string): Record { + const target = this.combatRepository.safeUserPath(file); + if (!target || !this.combatRepository.exists(target)) { + return { success: false, error: '用户出征计划不存在' }; + } + try { + this.combatRepository.remove(target); + this.removeIgnoredKey('battle', 'user', file); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** 删除一份用户独立编队计划。 */ + deleteUserTeam(file: string): Record { + const target = this.safeManagedTeamPath('user', file); + if (!target || !this.teamRepository.exists(target)) { + return { success: false, error: '用户舰队方案不存在' }; + } + try { + this.teamRepository.remove(target); + this.removeIgnoredKey('team', 'user', file); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** 返回计划引用但编队管理中不存在的编队名称。 */ + private missingTeamNames( + root: Record, + source: PlanPresetSource, + ): string[] { + if (!Array.isArray(root.fleet_presets)) return []; + const plans = this.teamRepository.list().plans; + const names = root.fleet_presets.flatMap((preset) => { + if ( + !this.combatCodec.isPlainObject(preset) + || typeof preset.name !== 'string' + ) { + return []; + } + const name = preset.name.trim(); + return ( + name + && !this.teamRepository.find(name, source, plans) + ) + ? [name] + : []; + }); + return [...new Set(names)]; + } + + /** 读取并兼容旧格式的未关联忽略项。 */ + private getIgnoredUnlinkedPlans(): string[] { + const raw = this.settings.read().plan_management_ignored_unlinked; + if (!Array.isArray(raw)) return []; + return raw.flatMap((value) => { + if (typeof value !== 'string') return []; + if ( + /^(battle|team)\/(system|user)\/[^/\\]+\.ya?ml$/i.test(value) + ) { + return [value]; + } + const legacy = /^(system|user)\/([^/\\]+\.ya?ml)$/i.exec(value); + return legacy ? [`battle/${legacy[1]}/${legacy[2]}`] : []; + }); + } + + /** 去重、排序并保存未关联忽略项。 */ + private writeIgnoredUnlinkedPlans(values: Iterable): string[] { + const normalized = [...new Set(values)].sort((left, right) => ( + left.localeCompare(right, 'zh-CN') + )); + this.settings.write({ + plan_management_ignored_unlinked: normalized, + }); + return normalized; + } + + /** 为有效的受管计划构造稳定忽略键。 */ + private ignoredUnlinkedPlanKey( + kind: 'battle' | 'team', + source: PlanPresetSource, + file: string, + ): string | null { + const valid = kind === 'battle' + ? this.combatRepository.safeManagedPath(source, file) + : this.safeManagedTeamPath(source, file); + return valid ? `${kind}/${source}/${file}` : null; + } + + /** 返回经过文件名边界校验的受管编队路径。 */ + private safeManagedTeamPath( + source: PlanPresetSource, + file: string, + ): string | null { + if ( + (source !== 'system' && source !== 'user') + || path.basename(file) !== file + || !TEAM_FILE_PATTERN.test(file) + ) { + return null; + } + return path.join(this.teamRepository.directory(source), file); + } + + /** 在计划重命名后同步迁移对应忽略键。 */ + private moveIgnoredKey( + kind: 'battle' | 'team', + source: PlanPresetSource, + oldFile: string, + newFile: string, + ): void { + const oldKey = this.ignoredUnlinkedPlanKey(kind, source, oldFile); + const newKey = this.ignoredUnlinkedPlanKey(kind, source, newFile); + const ignored = new Set(this.getIgnoredUnlinkedPlans()); + if (oldKey && newKey && ignored.delete(oldKey)) { + ignored.add(newKey); + this.writeIgnoredUnlinkedPlans(ignored); + } + } + + /** 删除计划后同步清理对应忽略键。 */ + private removeIgnoredKey( + kind: 'battle' | 'team', + source: PlanPresetSource, + file: string, + ): void { + const key = this.ignoredUnlinkedPlanKey(kind, source, file); + if (!key) return; + const ignored = new Set(this.getIgnoredUnlinkedPlans()); + if (ignored.delete(key)) { + this.writeIgnoredUnlinkedPlans(ignored); + } + } + + private isDailyTaskType(value: unknown): boolean { + return ( + value === 'exercise' + || value === 'campaign' + || value === 'decisive' + ); + } +} diff --git a/electron/services/PythonEnvironmentService.ts b/electron/services/PythonEnvironmentService.ts new file mode 100644 index 0000000..57f25d8 --- /dev/null +++ b/electron/services/PythonEnvironmentService.ts @@ -0,0 +1,118 @@ +/** + * 校验 Python 并编排环境检查和依赖安装。 + */ +import { exec } from 'child_process'; +import * as fs from 'fs'; +import { promisify } from 'util'; +import type { EnvCheckResult } from '../pythonEnv'; + +const execAsync = promisify(exec); + +export interface PythonValidationResult { + valid: boolean; + version: string | null; + error?: string; +} + +export interface PythonEnvironmentDependencies { + fileExists(filePath: string): boolean; + readVersion(pythonPath: string): Promise; + isAllowedVersion(version: string): boolean; + findPython(): Promise; + checkEnvironment(): Promise; + installDependencies( + pythonPath: string, + ): Promise<{ success: boolean; output: string }>; + installPortablePython(): Promise<{ success: boolean }>; +} + +/** 统一 Python 校验、环境检查和安装入口。 */ +export class PythonEnvironmentService { + constructor( + private readonly dependencies: PythonEnvironmentDependencies, + ) {} + + /** 构造保持原文件检查、命令格式和超时的系统依赖。 */ + static createDependencies( + dependencies: Omit< + PythonEnvironmentDependencies, + 'fileExists' | 'readVersion' + >, + ): PythonEnvironmentDependencies { + return { + ...dependencies, + fileExists: filePath => fs.existsSync(filePath), + readVersion: async pythonPath => { + const { stdout } = await execAsync( + `"${pythonPath}" --version`, + { + windowsHide: true, + timeout: 10000, + }, + ); + return stdout.trim(); + }, + }; + } + + /** 校验指定解释器是否存在且版本兼容。 */ + async validate( + pythonPath: string, + ): Promise { + if (!pythonPath) { + return { + valid: false, + version: null, + error: '路径为空', + }; + } + if (!this.dependencies.fileExists(pythonPath)) { + return { + valid: false, + version: null, + error: '文件不存在', + }; + } + try { + const version = await this.dependencies.readVersion(pythonPath); + if (!this.dependencies.isAllowedVersion(version)) { + return { + valid: false, + version, + error: `版本不兼容: ${version}(需要 3.12 或 3.13)`, + }; + } + return { valid: true, version }; + } catch (error) { + return { + valid: false, + version: null, + error: `执行失败: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + } + + /** 执行现有 Python 环境检查流程。 */ + check(): Promise { + return this.dependencies.checkEnvironment(); + } + + /** 查找 Python 后安装后端依赖。 */ + async installDependencies(): Promise<{ + success: boolean; + output: string; + }> { + const pythonPath = await this.dependencies.findPython(); + if (!pythonPath) { + return { success: false, output: '找不到 Python' }; + } + return this.dependencies.installDependencies(pythonPath); + } + + /** 安装或初始化现有便携 Python。 */ + installPortablePython(): Promise<{ success: boolean }> { + return this.dependencies.installPortablePython(); + } +} diff --git a/electron/services/RuntimePlanService.ts b/electron/services/RuntimePlanService.ts new file mode 100644 index 0000000..62371a7 --- /dev/null +++ b/electron/services/RuntimePlanService.ts @@ -0,0 +1,96 @@ +/** + * 生成供后端执行的已展开临时计划。 + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { AtomicFileStore } from './AtomicFileStore'; +import { CombatPlanCodec } from './CombatPlanCodec'; +import { CombatPlanRepository } from './CombatPlanRepository'; +import { type PlanPresetSource } from './TeamPlanCodec'; + +export interface PreparedCombatPlan { + sourcePath: string; + runtimePath: string; + content: string; +} + +export interface RuntimePlanDependencies { + getTempDirectory(): string; + processId: number; + now?(): number; +} + +/** 负责生成后端执行使用的已展开临时计划。 */ +export class RuntimePlanService { + private sequence = 0; + + constructor( + private readonly codec: CombatPlanCodec, + private readonly repository: CombatPlanRepository, + private readonly atomicFiles: AtomicFileStore, + private readonly dependencies: RuntimePlanDependencies, + ) {} + + /** 返回当前进程专用的运行时计划目录。 */ + directory(): string { + return path.join( + this.dependencies.getTempDirectory(), + 'AutoWSGR-GUI', + 'runtime_battle_plans', + String(this.dependencies.processId), + ); + } + + /** 校验并写入一份已经展开的运行时计划。 */ + write(content: string, hint: string): string { + const parsed = this.codec.parseRoot( + content, + '运行时出征计划必须包含 chapter 和 map', + ); + if (!('chapter' in parsed) || !('map' in parsed)) { + throw new Error('运行时出征计划必须包含 chapter 和 map'); + } + if ( + Array.isArray(parsed.fleet_presets) + && parsed.fleet_presets.some(preset => ( + !this.codec.isPlainObject(preset) + || !Array.isArray(preset.ships) + )) + ) { + throw new Error('运行时出征计划包含尚未展开的舰队引用'); + } + + const directory = this.directory(); + fs.mkdirSync(directory, { recursive: true }); + this.sequence++; + const safeHint = this.codec.safeBaseName(hint) || 'plan'; + const timestamp = this.dependencies.now?.() ?? Date.now(); + const file = `${safeHint}-${timestamp}-${this.sequence}.yaml`; + const target = path.join(directory, file); + this.atomicFiles.write(target, content); + return target; + } + + /** 读取受管计划、展开舰队引用并生成运行时文件。 */ + prepare( + source: PlanPresetSource, + file: string, + ): PreparedCombatPlan { + const sourcePath = this.repository.safeManagedPath(source, file); + if (!sourcePath || !this.repository.exists(sourcePath)) { + throw new Error('出征计划不存在'); + } + const originalContent = this.repository.read(sourcePath); + const parsed = this.codec.parseRoot( + originalContent, + '无效的出征计划', + ); + const expanded = this.codec.expandRoot(parsed, source); + const content = this.codec.serialize(expanded, originalContent); + return { + sourcePath, + runtimePath: this.write(content, file), + content, + }; + } +} diff --git a/electron/services/SafePathService.ts b/electron/services/SafePathService.ts new file mode 100644 index 0000000..0e45f83 --- /dev/null +++ b/electron/services/SafePathService.ts @@ -0,0 +1,126 @@ +/** + * 解析应用路径并阻止越界、穿越和链接逃逸。 + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { AppPaths } from './AppPaths'; + +type FileCapability = 'read' | 'write'; + +/** 统一执行主进程文件能力的目录边界检查。 */ +export class SafePathService { + constructor(private readonly appPaths: AppPaths) {} + + /** 为兼容现有调用方解析一个可读取的应用路径。 */ + resolveAppPath(filePath: string): string { + return this.resolve(filePath, 'read'); + } + + /** 解析只允许读取的 userData 或打包资源路径。 */ + resolveReadablePath(filePath: string): string { + return this.resolve(filePath, 'read'); + } + + /** 解析只允许写入的 userData 路径。 */ + resolveWritablePath(filePath: string): string { + return this.resolve(filePath, 'write'); + } + + private resolve(filePath: string, capability: FileCapability): string { + const raw = typeof filePath === 'string' ? filePath.trim() : ''; + if (!raw) throw new Error('文件路径不能为空'); + if (raw.includes('\0')) throw new Error('文件路径包含非法字符'); + if (/^[\\/]{2}/.test(raw)) throw new Error('不允许使用 UNC 路径'); + + const hasDrivePrefix = /^[a-zA-Z]:/.test(raw); + const windowsAbsolute = path.win32.isAbsolute(raw); + const nativeAbsolute = path.isAbsolute(raw); + const portableAbsolute = windowsAbsolute || path.posix.isAbsolute(raw); + if (hasDrivePrefix && !windowsAbsolute) { + throw new Error('不允许使用盘符相对路径'); + } + if (portableAbsolute && !nativeAbsolute) { + throw new Error('不允许切换路径根目录'); + } + const pathWithoutDrive = hasDrivePrefix ? raw.slice(2) : raw; + if (pathWithoutDrive.includes(':')) { + throw new Error('文件路径包含非法字符'); + } + + const segments = raw.split(/[\\/]+/); + if (segments.includes('..')) { + throw new Error('文件路径不允许包含 ..'); + } + + const resourceDirectory = path.join( + this.appPaths.resourceRoot(), + 'resource', + ); + const isResourceRelative = !nativeAbsolute + && segments[0]?.toLowerCase() === 'resource'; + if (capability === 'write' && isResourceRelative) { + throw new Error('安装资源目录为只读'); + } + + const relativeSegments = isResourceRelative + ? segments.slice(1) + : segments; + const relativeRoot = isResourceRelative + ? resourceDirectory + : this.appPaths.userDataRoot(); + const candidate = nativeAbsolute + ? path.resolve(raw) + : path.resolve(relativeRoot, ...relativeSegments); + const roots = capability === 'read' + ? [this.appPaths.userDataRoot(), resourceDirectory] + : [this.appPaths.userDataRoot()]; + const allowed = roots.some(root => this.isContained(candidate, root)); + if (!allowed) throw new Error('文件路径超出应用允许目录'); + return candidate; + } + + /** 先验证词法边界,再拒绝允许根目录内的任何链接节点。 */ + private isContained(candidate: string, root: string): boolean { + const resolvedCandidate = path.resolve(candidate); + const resolvedRoot = path.resolve(root); + const normalizedCandidate = this.normalizeForComparison( + resolvedCandidate, + ); + const normalizedRoot = this.normalizeForComparison(resolvedRoot); + const relative = path.relative(normalizedRoot, normalizedCandidate); + const contained = relative === '' + || ( + relative !== '..' + && !relative.startsWith(`..${path.sep}`) + && !path.isAbsolute(relative) + ); + if (!contained) return false; + this.assertNoLinks(resolvedCandidate, resolvedRoot); + return true; + } + + /** lstat 不跟随链接,因此目录联接和悬空链接也会被拒绝。 */ + private assertNoLinks(candidate: string, root: string): void { + const relative = path.relative(root, candidate); + const segments = relative ? relative.split(path.sep) : []; + let current = root; + for (const segment of ['', ...segments]) { + if (segment) current = path.join(current, segment); + try { + if (fs.lstatSync(current).isSymbolicLink()) { + throw new Error('文件路径不允许包含符号链接或联接点'); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') return; + throw error; + } + } + } + + /** Windows 文件系统路径按不区分大小写规则比较。 */ + private normalizeForComparison(value: string): string { + const resolved = path.resolve(value); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; + } +} diff --git a/electron/services/SecureFileService.ts b/electron/services/SecureFileService.ts new file mode 100644 index 0000000..4913917 --- /dev/null +++ b/electron/services/SecureFileService.ts @@ -0,0 +1,81 @@ +/** + * 在路径能力边界内读写主进程文本文件。 + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { AtomicFileStore } from './AtomicFileStore'; +import { SafePathService } from './SafePathService'; + +export interface TextFileSnapshot { + exists: boolean; + content: string; +} + +/** 集中管理主进程对普通文本文件的现有访问规则。 */ +export class SecureFileService { + constructor( + private readonly safePaths: SafePathService, + private readonly atomicFiles: AtomicFileStore, + ) {} + + /** 在允许目录内覆盖保存 UTF-8 文本。 */ + save(filePath: string, content: string): void { + let resolved = this.safePaths.resolveWritablePath(filePath); + const directory = path.dirname(resolved); + if (!fs.existsSync(directory)) { + fs.mkdirSync(directory, { recursive: true }); + } + resolved = this.safePaths.resolveWritablePath(resolved); + this.atomicFiles.write(resolved, content); + } + + /** 在允许目录内读取 UTF-8 文本,不存在时返回空字符串。 */ + read(filePath: string): string { + const resolved = this.safePaths.resolveReadablePath(filePath); + if (!fs.existsSync(resolved)) return ''; + return fs.readFileSync(resolved, 'utf-8'); + } + + /** 捕获受管文本文件,供跨文件提交失败时精确恢复。 */ + snapshot(filePath: string): TextFileSnapshot { + const resolved = this.safePaths.resolveWritablePath(filePath); + if (!fs.existsSync(resolved)) { + return { exists: false, content: '' }; + } + return { + exists: true, + content: fs.readFileSync(resolved, 'utf-8'), + }; + } + + /** 恢复文本文件到提交前状态。 */ + restore(filePath: string, snapshot: TextFileSnapshot): void { + if (snapshot.exists) { + this.save(filePath, snapshot.content); + return; + } + const resolved = this.safePaths.resolveWritablePath(filePath); + fs.rmSync(resolved, { force: true }); + } + + /** 在允许目录内追加 UTF-8 文本。 */ + append(filePath: string, content: string): void { + let resolved = this.safePaths.resolveWritablePath(filePath); + const directory = path.dirname(resolved); + if (!fs.existsSync(directory)) { + fs.mkdirSync(directory, { recursive: true }); + } + resolved = this.safePaths.resolveWritablePath(resolved); + fs.appendFileSync(resolved, content, 'utf-8'); + } + + /** 读取用户通过系统文件对话框显式选择的文件。 */ + readSelectedFile(filePath: string): string { + return fs.readFileSync(filePath, 'utf-8'); + } + + /** 写入用户通过系统保存对话框显式选择的文件。 */ + writeSelectedFile(filePath: string, content: string): void { + this.atomicFiles.write(filePath, content); + } +} diff --git a/electron/services/ShipLibraryService.ts b/electron/services/ShipLibraryService.ts new file mode 100644 index 0000000..6ade9af --- /dev/null +++ b/electron/services/ShipLibraryService.ts @@ -0,0 +1,382 @@ +/** + * 管理舰船资料库同步、状态和渲染清单。 + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { pathToFileURL } from 'url'; +import { AppPaths } from './AppPaths'; + +const NATIVE_SHIP_TYPE_SCHEMA_VERSION = 3; +const BACKEND_CANONICAL_SHIP_TYPE_SCHEMA_VERSION = 4; +const LEGACY_SHIP_LIBRARY_TYPE_CODES: Readonly> = + Object.freeze({ + cbg: 'bg', + cg: 'kp', + cgaa: 'cg', + ddg: 'asdg', + ddgaa: 'aadg', + }); + +export interface ShipLibraryStatus { + exists: boolean; + path: string; + generatedAt?: string; + shipCount: number; + assetCount: number; + missingAssets: number; + backendSynchronized?: boolean; + backendMissingRecords?: number; + backendMissingAliases?: number; + backendError?: string; + error?: string; +} + +export interface ShipLibraryManifest { + schemaVersion: number; + generatedAt: string; + labels: Record; + typeGroups: Record; + ships: Array>; +} + +export interface ShipLibraryDependencies { + processId: number; + now?(): number; +} + +/** 管理舰船资料库目录、内置升级、状态和渲染清单。 */ +export class ShipLibraryService { + constructor( + private readonly appPaths: AppPaths, + private readonly dependencies: ShipLibraryDependencies, + ) {} + + /** 返回当前可写的用户舰船资料库目录。 */ + directory(): string { + return path.join(this.appPaths.userDataRoot(), 'ship-library'); + } + + /** 返回开发或打包模式下的资料库更新脚本。 */ + updaterPath(): string { + const root = this.appPaths.isPackaged() + ? this.appPaths.resourceRoot() + : this.appPaths.appRoot(); + return path.join( + root, + 'tools', + 'ship_library', + 'update_ship_library.py', + ); + } + + /** 按清单版本把内置资料库安全同步到用户目录。 */ + initialize(): void { + const bundledDir = path.join( + this.appPaths.resourceRoot(), + 'resource', + 'ship-library', + ); + const bundledManifestPath = path.join( + bundledDir, + 'manifest.json', + ); + const userManifestPath = path.join( + this.directory(), + 'manifest.json', + ); + if (!fs.existsSync(bundledManifestPath)) return; + + let shouldSync = !fs.existsSync(userManifestPath); + if (!shouldSync) { + try { + const bundled = JSON.parse( + fs.readFileSync(bundledManifestPath, 'utf-8'), + ) as Record; + const user = JSON.parse( + fs.readFileSync(userManifestPath, 'utf-8'), + ) as Record; + shouldSync = Number( + bundled.schema_version ?? bundled.schemaVersion ?? 0, + ) > Number( + user.schema_version ?? user.schemaVersion ?? 0, + ) + || String( + bundled.generated_at ?? bundled.generatedAt ?? '', + ) > String( + user.generated_at ?? user.generatedAt ?? '', + ); + } catch { + shouldSync = true; + } + } + if (!shouldSync) return; + + const temporary = `${this.directory()}.${this.dependencies.processId}.${this.now()}.sync`; + const backup = `${this.directory()}.${this.dependencies.processId}.${this.now()}.backup`; + let movedExisting = false; + try { + fs.rmSync(temporary, { recursive: true, force: true }); + this.copyDirectoryNoOverwrite(bundledDir, temporary); + if (fs.existsSync(this.directory())) { + fs.renameSync(this.directory(), backup); + movedExisting = true; + } + fs.renameSync(temporary, this.directory()); + if (movedExisting) { + fs.rmSync(backup, { recursive: true, force: true }); + } + } catch (error) { + fs.rmSync(temporary, { recursive: true, force: true }); + if ( + movedExisting + && !fs.existsSync(this.directory()) + && fs.existsSync(backup) + ) { + try { + fs.renameSync(backup, this.directory()); + } catch { + console.error( + '[ShipLibrary] 资料库旧版本恢复失败:', + backup, + ); + } + } + console.error('[ShipLibrary] 资料库升级失败:', error); + } + } + + /** 读取清单,为配置页提供当前资料库状态。 */ + getStatus(): ShipLibraryStatus { + const directory = this.directory(); + const manifestPath = path.join(directory, 'manifest.json'); + if (!fs.existsSync(manifestPath)) { + return { + exists: false, + path: directory, + shipCount: 0, + assetCount: 0, + missingAssets: 0, + }; + } + try { + const manifest = JSON.parse( + fs.readFileSync(manifestPath, 'utf-8'), + ) as { + generated_at?: unknown; + counts?: Record; + }; + const counts = manifest.counts ?? {}; + return { + exists: true, + path: directory, + generatedAt: typeof manifest.generated_at === 'string' + ? manifest.generated_at + : undefined, + shipCount: typeof counts.ships === 'number' + ? counts.ships + : 0, + assetCount: typeof counts.assets === 'number' + ? counts.assets + : 0, + missingAssets: typeof counts.missing_assets === 'number' + ? counts.missing_assets + : 0, + }; + } catch (error) { + return { + exists: false, + path: directory, + shipCount: 0, + assetCount: 0, + missingAssets: 0, + error: `资料库清单读取失败: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + } + + /** 返回舰队规划使用的清单字段和受限本地资源 URL。 */ + getManifest(): ShipLibraryManifest { + const manifestPath = path.join(this.directory(), 'manifest.json'); + if (!fs.existsSync(manifestPath)) { + throw new Error( + '舰船资料库尚未建立,请先在配置页更新舰船数据库', + ); + } + const raw = JSON.parse( + fs.readFileSync(manifestPath, 'utf-8'), + ) as { + schema_version?: unknown; + generated_at?: unknown; + labels?: unknown; + type_groups?: unknown; + ships?: unknown; + }; + if (!Array.isArray(raw.ships)) { + throw new Error('舰船资料库清单格式无效'); + } + const schemaVersion = typeof raw.schema_version === 'number' + ? raw.schema_version + : 0; + return { + schemaVersion, + generatedAt: typeof raw.generated_at === 'string' + ? raw.generated_at + : '', + labels: this.normalizeLegacyLabels(raw.labels, schemaVersion), + typeGroups: this.normalizeLegacyTypeGroups( + raw.type_groups, + schemaVersion, + ), + ships: raw.ships.map((entry) => { + const ship = entry && typeof entry === 'object' + ? entry as Record + : {}; + const normalizedShip = this.normalizeLegacyShip( + ship, + schemaVersion, + ); + return { + ...normalizedShip, + portraitUrl: this.assetUrl(normalizedShip.portrait), + backgroundUrl: this.assetUrl(normalizedShip.background), + frameUrl: this.assetUrl(normalizedShip.frame), + typeIconUrl: this.assetUrl(normalizedShip.type_icon), + }; + }), + }; + } + + /** + * schema 2 及更早版本使用 Wiki 旧舰种代码,schema 3 仍保留 CF。 + * 只在资料库读取边界转换,不把源代码加入编队 API 的允许集合。 + */ + private normalizeLegacyShipTypeCode( + value: string, + schemaVersion: number, + ): string { + let code = value.trim().toLowerCase(); + if (schemaVersion < NATIVE_SHIP_TYPE_SCHEMA_VERSION) { + code = LEGACY_SHIP_LIBRARY_TYPE_CODES[code] ?? code; + } + if ( + schemaVersion < BACKEND_CANONICAL_SHIP_TYPE_SCHEMA_VERSION + && code === 'cf' + ) { + return 'cav'; + } + return code; + } + + private normalizeLegacyShip( + ship: Record, + schemaVersion: number, + ): Record
请选择要迁移的数据。关闭此窗口不会记录决定,下次启动仍会询问。