diff --git a/.github/workflows/desktop-localization.yml b/.github/workflows/desktop-localization.yml
new file mode 100644
index 00000000..b576da5a
--- /dev/null
+++ b/.github/workflows/desktop-localization.yml
@@ -0,0 +1,261 @@
+name: Windows desktop localization (unsigned)
+
+# Independent, focused validation of a NEW build. Never reuses an installer PIN.
+on:
+ push:
+ branches: [feat/20261019-localization-integration]
+ paths:
+ - 'desktop/*.cjs'
+ - 'desktop/*.py'
+ - 'desktop/*.spec'
+ - 'desktop/package*.json'
+ - 'desktop/assets/**'
+ - 'desktop/test/**'
+ - 'web/**'
+ - 'src/offerpilot/**'
+ - 'tests/**'
+ - 'pyproject.toml'
+ - 'uv.lock'
+ - 'LICENSE'
+ - '.github/workflows/desktop-localization.yml'
+ - 'docs/architecture/desktop-localization-validation.md'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+# No concurrency cancellation, release, full-regression shards or real services.
+# Harness-only changes do not rebuild the package; same-artifact retries are separate.
+env:
+ LITELLM_LOCAL_MODEL_COST_MAP: 'True'
+ PYTHONUTF8: '1'
+ PYTHONUNBUFFERED: '1'
+ CSC_IDENTITY_AUTO_DISCOVERY: 'false'
+ ONNXRUNTIME_NODE_INSTALL: skip
+
+defaults:
+ run:
+ shell: pwsh
+
+jobs:
+ localization-package:
+ name: Focused localization tests and new unsigned installer
+ runs-on: windows-2022
+ timeout-minutes: 60
+ steps:
+ - uses: actions/checkout@v4
+ - name: Record exact source and limited validation scope
+ run: |
+ New-Item -ItemType Directory -Force desktop/localization-ci-evidence | Out-Null
+ @(
+ 'NEW LOCALIZATION VALIDATION BUILD. UNSIGNED. NOT RELEASE READY.',
+ "Commit: $env:GITHUB_SHA",
+ "Ref: $env:GITHUB_REF",
+ "Run: $env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY/actions/runs/$env:GITHUB_RUN_ID",
+ "Attempt: $env:GITHUB_RUN_ATTEMPT",
+ 'Full release/regression gate: NOT RUN. Focused installed UI: required against this new build; see final result.',
+ 'No old installer PIN/helper, real AI, real mail, release or publishing.',
+ "Started (UTC): $([DateTime]::UtcNow.ToString('o'))"
+ ) | Tee-Object -FilePath desktop/localization-ci-evidence/scope.txt
+ git rev-parse HEAD | Set-Content desktop/localization-ci-evidence/checkout-sha.txt
+ if ($LASTEXITCODE -ne 0) { throw 'Could not record checkout SHA' }
+ Get-FileHash -Algorithm SHA256 uv.lock, web/package-lock.json, desktop/package-lock.json |
+ Format-List | Out-File desktop/localization-ci-evidence/lockfile-sha256.txt
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ architecture: x64
+ - uses: astral-sh/setup-uv@v6
+ with:
+ version: '0.12.19'
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: npm
+ cache-dependency-path: |
+ web/package-lock.json
+ desktop/package-lock.json
+ - name: Install locked application and build dependencies
+ run: |
+ Start-Transcript -Path desktop/localization-ci-evidence/01-install.log
+ try {
+ uv sync --frozen
+ if ($LASTEXITCODE -ne 0) { throw 'Frozen Python install failed' }
+ npm.cmd ci --prefix web
+ if ($LASTEXITCODE -ne 0) { throw 'Frontend install failed' }
+ npm.cmd ci --prefix desktop
+ if ($LASTEXITCODE -ne 0) { throw 'Desktop install failed' }
+ } finally { Stop-Transcript }
+ - name: Required native locale, IPC and desktop regression tests
+ run: |
+ Start-Transcript -Path desktop/localization-ci-evidence/02-native-tests.log
+ try {
+ npm.cmd test --prefix desktop
+ if ($LASTEXITCODE -ne 0) { throw 'Native locale and desktop regression failed' }
+ node --test desktop/localization-ui/test/*.test.mjs
+ if ($LASTEXITCODE -ne 0) { throw 'Installed-UI harness contract regression failed' }
+ } finally { Stop-Transcript }
+ - name: Required renderer locale, IPC and mounted localization tests
+ run: |
+ Start-Transcript -Path desktop/localization-ci-evidence/03-renderer-tests.log
+ try {
+ $tests = @(
+ 'src/i18n',
+ 'src/features/assistantSurface/DesktopHaruWindow.test.tsx',
+ 'src/features/assistantSurface/useDesktopHaruOwner.test.tsx',
+ 'src/features/assistantSurface/ConversationControlsLocalization.test.tsx',
+ 'src/features/desktopUpdates/useDesktopUpdateSafety.test.tsx',
+ 'src/components/ChatPanel/localization.test.tsx',
+ 'tests/localization/mounted.test.tsx'
+ )
+ npm.cmd test --prefix web -- @tests --maxWorkers=2 --minWorkers=1 --reporter=default --reporter=junit --outputFile.junit=../desktop/localization-ci-evidence/renderer-vitest.xml
+ if ($LASTEXITCODE -ne 0) { throw 'Renderer locale and IPC regression failed' }
+ } finally { Stop-Transcript }
+ - name: Required combined desktop, mail Origin and auth regression
+ run: |
+ Start-Transcript -Path desktop/localization-ci-evidence/04-python-tests.log
+ try {
+ uv run --frozen pytest -v tests/test_desktop.py tests/test_auth_api.py tests/test_static_frontend.py tests/test_desktop_mail_localization_integration.py tests/test_job_mail_api.py tests/test_job_mail_secure_setup.py --durations=15 --junitxml=desktop/localization-ci-evidence/focused-pytest.xml
+ if ($LASTEXITCODE -ne 0) { throw 'Desktop, mail Origin and auth regression failed' }
+ } finally { Stop-Transcript }
+ - name: Required static checks and production frontend build
+ run: |
+ Start-Transcript -Path desktop/localization-ci-evidence/05-static-build.log
+ try {
+ uv run --frozen ruff check src/offerpilot/desktop.py src/offerpilot/api.py src/offerpilot/job_mail tests/test_desktop.py tests/test_auth_api.py tests/test_static_frontend.py tests/test_desktop_mail_localization_integration.py tests/test_job_mail_api.py tests/test_job_mail_secure_setup.py
+ if ($LASTEXITCODE -ne 0) { throw 'Focused Python lint failed' }
+ uv run --frozen mypy src/offerpilot/desktop.py src/offerpilot/api.py src/offerpilot/job_mail
+ if ($LASTEXITCODE -ne 0) { throw 'Desktop and mail type checks failed' }
+ npm.cmd run build --prefix web
+ if ($LASTEXITCODE -ne 0) { throw 'TypeScript or production frontend build failed' }
+ } finally { Stop-Transcript }
+ - name: Freeze the current checkout Python backend
+ run: |
+ Start-Transcript -Path desktop/localization-ci-evidence/06-freeze.log
+ try {
+ uv run --frozen --with pyinstaller==6.16.0 --with pyinstaller-hooks-contrib==2025.9 python desktop/build-backend.py
+ if ($LASTEXITCODE -ne 0) { throw 'Frozen backend build failed' }
+ } finally { Stop-Transcript }
+ - name: Required frozen process persistence and security smoke
+ run: |
+ Start-Transcript -Path desktop/localization-ci-evidence/07-frozen-smoke.log
+ try {
+ python desktop/smoke-backend.py --backend desktop/backend-dist/offerpilot-backend/offerpilot-backend.exe --static-dir web/dist
+ if ($LASTEXITCODE -ne 0) { throw 'Frozen backend smoke failed' }
+ } finally { Stop-Transcript }
+ - name: Build a new unsigned NSIS installer without publishing
+ run: |
+ Start-Transcript -Path desktop/localization-ci-evidence/08-nsis.log
+ try {
+ $shortSha = $env:GITHUB_SHA.Substring(0, 12)
+ $artifactName = "OfferPilot-Desktop-localization-$shortSha-win-x64-setup.exe"
+ npm.cmd run build:win --prefix desktop -- "--config.win.artifactName=$artifactName"
+ if ($LASTEXITCODE -ne 0) { throw 'NSIS packaging failed' }
+ } finally { Stop-Transcript }
+ - name: Required packaged resource layout and backend smoke
+ run: |
+ Start-Transcript -Path desktop/localization-ci-evidence/09-packaged-smoke.log
+ try {
+ python desktop/smoke-backend.py --backend desktop/dist/win-unpacked/resources/backend/offerpilot-backend.exe --static-dir desktop/dist/win-unpacked/resources/web
+ if ($LASTEXITCODE -ne 0) { throw 'Packaged resource smoke failed' }
+ } finally { Stop-Transcript }
+ - name: Hash the exact new localization installer
+ id: installer_hash
+ run: |
+ $shortSha = $env:GITHUB_SHA.Substring(0, 12)
+ $installers = @(Get-ChildItem -Path desktop/dist -Filter "OfferPilot-Desktop-localization-$shortSha-win-x64-setup.exe")
+ if ($installers.Count -ne 1) { throw 'Expected exactly one new localization installer' }
+ $installer = $installers[0]
+ $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $installer.FullName).Hash.ToLowerInvariant()
+ "$hash $($installer.Name)" | Set-Content -Encoding ascii -LiteralPath "$($installer.FullName).sha256"
+ Copy-Item "$($installer.FullName).sha256" desktop/localization-ci-evidence/
+ - name: Required same-package installed localization UI checks
+ id: installed_ui
+ timeout-minutes: 15
+ run: |
+ Start-Transcript -Path desktop/localization-ci-evidence/10-installed-ui.log
+ try {
+ $shortSha = $env:GITHUB_SHA.Substring(0, 12)
+ $installer = Get-Item "desktop/dist/OfferPilot-Desktop-localization-$shortSha-win-x64-setup.exe"
+ $env:LOCALIZATION_INSTALLER = $installer.FullName
+ $env:LOCALIZATION_SOURCE_SHA = $env:GITHUB_SHA
+ $env:LOCALIZATION_BUILD_DIR = (Resolve-Path desktop/dist/win-unpacked).Path
+ $env:LOCALIZATION_EVIDENCE_DIR = Join-Path $env:GITHUB_WORKSPACE 'desktop/localization-ci-evidence/installed-ui'
+ $env:LOCALIZATION_INSTALL_DIR = Join-Path $env:RUNNER_TEMP "OfferPilot-localization-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT"
+ node desktop/localization-ui/smoke.mjs
+ if ($LASTEXITCODE -ne 0) { throw 'Same-package installed localization UI checks failed' }
+ } finally { Stop-Transcript }
+ - name: Record exact package and installed UI acceptance status
+ id: package_status
+ if: ${{ always() && steps.installer_hash.outcome == 'success' }}
+ env:
+ LOCALIZATION_UI_OUTCOME: ${{ steps.installed_ui.outcome }}
+ run: |
+ $shortSha = $env:GITHUB_SHA.Substring(0, 12)
+ $installer = Get-Item "desktop/dist/OfferPilot-Desktop-localization-$shortSha-win-x64-setup.exe"
+ $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $installer.FullName).Hash.ToLowerInvariant()
+ $expectedHash = ((Get-Content -Raw -LiteralPath "$($installer.FullName).sha256") -split '\s+')[0]
+ if ($hash -ne $expectedHash) { throw 'Installer changed after its original build hash was recorded' }
+ $uiPassed = $env:LOCALIZATION_UI_OUTCOME -eq 'success'
+ $uiStatus = "NOT ACCEPTED: focused installed UI outcome is '$env:LOCALIZATION_UI_OUTCOME'; inspect evidence and retry this same package before acceptance."
+ if ($uiPassed) {
+ $uiStatus = 'Focused installed UI checks passed against this exact new installer; comprehensive desktop/release acceptance remains NOT RUN.'
+ }
+ $notes = @(Get-Content desktop/localization-ci-evidence/scope.txt) + @(
+ 'EXPERIMENTAL ARTIFACT RETAINED FOR SAME-PACKAGE VALIDATION. ITS PRESENCE DOES NOT MEAN INSTALLED UI PASSED.',
+ 'Passed: native desktop tests; selected renderer locale/IPC/mounted tests; selected desktop/mail/auth Python tests; focused lint/types; TypeScript and frontend build; frozen backend and packaged-resource smoke; unsigned NSIS build.',
+ $uiStatus,
+ 'NOT RUN: comprehensive installed desktop acceptance, complete regression/release gate, real AI and real mailbox acceptance, signing, updater publication or production release.',
+ 'Installer checksum: SHA256, in the adjacent .sha256 file.'
+ )
+ $notes | Set-Content desktop/dist/LOCALIZATION-VALIDATION-NOTES.txt
+ @{
+ schemaVersion = 1
+ sourceSha = $env:GITHUB_SHA
+ runUrl = "$env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY/actions/runs/$env:GITHUB_RUN_ID"
+ runAttempt = $env:GITHUB_RUN_ATTEMPT
+ installer = $installer.Name
+ installerSha256 = $hash
+ buildAndPackagedSmoke = 'success'
+ installedUiOutcome = $env:LOCALIZATION_UI_OUTCOME
+ focusedInstalledUiAccepted = $uiPassed
+ comprehensiveInstalledUi = 'not-run'
+ fullReleaseGate = 'not-run'
+ realAiAndMail = 'not-run'
+ releaseReady = $false
+ } | ConvertTo-Json | Set-Content desktop/dist/LOCALIZATION-VALIDATION-STATUS.json
+ Copy-Item desktop/dist/LOCALIZATION-VALIDATION-NOTES.txt desktop/localization-ci-evidence/
+ Copy-Item desktop/dist/LOCALIZATION-VALIDATION-STATUS.json desktop/localization-ci-evidence/
+ - name: Preserve experimental localization installer for same-package validation
+ if: ${{ always() && steps.installer_hash.outcome == 'success' && steps.package_status.outcome == 'success' }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: offerpilot-windows-localization-experimental-${{ github.sha }}-${{ github.run_attempt }}
+ if-no-files-found: error
+ retention-days: 14
+ path: |
+ desktop/dist/OfferPilot-Desktop-localization-*-setup.exe
+ desktop/dist/OfferPilot-Desktop-localization-*-setup.exe.sha256
+ desktop/dist/LOCALIZATION-VALIDATION-NOTES.txt
+ desktop/dist/LOCALIZATION-VALIDATION-STATUS.json
+ - name: Record result even on failure
+ if: ${{ always() }}
+ env:
+ LOCALIZATION_RESULT: ${{ job.status }}
+ run: |
+ New-Item -ItemType Directory -Force desktop/localization-ci-evidence | Out-Null
+ "Result before evidence upload: $env:LOCALIZATION_RESULT" | Set-Content desktop/localization-ci-evidence/result.txt
+ if (Test-Path desktop/localization-ci-evidence/LOCALIZATION-VALIDATION-NOTES.txt) {
+ Get-Content desktop/localization-ci-evidence/LOCALIZATION-VALIDATION-NOTES.txt | Add-Content $env:GITHUB_STEP_SUMMARY
+ } elseif (Test-Path desktop/localization-ci-evidence/scope.txt) {
+ Get-Content desktop/localization-ci-evidence/scope.txt | Add-Content $env:GITHUB_STEP_SUMMARY
+ }
+ Get-Content desktop/localization-ci-evidence/result.txt | Add-Content $env:GITHUB_STEP_SUMMARY
+ - name: Preserve localization evidence even on failure
+ if: ${{ always() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: windows-localization-evidence-${{ github.sha }}-${{ github.run_attempt }}
+ if-no-files-found: warn
+ retention-days: 14
+ path: desktop/localization-ci-evidence/
diff --git a/.github/workflows/desktop-windows.yml b/.github/workflows/desktop-windows.yml
index 5e78583d..e1450049 100644
--- a/.github/workflows/desktop-windows.yml
+++ b/.github/workflows/desktop-windows.yml
@@ -78,11 +78,23 @@ jobs:
cache-dependency-path: |
web/package-lock.json
desktop/package-lock.json
- - name: Install locked validation dependencies
+ - name: Install locked Python validation dependencies
+ timeout-minutes: 5
+ env:
+ UV_HTTP_RETRIES: "6"
+ UV_HTTP_CONNECT_TIMEOUT: "10"
+ UV_HTTP_TIMEOUT: "30"
run: |
Start-Transcript -Path desktop/ci-evidence/01-install.log
- uv sync --frozen
- if ($LASTEXITCODE -ne 0) { throw "Frozen Python install failed" }
+ try {
+ uv sync --frozen
+ if ($LASTEXITCODE -ne 0) { throw "Frozen Python install failed" }
+ } finally {
+ Stop-Transcript
+ }
+ - name: Install locked validation dependencies
+ run: |
+ Start-Transcript -Path desktop/ci-evidence/01-install.log -Append
npm.cmd ci --prefix web
if ($LASTEXITCODE -ne 0) { throw "Frontend install failed" }
npm.cmd ci --prefix desktop
@@ -239,9 +251,16 @@ jobs:
node-version: "22"
cache: npm
cache-dependency-path: web/package-lock.json
- - run: |
+ - name: Install locked Python manifest dependencies
+ timeout-minutes: 5
+ env:
+ UV_HTTP_RETRIES: "6"
+ UV_HTTP_CONNECT_TIMEOUT: "10"
+ UV_HTTP_TIMEOUT: "30"
+ run: |
uv sync --frozen
if ($LASTEXITCODE -ne 0) { throw "Frozen Python install failed" }
+ - run: |
npm.cmd ci --prefix web
if ($LASTEXITCODE -ne 0) { throw "Frontend dependency install failed" }
uv run --frozen python scripts/test_shards/gate.py collect --count 20 --manifest pytest-evidence/manifest.json
@@ -302,10 +321,17 @@ jobs:
with:
name: pytest-manifest-${{ github.sha }}-${{ github.run_attempt }}
path: pytest-evidence
- - name: Execute exact manifest partition in isolated process
+ - name: Install locked Python shard dependencies
+ timeout-minutes: 5
+ env:
+ UV_HTTP_RETRIES: "6"
+ UV_HTTP_CONNECT_TIMEOUT: "10"
+ UV_HTTP_TIMEOUT: "30"
run: |
uv sync --frozen
if ($LASTEXITCODE -ne 0) { throw "Frozen Python install failed" }
+ - name: Execute exact manifest partition in isolated process
+ run: |
npm.cmd ci --prefix web
if ($LASTEXITCODE -ne 0) { throw "Frontend dependency install failed" }
# No global PYTEST_ADDOPTS: nested pytest collection/parsing is unchanged.
@@ -353,10 +379,17 @@ jobs:
pattern: pytest-*-${{ github.sha }}-${{ github.run_attempt }}
path: pytest-evidence
merge-multiple: true
- - name: Install locked regression dependencies
+ - name: Install locked Python regression dependencies
+ timeout-minutes: 5
+ env:
+ UV_HTTP_RETRIES: "6"
+ UV_HTTP_CONNECT_TIMEOUT: "10"
+ UV_HTTP_TIMEOUT: "30"
run: |
uv sync --frozen
if ($LASTEXITCODE -ne 0) { throw "Frozen Python install failed" }
+ - name: Install locked regression dependencies
+ run: |
npm.cmd ci --prefix web
if ($LASTEXITCODE -ne 0) { throw "Frontend install failed" }
- name: Verify full pytest evidence and complete every remaining release phase
diff --git a/.github/workflows/ui-localization.yml b/.github/workflows/ui-localization.yml
new file mode 100644
index 00000000..93946e42
--- /dev/null
+++ b/.github/workflows/ui-localization.yml
@@ -0,0 +1,45 @@
+name: UI localization synthetic browser QA
+on:
+ workflow_dispatch:
+ push:
+ branches: [feat/20261019-localization-integration, test/20261019-localization-browser-review]
+ paths: ['web/**', '.github/workflows/ui-localization.yml']
+permissions:
+ contents: read
+concurrency:
+ group: ui-localization-${{ github.ref }}
+ cancel-in-progress: false
+jobs:
+ synthetic-browser:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: npm
+ cache-dependency-path: web/package-lock.json
+ - run: npm ci --prefix web
+ - name: Build actual production UI and run mounted integration supplement
+ run: |
+ npm run build --prefix web
+ npm test --prefix web -- tests/localization/mounted.test.tsx
+ - name: Install test browser and CJK fonts
+ working-directory: web
+ run: |
+ npx playwright-core install --with-deps chromium
+ sudo apt-get update
+ sudo apt-get install -y fonts-noto-cjk
+ - name: Strict synthetic browser acceptance (no backend, credentials, mail or models)
+ env:
+ LOCALIZATION_EVIDENCE_DIR: ${{ github.workspace }}/artifacts/ui-localization
+ run: npm run test:localization --prefix web
+ - name: Upload browser report, request log and bilingual screenshots
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: ui-localization-${{ github.sha }}
+ path: artifacts/ui-localization
+ if-no-files-found: warn
+ retention-days: 14
diff --git a/.gitignore b/.gitignore
index d72e6e21..9964c6b0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -78,3 +78,6 @@ desktop/node_modules/
desktop/dist/
desktop/backend-dist/
desktop/.build/
+
+# Focused desktop localization evidence (generated)
+desktop/localization-ci-evidence/
diff --git a/desktop/capabilities.cjs b/desktop/capabilities.cjs
index 0d877207..f08fae43 100644
--- a/desktop/capabilities.cjs
+++ b/desktop/capabilities.cjs
@@ -1,4 +1,5 @@
'use strict';
+const { translate } = require('./locale.cjs');
const path = require('node:path');
// Model data only. Executable ONNX modules/WASM are packaged with the frontend.
@@ -31,7 +32,7 @@ function sourceDownload(value, origin) {
&& /^\/api\/knowledge\/sources\/\d+\/(?:content|assets\/\d+\/content)$/.test(url.pathname);
} catch { return false; }
}
-function createCapabilities({ origin, desktopSession, isTrustedContents, dialog, shell, BrowserWindow }) {
+function createCapabilities({ origin, desktopSession, isTrustedContents, dialog, shell, BrowserWindow, t = translate }) {
const audioGrants = new WeakSet();
const pending = new WeakSet();
const epochs = new WeakMap();
@@ -51,11 +52,11 @@ function createCapabilities({ origin, desktopSession, isTrustedContents, dialog,
const startingURL = contents.getURL();
const epoch = epochs.get(contents);
Promise.resolve().then(() => dialog.showMessageBox(parent(contents), {
- type: 'question', title: clipboardWrite ? '复制到剪贴板?' : '允许录音?',
- message: clipboardWrite ? '允许将当前选定内容复制到系统剪贴板?' : '允许 OfferPilot 在当前窗口使用麦克风?',
- detail: clipboardWrite ? '会替换现有剪贴板内容,仅允许这一次复制,不读取剪贴板。'
- : '仅用于你主动开始的录音。不会授权摄像头、屏幕录制或其他设备。关闭或重新加载窗口后需要再次确认。',
- buttons: ['取消', clipboardWrite ? '复制' : '允许麦克风'], defaultId: 0, cancelId: 0, noLink: true,
+ type: 'question', title: clipboardWrite ? t('复制到剪贴板?') : t('允许录音?'),
+ message: clipboardWrite ? t('允许将当前选定内容复制到系统剪贴板?') : t('允许 OfferPilot 在当前窗口使用麦克风?'),
+ detail: clipboardWrite ? t('会替换现有剪贴板内容,仅允许这一次复制,不读取剪贴板。')
+ : t('仅用于你主动开始的录音。不会授权摄像头、屏幕录制或其他设备。关闭或重新加载窗口后需要再次确认。'),
+ buttons: [t('取消'), clipboardWrite ? t('复制') : t('允许麦克风')], defaultId: 0, cancelId: 0, noLink: true,
})).then(result => {
const allowed = result.response === 1 && mainFrame(contents, details) && contents.getURL() === startingURL && epochs.get(contents) === epoch;
if (allowed && audioOnly) audioGrants.add(contents);
@@ -79,8 +80,8 @@ function createCapabilities({ origin, desktopSession, isTrustedContents, dialog,
// Electron's native save dialog confirms the path and overwrite explicitly.
let selected;
try { selected = dialog.showSaveDialogSync(parent(contents), {
- title: '保存 OfferPilot 导出文件', defaultPath: safeFilename(item.getFilename()),
- buttonLabel: '保存', properties: ['showOverwriteConfirmation', 'dontAddToRecent'],
+ title: t('保存 OfferPilot 导出文件'), defaultPath: safeFilename(item.getFilename()),
+ buttonLabel: t('保存'), properties: ['showOverwriteConfirmation', 'dontAddToRecent'],
}); } catch { event.preventDefault(); return; }
if (!selected || !trusted(contents)) { event.preventDefault(); return; }
item.setSavePath(selected);
@@ -92,8 +93,8 @@ function createCapabilities({ origin, desktopSession, isTrustedContents, dialog,
const epoch = epochs.get(contents);
pending.add(contents);
Promise.resolve().then(() => dialog.showMessageBox(parent(contents), {
- type: 'question', title: '打开外部链接?', message: '在系统浏览器中打开此链接?', detail: url,
- buttons: ['取消', '打开浏览器'], defaultId: 0, cancelId: 0, noLink: true,
+ type: 'question', title: t('打开外部链接?'), message: t('在系统浏览器中打开此链接?'), detail: url,
+ buttons: [t('取消'), t('打开浏览器')], defaultId: 0, cancelId: 0, noLink: true,
})).then(async result => {
if (result.response === 1 && trusted(contents) && contents.getURL() === startingURL && epochs.get(contents) === epoch) await shell.openExternal(url);
}).catch(() => { /* Closing a window or a rejected OS launch must not crash the app. */ })
diff --git a/desktop/haru-protocol.cjs b/desktop/haru-protocol.cjs
index 4e259317..c8419f07 100644
--- a/desktop/haru-protocol.cjs
+++ b/desktop/haru-protocol.cjs
@@ -23,6 +23,7 @@ function sanitizeSnapshot(value) {
canStop: value.canStop === true,
stopping: value.stopping === true,
error: text(value.error, 1200),
+ ...(value.errorKey === 'desktop_send_failed' ? { errorKey: value.errorKey } : {}),
stopMessage: text(value.stopMessage, 500),
};
}
diff --git a/desktop/haru.cjs b/desktop/haru.cjs
index c7ef5790..8f2cdbdd 100644
--- a/desktop/haru.cjs
+++ b/desktop/haru.cjs
@@ -1,9 +1,10 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
+const { translate } = require('./locale.cjs');
const { CHANNEL, sanitizeSnapshot, validateRequest, isTrustedFrame, clampBounds } = require('./haru-protocol.cjs');
-function createHaruShell({ BrowserWindow, Tray, Menu, nativeImage, screen, ipcMain, mainWindow, desktopSession, origin, userData, registerWindow, isQuitting, quit, log = () => {} }) {
+function createHaruShell({ BrowserWindow, Tray, Menu, nativeImage, screen, ipcMain, mainWindow, desktopSession, origin, userData, registerWindow, isQuitting, quit, log = () => {}, t = translate, subscribeLocale = () => () => {} }) {
let haru;
let tray;
let snapshot = null;
@@ -61,12 +62,13 @@ function createHaruShell({ BrowserWindow, Tray, Menu, nativeImage, screen, ipcMa
};
const refreshMenu = () => {
if (!tray || tray.isDestroyed()) return;
+ tray.setToolTip(t('OfferPilot · Haru(退出请使用托盘菜单)'));
tray.setContextMenu(Menu.buildFromTemplate([
- { label: '打开 OfferPilot', click: showMain },
- { label: liveHaru() && haru.isVisible() ? '隐藏 Haru' : '显示 Haru', click: () => liveHaru() && haru.isVisible() ? haru.hide() : showHaru() },
- { label: 'Haru 始终置顶', type: 'checkbox', checked: pinned, click: () => setPinned(!pinned) },
+ { label: t('打开 OfferPilot'), click: showMain },
+ { label: liveHaru() && haru.isVisible() ? t('隐藏 Haru') : t('显示 Haru'), click: () => liveHaru() && haru.isVisible() ? haru.hide() : showHaru() },
+ { label: t('Haru 始终置顶'), type: 'checkbox', checked: pinned, click: () => setPinned(!pinned) },
{ type: 'separator' },
- { label: '退出并停止本地服务', click: quit },
+ { label: t('退出并停止本地服务'), click: quit },
]));
};
const setPinned = value => {
@@ -89,8 +91,8 @@ function createHaruShell({ BrowserWindow, Tray, Menu, nativeImage, screen, ipcMa
const showContextMenu = () => {
cancelDrag();
Menu.buildFromTemplate([
- { label: '隐藏 Haru', click: () => { if (!isQuitting() && liveHaru()) haru.hide(); } },
- { label: 'Haru 始终置顶', type: 'checkbox', checked: pinned, click: () => { if (!isQuitting()) setPinned(!pinned); } },
+ { label: t('隐藏 Haru'), click: () => { if (!isQuitting() && liveHaru()) haru.hide(); } },
+ { label: t('Haru 始终置顶'), type: 'checkbox', checked: pinned, click: () => { if (!isQuitting()) setPinned(!pinned); } },
]).popup({ window: haru });
};
const invalidateOwner = () => {
@@ -231,15 +233,18 @@ function createHaruShell({ BrowserWindow, Tray, Menu, nativeImage, screen, ipcMa
screen.on('display-metrics-changed', fit);
try {
tray = new Tray(nativeImage.createFromPath(path.join(__dirname, 'assets', 'haru-tray.png')));
- tray.setToolTip('OfferPilot · Haru(退出请使用托盘菜单)');
+ tray.setToolTip(t('OfferPilot · Haru(退出请使用托盘菜单)'));
tray.on('double-click', showMain);
tray.on('click', showHaru);
refreshMenu();
} catch { log('System tray unavailable; closing the main window will exit.\n'); }
+ const unsubscribeLocale = subscribeLocale(refreshMenu);
showHaru();
return {
+ getHaruContents: () => liveHaru() ? haru.webContents : null,
showHaru,
dispose() {
+ unsubscribeLocale();
cancelDrag();
ownerDestination = null;
savePosition();
diff --git a/desktop/installed-ui/desktop-source-manifest.mjs b/desktop/installed-ui/desktop-source-manifest.mjs
index 20128add..38a4697c 100644
--- a/desktop/installed-ui/desktop-source-manifest.mjs
+++ b/desktop/installed-ui/desktop-source-manifest.mjs
@@ -16,3 +16,12 @@ export const AUDITED_DESKTOP_MODULE_SHA256 = Object.freeze({
'update-integrity.cjs': 'ae261c3edac0f574612ff411c0660cce4f9c8f2bc22718392774015e23640625',
});
export const DESKTOP_SOURCE_FILES = Object.freeze(Object.keys(AUDITED_DESKTOP_MODULE_SHA256));
+
+// Current checkout's reviewed packaging closure. Keep this separate from the
+// immutable historical installer hashes above: localization adds modules but
+// must not relabel new source as the previously audited product.
+export const CURRENT_DESKTOP_SOURCE_FILES = Object.freeze([
+ ...DESKTOP_SOURCE_FILES,
+ 'locale.cjs',
+ 'locale-catalog.cjs',
+]);
diff --git a/desktop/installed-ui/test/coverage-model.test.mjs b/desktop/installed-ui/test/coverage-model.test.mjs
index e0d31dfe..15a82441 100644
--- a/desktop/installed-ui/test/coverage-model.test.mjs
+++ b/desktop/installed-ui/test/coverage-model.test.mjs
@@ -1,39 +1,122 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
-import { execFileSync } from 'node:child_process';
+import os from 'node:os';
+import path from 'node:path';
+import { createRequire } from 'node:module';
+import { fileURLToPath, pathToFileURL } from 'node:url';
import { ROOTS, ROOT_CASES, ROOT_STATES, SUBVIEWS, WIDTHS, safeShotName, summarizeCoverage, classifyRuntimeMessage, publicRequestFailure, checkGeometry } from '../coverage-model.mjs';
-test('coverage inventory exactly matches production navigation routes, module labels and visible tabs', () => {
+// Cleanup cannot hide a failing assertion, and cleanup-only errors must still fail.
+async function runWithCoverageCleanup(operation, close, remove) {
+ let value;
+ let primaryError;
+ let failed = false;
+ try { value = await operation(); }
+ catch (error) { primaryError = error; failed = true; }
+ const cleanupErrors = [];
+ try {
+ try { await close(); } catch (error) { cleanupErrors.push(error); }
+ } finally {
+ try { await remove(); } catch (error) { cleanupErrors.push(error); }
+ }
+ if (failed) {
+ if (cleanupErrors.length) throw new AggregateError([primaryError, ...cleanupErrors], 'Coverage test and cache cleanup failed', { cause: primaryError });
+ throw primaryError;
+ }
+ if (cleanupErrors.length) throw new AggregateError(cleanupErrors, 'Coverage cache cleanup failed');
+ return value;
+}
+
+test('coverage inventory exactly matches production navigation routes, module labels and visible tabs', async () => {
const navigationUrl = new URL('../../../web/src/layout/navigation.ts', import.meta.url);
const source = fs.readFileSync(navigationUrl, 'utf8');
const union = source.match(/export type ViewMode\s*=([\s\S]*?);/);
assert.ok(union, 'production ViewMode union is required');
const views = [...union[1].matchAll(/'([^']+)'/g)].map(([, view]) => view);
- // Run the actual typed module in Node's supported strip-only mode. Looking for
- // view-name substrings cannot catch missing routes or wrong module/tab labels.
- const script = `import { MODULE_NAV, MODULE_TABS, resolveModuleForView, defaultViewForModule, moduleTabsForView } from ${JSON.stringify(navigationUrl.href)};
- const views = ${JSON.stringify(views)};
- console.log(JSON.stringify({ nav: MODULE_NAV, tabs: MODULE_TABS, routes: views.map(view => ({
+ // Use the application's locked Vite toolchain: navigation now depends on the
+ // real i18n catalog's alias and import.meta.glob transformation. Do not stub
+ // translations or duplicate the production navigation in a test fixture.
+ const webRoot = fileURLToPath(new URL('../../../web/', import.meta.url));
+ const requireWeb = createRequire(new URL('../../../web/package.json', import.meta.url));
+ const loadWeb = name => import(pathToFileURL(requireWeb.resolve(name)).href);
+ const [{ createServer }, { ViteNodeServer }, { ViteNodeRunner }] = await Promise.all([
+ import(new URL('./dist/node/index.js', pathToFileURL(requireWeb.resolve('vite/package.json'))).href),
+ loadWeb('vite-node/server'), loadWeb('vite-node/client'),
+ ]);
+ // This helper disables discovery; its partial optimized set must never seed browser QA.
+ const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offerpilot-coverage-vite-'));
+ let server;
+ await runWithCoverageCleanup(async () => {
+ server = await createServer({ root: webRoot, cacheDir,
+ server: { middlewareMode: true, hmr: false, watch: null },
+ optimizeDeps: { noDiscovery: true, include: [] },
+ });
+ await server.pluginContainer.buildStart({});
+ const node = new ViteNodeServer(server);
+ const runner = new ViteNodeRunner({ root: server.config.root, base: server.config.base,
+ fetchModule: id => node.fetchModule(id), resolveId: (id, importer) => node.resolveId(id, importer),
+ });
+ const i18n = await runner.executeFile(fileURLToPath(new URL('../../../web/src/i18n/index.ts', import.meta.url)));
+ await i18n.setLocale('zh-CN'); // This installed-UI inventory targets the Chinese baseline.
+ assert.equal(i18n.getLocale(), 'zh-CN');
+ const { MODULE_NAV, MODULE_TABS, resolveModuleForView, defaultViewForModule, moduleTabsForView } =
+ await runner.executeFile(fileURLToPath(navigationUrl));
+ const production = { nav: MODULE_NAV, tabs: MODULE_TABS, routes: views.map(view => ({
view, module: resolveModuleForView(view), defaultView: defaultViewForModule(resolveModuleForView(view)), tabs: moduleTabsForView(view),
- })) }));`;
- const production = JSON.parse(execFileSync(process.execPath,
- ['--experimental-strip-types', '--input-type=module', '--eval', script], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }));
- assert.equal(ROOTS.length, 13);
- assert.equal(new Set(ROOTS.map(({ view }) => view)).size, ROOTS.length);
- assert.deepEqual(ROOTS.map(({ view }) => view).sort(), [...views].sort());
- assert.deepEqual(Object.values(production.tabs).flat().map(({ view }) => view).sort(), [...views].sort());
- for (const item of ROOTS) {
- const route = production.routes.find(({ view }) => view === item.view);
- const module = production.nav.find(({ key }) => key === route.module);
- assert.equal(item.module, module?.label ?? null, `${item.view} must use the production sidebar label`);
- assert.equal(item.tab, route.tabs.length > 1 ? route.tabs.find(({ view }) => view === item.view)?.label : null,
- `${item.view} must select an actual visible module tab`);
- if (module) assert.equal(module.defaultView, route.defaultView, `${item.view} module default must be consistent`);
- else assert.equal(item.view, 'pilot', 'only the command-palette Pilot root has no sidebar entry');
+ })) };
+ assert.equal(ROOTS.length, 13);
+ assert.equal(new Set(ROOTS.map(({ view }) => view)).size, ROOTS.length);
+ assert.deepEqual(ROOTS.map(({ view }) => view).sort(), [...views].sort());
+ assert.deepEqual(Object.values(production.tabs).flat().map(({ view }) => view).sort(), [...views].sort());
+ for (const item of ROOTS) {
+ const route = production.routes.find(({ view }) => view === item.view);
+ const module = production.nav.find(({ key }) => key === route.module);
+ assert.equal(item.module, module?.label ?? null, `${item.view} must use the production sidebar label`);
+ assert.equal(item.tab, route.tabs.length > 1 ? route.tabs.find(({ view }) => view === item.view)?.label : null,
+ `${item.view} must select an actual visible module tab`);
+ if (module) assert.equal(module.defaultView, route.defaultView, `${item.view} module default must be consistent`);
+ else assert.equal(item.view, 'pilot', 'only the command-palette Pilot root has no sidebar entry');
+ }
+ assert.equal(SUBVIEWS.length, 32); assert.equal(new Set(SUBVIEWS.map(({ id }) => id)).size, 32);
+ assert.deepEqual(WIDTHS, [900, 1008, 1280, 1440]);
+ }, () => server?.close(), () => fs.rmSync(cacheDir, { recursive: true, force: true }));
+});
+
+test('coverage cleanup preserves the primary failure and attempts removal even when close fails', async () => {
+ const primary = new assert.AssertionError({ message: 'Original inventory assertion' });
+ const closeError = new Error('Close failed');
+ const removeError = new Error('Remove failed');
+ const calls = [];
+ await assert.rejects(runWithCoverageCleanup(
+ async () => { throw primary; },
+ async () => { calls.push('close'); throw closeError; },
+ () => { calls.push('remove'); throw removeError; },
+ ), error => {
+ assert.ok(error instanceof AggregateError);
+ assert.equal(error.cause, primary);
+ assert.deepEqual(error.errors, [primary, closeError, removeError]);
+ return true;
+ });
+ assert.deepEqual(calls, ['close', 'remove']);
+ await assert.rejects(runWithCoverageCleanup(async () => { throw primary; }, () => {}, () => {}), error => error === primary);
+});
+
+test('coverage cleanup-only failures cannot turn into successful tests', async () => {
+ for (const failedStep of ['close', 'remove']) {
+ const cleanupError = new Error(`${failedStep} failed`);
+ const calls = [];
+ await assert.rejects(runWithCoverageCleanup(
+ () => 'passed',
+ () => { calls.push('close'); if (failedStep === 'close') throw cleanupError; },
+ () => { calls.push('remove'); if (failedStep === 'remove') throw cleanupError; },
+ ), error => {
+ assert.ok(error instanceof AggregateError);
+ assert.deepEqual(error.errors, [cleanupError]);
+ return true;
+ });
+ assert.deepEqual(calls, ['close', 'remove']);
}
- assert.equal(SUBVIEWS.length, 32); assert.equal(new Set(SUBVIEWS.map(({ id }) => id)).size, 32);
- assert.deepEqual(WIDTHS, [900, 1008, 1280, 1440]);
});
test('the complete 130-target root matrix covers both populations, dark widths and light 1280 exactly once', () => {
diff --git a/desktop/installed-ui/test/effective-csp-observer.test.mjs b/desktop/installed-ui/test/effective-csp-observer.test.mjs
index b7fbcdb0..7630be29 100644
--- a/desktop/installed-ui/test/effective-csp-observer.test.mjs
+++ b/desktop/installed-ui/test/effective-csp-observer.test.mjs
@@ -204,14 +204,22 @@ test('partial registration rejection remains owned and recoverable without touch
assert.equal(f.registered(), false);
});
test('reviewed desktop module manifest covers every packaged local CJS and relative import', async () => {
- const { DESKTOP_SOURCE_FILES, AUDITED_DESKTOP_MODULE_SHA256 } = await import('../desktop-source-manifest.mjs');
+ const { DESKTOP_SOURCE_FILES, CURRENT_DESKTOP_SOURCE_FILES, AUDITED_DESKTOP_MODULE_SHA256 } = await import('../desktop-source-manifest.mjs');
const packageJson = JSON.parse(await fs.readFile(new URL('../../package.json', import.meta.url), 'utf8'));
- assert.deepEqual([...DESKTOP_SOURCE_FILES].sort(), packageJson.build.files.filter(name => name.endsWith('.cjs')).sort());
+ assert.deepEqual([...CURRENT_DESKTOP_SOURCE_FILES].sort(), packageJson.build.files.filter(name => name.endsWith('.cjs')).sort());
assert.deepEqual([...DESKTOP_SOURCE_FILES].sort(), Object.keys(AUDITED_DESKTOP_MODULE_SHA256).sort());
for (const filename of DESKTOP_SOURCE_FILES) {
+ for (const match of auditedModule(filename).matchAll(/require\(['"](\.\/[^'"]+)['"]\)/g)) {
+ assert.ok(DESKTOP_SOURCE_FILES.includes(match[1].slice(2)), 'relative historical import missing from reviewed closure');
+ }
+ }
+ assert.equal(CURRENT_DESKTOP_SOURCE_FILES.length, 14);
+ assert.ok(CURRENT_DESKTOP_SOURCE_FILES.includes('locale.cjs'));
+ assert.ok(CURRENT_DESKTOP_SOURCE_FILES.includes('locale-catalog.cjs'));
+ for (const filename of CURRENT_DESKTOP_SOURCE_FILES) {
const source = await fs.readFile(new URL(`../../${filename}`, import.meta.url), 'utf8');
for (const match of source.matchAll(/require\(['"](\.\/[^'"]+)['"]\)/g)) {
- assert.ok(DESKTOP_SOURCE_FILES.includes(match[1].slice(2)), 'relative desktop import missing from reviewed closure');
+ assert.ok(CURRENT_DESKTOP_SOURCE_FILES.includes(match[1].slice(2)), 'relative desktop import missing from reviewed closure');
}
}
const updater = await fs.readFile(new URL('../../updater.cjs', import.meta.url), 'utf8');
diff --git a/desktop/installed-ui/test/layout-failure-evidence.test.mjs b/desktop/installed-ui/test/layout-failure-evidence.test.mjs
new file mode 100644
index 00000000..b989bfb9
--- /dev/null
+++ b/desktop/installed-ui/test/layout-failure-evidence.test.mjs
@@ -0,0 +1,183 @@
+import assert from 'node:assert/strict';
+import { EventEmitter } from 'node:events';
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
+import { createRequire } from 'node:module';
+import os from 'node:os';
+import path from 'node:path';
+import { spawn } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import test from 'node:test';
+import { captureFailureScreenshot, collectLayoutState, messageKind, observeLayoutFailure, resourceName } from '../../../web/tests/desktop-layout/failure-evidence.mjs';
+
+const requireWeb = createRequire(new URL('../../../web/package.json', import.meta.url));
+const { JSDOM } = requireWeb('jsdom');
+const secret = 'DO_NOT_PERSIST_SYNTHETIC_SECRET_123';
+const html = `${secret}${secret}`;
+
+async function timeoutProbe(output) {
+ const requireHelper = createRequire(new URL('../package.json', import.meta.url));
+ const { chromium } = requireHelper('playwright-core');
+ const browser = await chromium.launch({ channel: 'msedge' });
+ const page = await browser.newPage({ viewport: { width: 900, height: 689 } });
+ page.setDefaultTimeout(15000);
+ const evidence = observeLayoutFailure(page, output, 'intentional-timeout');
+ try {
+ await page.setContent(html);
+ await page.evaluate(value => { console.error(value); setTimeout(() => { throw new Error(value); }, 0); }, secret);
+ // Same predicate and timeout as the real harness, on a controlled pending fixture.
+ await page.waitForFunction(() => {
+ const state = document.querySelector('[data-layout-result]')?.getAttribute('data-layout-result');
+ return state && state !== 'pending';
+ });
+ throw new Error('Negative fixture unexpectedly became ready');
+ } catch (error) {
+ await evidence.capture('readiness-wait', error);
+ await captureFailureScreenshot(page, output, 'intentional-timeout');
+ throw error;
+ } finally { evidence.dispose(); await browser.close(); }
+}
+
+if (process.argv[2] === '--intentional-timeout') {
+ await timeoutProbe(process.argv[3]);
+} else {
+ test('error kinds never retain arbitrary messages or credentials', () => {
+ assert.equal(messageKind(`TimeoutError: Timeout 15000ms exceeded ${secret}`), 'timeout');
+ assert.equal(messageKind(`Invalid hook call ${secret}`), 'react-hook-error');
+ assert.equal(messageKind(secret), 'unclassified');
+ assert.equal(resourceName(`http://127.0.0.1:5174/src/main.tsx?token=${secret}#${secret}`), '/src/main.tsx');
+ assert.equal(resourceName(`http://127.0.0.1:5174/api/tokens/${secret}`), '[other-loopback]');
+ assert.equal(resourceName(`https://example.test/${secret}`), '[non-loopback]');
+ for (const category of ['assets', 'src', 'vendor', 'node_modules', 'tests/desktop-layout']) {
+ const resource = resourceName(`http://127.0.0.1:5174/${category}/${secret}?token=${secret}`);
+ assert.ok(!resource.includes(secret));
+ assert.match(resource, /:\[[a-f0-9]{12}\]$/);
+ }
+ });
+
+ test('DOM evidence keeps readiness structure while removing text, values and attributes', () => {
+ const dom = new JSDOM(html);
+ const savedDocument = globalThis.document;
+ const savedFilter = globalThis.NodeFilter;
+ try {
+ globalThis.document = dom.window.document;
+ globalThis.NodeFilter = dom.window.NodeFilter;
+ const state = collectLayoutState();
+ assert.equal(state.rootPresent, true);
+ assert.equal(state.layoutResult, 'pending');
+ assert.equal(state.tablePresent, true);
+ assert.ok(state.dom.includes('id="root"'));
+ assert.ok(state.dom.includes('data-layout-result'));
+ assert.ok(!JSON.stringify(state).includes(secret));
+ assert.ok(!state.dom.includes('