From 0ae10c81d9fb6fc18e266a4ad6071e57137fd6bb Mon Sep 17 00:00:00 2001 From: cyq <112178718+XiaoZheBrother@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:00:41 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20AI=20=E6=95=B4=E5=90=88=E6=9C=80?= =?UTF-8?q?=E6=96=B0=E4=B8=BB=E7=BA=BF=E4=B8=AD=E8=8B=B1=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E4=B8=8E=E6=A1=8C=E9=9D=A2=E5=90=8C=E5=8C=85=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port shared web localization and desktop language delta without reverting current Haru behavior. Includes focused installed same-package validation; no release publication. --- .github/workflows/desktop-localization.yml | 261 ++++ .github/workflows/ui-localization.yml | 45 + .gitignore | 3 + desktop/capabilities.cjs | 21 +- desktop/haru-protocol.cjs | 1 + desktop/haru.cjs | 21 +- desktop/locale-catalog.cjs | 42 + desktop/locale.cjs | 97 ++ desktop/localization-ui/.gitignore | 1 + desktop/localization-ui/contract.mjs | 95 ++ desktop/localization-ui/retry-provenance.json | 18 + desktop/localization-ui/retry.mjs | 36 + desktop/localization-ui/security.mjs | 44 + desktop/localization-ui/smoke.mjs | 624 ++++++++ .../localization-ui/test/contract.test.mjs | 126 ++ desktop/localization-ui/test/retry.test.mjs | 111 ++ .../localization-ui/test/security.test.mjs | 75 + desktop/localization-ui/windows.ps1 | 53 + desktop/main.cjs | 31 +- desktop/package.json | 2 +- desktop/preload.cjs | 14 + desktop/test/capabilities.test.cjs | 18 +- desktop/test/haru.test.cjs | 36 +- desktop/test/locale.test.cjs | 91 ++ desktop/test/update-backup.test.cjs | 2 + desktop/test/update-safety.test.cjs | 12 +- desktop/update-backup.cjs | 2 +- desktop/update-safety.cjs | 5 +- docs/BUGS.md | 8 + .../0014-localize-authored-interface-copy.md | 44 + docs/architecture/decisions/README.md | 5 + .../desktop-localization-validation.md | 62 + ...t_desktop_mail_localization_integration.py | 119 ++ web/package-lock.json | 14 + web/package.json | 4 +- web/src/components/AISettingsDrawer.tsx | 140 +- .../AdaptiveInterviewPracticeWorkspace.tsx | 59 +- web/src/components/AddApplicationForm.tsx | 93 +- web/src/components/AddOfferForm.test.tsx | 56 + web/src/components/AddOfferForm.tsx | 54 +- .../ApplicationCommunicationDraftPanel.tsx | 98 +- web/src/components/ApplicationDetail.tsx | 449 +++--- web/src/components/ApplicationListView.tsx | 47 +- .../components/ApplicationOutcomeDrawer.tsx | 147 +- web/src/components/AuthGate/index.tsx | 10 +- web/src/components/CalendarView.tsx | 81 +- .../CalendarView.workspace.test.tsx | 23 +- web/src/components/ChatPanel/Composer.tsx | 14 +- .../ChatPanel/ContextAttachmentRail.tsx | 12 +- web/src/components/ChatPanel/ContextPanel.tsx | 61 +- web/src/components/ChatPanel/EvidenceList.tsx | 26 +- .../components/ChatPanel/MessageBubble.tsx | 12 +- .../NativePilotAttachmentDropSurface.tsx | 4 +- .../ChatPanel/PilotOfferSelectionCard.tsx | 16 +- .../components/ChatPanel/PilotTaskCard.tsx | 22 +- .../components/ChatPanel/ProcessTimeline.tsx | 18 +- web/src/components/ChatPanel/ProposalCard.tsx | 276 ++-- web/src/components/ChatPanel/SlashMenu.tsx | 4 +- .../ChatPanel/ThinkingIndicator.tsx | 5 +- web/src/components/ChatPanel/ThreadRail.tsx | 70 +- web/src/components/ChatPanel/capabilities.ts | 111 +- .../components/ChatPanel/conversationList.ts | 3 +- web/src/components/ChatPanel/index.tsx | 174 +-- web/src/components/ChatPanel/layout.test.ts | 30 +- .../ChatPanel/localization.test.tsx | 125 ++ web/src/components/ChatPanel/model.ts | 71 +- .../components/ConfirmedMemorySettings.tsx | 41 +- web/src/components/ContextPolicySettings.tsx | 10 +- .../ConversationReadinessControl.tsx | 46 +- .../components/ConversationSummaryControl.tsx | 18 +- .../components/DesktopUpdatesCard.test.tsx | 18 +- web/src/components/DesktopUpdatesCard.tsx | 32 +- .../components/ExperienceMaterialsView.tsx | 26 +- .../InterviewFormsLocalization.test.tsx | 114 ++ .../InterviewKnowledgeCaptureDrawer.tsx | 78 +- .../components/InterviewPracticeView.test.tsx | 17 + web/src/components/InterviewPracticeView.tsx | 13 +- .../InterviewPreparationProposalDrawer.tsx | 66 +- .../InterviewReviewProposalDrawer.tsx | 57 +- web/src/components/InterviewStoryDrawer.tsx | 141 +- .../components/InterviewStoryLibraryView.tsx | 105 +- web/src/components/InterviewV01View.test.tsx | 12 +- web/src/components/InterviewV01View.tsx | 86 +- web/src/components/JDAnalyzeModal.tsx | 43 +- web/src/components/JobDescriptionContent.tsx | 2 + web/src/components/KanbanBoard/KanbanCard.tsx | 23 +- .../components/KanbanBoard/KanbanColumn.tsx | 4 +- .../KanbanBoard/PilotContextDropTarget.tsx | 4 +- .../KanbanBoard/applicationLifecycle.ts | 3 +- web/src/components/KanbanBoard/index.tsx | 24 +- web/src/components/KnowledgeNoteManager.tsx | 33 +- web/src/components/KnowledgeSourcesView.tsx | 579 ++++---- ...KnowledgeSourcesView.workspaceCopy.test.ts | 4 +- web/src/components/MaterialKitDrawer.tsx | 212 ++- .../components/MaterialLocalization.test.tsx | 69 + .../MaterialProposalReviewModal.tsx | 12 +- web/src/components/MockInterviewDrawer.tsx | 94 +- web/src/components/NextStepSuggestions.tsx | 23 +- web/src/components/OfferCard.tsx | 39 +- web/src/components/OfferCenterView.tsx | 30 +- .../components/OfferCompareDrawer.test.tsx | 21 + web/src/components/OfferCompareDrawer.tsx | 60 +- .../OfferComparisonDimensionPanel.tsx | 36 +- web/src/components/OfferNegotiationDrawer.tsx | 158 +- .../components/OpportunityFitReviewDrawer.tsx | 63 +- web/src/components/ProactiveInbox.tsx | 73 +- web/src/components/ProactiveSettings.tsx | 94 +- web/src/components/QuestionBankView.test.tsx | 6 +- web/src/components/QuestionBankView.tsx | 203 ++- web/src/components/ResumeCard.tsx | 54 +- .../ResumeEditorDrawer.structured.test.ts | 2 +- web/src/components/ResumeEditorDrawer.tsx | 146 +- .../components/ResumeEvidenceAuditPanel.tsx | 38 +- .../ResumeFactSupplementWorkspace.tsx | 85 +- web/src/components/ResumeImportReview.tsx | 48 +- web/src/components/ResumeLibraryView.tsx | 78 +- web/src/components/ResumeMatchModal.tsx | 59 +- web/src/components/ResumeUploadModal.tsx | 16 +- .../components/ResumeVersionCompareDrawer.tsx | 67 +- web/src/components/ReviewFormDrawer.tsx | 66 +- web/src/components/ReviewManagementView.tsx | 58 +- web/src/components/ScheduleEventForm.tsx | 76 +- web/src/components/SettingsView.tsx | 169 ++- web/src/components/StorySourcePicker.tsx | 25 +- .../components/VoiceCoachingGrowthView.tsx | 101 +- .../VoiceCoachingSnapshotSaveCard.tsx | 61 +- web/src/components/applicationUiText.ts | 30 + .../components/applicationWorkspaceModel.ts | 21 +- web/src/components/calendarPresentation.ts | 5 +- web/src/components/materialFlowCopy.ts | 138 +- .../NegotiationBriefForm.tsx | 14 +- .../NegotiationHistoryList.tsx | 14 +- .../NegotiationProposalCard.tsx | 54 +- .../OfferSnapshotSummary.tsx | 48 +- web/src/components/offerComparisonModel.ts | 15 +- web/src/components/offerWorkspaceModel.ts | 9 +- web/src/components/opportunityFitCopy.ts | 123 +- .../components/ui/ConfirmationPanel.test.tsx | 15 +- web/src/components/ui/ConfirmationPanel.tsx | 4 +- .../ui/EvidenceTechnicalDetails.tsx | 4 +- web/src/components/ui/SourceStateTag.test.tsx | 15 +- web/src/components/ui/SourceStateTag.tsx | 12 +- .../actionPresentation/ActionCard.tsx | 29 +- .../PendingStartRecovery.tsx | 18 +- .../PresentationRecovery.tsx | 6 +- .../applicationTasks/opportunityFitHistory.ts | 5 +- .../CalendarHaruPresentation.tsx | 14 +- .../CompactMessageRenderer.tsx | 4 +- .../ConversationControlsLocalization.test.tsx | 85 ++ .../DesktopHaruWindow.test.tsx | 71 +- .../assistantSurface/DesktopHaruWindow.tsx | 62 +- .../assistantSurface/HaruChatWindow.test.tsx | 54 + .../assistantSurface/HaruChatWindow.tsx | 70 +- .../assistantSurface/PilotWorkspace.tsx | 12 +- .../assistantSurface/assistantPresentation.ts | 3 +- .../features/assistantSurface/desktopHaru.ts | 3 + .../useDesktopHaruOwner.test.tsx | 147 +- .../assistantSurface/useDesktopHaruOwner.ts | 25 +- .../usePilotConversationController.test.tsx | 24 +- .../usePilotConversationController.ts | 43 +- .../usePilotExecution.test.tsx | 19 + .../assistantSurface/usePilotExecution.ts | 6 +- .../coreTaskSurface/CoreTaskSurfaceHost.tsx | 9 +- web/src/features/dashboard/DashboardView.tsx | 58 +- web/src/features/dashboard/todayWorkspace.ts | 9 +- .../dashboard/widgets/ActionQueue.tsx | 13 +- .../dashboard/widgets/ActionSummary.tsx | 10 +- .../widgets/ApplicationReadinessStrip.tsx | 14 +- .../dashboard/widgets/CommandCenter.tsx | 24 +- .../dashboard/widgets/ConversionFunnel.tsx | 4 +- .../dashboard/widgets/FocusWorkspace.tsx | 22 +- .../features/dashboard/widgets/KpiCards.tsx | 10 +- .../dashboard/widgets/MissionHeader.tsx | 8 +- .../dashboard/widgets/MomentumChart.tsx | 6 +- .../dashboard/widgets/RemindersSummary.tsx | 9 +- .../dashboard/widgets/TodayActionPlan.tsx | 16 +- .../dashboard/widgets/UpcomingSchedule.tsx | 12 +- .../dashboard/widgets/WeeklyMissionPanel.tsx | 42 +- .../dashboard/widgets/WeeklyRhythm.tsx | 13 +- .../InterviewReadinessCenter.tsx | 99 +- .../interviewReadinessModel.ts | 61 +- .../ContinuousVoiceModePanel.tsx | 65 +- .../interviewStudio/InterviewStudio.test.tsx | 31 + .../interviewStudio/InterviewStudio.tsx | 107 +- .../interviewStudio/evidenceLocator.ts | 7 +- .../features/jobMail/JobMailFlows.test.tsx | 46 +- web/src/features/jobMail/JobMailImport.tsx | 41 +- web/src/features/jobMail/JobMailInbox.tsx | 16 +- .../jobMail/JobMailPendingSummary.tsx | 6 +- web/src/features/jobMail/JobMailReview.tsx | 158 +- .../features/jobMail/JobMailSecureSetup.tsx | 67 +- web/src/features/jobMail/JobMailSettings.tsx | 103 +- web/src/features/jobMail/jobMailModel.ts | 48 +- .../jobMailWarningLocalization.test.ts | 17 + web/src/features/jobMail/secureSetupModel.ts | 71 +- .../materialClassification.ts | 3 +- .../materialSurfaces/materialKitSurface.ts | 18 +- .../materialSurfaces/materialLabels.ts | 25 +- .../OfflineWhisperModelCard.tsx | 46 +- .../VoiceAnswerComposer.test.tsx | 83 ++ .../VoiceAnswerComposer.tsx | 97 +- .../VoiceDeliverySummaryCard.tsx | 31 +- .../offlineWhisperManifest.ts | 3 +- .../onboarding/OnboardingChecklist.tsx | 19 +- .../pilot/PilotOpportunityFitV2Card.tsx | 42 +- .../features/pilotMascot/PilotMascot.test.tsx | 43 + web/src/features/pilotMascot/PilotMascot.tsx | 48 +- .../features/pipeline/ActionDetailDrawer.tsx | 21 +- web/src/features/reminders/RemindersView.tsx | 40 +- .../ProductActionConfirmation.tsx | 44 +- .../ReadinessFeedbackAdvisory.tsx | 30 +- .../ReviewReadinessNextStep.tsx | 35 +- .../reviewReadinessGate.test.ts | 13 +- .../desktopTaskFlowGate.test.ts | 6 +- .../desktopTaskFlowGolden.test.ts | 10 +- .../workspaceExperienceGate.test.ts | 8 +- web/src/i18n/catalogs.test.ts | 62 + web/src/i18n/catalogs/applications.ts | 1313 +++++++++++++++++ web/src/i18n/catalogs/auditFixes.ts | 10 + web/src/i18n/catalogs/core.ts | 626 ++++++++ web/src/i18n/catalogs/desktop.ts | 112 ++ web/src/i18n/catalogs/haru.ts | 152 ++ web/src/i18n/catalogs/interviews.ts | 965 ++++++++++++ web/src/i18n/catalogs/materials.ts | 867 +++++++++++ web/src/i18n/catalogs/pilot.ts | 380 +++++ web/src/i18n/catalogs/services.ts | 49 + web/src/i18n/controlCaching.test.tsx | 28 + web/src/i18n/desktopLocale.test.ts | 43 + web/src/i18n/desktopLocale.ts | 52 + web/src/i18n/i18n.test.tsx | 97 ++ web/src/i18n/index.ts | 106 ++ web/src/i18n/liveText.test.tsx | 22 + web/src/i18n/liveText.ts | 11 + web/src/i18n/modal.test.ts | 29 + web/src/i18n/modal.ts | 19 + web/src/i18n/nativePreference.test.ts | 37 + web/src/layout/AppShell.mascot.test.tsx | 2 +- web/src/layout/AppShell.test.ts | 8 +- web/src/layout/AppShell.tsx | 81 +- web/src/layout/CommandPalette.test.ts | 4 +- web/src/layout/CommandPalette.tsx | 46 +- web/src/layout/Sidebar.tsx | 14 +- web/src/layout/TopBar.test.ts | 2 +- web/src/layout/TopBar.tsx | 18 +- web/src/layout/navigation.ts | 41 +- web/src/layout/workspaceDrilldown.test.ts | 2 +- web/src/lib/actionItems.ts | 43 +- web/src/lib/applicationCommunicationDraft.ts | 79 +- web/src/lib/diagnostics.ts | 13 +- web/src/lib/evidencePresentation.ts | 27 +- web/src/lib/insights.ts | 19 +- web/src/lib/missionControl.ts | 73 +- web/src/lib/nextStepSuggestions.ts | 37 +- web/src/lib/pilotAttachments.ts | 15 +- web/src/lib/pilotPageContext.test.ts | 34 + web/src/lib/pilotPageContext.ts | 80 +- web/src/lib/pipelineInsights.ts | 82 +- web/src/lib/recoveryPolicy/recoveryPolicy.ts | 33 +- web/src/lib/resumeEvidenceAudit.ts | 55 +- web/src/lib/resumeFactSupplement.ts | 55 +- web/src/lib/resumeImport.ts | 5 +- web/src/lib/resumeVersionDiff.ts | 13 +- web/src/lib/storySourcePresentation.ts | 23 +- web/src/lib/structuredResume.ts | 7 +- web/src/main.tsx | 14 +- web/src/services/adaptiveInterviewPractice.ts | 13 +- web/src/services/applicationJdVersions.ts | 3 +- web/src/services/applications.ts | 3 +- web/src/services/chat.ts | 9 +- web/src/services/interviewKnowledgeCapture.ts | 23 +- web/src/services/interviewReviewProposals.ts | 13 +- web/src/services/jobMail.ts | 3 +- web/src/services/knowledge.ts | 5 +- web/src/services/pilotRuntime.ts | 13 +- web/src/types/application.ts | 11 +- web/src/types/event.ts | 11 +- web/src/types/jobMailSecureSetup.ts | 3 +- web/src/types/offer.ts | 11 +- web/src/types/proactive.ts | 25 +- web/tests/localization/fixture-api.mjs | 63 + web/tests/localization/mounted.test.tsx | 126 ++ web/tests/localization/run.mjs | 54 + 282 files changed, 13271 insertions(+), 4526 deletions(-) create mode 100644 .github/workflows/desktop-localization.yml create mode 100644 .github/workflows/ui-localization.yml create mode 100644 desktop/locale-catalog.cjs create mode 100644 desktop/locale.cjs create mode 100644 desktop/localization-ui/.gitignore create mode 100644 desktop/localization-ui/contract.mjs create mode 100644 desktop/localization-ui/retry-provenance.json create mode 100644 desktop/localization-ui/retry.mjs create mode 100644 desktop/localization-ui/security.mjs create mode 100644 desktop/localization-ui/smoke.mjs create mode 100644 desktop/localization-ui/test/contract.test.mjs create mode 100644 desktop/localization-ui/test/retry.test.mjs create mode 100644 desktop/localization-ui/test/security.test.mjs create mode 100644 desktop/localization-ui/windows.ps1 create mode 100644 desktop/test/locale.test.cjs create mode 100644 docs/architecture/decisions/0014-localize-authored-interface-copy.md create mode 100644 docs/architecture/desktop-localization-validation.md create mode 100644 tests/test_desktop_mail_localization_integration.py create mode 100644 web/src/components/ChatPanel/localization.test.tsx create mode 100644 web/src/components/InterviewFormsLocalization.test.tsx create mode 100644 web/src/components/MaterialLocalization.test.tsx create mode 100644 web/src/components/applicationUiText.ts create mode 100644 web/src/features/assistantSurface/ConversationControlsLocalization.test.tsx create mode 100644 web/src/features/jobMail/jobMailWarningLocalization.test.ts create mode 100644 web/src/i18n/catalogs.test.ts create mode 100644 web/src/i18n/catalogs/applications.ts create mode 100644 web/src/i18n/catalogs/auditFixes.ts create mode 100644 web/src/i18n/catalogs/core.ts create mode 100644 web/src/i18n/catalogs/desktop.ts create mode 100644 web/src/i18n/catalogs/haru.ts create mode 100644 web/src/i18n/catalogs/interviews.ts create mode 100644 web/src/i18n/catalogs/materials.ts create mode 100644 web/src/i18n/catalogs/pilot.ts create mode 100644 web/src/i18n/catalogs/services.ts create mode 100644 web/src/i18n/controlCaching.test.tsx create mode 100644 web/src/i18n/desktopLocale.test.ts create mode 100644 web/src/i18n/desktopLocale.ts create mode 100644 web/src/i18n/i18n.test.tsx create mode 100644 web/src/i18n/index.ts create mode 100644 web/src/i18n/liveText.test.tsx create mode 100644 web/src/i18n/liveText.ts create mode 100644 web/src/i18n/modal.test.ts create mode 100644 web/src/i18n/modal.ts create mode 100644 web/src/i18n/nativePreference.test.ts create mode 100644 web/tests/localization/fixture-api.mjs create mode 100644 web/tests/localization/mounted.test.tsx create mode 100644 web/tests/localization/run.mjs 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/ui-localization.yml b/.github/workflows/ui-localization.yml new file mode 100644 index 00000000..30ce2497 --- /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] + 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/locale-catalog.cjs b/desktop/locale-catalog.cjs new file mode 100644 index 00000000..8cb96152 --- /dev/null +++ b/desktop/locale-catalog.cjs @@ -0,0 +1,42 @@ +'use strict'; +// Native UI only. Protocol identifiers and user/provider content stay unchanged. +module.exports = { + '无法保存语言设置,请重试。': 'Could not save the language setting. Please try again.', + '文件': 'File', '编辑': 'Edit', '视图': 'View', '窗口': 'Window', + '撤销': 'Undo', '重做': 'Redo', '剪切': 'Cut', '复制': 'Copy', '粘贴': 'Paste', '全选': 'Select All', + '重新加载': 'Reload', '开发者工具': 'Developer Tools', '实际大小': 'Actual Size', '放大': 'Zoom In', '缩小': 'Zoom Out', '全屏': 'Full Screen', '最小化': 'Minimize', '关闭窗口': 'Close Window', + '打开 OfferPilot': 'Open OfferPilot', '隐藏 Haru': 'Hide Haru', '显示 Haru': 'Show Haru', 'Haru 始终置顶': 'Keep Haru on Top', + '退出并停止本地服务': 'Quit and Stop Local Service', + 'OfferPilot · Haru(退出请使用托盘菜单)': 'OfferPilot · Haru (use the tray menu to quit)', + '复制到剪贴板?': 'Copy to clipboard?', '允许录音?': 'Allow recording?', + '允许将当前选定内容复制到系统剪贴板?': 'Allow copying the selected content to the system clipboard?', + '允许 OfferPilot 在当前窗口使用麦克风?': 'Allow OfferPilot to use the microphone in this window?', + '会替换现有剪贴板内容,仅允许这一次复制,不读取剪贴板。': 'This replaces the clipboard contents. It allows this copy only and does not read the clipboard.', + '仅用于你主动开始的录音。不会授权摄像头、屏幕录制或其他设备。关闭或重新加载窗口后需要再次确认。': 'Only for recordings you start. This does not grant access to the camera, screen recording, or other devices. Confirmation is required again after closing or reloading the window.', + '取消': 'Cancel', '允许麦克风': 'Allow Microphone', '保存 OfferPilot 导出文件': 'Save OfferPilot Export', '保存': 'Save', + '打开外部链接?': 'Open external link?', '在系统浏览器中打开此链接?': 'Open this link in your default browser?', '打开浏览器': 'Open Browser', + '安装更新并重新启动?': 'Install update and restart?', + '请确认已保存所有编辑、可结束后台任务,并同意关闭应用安装。': 'Confirm that all edits are saved, background tasks can end, and the app may close to install the update.', + '部分页面的未保存编辑及后台任务无法完整自动检测。点击继续表示你已保存所有编辑,并确认可结束后台任务。安装前会在本机备份数据与配置;浏览器存储保留原位。安装过程中请勿关机。安装中断可能需要手动重新安装;备份不保证旧版本能直接读取已迁移的数据。': 'Unsaved edits on some pages and background tasks cannot all be detected automatically. Continuing confirms that all edits are saved and background tasks can end. Data and settings will be backed up locally before installation; browser storage stays in place. Do not turn off the computer during installation. An interrupted installation may require a manual reinstall. A backup does not guarantee that older versions can read migrated data.', + '已保存且可结束任务,关闭并安装': 'Saved; Tasks Can End. Close and Install', + '本地服务已停止。请关闭并重新打开 OfferPilot。已保存的数据仍在桌面数据文件夹中。': 'The local service stopped. Close and reopen OfferPilot. Saved data remains in the desktop data folder.', + '应用窗口已停止。请重新打开 OfferPilot,恢复已保存的工作。': 'The application window stopped. Reopen OfferPilot to recover your saved work.', + 'OfferPilot Desktop 无法启动': 'OfferPilot Desktop Could Not Start', + '更新未完成': 'Update Not Completed', + '更新准备或安装启动失败。将尝试重新启动原版本;若未重新打开,请手动启动并核实安装结果。备份与数据目录未被删除。': 'Update preparation or installer startup failed. The app will try to restart the previous version. If it does not reopen, start it manually and verify the installation result. The backup and data folders have not been deleted.', + '更新未安装': 'Update Not Installed', + '无法确认本地服务已停止,已阻止备份和安装。请等待当前操作结束,核对保存结果后重新启动应用。': 'The local service could not be confirmed stopped. Backup and installation were blocked. Wait for the current operation to finish, verify your saved work, then restart the app.', + '日志:{path}': 'Log: {path}', + '如果保存的端口被占用,请关闭冲突的应用后重试。请勿删除数据文件夹。': 'If the saved port is in use, close the conflicting application and try again. Do not delete the data folder.', + '保存的桌面端口无效。请从备份恢复 desktop-port.json。': 'The saved desktop port is invalid. Restore desktop-port.json from a backup.', + '缺少本地服务或网页资源。请重新构建或安装验证包。': 'The local service or web assets are missing. Rebuild or reinstall the validation package.', + '本地服务意外更改了保存的端口。': 'The local service unexpectedly changed the saved port.', + '本地服务启动超时。': 'The local service startup timed out.', + '无法启动随应用提供的本地服务。': 'Could not start the bundled local service.', + '本地服务启动失败,请查看桌面日志了解详情。': 'The local service failed to start. See the desktop log for details.', + '本地服务的启动响应无效。': 'The local service returned an invalid startup response.', + '本地服务健康检查失败。': 'The local service health check failed.', + '本地服务健康检查超时。': 'The local service health check timed out.', + '本地服务在启动前已退出({code})。': 'The local service exited before startup ({code}).', + '本地服务未能启动,请查看桌面日志了解详情。': 'The local service could not start. See the desktop log for details.', +}; diff --git a/desktop/locale.cjs b/desktop/locale.cjs new file mode 100644 index 00000000..9a7c6c2f --- /dev/null +++ b/desktop/locale.cjs @@ -0,0 +1,97 @@ +'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); +const { randomUUID } = require('node:crypto'); +const { isTrustedFrame } = require('./haru-protocol.cjs'); +const english = require('./locale-catalog.cjs'); +const CHANNEL = 'offerpilot:locale:'; +const DEFAULT_LOCALE = 'zh-CN'; +const isLocale = value => value === 'zh-CN' || value === 'en-US'; +function translate(source, locale = DEFAULT_LOCALE, values) { + const text = locale === 'en-US' ? english[source] ?? source : source; + return values ? text.replace(/\{(\w+)\}/g, (match, key) => Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : match) : text; +} +function createLocaleStore(userData) { + const filename = path.join(userData, 'desktop-locale.json'); + let locale = DEFAULT_LOCALE; + let persisted = false; + try { + if (fs.statSync(filename).size <= 1024) { + const saved = JSON.parse(fs.readFileSync(filename, 'utf8')).locale; + if (isLocale(saved)) { locale = saved; persisted = true; } + } + } catch { /* First launch or invalid preferences default to Chinese. */ } + const listeners = new Set(); + return { + getLocale: () => locale, + t: (source, values) => translate(source, locale, values), + setLocale(next) { + if (!isLocale(next)) return null; + if (next === locale && persisted) return locale; + const temporary = `${filename}.${randomUUID()}.tmp`; + try { + fs.mkdirSync(userData, { recursive: true }); + const fd = fs.openSync(temporary, 'wx', 0o600); + try { fs.writeFileSync(fd, `${JSON.stringify({ locale: next })}\n`); fs.fsyncSync(fd); } + finally { fs.closeSync(fd); } + fs.renameSync(temporary, filename); + } catch { + try { fs.unlinkSync(temporary); } catch { /* There may be no temporary file. */ } + throw new Error(translate('无法保存语言设置,请重试。', locale)); + } + locale = next; + persisted = true; + // A closed native menu/window must not turn a successful disk write into + // a reported persistence failure or prevent other windows from updating. + for (const listener of listeners) { try { listener(locale); } catch { /* The next get reads the saved value. */ } } + return locale; + }, + subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); }, + }; +} +function registerLocaleIPC({ ipcMain, owner, getHaruContents, origin, store, isQuitting }) { + const trustedOwner = event => !isQuitting() && isTrustedFrame(event, owner.webContents, origin); + const trustedHaru = event => !isQuitting() && isTrustedFrame(event, getHaruContents(), origin); + ipcMain.handle(CHANNEL + 'get', event => trustedOwner(event) || trustedHaru(event) ? store.getLocale() : null); + ipcMain.handle(CHANNEL + 'set', (event, locale) => trustedOwner(event) && isLocale(locale) ? store.setLocale(locale) : null); + const unsubscribe = store.subscribe(locale => { + if (isQuitting()) return; + for (const contents of [owner.webContents, getHaruContents()]) { + if (contents && isTrustedFrame({ sender: contents, senderFrame: contents.mainFrame }, contents, origin)) { + try { contents.send(CHANNEL + 'changed', locale); } catch { /* The renderer may have closed since the check. */ } + } + } + }); + return () => { unsubscribe(); ipcMain.removeHandler(CHANNEL + 'get'); ipcMain.removeHandler(CHANNEL + 'set'); }; +} +function applicationMenu({ t, showMain, quit, development = false }) { + return [ + { label: t('文件'), submenu: [{ label: t('打开 OfferPilot'), click: showMain }, { type: 'separator' }, { label: t('退出并停止本地服务'), click: quit }] }, + { label: t('编辑'), submenu: [ + { role: 'undo', label: t('撤销') }, { role: 'redo', label: t('重做') }, { type: 'separator' }, + { role: 'cut', label: t('剪切') }, { role: 'copy', label: t('复制') }, { role: 'paste', label: t('粘贴') }, { role: 'selectAll', label: t('全选') }, + ] }, + { label: t('视图'), submenu: [ + ...(development ? [{ role: 'reload', label: t('重新加载') }, { role: 'toggleDevTools', label: t('开发者工具') }, { type: 'separator' }] : []), + { role: 'resetZoom', label: t('实际大小') }, { role: 'zoomIn', label: t('放大') }, { role: 'zoomOut', label: t('缩小') }, { role: 'togglefullscreen', label: t('全屏') }, + ] }, + { label: t('窗口'), submenu: [{ role: 'minimize', label: t('最小化') }, { role: 'close', label: t('关闭窗口') }] }, + ]; +} +function startupErrorText(error, t) { + const messages = { + 'Invalid saved desktop port. Restore desktop-port.json from backup.': '保存的桌面端口无效。请从备份恢复 desktop-port.json。', + 'Backend or web assets are missing. Rebuild or reinstall the validation package.': '缺少本地服务或网页资源。请重新构建或安装验证包。', + 'Backend changed the saved port unexpectedly.': '本地服务意外更改了保存的端口。', + 'Backend startup timed out.': '本地服务启动超时。', + 'Could not start the bundled backend.': '无法启动随应用提供的本地服务。', + 'Backend startup failed. Check the desktop log for details.': '本地服务启动失败,请查看桌面日志了解详情。', + 'Invalid backend readiness response.': '本地服务的启动响应无效。', + 'Backend health check failed.': '本地服务健康检查失败。', + 'Backend health check timed out.': '本地服务健康检查超时。', + }; + const exited = /^Backend exited before startup \((-?\d+|null)\)\.$/.exec(error?.message || ''); + if (exited) return t('本地服务在启动前已退出({code})。', { code: exited[1] }); + return t(messages[error?.message] || '本地服务未能启动,请查看桌面日志了解详情。'); +} +module.exports = { CHANNEL, DEFAULT_LOCALE, isLocale, translate, createLocaleStore, registerLocaleIPC, applicationMenu, startupErrorText }; diff --git a/desktop/localization-ui/.gitignore b/desktop/localization-ui/.gitignore new file mode 100644 index 00000000..48a58e61 --- /dev/null +++ b/desktop/localization-ui/.gitignore @@ -0,0 +1 @@ +evidence/ diff --git a/desktop/localization-ui/contract.mjs b/desktop/localization-ui/contract.mjs new file mode 100644 index 00000000..54bd3864 --- /dev/null +++ b/desktop/localization-ui/contract.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; + +export const PROFILE = 'OfferPilot Desktop'; +export const DRAFT = '未发送的原文 draft · keep exactly 17:30 & '; +export const MAIL = Object.freeze({ + subject: '合成面试 invitation · exact original', + sender: 'synthetic@example.invalid', + received: '2026-10-15T08:00:00Z', + body: '仅供本地预览。Interview 2026-10-20T15:00:00+08:00 for 45 minutes.\n\nDo not translate this user-provided text.', +}); +export const TEXT = { + 'zh-CN': { navigation: '主导航', settings: '设置', language: '界面语言', haru: 'Haru 桌面小窗', + expand: '展开 Haru 对话', pin: 'Haru 始终置顶', send: '发送', mailbox: '求职邮箱', + paste: '粘贴单封邮件', preview: '预览将提交的文本', submit: '提交文本并生成待确认建议', + back: '返回编辑', cancel: '取消', guide: '连接 QQ 邮箱说明', + file: '文件', menu: ['文件', '编辑', '视图', '窗口'], quit: '退出并停止本地服务', + tray: ['打开 OfferPilot', '隐藏 Haru', 'Haru 始终置顶', '退出并停止本地服务'] }, + 'en-US': { navigation: 'Main navigation', settings: 'Settings', language: 'Interface language', haru: 'Haru Desktop Companion', + expand: 'Expand Haru Chat', pin: 'Keep Haru on Top', send: 'Send', mailbox: 'Job-search mailbox', + paste: 'Paste one email', preview: 'Preview text to submit', submit: 'Submit text and generate suggestions', + back: 'Back to editing', cancel: 'Cancel', guide: 'QQ Mail connection guide', + file: 'File', menu: ['File', 'Edit', 'View', 'Window'], quit: 'Quit and Stop Local Service', + tray: ['Open OfferPilot', 'Hide Haru', 'Keep Haru on Top', 'Quit and Stop Local Service'] }, +}; + +export function samePath(a, b) { return path.win32.normalize(a).toLowerCase() === path.win32.normalize(b).toLowerCase(); } +export function buttonName(label) { + const escaped = [...label].map(char => char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + return new RegExp(`^${escaped.join(/^[\u3400-\u9fff]{2}$/u.test(label) ? '\\s*' : '')}$`, 'u'); +} +export function classifyRequest(raw, method, origin) { + let url; + try { url = new URL(raw); } catch { return { allowed: false, reason: 'invalid-url' }; } + if (url.username || url.password) return { allowed: false, reason: 'url-credentials' }; + if (['data:', 'blob:'].includes(url.protocol)) return { allowed: true, reason: 'local-resource' }; + if (url.protocol === 'about:' && url.href === 'about:blank') return { allowed: true, reason: 'initial-document' }; + if (!['http:', 'https:'].includes(url.protocol) || url.origin !== origin) return { allowed: false, reason: 'outbound-or-unexpected-origin' }; + if (!['GET', 'HEAD'].includes(method)) return { allowed: false, reason: 'backend-mutation' }; + return { allowed: true, reason: 'local-read' }; +} +export function safeRequest(raw, method, decision) { + let pathname = ''; + try { const value = new URL(raw); pathname = value.origin === 'null' ? value.protocol : `${value.origin}${value.pathname}`; } catch { /* Never retain raw malformed input. */ } + return { method, target: pathname, reason: decision.reason }; +} +export function ownedProcesses(processes, pid, exe, backend) { + const main = processes.find(item => item.pid === pid && samePath(item.path, exe)); + assert.ok(main?.created, 'CIM must independently identify the launched installed executable'); + const children = processes.filter(item => item.parentPid === pid && samePath(item.path, backend)); + assert.ok(children.length <= 1, 'unexpected multiple direct backend children'); + return [main, ...children]; +} +export function earlyOwnedProcesses(processes, launch, exe, backend) { + assert.equal(launch.handleRunning, true, 'launch wrapper must still be alive to establish lineage'); + assert.ok(Number.isSafeInteger(launch.handlePid) && launch.handlePid > 0); + assert.ok(Number.isFinite(launch.startedAt) && Number.isFinite(launch.observedAt) && launch.observedAt >= launch.startedAt); + const roots = processes.filter(item => samePath(item.path, exe) + && (item.parentPid === launch.handlePid || item.pid === launch.handlePid) + && Date.parse(item.created) >= launch.startedAt && Date.parse(item.created) <= launch.observedAt); + assert.equal(roots.length, 1, 'one exact installed EXE in this live launch lineage is required'); + const result = ownedProcesses(processes, roots[0].pid, exe, backend); + for (const item of result) assert.ok(Date.parse(item.created) >= Date.parse(roots[0].created) + && Date.parse(item.created) <= launch.observedAt, 'owned child birth time must fit the verified main lifetime'); + return result; +} +export function validateSockets(snapshot, backendPid, port) { + const backend = snapshot.listeners.filter(item => item.pid === backendPid); + assert.deepEqual(backend.map(item => ({ address: item.address, port: item.port })), [{ address: '127.0.0.1', port }], 'backend must listen only on its authenticated IPv4 loopback origin'); + for (const item of snapshot.listeners) assert.ok(['127.0.0.1', '::1'].includes(item.address), 'debugging and backend listeners must stay on loopback'); + assert.deepEqual(snapshot.outbound, [], 'observed product non-loopback TCP connection'); +} +export async function sha256(filename) { return createHash('sha256').update(await fs.readFile(filename)).digest('hex'); } +export async function treeDigest(root) { + const files = []; + async function visit(relative = '') { + for (const entry of (await fs.readdir(path.join(root, relative), { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name, 'en'))) { + const name = relative ? `${relative}/${entry.name}` : entry.name; + assert.equal(entry.isSymbolicLink(), false, 'packaged payload must not contain symlinks'); + if (entry.isDirectory()) await visit(name); + else if (entry.isFile()) files.push({ path: name, sha256: await sha256(path.join(root, name)) }); + else assert.fail('unexpected payload entry'); + } + } + await visit(); + assert.ok(files.length > 0, 'empty payload tree'); + return { files, sha256: createHash('sha256').update(JSON.stringify(files)).digest('hex') }; +} +export function assertNativeMenu(menu, locale) { + const copy = TEXT[locale]; + assert.deepEqual(menu.map(item => item.label), copy.menu, 'native application menu language'); + assert.ok(menu[0].children.some(item => item.label === copy.quit && item.enabled), 'explicit localized quit must be available'); +} diff --git a/desktop/localization-ui/retry-provenance.json b/desktop/localization-ui/retry-provenance.json new file mode 100644 index 00000000..63f68d9a --- /dev/null +++ b/desktop/localization-ui/retry-provenance.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "repository": "offercontext/offerPilot", + "sourceSHA": "05fcd54a3bc0f3ebd792083704c6a5a861099797", + "runId": "38710428545", + "runURL": "https://github.com/offercontext/offerPilot/actions/runs/38710428545", + "installerArtifactId": "11961578103", + "evidenceArtifactId": "11961468467", + "evidenceArchiveSha256": "938b56ff547d9571e3dd692e9aef32db19d9ad67c005a5f789dc57c209ade3a4", + "installer": "OfferPilot-Desktop-localization-05fcd54a3bc0-win-x64-setup.exe", + "installerSha256": "614acdc2e4eae82c97ca0c699038a83ef89db7d0a32f5f269e64d4619e20d520", + "evidenceFiles": { + "result.json": "0dca6c6f6ca173e32c3f361729018f5e345e22d965e85b0f0a93a26647467f05", + "source-sha256.json": "ea3c9667d04072a433f15a687a9007d86a84f4f75ce91f1ed674bd83da70aabb", + "installed-web-sha256.json": "0c34259728d7acae6fccfc69a07faf8b179fdcbc49e74b632a3e39444cc8fbbf", + "installed-backend-sha256.json": "b9613df100f5a9e243cc4f4449bef3c923f5e09138f239fba5a2a9b98d3522df" + } +} diff --git a/desktop/localization-ui/retry.mjs b/desktop/localization-ui/retry.mjs new file mode 100644 index 00000000..67ab54fa --- /dev/null +++ b/desktop/localization-ui/retry.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; + +export async function readHashedJSON(filename, expected) { + assert.match(expected, /^[a-f0-9]{64}$/); + const bytes = await fs.readFile(filename); + assert.equal(createHash('sha256').update(bytes).digest('hex'), expected, 'original evidence file hash must match pinned provenance'); + return JSON.parse(bytes.toString('utf8')); +} +export function validateOriginalEvidence(provenance, evidence) { + const original = evidence['result.json']; + assert.equal(original.sourceSHA, provenance.sourceSHA, 'original report source identity'); + assert.equal(original.runURL, provenance.runURL, 'original report run identity'); + assert.equal(original.installer.filename, provenance.installer, 'original installer filename'); + assert.equal(original.installer.sha256, provenance.installerSha256, 'original installer checksum'); + assert.ok(original.stages.some(item => item.name === 'install-new-nsis' && item.status === 'passed' + && item.nsisActuallyInstalled === true && item.sameBuiltPayloadVerified === true), 'original build-to-install identity proof is required'); + assert.equal(evidence['source-sha256.json'].sourceSHA, provenance.sourceSHA, 'original source manifest identity'); + assert.ok(evidence['source-sha256.json'].files.length > 0, 'original source file manifest must not be empty'); + for (const name of ['web', 'backend']) { + const tree = evidence[`installed-${name}-sha256.json`]; + assert.equal(tree.sha256, original.installedIdentityBefore[`${name}Tree`].sha256, 'original resource manifest digest'); + assert.equal(tree.files.length, original.installedIdentityBefore[`${name}Tree`].files, 'original resource manifest file count'); + assert.equal(createHash('sha256').update(JSON.stringify(tree.files)).digest('hex'), tree.sha256, 'original resource file list integrity'); + } + return evidence; +} +export async function loadOriginalEvidence(directory) { + const provenance = JSON.parse(await fs.readFile(new URL('./retry-provenance.json', import.meta.url), 'utf8')); + const evidence = {}; + for (const [filename, hash] of Object.entries(provenance.evidenceFiles)) evidence[filename] = await readHashedJSON(path.join(directory, filename), hash); + validateOriginalEvidence(provenance, evidence); + return { provenance, evidence }; +} diff --git a/desktop/localization-ui/security.mjs b/desktop/localization-ui/security.mjs new file mode 100644 index 00000000..8b111e3c --- /dev/null +++ b/desktop/localization-ui/security.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; + +export function preferenceObservation(value) { + if (value === undefined) return 'undefined'; + if (typeof value === 'boolean') return value; + return 'invalid-type'; +} + +export function assertDevToolsPreference(value, runtimeElectron, proof) { + if (value !== undefined) { + assert.equal(value, false, 'reported packaged devTools preference must be explicitly false'); + return; + } + // Electron 44.5.1 can omit this constructor-only preference. Missing is not + // equivalent to false: require the exact version and verified source policy. + assert.equal(runtimeElectron, '44.5.1', 'missing devTools getter is supported only for exact Electron 44.5.1'); + assert.deepEqual(proof, { lockedElectron: '44.5.1', installedSourcesHashMatched: true, + ownerPackagedDisabled: true, haruDisabled: true }, 'missing devTools getter requires exact hash-bound packaged constructor policies'); +} + +export function assertDevToolsProbe(probe) { + assert.deepEqual(probe, { beforeOpen: false, beforeContents: false, openedEvent: false, + afterOpen: false, afterContents: false }, 'installed renderer must reject opening DevTools without any event or contents'); +} + +// Serialized by Playwright into the real Electron main process. This observes +// the existing product window and never patches preferences or closes a probe +// window to convert a security failure into passing evidence. +export async function probeDevToolsDisabled({ BrowserWindow }, windowId) { + const windows = BrowserWindow.getAllWindows().filter(win => win.id === windowId); + if (windows.length !== 1) throw new Error('exact-product-window-required'); + const contents = windows[0].webContents; + const beforeOpen = contents.isDevToolsOpened(); + const beforeContents = Boolean(contents.devToolsWebContents); + let openedEvent = false; + const opened = () => { openedEvent = true; }; + contents.on('devtools-opened', opened); + try { + if (!beforeOpen && !beforeContents) contents.openDevTools({ mode: 'detach', activate: false }); + await new Promise(resolve => setTimeout(resolve, 1000)); + return { beforeOpen, beforeContents, openedEvent, + afterOpen: contents.isDevToolsOpened(), afterContents: Boolean(contents.devToolsWebContents) }; + } finally { contents.removeListener('devtools-opened', opened); } +} diff --git a/desktop/localization-ui/smoke.mjs b/desktop/localization-ui/smoke.mjs new file mode 100644 index 00000000..86ffd2e4 --- /dev/null +++ b/desktop/localization-ui/smoke.mjs @@ -0,0 +1,624 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createRequire } from 'node:module'; +import { spawn } from 'node:child_process'; +import net from 'node:net'; +import { loadOriginalEvidence } from './retry.mjs'; +import { preferenceObservation, assertDevToolsPreference, assertDevToolsProbe, probeDevToolsDisabled } from './security.mjs'; +import { PROFILE, DRAFT, MAIL, TEXT, samePath, buttonName, classifyRequest, safeRequest, ownedProcesses, earlyOwnedProcesses, + validateSockets, sha256, treeDigest, assertNativeMenu } from './contract.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, '../..'); +const retryOriginal = process.env.LOCALIZATION_RETRY_ORIGINAL === '1'; +const productRoot = path.resolve(process.env.LOCALIZATION_PRODUCT_SOURCE_DIR || root); +const evidence = path.resolve(process.env.LOCALIZATION_EVIDENCE_DIR || path.join(here, 'evidence')); +const report = { + status: 'running', scope: retryOriginal ? 'same-original-installer-localization-retry' : 'new-unsigned-NSIS-installed-localization-smoke', releaseReady: false, + sourceSHA: process.env.LOCALIZATION_SOURCE_SHA || process.env.GITHUB_SHA, + runURL: process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID + ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}` : null, + instrumentation: { + renderer: 'real product Settings clicks and synthetic unsent text, no fixtures or response replacement', + electron: 'Playwright inspector launch of installed executable; security switches remain enabled', + localeReads: 'read-only trusted owner/Haru preload getLocale and DOM lang', + applicationMenu: 'read-only native Menu object; explicit production quit callback invoked through inspector', + tray: 'transparent setContextMenu observation; production call, this and return preserved', + nativeMenuPointerValidated: false, unsignedSmartScreenValidated: false, undebuggedLaunchValidated: false, + devTools: 'negative openDevTools event/state/contents probes on both installed windows; omitted getter accepted only for exact Electron 44.5.1 plus hash-verified packaged constructor policies', + network: 'renderer requests abort on non-GET/HEAD or unexpected origin; process TCP snapshots at checkpoints', + networkLimit: 'Renderer guard begins after Playwright attaches. OS snapshots are not continuous backend packet capture.', + }, + exclusions: ['real mail', 'real models', 'credential setup', 'mail submission', 'business confirmation', + 'full release gate', 'language switch inside a modal that blocks Settings', 'unsent draft persistence across process exit'], + stages: [], launchAttempts: [], launches: [], screenshots: [], blockedRequests: [], apiReads: 0, localReads: 0, + requiredStages: [ + 'install-new-nsis', 'launch-1-installed-identity-security-and-locale', + 'launch-1-zh-CN-mail-preview-boundary', 'launch-1-settings-zh-CN-to-en-US', + 'launch-1-en-US-mail-preview-boundary', 'launch-1-explicit-menu-exit', + 'launch-2-installed-identity-security-and-locale', 'launch-2-settings-en-US-to-zh-CN', + 'launch-2-explicit-menu-exit', 'launch-3-installed-identity-security-and-locale', + 'launch-3-explicit-menu-exit', 'immutable-installed-payload', + ], +}; +let stage = 'environment'; +let active; +let owned = []; +let paths; +let environment; +let installedIdentity; +let sourceIdentity; +let originalEvidence; +let _electron; +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); +const exists = async filename => fs.access(filename).then(() => true, () => false); +let reportWrite = Promise.resolve(); +async function saveReport() { + const text = JSON.stringify(report, null, 2) + '\n'; + reportWrite = reportWrite.then(() => fs.writeFile(path.join(evidence, 'result.json'), text)); + await reportWrite; +} +function ensureSafe() { assert.equal(report.blockedRequests.length, 0, 'unexpected request was aborted; acceptance fails closed'); } +async function checkpoint(name, details = {}) { + ensureSafe(); + report.stages.push({ name, status: 'passed', ...details }); + await saveReport(); + console.log(`Passed: ${name}`); +} +async function launchPhase(name, observations) { + stage = `launch-${active.number}-${name}`; + active.attempt.phase = name; + if (observations) active.attempt.observations.push({ phase: name, ...observations }); + await saveReport(); +} +async function until(test, timeout = 30000) { + const end = Date.now() + timeout; + while (Date.now() < end) { ensureSafe(); if (await test()) return; await delay(200); } + throw new Error('bounded-wait-timeout'); +} +async function command(program, args, env = process.env, timeout = 240000) { + return new Promise((resolve, reject) => { + const child = spawn(program, args, { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); + const chunks = []; + let bytes = 0; + const timer = setTimeout(() => { child.kill(); reject(new Error('owned-command-timeout')); }, timeout); + child.stdout.on('data', chunk => { bytes += chunk.length; if (bytes <= 16 * 1024 * 1024) chunks.push(chunk); }); + // Never publish child stderr, inspector endpoints, tokens, or a whole environment. + child.stderr.resume(); + child.once('error', () => { clearTimeout(timer); reject(new Error('command-start-failed')); }); + child.once('close', code => { + clearTimeout(timer); + if (code !== 0 || bytes > 16 * 1024 * 1024) reject(new Error('command-failed')); + else resolve(Buffer.concat(chunks).toString('utf8').replace(/^\uFEFF/, '').trim()); + }); + }); +} +async function windows(mode, extra = {}) { + return JSON.parse(await command('pwsh.exe', ['-NoProfile', '-NonInteractive', '-File', path.join(here, 'windows.ps1'), '-Mode', mode], { ...environment, ...extra })); +} +async function port() { + const saved = JSON.parse(await fs.readFile(path.join(paths.userData, 'desktop-port.json'), 'utf8')); + assert.ok(Number.isInteger(saved.port) && saved.port > 0 && saved.port < 65536, 'saved backend port must be a valid TCP port'); + return saved.port; +} +async function shot(page, label) { + ensureSafe(); + await page.screenshot({ path: path.join(evidence, `${label}.png`), timeout: 15000 }); + report.screenshots.push({ file: `${label}.png`, label, launch: active.number }); + await saveReport(); +} +async function verifyPayload() { + const identity = {}; + for (const [key, relative] of [['exeSha256', 'OfferPilot Desktop.exe'], ['asarSha256', 'resources/app.asar'], ['backendExeSha256', 'resources/backend/offerpilot-backend.exe']]) { + const hash = await sha256(path.join(paths.install, relative)); + const expected = originalEvidence ? originalEvidence.evidence['result.json'].installedIdentityBefore[key] : await sha256(path.join(paths.build, relative)); + assert.equal(hash, expected, 'installed file differs from the original same-built payload identity'); + identity[key] = hash; + } + for (const name of ['web', 'backend']) { + const installed = await treeDigest(path.join(paths.install, 'resources', name)); + const built = originalEvidence ? originalEvidence.evidence[`installed-${name}-sha256.json`] : await treeDigest(path.join(paths.build, 'resources', name)); + assert.deepEqual(installed, built, 'installed resources differ from the same-built payload'); + identity[`${name}Tree`] = { sha256: installed.sha256, files: installed.files.length }; + await fs.writeFile(path.join(evidence, `installed-${name}-sha256.json`), JSON.stringify(installed, null, 2) + '\n'); + } + return identity; +} +async function verifySource() { + const tracked = (await command('git', ['-C', productRoot, 'ls-files', '-z', '--', 'desktop/*.cjs', 'desktop/*.py', 'desktop/*.spec', + 'desktop/package.json', 'desktop/package-lock.json', 'src/offerpilot', 'web/src', 'web/index.html', 'web/vite.config.ts', + 'web/package.json', 'web/package-lock.json', 'pyproject.toml', 'uv.lock'])).split('\0').filter(Boolean).sort(); + assert.ok(tracked.length > 0, 'product source manifest must not be empty'); + const files = await Promise.all(tracked.map(async file => ({ file, sha256: await sha256(path.join(productRoot, file)) }))); + const desktopRequire = createRequire(path.join(root, 'desktop/package.json')); + const { extractFile } = await import(pathToFileURL(desktopRequire.resolve('@electron/asar')).href); + const desktopPackage = JSON.parse(await fs.readFile(path.join(productRoot, 'desktop/package.json'), 'utf8')); + const packedSources = desktopPackage.build.files.filter(name => name.endsWith('.cjs')); + assert.ok(['main.cjs', 'preload.cjs', 'locale.cjs', 'haru.cjs'].every(file => packedSources.includes(file)), 'ASAR source mapping must include main, preload, locale and Haru modules'); + for (const file of packedSources) { + assert.deepEqual(extractFile(path.join(paths.install, 'resources/app.asar'), file), + await fs.readFile(path.join(productRoot, 'desktop', file)), 'installed ASAR source must match the immutable product checkout'); + } + const identity = { sourceSHA: report.sourceSHA, files, packedSourcesVerified: packedSources }; + if (originalEvidence) assert.deepEqual(identity, originalEvidence.evidence['source-sha256.json'], 'product checkout must match the original retained source manifest'); + else assert.deepEqual(await treeDigest(path.join(paths.install, 'resources/web')), await treeDigest(path.join(productRoot, 'web/dist')), + 'installed frontend assets must match this checkout build'); + report.devToolsConstructorPolicy = { + lockedElectron: desktopPackage.devDependencies.electron, + installedSourcesHashMatched: true, + ownerPackagedDisabled: /devTools:\s*!app\.isPackaged\b/.test(extractFile(path.join(paths.install, 'resources/app.asar'), 'main.cjs').toString('utf8')), + haruDisabled: /devTools:\s*false\b/.test(extractFile(path.join(paths.install, 'resources/app.asar'), 'haru.cjs').toString('utf8')), + }; + return identity; +} +async function installRequestGuard(app) { + await app.context().routeWebSocket('**/*', socket => { + report.blockedRequests.push(safeRequest(socket.url(), 'WEBSOCKET', { reason: 'unexpected-websocket' })); + socket.close({ code: 1008, reason: 'Read-only localization validation' }); + void saveReport(); + }); + await app.context().route('**/*', async route => { + const request = route.request(); + let decision; + try { decision = classifyRequest(request.url(), request.method(), `http://127.0.0.1:${await port()}`); } + catch { decision = { allowed: false, reason: 'unverified-backend-origin' }; } + if (!decision.allowed) { + report.blockedRequests.push(safeRequest(request.url(), request.method(), decision)); + await route.abort('blockedbyclient'); + await saveReport(); + return; + } + report.localReads++; + if (new URL(request.url()).pathname.startsWith('/api/')) report.apiReads++; + await route.continue(); + }); +} +async function launch(number, locale) { + stage = `launch-${number}-preflight`; + assert.deepEqual((await windows('snapshot')).processes, [], 'old installed processes must exit before restart'); + const startedAt = Date.now(); + const attempt = { number, phase: 'electron-start', observations: [] }; + report.launchAttempts.push(attempt); + stage = `launch-${number}-electron-start`; + await saveReport(); + const app = await _electron.launch({ executablePath: paths.exe, cwd: paths.install, env: environment, + chromiumSandbox: true, bypassCSP: false, timeout: 90000 }); + active = { app, number, child: app.process(), startedAt, attempt }; + // Guard installation must remain the first awaited action after attachment. + stage = `launch-${number}-request-guards`; + attempt.phase = 'request-guards'; + await installRequestGuard(app); + await launchPhase('main-identity'); + const identity = await app.evaluate(({ app }) => ({ pid: process.pid, exe: process.execPath, userData: app.getPath('userData'), packaged: app.isPackaged })); + await launchPhase('main-identity', { packaged: identity.packaged === true, + installedExecutableMatches: samePath(identity.exe, paths.exe), realProfileMatches: samePath(identity.userData, paths.userData) }); + assert.equal(identity.packaged, true, 'installed Electron must report app.isPackaged true'); + assert.ok(samePath(identity.exe, paths.exe), 'inspector process executable must equal the installed executable'); + assert.ok(samePath(identity.userData, paths.userData), 'real APPDATA must be used; no redirected profile'); + await launchPhase('owned-main-process'); + owned = ownedProcesses((await windows('snapshot')).processes, identity.pid, paths.exe, paths.backend); + await launchPhase('backend-port-file'); + await until(() => exists(path.join(paths.userData, 'desktop-port.json')), 90000); + active.port = await port(); + active.origin = `http://127.0.0.1:${active.port}`; + await launchPhase('renderer-roles'); + await until(async () => { + const pages = app.windows(); + assert.ok(pages.length <= 2, 'unexpected extra product window'); + if (pages.length !== 2) return false; + const candidates = await Promise.all(pages.map(async page => ({ page, url: page.url(), role: await page.evaluate(() => window.offerpilotDesktop?.role).catch(() => null) }))); + for (const candidate of candidates) { + if (!candidate.role) return false; + const url = new URL(candidate.url); + await launchPhase('renderer-roles', { role: ['owner', 'haru'].includes(candidate.role) ? candidate.role : 'unexpected', + originMatches: url.origin === active.origin, rootPath: url.pathname === '/', + queryRole: url.search === '' ? 'owner' : url.search === '?desktopSurface=haru' ? 'haru' : 'unexpected' }); + assert.equal(url.origin, active.origin, 'renderer origin must equal the authenticated backend origin'); + assert.equal(url.pathname, '/', 'renderer must load the packaged root document'); + if (candidate.role === 'owner') { assert.equal(url.search, '', 'owner renderer must have no surface query'); active.page = candidate.page; } + else if (candidate.role === 'haru') { assert.equal(url.search, '?desktopSurface=haru', 'Haru renderer must have its exact bounded surface query'); active.haru = candidate.page; } + else assert.fail('unrecognized preload role'); + } + assert.ok(active.page && active.haru && active.page !== active.haru, 'distinct owner and Haru renderers must both exist'); + return true; + }, 90000); + for (const page of [active.page, active.haru]) page.setDefaultTimeout(20000); + await launchPhase('initial-localized-landmarks'); + await active.page.getByRole('navigation', { name: TEXT[locale].navigation, exact: true }).waitFor(); + await active.haru.getByRole('main', { name: TEXT[locale].haru, exact: true }).waitFor(); + await launchPhase('runtime-security-read'); + const security = await app.evaluate(({ app, BrowserWindow, session }) => { + const windows = BrowserWindow.getAllWindows(); + const owner = windows.find(win => !new URL(win.webContents.getURL()).search); + const haru = windows.find(win => new URL(win.webContents.getURL()).search === '?desktopSurface=haru'); + const details = windows.map(win => { + const prefs = win.webContents.getLastWebPreferences(); + return { windowId: win.id, nodeIntegration: prefs.nodeIntegration, contextIsolation: prefs.contextIsolation, sandbox: prefs.sandbox, + webSecurity: prefs.webSecurity, devTools: prefs.devTools, devToolsOpened: win.webContents.isDevToolsOpened() }; + }); + return { details, runtimeElectron: process.versions.electron, packaged: app.isPackaged, + isolated: owner.webContents.session !== haru.webContents.session, + ownerPartition: owner.webContents.session === session.fromPartition('persist:offerpilot-desktop'), + haruPartition: haru.webContents.session === session.fromPartition('offerpilot-haru'), + haruEphemeral: haru.webContents.session.getStoragePath() === null, + unsafeSwitches: ['no-sandbox', 'disable-web-security', 'disable-site-isolation-trials', 'allow-running-insecure-content', 'ignore-certificate-errors'].filter(name => app.commandLine.hasSwitch(name)) }; + }); + const safeBoolean = value => typeof value === 'boolean' ? value : null; + await launchPhase('runtime-security-assertions', { security: { + details: security.details.map(({ windowId, devTools, ...detail }) => ({ + ...Object.fromEntries(Object.entries(detail).map(([name, value]) => [name, safeBoolean(value)])), + devToolsPreference: preferenceObservation(devTools), + })), + runtimeElectronMatchesLocked: security.runtimeElectron === report.devToolsConstructorPolicy.lockedElectron, + ...Object.fromEntries(['packaged', 'isolated', 'ownerPartition', 'haruPartition', 'haruEphemeral'].map(name => [name, safeBoolean(security[name])])), + unsafeSwitches: security.unsafeSwitches, + } }); + assert.deepEqual(security.details.map(({ windowId, devTools, ...detail }) => detail), Array.from({ length: 2 }, () => ({ nodeIntegration: false, contextIsolation: true, + sandbox: true, webSecurity: true, devToolsOpened: false })), 'both installed renderers must retain every exposed packaged security preference'); + for (const name of ['packaged', 'isolated', 'ownerPartition', 'haruPartition', 'haruEphemeral']) assert.equal(security[name], true, `installed security invariant must remain true: ${name}`); + assert.deepEqual(security.unsafeSwitches, [], 'installed Electron must have no unsafe Chromium switches'); + for (const detail of security.details) assertDevToolsPreference(detail.devTools, security.runtimeElectron, report.devToolsConstructorPolicy); + for (const [index, detail] of security.details.entries()) { + await launchPhase('devtools-blocked-open-probe', { productWindowIndex: index, devToolsPreference: preferenceObservation(detail.devTools) }); + detail.devToolsProbe = await app.evaluate(probeDevToolsDisabled, detail.windowId); + await launchPhase('devtools-blocked-open-probe', { productWindowIndex: index, probe: detail.devToolsProbe }); + assertDevToolsProbe(detail.devToolsProbe); + } + await launchPhase('owned-backend-and-sockets'); + const snapshot = await windows('snapshot'); + await launchPhase('owned-backend-and-sockets', { processCount: snapshot.processes.length, + listenerCount: snapshot.listeners.length, observedOutboundTcpCount: snapshot.outbound.length }); + owned = ownedProcesses(snapshot.processes, identity.pid, paths.exe, paths.backend); + assert.equal(owned.length, 2, 'actual installed backend must be a direct owned child'); + validateSockets(snapshot, owned[1].pid, active.port); + active.info = { number, initialLocale: locale, mainPid: owned[0].pid, backendPid: owned[1].pid, + port: active.port, realAppData: true, security, snapshotsWithoutOutboundTcp: 1 }; + report.launches.push(active.info); + await launchPhase('owner-haru-read-isolation'); + const ownerHealth = () => active.page.evaluate(async () => (await fetch('/api/health', { signal: AbortSignal.timeout(5000) })).status); + assert.equal(await ownerHealth(), 200, 'trusted owner must reach the authenticated backend'); + const haruRead = await active.haru.evaluate(async () => { + try { return { blocked: false, status: (await fetch('/api/health', { signal: AbortSignal.timeout(5000) })).status }; } + catch (error) { return { blocked: error.name === 'TypeError' }; } + }); + assert.deepEqual(haruRead, { blocked: true }, 'Haru must remain unable to read the backend; a timeout or HTTP 401 is not equivalent'); + assert.equal(await ownerHealth(), 200, 'trusted backend must remain available after Haru read denial'); + active.info.haruApiReadIsolation = { ownerHealthBeforeAndAfter: 200, haruGetBlockedBeforeHttp: true, onlyReadRequests: true }; + await launchPhase('observe-tray-and-expand-haru'); + await app.evaluate(({ Tray }) => { + const original = Tray.prototype.setContextMenu; + globalThis.__offerpilotLocalizationTrayLabels = null; + Tray.prototype.setContextMenu = function (menu) { + const result = original.call(this, menu); + globalThis.__offerpilotLocalizationTrayLabels = menu.items.filter(item => item.type !== 'separator').map(item => item.label); + return result; + }; + }); + await active.haru.getByRole('button', { name: TEXT[locale].expand, exact: true }).click(); + await launchPhase('wait-unconfigured-owner-mirror'); + await until(() => active.haru.evaluate(async () => { + const state = await window.offerpilotDesktop.getState(); + return state.connected && state.snapshot?.aiConfigured === false; + })); + await launchPhase('real-haru-close-collapses-to-character'); + await active.haru.getByRole('button', { name: locale === 'zh-CN' ? '关闭 Haru 对话' : 'Close Haru chat', exact: true }).click(); + await active.haru.getByRole('button', { name: TEXT[locale].expand, exact: true }).waitFor(); + const collapsed = await active.haru.evaluate(async () => window.offerpilotDesktop.getState()); + assert.equal(collapsed.expanded, false, 'X must collapse the conversation'); + assert.equal(collapsed.visible, true, 'X must keep the character visible'); + await shot(active.haru, `${number}-${locale}-haru-close-keeps-character`); + await active.haru.getByRole('button', { name: TEXT[locale].expand, exact: true }).click(); + await launchPhase('real-haru-pin-controls'); + const pin = active.haru.getByRole('button', { name: TEXT[locale].pin, exact: true }); + const before = await pin.getAttribute('aria-pressed'); + await pin.click(); + await until(async () => await pin.getAttribute('aria-pressed') !== before); + await pin.click(); + await until(async () => await pin.getAttribute('aria-pressed') === before); + await launchPhase('initial-locale-and-menus'); + await assertLocale(locale, `${number}-initial`); + await checkpoint(`launch-${number}-installed-identity-security-and-locale`); +} +async function assertLocale(locale, label) { + const copy = TEXT[locale]; + await until(async () => (await Promise.all([active.page, active.haru].map(page => page.evaluate(async () => ({ + locale: await window.offerpilotLocale.getLocale(), lang: document.documentElement.lang, + writer: typeof window.offerpilotLocale.setLocale, + }))))).every(value => value.locale === locale && value.lang === locale)); + const readers = await Promise.all([active.page, active.haru].map(page => page.evaluate(() => typeof window.offerpilotLocale.setLocale))); + assert.deepEqual(readers, ['function', 'undefined'], 'Haru must remain read-only for locale'); + await active.page.getByRole('navigation', { name: copy.navigation, exact: true }).waitFor(); + await active.haru.getByRole('main', { name: copy.haru, exact: true }).waitFor(); + const menus = await active.app.evaluate(({ Menu }) => ({ + application: Menu.getApplicationMenu().items.map(item => ({ label: item.label, + children: item.submenu?.items.map(child => ({ label: child.label, enabled: child.enabled })) || [] })), + tray: globalThis.__offerpilotLocalizationTrayLabels, + })); + assertNativeMenu(menus.application, locale); + assert.deepEqual(menus.tray, copy.tray, 'observed native tray menu must track locale'); + active.info.menuObservations ??= []; + active.info.menuObservations.push({ label, locale, ...menus }); + ensureSafe(); +} +async function businessSnapshot() { + const result = await active.page.evaluate(async () => { + const read = async route => { + const response = await fetch(route, { method: 'GET', credentials: 'same-origin', cache: 'no-store' }); + if (!response.ok) throw new Error('read-only-backend-snapshot-failed'); + return response.json(); + }; + const [applications, events, conversations, suggestions, status, secure] = await Promise.all([ + '/api/applications', '/api/application-events', '/api/chat/conversations', '/api/job-mail/suggestions', + '/api/job-mail/status', '/api/job-mail/secure-setup/status', + ].map(read)); + return { applications, events, conversations, suggestions, + mail: { connection: status.connection, run: status.run, budget: status.budget, capabilities: status.capabilities }, + secure: { available: secure.available, configured: secure.configured, deletion_pending: secure.deletion_pending, + credential_input_allowed: secure.credential_input_allowed, reason: secure.reason } }; + }); + assert.equal(result.secure.configured, false); + assert.equal(result.secure.credential_input_allowed, false); + assert.equal(result.secure.reason, 'local_setup_not_enabled'); + assert.equal(result.mail.connection, null); + assert.equal(result.mail.run, null); + assert.deepEqual(result.applications, []); + assert.deepEqual(result.events, []); + assert.deepEqual(result.conversations, []); + ensureSafe(); + return result; +} +async function settings(locale) { + const copy = TEXT[locale]; + await active.page.getByRole('navigation', { name: copy.navigation, exact: true }).getByRole('button', { name: copy.settings, exact: true }).click(); + await active.page.getByRole('combobox', { name: copy.language, exact: true }).waitFor(); + await active.page.getByRole('heading', { name: copy.mailbox, exact: true }).waitFor(); +} +async function switchLocale(from, to) { + stage = `launch-${active.number}-settings-${from}-to-${to}`; + const baseline = await businessSnapshot(); + const draft = active.haru.locator('#desktop-haru-input'); + assert.equal(await draft.inputValue(), DRAFT); + const select = active.page.getByRole('combobox', { name: TEXT[from].language, exact: true }); + // AntD's actual combobox input is covered by its selected-text span. + await select.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " ant-select ")][1]').locator('.ant-select-selector').click(); + await active.page.locator('.ant-select-item-option-content').filter({ hasText: to === 'en-US' ? /^English$/ : /^简体中文$/ }).click(); + await assertLocale(to, `${active.number}-after-settings-click`); + assert.equal(await draft.inputValue(), DRAFT, 'unsent Haru text must remain byte-for-byte unchanged'); + assert.equal(await active.haru.getByRole('button', { name: TEXT[to].send, exact: true }).isDisabled(), true); + assert.deepEqual(JSON.parse(await fs.readFile(path.join(paths.userData, 'desktop-locale.json'), 'utf8')), { locale: to }); + assert.deepEqual(await businessSnapshot(), baseline, 'language switch must not alter backend business or mailbox state'); + await shot(active.page, `${active.number}-${to}-settings-after-real-switch`); + await shot(active.haru, `${active.number}-${to}-haru-draft-preserved`); + validateSockets(await windows('snapshot'), active.info.backendPid, active.port); + active.info.snapshotsWithoutOutboundTcp++; + await checkpoint(stage, { settingsClick: true, draftPreserved: true, nativeAndHaruSynchronized: true, businessStateUnchanged: true }); +} +async function mailPreview(locale) { + stage = `launch-${active.number}-${locale}-mail-preview-boundary`; + const copy = TEXT[locale]; + const baseline = await businessSnapshot(); + const panel = active.page.locator('section[aria-labelledby="job-mail-settings-title"]'); + await panel.scrollIntoViewIfNeeded(); + await shot(active.page, `${active.number}-${locale}-mail-settings-disabled-setup`); + await panel.getByRole('button', { name: copy.guide, exact: true }).click(); + const guide = active.page.getByRole('dialog'); + await guide.waitFor(); + assert.equal(await guide.locator('input[type="password"]').count(), 0, 'disabled native setup must not solicit credentials'); + await shot(active.page, `${active.number}-${locale}-mail-safe-connection-guide`); + await guide.getByRole('button', { name: locale === 'zh-CN' ? '关闭并释放临时会话' : 'Close and release temporary session', exact: true }).click(); + await guide.waitFor({ state: 'hidden' }); + await panel.getByRole('button', { name: copy.paste, exact: true }).click(); + const dialog = active.page.getByRole('dialog', { name: copy.paste, exact: true }); + await dialog.waitFor(); + const inputs = dialog.locator('input:not([type="hidden"])'); + assert.equal(await inputs.count(), 3); + await inputs.nth(0).fill(MAIL.subject); + await inputs.nth(1).fill(MAIL.sender); + await inputs.nth(2).fill(MAIL.received); + await dialog.locator('textarea').fill(MAIL.body); + await dialog.getByRole('button', { name: copy.preview, exact: true }).click(); + await dialog.getByRole('button', { name: copy.submit, exact: true }).waitFor(); + assert.equal(await dialog.locator('pre').textContent(), MAIL.body, 'preview must retain untrusted mail as literal text'); + assert.ok((await dialog.textContent()).includes(MAIL.subject)); + assert.ok((await dialog.textContent()).includes(MAIL.sender)); + assert.ok((await dialog.textContent()).includes(MAIL.received)); + assert.equal(await dialog.locator('img,iframe,video,a[href]').count(), 0, 'mail HTML must not create active content'); + await shot(active.page, `${active.number}-${locale}-mail-preview-unsubmitted`); + assert.deepEqual(await businessSnapshot(), baseline, 'local preview must not submit or mutate backend state'); + await dialog.getByRole('button', { name: copy.back, exact: true }).click(); + assert.deepEqual(await inputs.evaluateAll(nodes => nodes.map(node => node.value)), [MAIL.subject, MAIL.sender, MAIL.received]); + assert.equal(await dialog.locator('textarea').inputValue(), MAIL.body); + await dialog.getByRole('button', { name: buttonName(copy.cancel) }).click(); + await dialog.waitFor({ state: 'hidden' }); + assert.deepEqual(await businessSnapshot(), baseline); + await checkpoint(stage, { previewOnly: true, rawContentPreserved: true, backPreservesDraft: true, + mailSubmitted: false, credentialsEntered: false, modelCalled: false }); +} +async function quitNormally() { + stage = `launch-${active.number}-explicit-menu-exit`; + const current = active; + const quit = await current.app.evaluate(({ Menu }) => { + const matches = Menu.getApplicationMenu().items.flatMap(item => item.submenu?.items || []) + .filter(item => ['退出并停止本地服务', 'Quit and Stop Local Service'].includes(item.label)); + if (matches.length !== 1 || matches[0].enabled === false || typeof matches[0].click !== 'function') throw new Error('explicit-quit-not-found'); + setTimeout(() => matches[0].click(matches[0], undefined, {}), 0); + return true; + }); + assert.equal(quit, true); + await until(async () => (await windows('snapshot')).processes.length === 0, 60000); + const portOpen = () => new Promise(resolve => { + const socket = net.connect({ host: '127.0.0.1', port: current.port }); + const done = open => { socket.destroy(); resolve(open); }; + socket.setTimeout(1000, () => done(false)); socket.once('connect', () => done(true)); socket.once('error', () => done(false)); + }); + await until(async () => !(await portOpen()), 10000); + current.info.explicitProductionMenuQuit = true; + current.info.mainAndBackendExited = true; + current.info.backendPortClosedBeforeRestart = true; + active = undefined; + owned = []; + await checkpoint(stage); +} + +await fs.mkdir(evidence, { recursive: true }); +try { + assert.equal(process.platform, 'win32', 'installed acceptance requires actual Windows'); + assert.equal(process.env.GITHUB_ACTIONS, 'true', 'dedicated CI runner required; never use a user computer'); + assert.ok(process.env.RUNNER_TEMP && process.env.APPDATA, 'Windows CI runtime paths are required'); + assert.match(report.sourceSHA || '', /^[0-9a-f]{40}$/, 'full original product source SHA is required'); + report.harnessSHA = await command('git', ['-C', root, 'rev-parse', 'HEAD']); + assert.equal(report.harnessSHA, process.env.GITHUB_SHA, 'harness checkout must equal this workflow revision'); + if (retryOriginal) { + for (const key of ['LOCALIZATION_PRODUCT_SOURCE_DIR', 'LOCALIZATION_ORIGINAL_EVIDENCE_DIR']) assert.ok(process.env[key] && path.isAbsolute(process.env[key]), `${key} must be absolute`); + assert.notEqual(productRoot, root, 'same-installer retry keeps the original product checkout separate from its harness'); + originalEvidence = await loadOriginalEvidence(process.env.LOCALIZATION_ORIGINAL_EVIDENCE_DIR); + assert.equal(report.sourceSHA, originalEvidence.provenance.sourceSHA, 'retry source must equal the pinned original build'); + assert.equal(process.env.LOCALIZATION_BUILD_DIR, undefined, 'retry must use retained original manifests, not rebuilt payloads'); + report.originalBuild = originalEvidence.provenance; + report.retryRebuildPerformed = false; + for (const file of ['web/package-lock.json', 'desktop/package-lock.json']) assert.equal(await sha256(path.join(root, file)), + await sha256(path.join(productRoot, file)), 'harness dependency lock must remain identical to the original product lock'); + } else assert.equal(productRoot, root, 'ordinary new-build validation uses its own product checkout'); + const sourceSHA = await command('git', ['-C', productRoot, 'rev-parse', 'HEAD']); + assert.equal(sourceSHA, report.sourceSHA, 'product source SHA must equal the immutable product checkout'); + for (const key of ['LOCALIZATION_INSTALLER', 'LOCALIZATION_INSTALL_DIR', ...(retryOriginal ? [] : ['LOCALIZATION_BUILD_DIR'])]) assert.ok(process.env[key] && path.isAbsolute(process.env[key]), `${key} must be absolute`); + paths = { installer: process.env.LOCALIZATION_INSTALLER, install: process.env.LOCALIZATION_INSTALL_DIR, + build: process.env.LOCALIZATION_BUILD_DIR, userData: path.join(process.env.APPDATA, PROFILE) }; + const relativeInstall = path.relative(process.env.RUNNER_TEMP, paths.install); + assert.ok(relativeInstall && !relativeInstall.startsWith('..') && !path.isAbsolute(relativeInstall), 'install only beneath this runner temp directory'); + paths.exe = path.join(paths.install, 'OfferPilot Desktop.exe'); + paths.backend = path.join(paths.install, 'resources/backend/offerpilot-backend.exe'); + assert.equal(await exists(paths.userData), false, 'existing real profile blocks acceptance; never delete it'); + assert.equal(await exists(paths.install), false, 'install directory must be fresh'); + const webRequire = createRequire(path.join(root, 'web/package.json')); + // require resolution selects Playwright's CJS entry; dynamic import of that + // file exposes only default in current Playwright, not a named _electron. + ({ _electron } = webRequire('playwright-core')); + assert.equal(typeof _electron?.launch, 'function', 'locked frontend Playwright Electron support is required'); + report.playwrightVersion = webRequire('playwright-core/package.json').version; + environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => + /^(SystemRoot|WINDIR|SystemDrive|COMSPEC|PATH|PATHEXT|TEMP|TMP|APPDATA|LOCALAPPDATA|USERPROFILE|HOMEDRIVE|HOMEPATH|ProgramFiles|ProgramFiles\(x86\)|ProgramData|ALLUSERSPROFILE|NUMBER_OF_PROCESSORS|PROCESSOR_ARCHITECTURE|OS|SESSIONNAME)$/i.test(key))); + Object.assign(environment, { LITELLM_LOCAL_MODEL_COST_MAP: 'True', PYTHONUTF8: '1', PYTHONUNBUFFERED: '1', + GITHUB_ACTIONS: 'true', RUNNER_TEMP: process.env.RUNNER_TEMP, LOCALIZATION_INSTALLER: paths.installer, + LOCALIZATION_INSTALL_DIR: paths.install, LOCALIZATION_USER_DATA: paths.userData, + LOCALIZATION_EXE: paths.exe, LOCALIZATION_BACKEND: paths.backend }); + report.installer = { filename: path.basename(paths.installer), sha256: await sha256(paths.installer) }; + if (originalEvidence) { + assert.equal(report.installer.filename, originalEvidence.provenance.installer, 'retry must use the exact original installer filename'); + assert.equal(report.installer.sha256, originalEvidence.provenance.installerSha256, 'retry must use the exact original installer bytes'); + } + report.installedExecutable = paths.exe; + report.profilePolicy = 'real APPDATA/OfferPilot Desktop asserted absent; no profile deletion or user-data-dir override'; + await saveReport(); + stage = 'install-new-nsis'; + assert.equal((await windows('install')).exitCode, 0, 'original NSIS must install successfully on the fresh runner'); + installedIdentity = await verifyPayload(); + report.installedIdentityBefore = installedIdentity; + sourceIdentity = await verifySource(); + await fs.writeFile(path.join(evidence, 'source-sha256.json'), JSON.stringify(sourceIdentity, null, 2) + '\n'); + await checkpoint(stage, { nsisActuallyInstalled: true, sameBuiltPayloadVerified: true, + payloadIdentitySource: retryOriginal ? 'pinned-original-build-evidence' : 'same-job-unpacked-build' }); + + await launch(1, 'zh-CN'); + await settings('zh-CN'); + const baseline = await businessSnapshot(); + report.initialBusinessState = baseline; + await active.haru.locator('#desktop-haru-input').fill(DRAFT); + await shot(active.page, '1-zh-CN-settings-initial'); + await shot(active.haru, '1-zh-CN-haru-original-draft'); + await mailPreview('zh-CN'); + await switchLocale('zh-CN', 'en-US'); + await mailPreview('en-US'); + await quitNormally(); + + await launch(2, 'en-US'); + await settings('en-US'); + assert.deepEqual(await businessSnapshot(), baseline, 'restart must not create business or mail state'); + await shot(active.page, '2-en-US-settings-persisted-after-restart'); + await shot(active.haru, '2-en-US-haru-persisted-after-restart'); + await active.haru.locator('#desktop-haru-input').fill(DRAFT); + await switchLocale('en-US', 'zh-CN'); + await quitNormally(); + + await launch(3, 'zh-CN'); + await settings('zh-CN'); + assert.deepEqual(await businessSnapshot(), baseline); + await shot(active.page, '3-zh-CN-settings-persisted-after-restart'); + await shot(active.haru, '3-zh-CN-haru-persisted-after-restart'); + await quitNormally(); + stage = 'immutable-installed-payload'; + report.installedIdentityAfter = await verifyPayload(); + assert.deepEqual(report.installedIdentityAfter, installedIdentity, 'installed product files must remain unmodified'); + assert.deepEqual(await verifySource(), sourceIdentity, 'source files and packed source mapping must remain unchanged'); + assert.equal(await sha256(paths.installer), report.installer.sha256); + assert.ok(report.apiReads > 0, 'guard must observe actual backend GET traffic'); + await checkpoint(stage, { installerAndInstalledPayloadUnchanged: true }); + report.status = 'passed'; +} catch (error) { + report.status = 'failed'; + // Do not serialize Playwright errors: they may contain full request URLs or inspector endpoints. + report.failure = { stage, kind: error instanceof assert.AssertionError ? 'assertion-failed' : 'operation-failed' }; + if (error instanceof assert.AssertionError && !error.generatedMessage) report.failure.assertion = error.message.slice(0, 250); + else if (/^[a-z][a-z-]{1,100}$/.test(error?.message || '')) report.failure.code = error.message; + if (['TimeoutError', 'TypeError', 'Error', 'AssertionError'].includes(error?.name)) report.failure.errorType = error.name; + console.error(`Installed localization failed at: ${stage}`); + process.exitCode = 1; + // Fresh product-only surfaces contain no credentials or user account data. + // Never capture a page that left the authenticated product origin. + if (active && report.blockedRequests.length === 0) { + for (const [role, page] of [['owner', active.page], ['haru', active.haru]]) { + if (!page) continue; + try { + const url = new URL(page.url()); + if (url.origin === active.origin && url.pathname === '/' && url.search === (role === 'owner' ? '' : '?desktopSurface=haru')) { + await shot(page, `failure-${report.failure.stage}-${role}`); + } + } catch { report.failure.screenshotIncomplete = true; } + } + } +} finally { + if (active) { + try { await quitNormally(); report.failureCleanup = 'explicit-menu-exit'; } + catch { + if (!owned.length && active.child) { + try { + const snapshot = await windows('snapshot'); + // Playwright uses shell:true on Windows: this handle may be cmd.exe. + // Independently identify only the installed EXE in its live lineage. + // Never kill the wrapper or infer that doing so stops Electron. + owned = earlyOwnedProcesses(snapshot.processes, { handlePid: active.child.pid, + handleRunning: active.child.exitCode === null && active.child.signalCode === null, + startedAt: active.startedAt, observedAt: Date.now() }, paths.exe, paths.backend); + } catch { report.failureCleanup = 'early-installed-process-lineage-unconfirmed'; } + } + if (owned.length) { + try { + const snapshot = await windows('snapshot'); + const main = owned[0]; + // Refresh only if the independently captured main identity still exists. + // This includes a backend that became ready after an early UI failure. + if (snapshot.processes.some(item => item.pid === main.pid && item.parentPid === main.parentPid && item.created === main.created && samePath(item.path, main.path))) { + owned = ownedProcesses(snapshot.processes, main.pid, paths.exe, paths.backend); + } + report.failureCleanup = await windows('cleanup', { LOCALIZATION_OWNED_IDENTITIES: JSON.stringify(owned) }); + const end = Date.now() + 15000; + while (Date.now() < end && (await windows('snapshot')).processes.length > 0) await delay(250); + report.failureCleanup.allInstalledProcessesExited = (await windows('snapshot')).processes.length === 0; + } + catch { report.failureCleanup = 'owned-process-cleanup-unconfirmed'; } + } else report.failureCleanup ??= 'no-independently-verified-process-identity'; + } + } + const passed = new Set(report.stages.map(item => item.name)); + report.requiredStageResults = report.requiredStages.map(name => ({ name, + status: passed.has(name) ? 'passed' : name === report.failure?.stage ? 'failed' : 'not-run-or-incomplete' })); + if (report.status === 'passed' && report.requiredStageResults.some(item => item.status !== 'passed')) { + report.status = 'failed'; + report.failure = { stage: 'stage-accounting', kind: 'required-stage-incomplete' }; + process.exitCode = 1; + } + report.completedAt = new Date().toISOString(); + await saveReport(); +} diff --git a/desktop/localization-ui/test/contract.test.mjs b/desktop/localization-ui/test/contract.test.mjs new file mode 100644 index 00000000..0b8b32f5 --- /dev/null +++ b/desktop/localization-ui/test/contract.test.mjs @@ -0,0 +1,126 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { buttonName, classifyRequest, safeRequest, samePath, ownedProcesses, earlyOwnedProcesses, validateSockets, treeDigest, assertNativeMenu, TEXT } from '../contract.mjs'; + +const origin = 'http://127.0.0.1:43123'; +const here = path.dirname(fileURLToPath(import.meta.url)); +test('visible AntD two-Han-character button typography is matched without broadening the action', () => { + assert.match('取消', buttonName('取消')); + assert.match('取 消', buttonName('取消')); + assert.doesNotMatch('取消并清空输入', buttonName('取消')); + assert.match('Cancel', buttonName('Cancel')); + assert.doesNotMatch('Cancel and submit', buttonName('Cancel')); + assert.match('Cancel (safe)', buttonName('Cancel (safe)')); +}); +test('only same-origin GET/HEAD reach the installed backend', () => { + for (const method of ['GET', 'HEAD']) assert.equal(classifyRequest(`${origin}/api/job-mail/status`, method, origin).allowed, true); + for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'get']) { + assert.deepEqual(classifyRequest(`${origin}/api/job-mail/imports`, method, origin), { allowed: false, reason: 'backend-mutation' }); + } + for (const url of ['https://api.example.invalid/v1', 'http://localhost:43123/api', 'http://127.0.0.1:43124/api', + 'http://127.0.0.1.evil.invalid:43123/api', 'ws://127.0.0.1:43123/api', 'file:///C:/credentials']) { + assert.equal(classifyRequest(url, 'GET', origin).allowed, false, url); + } + assert.equal(classifyRequest('http://user:pass@127.0.0.1:43123/', 'GET', origin).allowed, false); + assert.equal(classifyRequest('not a URL', 'GET', origin).allowed, false); + assert.equal(classifyRequest('data:image/png;base64,abc', 'GET', origin).allowed, true); + assert.equal(classifyRequest('blob:http://127.0.0.1:43123/id', 'GET', origin).allowed, true); +}); +test('failure request diagnostics drop query, fragments, credentials and inline content', () => { + const reason = { reason: 'outbound-or-unexpected-origin' }; + const safe = safeRequest('https://user:secret@example.invalid/api?token=private#hidden', 'GET', reason); + assert.deepEqual(safe, { method: 'GET', target: 'https://example.invalid/api', reason: reason.reason }); + assert.equal(safeRequest('data:text/plain,private', 'GET', reason).target, 'data:'); + assert.equal(safeRequest('invalid secret', 'GET', reason).target, ''); +}); +test('cleanup ownership requires independently observed exact installed paths and parent PID', () => { + const exe = 'C:\\runner\\安装 Application\\OfferPilot Desktop.exe'; + const backend = 'C:\\runner\\安装 Application\\resources\\backend\\offerpilot-backend.exe'; + const main = { pid: 10, parentPid: 4, path: exe, created: '2026-10-15T00:00:00Z' }; + const child = { pid: 11, parentPid: 10, path: backend, created: '2026-10-15T00:00:01Z' }; + assert.equal(samePath(exe, 'c:/RUNNER/安装 Application/OfferPilot Desktop.exe'), true); + assert.deepEqual(ownedProcesses([main, child, { ...child, pid: 15, parentPid: 7 }], 10, exe, backend), [main, child]); + assert.throws(() => ownedProcesses([{ ...main, created: undefined }], 10, exe, backend)); + assert.throws(() => ownedProcesses([{ ...main, path: 'C:\\other\\OfferPilot Desktop.exe' }], 10, exe, backend)); + assert.throws(() => ownedProcesses([main, child, { ...child, pid: 12 }], 10, exe, backend)); +}); +test('runtime socket check rejects nonloopback exposure and product outbound TCP', () => { + const snapshot = { listeners: [{ pid: 11, address: '127.0.0.1', port: 43123 }, { pid: 10, address: '127.0.0.1', port: 49152 }], outbound: [] }; + validateSockets(snapshot, 11, 43123); + assert.throws(() => validateSockets({ ...snapshot, outbound: [{ pid: 11, address: '192.0.2.1', port: 443 }] }, 11, 43123)); + assert.throws(() => validateSockets({ ...snapshot, listeners: [{ pid: 11, address: '0.0.0.0', port: 43123 }] }, 11, 43123)); + assert.throws(() => validateSockets(snapshot, 11, 43124)); +}); +test('early Windows cleanup distinguishes the live cmd wrapper from its installed EXE child', () => { + const exe = 'C:\\temp\\OfferPilot Desktop.exe'; + const backend = 'C:\\temp\\resources\\backend\\offerpilot-backend.exe'; + const main = { pid: 101, parentPid: 100, path: exe, created: '2026-10-15T08:00:01Z' }; + const child = { pid: 102, parentPid: 101, path: backend, created: '2026-10-15T08:00:02Z' }; + const launch = { handlePid: 100, handleRunning: true, startedAt: Date.parse('2026-10-15T08:00:00Z'), observedAt: Date.parse('2026-10-15T08:00:03Z') }; + assert.deepEqual(earlyOwnedProcesses([main, child], launch, exe, backend), [main, child]); + assert.deepEqual(earlyOwnedProcesses([main, child], { ...launch, handlePid: 101 }, exe, backend), [main, child]); + assert.throws(() => earlyOwnedProcesses([main, child], { ...launch, handleRunning: false }, exe, backend)); + assert.throws(() => earlyOwnedProcesses([{ ...main, parentPid: 99 }, child], launch, exe, backend)); + assert.throws(() => earlyOwnedProcesses([{ ...main, created: '2026-10-15T07:59:59Z' }, child], launch, exe, backend)); + assert.throws(() => earlyOwnedProcesses([{ ...main, created: '2026-10-15T08:00:04Z' }, child], launch, exe, backend)); + assert.throws(() => earlyOwnedProcesses([main, { ...main, pid: 103 }, child], launch, exe, backend)); + assert.throws(() => earlyOwnedProcesses([{ ...main, path: 'C:\\Windows\\System32\\cmd.exe' }], launch, exe, backend)); +}); +test('payload digest binds file names and bytes and changes on added or edited files', async t => { + const folder = await fs.mkdtemp(path.join(os.tmpdir(), 'offerpilot-localization-hash-')); + t.after(() => fs.rm(folder, { recursive: true, force: true })); + await fs.mkdir(path.join(folder, 'nested')); + await fs.writeFile(path.join(folder, 'nested/a'), 'original'); + const before = await treeDigest(folder); + assert.deepEqual(await treeDigest(folder), before); + await fs.writeFile(path.join(folder, 'nested/a'), 'changed'); + assert.notEqual((await treeDigest(folder)).sha256, before.sha256); + await fs.writeFile(path.join(folder, 'nested/a'), 'original'); + await fs.writeFile(path.join(folder, 'extra'), 'original'); + assert.notEqual((await treeDigest(folder)).sha256, before.sha256); +}); +test('native locale assertion requires exact localized menu and enabled explicit exit', () => { + const menu = locale => TEXT[locale].menu.map((label, i) => ({ label, children: i === 0 ? [{ label: TEXT[locale].quit, enabled: true }] : [] })); + for (const locale of ['zh-CN', 'en-US']) assertNativeMenu(menu(locale), locale); + assert.throws(() => assertNativeMenu(menu('zh-CN'), 'en-US')); + const disabled = menu('en-US'); disabled[0].children[0].enabled = false; + assert.throws(() => assertNativeMenu(disabled, 'en-US')); +}); +test('new harness uses installed executable, explicit quit, and no product or old helper rewrites', async () => { + const code = await fs.readFile(path.join(here, '../smoke.mjs'), 'utf8'); + const windows = await fs.readFile(path.join(here, '../windows.ps1'), 'utf8'); + assert.match(code, /executablePath: paths\.exe/); + assert.match(code, /chromiumSandbox: true, bypassCSP: false/); + assert.match(code, /app\.getPath\('userData'\)/); + assert.match(code, /getByRole\('combobox'/); + assert.match(code, /route\.abort\('blockedbyclient'\)/); + assert.match(code, /original\.call\(this, menu\)/); + assert.match(code, /await launch\(1, 'zh-CN'\)/); + assert.match(code, /await launch\(2, 'en-US'\)/); + assert.match(code, /await launch\(3, 'zh-CN'\)/); + assert.doesNotMatch(code, /(?:route\.fulfill|addInitScript|\.setLocale\(|--user-data-dir|installed-ui\/|request\.json|PIN\.|active\.child\.kill)/); + assert.doesNotMatch(code, /(?:writeFile|appendFile)\(path\.join\(paths\.(?:install|userData)/); + assert.doesNotMatch(windows.replace(/^\s*#.*$/gm, ''), /Remove-Item|taskkill|Stop-Process\s+-Name|Set-NetFirewall|Set-ExecutionPolicy/); + assert.match(windows, /\$start\.Arguments = '\/S \/currentuser \/D='/); + for (const field of ['pid', 'parentPid', 'path', 'created']) assert.ok(windows.includes(`$identity.${field}`)); +}); +test('non-CI invocation fails before installer or browser launch and writes explicit incomplete results', async t => { + const folder = await fs.mkdtemp(path.join(os.tmpdir(), 'offerpilot-localization-rejection-')); + t.after(() => fs.rm(folder, { recursive: true, force: true })); + const child = spawnSync(process.execPath, [path.join(here, '../smoke.mjs')], { + env: { GITHUB_ACTIONS: 'false', LOCALIZATION_EVIDENCE_DIR: folder }, + encoding: 'utf8', timeout: 10000, + }); + assert.equal(child.status, 1); + const result = JSON.parse(await fs.readFile(path.join(folder, 'result.json'), 'utf8')); + assert.equal(result.status, 'failed'); + assert.equal(result.failure.stage, 'environment'); + assert.deepEqual(result.launches, []); + assert.deepEqual(result.screenshots, []); + assert.ok(result.requiredStageResults.every(item => item.status === 'not-run-or-incomplete')); +}); diff --git a/desktop/localization-ui/test/retry.test.mjs b/desktop/localization-ui/test/retry.test.mjs new file mode 100644 index 00000000..c39ebc20 --- /dev/null +++ b/desktop/localization-ui/test/retry.test.mjs @@ -0,0 +1,111 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { createHash } from 'node:crypto'; +import { readHashedJSON, validateOriginalEvidence } from '../retry.mjs'; + +const hash = value => createHash('sha256').update(value).digest('hex'); +function fixture() { + const provenance = { + sourceSHA: 'a'.repeat(40), runURL: 'https://github.com/example/project/actions/runs/123', + installer: 'original.exe', installerSha256: 'b'.repeat(64), + }; + const tree = { files: [{ path: 'original.txt', sha256: 'c'.repeat(64) }] }; + tree.sha256 = hash(JSON.stringify(tree.files)); + const evidence = { + 'result.json': { + sourceSHA: provenance.sourceSHA, runURL: provenance.runURL, + installer: { filename: provenance.installer, sha256: provenance.installerSha256 }, + stages: [{ name: 'install-new-nsis', status: 'passed', nsisActuallyInstalled: true, sameBuiltPayloadVerified: true }], + installedIdentityBefore: { + webTree: { sha256: tree.sha256, files: 1 }, backendTree: { sha256: tree.sha256, files: 1 }, + }, + }, + 'source-sha256.json': { sourceSHA: provenance.sourceSHA, files: [{ file: 'desktop/main.cjs', sha256: 'd'.repeat(64) }] }, + 'installed-web-sha256.json': structuredClone(tree), + 'installed-backend-sha256.json': structuredClone(tree), + }; + return { provenance, evidence }; +} + +test('original evidence is accepted only with the exact pinned JSON bytes', async t => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'offerpilot-retry-evidence-')); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const filename = path.join(directory, 'original.json'); + const bytes = '{"passed":true}\n'; + await fs.writeFile(filename, bytes); + assert.deepEqual(await readHashedJSON(filename, hash(bytes)), { passed: true }); + await assert.rejects(readHashedJSON(filename, '0'.repeat(64)), /original evidence file hash/); + await assert.rejects(readHashedJSON(filename, 'invalid')); + await fs.writeFile(filename, bytes.replace('\n', '\r\n')); + await assert.rejects(readHashedJSON(filename, hash(bytes)), /original evidence file hash/); + await fs.writeFile(filename, '{"passed":false}\n'); + await assert.rejects(readHashedJSON(filename, hash(bytes)), /original evidence file hash/); +}); + +test('retained proof binds original source, run, installer and actual same-build installation', () => { + const good = fixture(); + assert.equal(validateOriginalEvidence(good.provenance, good.evidence), good.evidence); + for (const mutate of [ + value => { value['result.json'].sourceSHA = 'e'.repeat(40); }, + value => { value['result.json'].runURL += '4'; }, + value => { value['result.json'].installer.filename = 'rebuilt.exe'; }, + value => { value['result.json'].installer.sha256 = 'e'.repeat(64); }, + value => { value['result.json'].stages[0].status = 'failed'; }, + value => { value['result.json'].stages[0].nsisActuallyInstalled = false; }, + value => { value['result.json'].stages[0].sameBuiltPayloadVerified = false; }, + value => { value['source-sha256.json'].sourceSHA = 'e'.repeat(40); }, + value => { value['source-sha256.json'].files = []; }, + ]) { + const sample = fixture(); + mutate(sample.evidence); + assert.throws(() => validateOriginalEvidence(sample.provenance, sample.evidence), assert.AssertionError); + } +}); + +test('retained resource manifests reject changed digest, file count or file bytes', () => { + for (const name of ['web', 'backend']) { + for (const mutate of [ + value => { value[`installed-${name}-sha256.json`].sha256 = 'e'.repeat(64); }, + value => { value['result.json'].installedIdentityBefore[`${name}Tree`].files = 2; }, + value => { value[`installed-${name}-sha256.json`].files[0].sha256 = 'e'.repeat(64); }, + value => { value[`installed-${name}-sha256.json`].files[0].path = 'replacement.txt'; }, + ]) { + const sample = fixture(); + mutate(sample.evidence); + assert.throws(() => validateOriginalEvidence(sample.provenance, sample.evidence), assert.AssertionError); + } + } +}); + +test('retry separates harness and product identity and has no rebuilt payload fallback', async () => { + const code = await fs.readFile(new URL('../smoke.mjs', import.meta.url), 'utf8'); + assert.match(code, /assert\.equal\(report\.harnessSHA, process\.env\.GITHUB_SHA,/); + assert.match(code, /assert\.equal\(sourceSHA, report\.sourceSHA,/); + assert.match(code, /assert\.equal\(report\.sourceSHA, originalEvidence\.provenance\.sourceSHA,/); + assert.match(code, /assert\.equal\(process\.env\.LOCALIZATION_BUILD_DIR, undefined,/); + assert.match(code, /assert\.equal\(report\.installer\.sha256, originalEvidence\.provenance\.installerSha256,/); + assert.match(code, /originalEvidence \? originalEvidence\.evidence\['result\.json'\]\.installedIdentityBefore\[key\]/); + assert.match(code, /originalEvidence \? originalEvidence\.evidence\[`installed-\$\{name\}-sha256\.json`\]/); + assert.match(code, /assert\.deepEqual\(identity, originalEvidence\.evidence\['source-sha256\.json'\],/); +}); + +test('safe diagnostics precede unchanged security assertions without delaying request guards', async () => { + const code = await fs.readFile(new URL('../smoke.mjs', import.meta.url), 'utf8'); + const attached = code.slice(code.indexOf('active = { app, number,')); + assert.equal(attached.indexOf('await '), attached.indexOf('await installRequestGuard(app);')); + assert.ok(code.indexOf("await launchPhase('main-identity',") < code.indexOf('assert.equal(identity.packaged,')); + assert.ok(code.indexOf("await launchPhase('renderer-roles',") < code.indexOf('assert.equal(url.origin,')); + assert.ok(code.indexOf("await launchPhase('runtime-security-assertions',") < code.indexOf('assert.deepEqual(security.details.map(')); + assert.match(code, /nodeIntegration: false, contextIsolation: true,\s+sandbox: true, webSecurity: true, devToolsOpened: false/); + assert.match(code, /assertDevToolsPreference\(detail\.devTools, security\.runtimeElectron, report\.devToolsConstructorPolicy\)/); + assert.match(code, /detail\.devToolsProbe = await app\.evaluate\(probeDevToolsDisabled, detail\.windowId\)/); + assert.match(code, /assertDevToolsProbe\(detail\.devToolsProbe\)/); + assert.match(code, /\['packaged', 'isolated', 'ownerPartition', 'haruPartition', 'haruEphemeral'\]\) assert\.equal\(security\[name\], true,/); + assert.match(code, /assert\.deepEqual\(security\.unsafeSwitches, \[\],/); + assert.match(code, /role: \['owner', 'haru'\]\.includes\(candidate\.role\) \? candidate\.role : 'unexpected'/); + assert.match(code, /const safeBoolean = value => typeof value === 'boolean' \? value : null/); + assert.doesNotMatch(code, /report\.failure\.(?:stack|actual|expected|url)\s*=/); +}); diff --git a/desktop/localization-ui/test/security.test.mjs b/desktop/localization-ui/test/security.test.mjs new file mode 100644 index 00000000..61b16a69 --- /dev/null +++ b/desktop/localization-ui/test/security.test.mjs @@ -0,0 +1,75 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { preferenceObservation, assertDevToolsPreference, assertDevToolsProbe, probeDevToolsDisabled } from '../security.mjs'; + +const policy = { lockedElectron: '44.5.1', installedSourcesHashMatched: true, + ownerPackagedDisabled: true, haruDisabled: true }; +const negative = { beforeOpen: false, beforeContents: false, openedEvent: false, + afterOpen: false, afterContents: false }; + +test('raw getter diagnostics distinguish omitted, false, true and invalid values', () => { + assert.equal(preferenceObservation(undefined), 'undefined'); + assert.equal(preferenceObservation(false), false); + assert.equal(preferenceObservation(true), true); + for (const value of [null, 0, 'secret']) assert.equal(preferenceObservation(value), 'invalid-type'); +}); + +test('omitted DevTools preference requires exact Electron and independently hash-bound source policies', () => { + assertDevToolsPreference(false, '44.5.1', policy); + assertDevToolsPreference(undefined, '44.5.1', policy); + for (const value of [true, null, 0, 'false']) assert.throws(() => assertDevToolsPreference(value, '44.5.1', policy)); + for (const version of [undefined, '44.5.0', '44.5.2', '45.0.0']) { + assert.throws(() => assertDevToolsPreference(undefined, version, policy)); + } + for (const field of Object.keys(policy)) { + assert.throws(() => assertDevToolsPreference(undefined, '44.5.1', { ...policy, [field]: false })); + const missing = { ...policy }; delete missing[field]; + assert.throws(() => assertDevToolsPreference(undefined, '44.5.1', missing)); + } + assert.throws(() => assertDevToolsPreference(undefined, '44.5.1', undefined)); +}); + +test('negative behavioral proof rejects every open-state, contents, event or missing observation', () => { + assertDevToolsProbe(negative); + for (const field of Object.keys(negative)) { + assert.throws(() => assertDevToolsProbe({ ...negative, [field]: true })); + const missing = { ...negative }; delete missing[field]; + assert.throws(() => assertDevToolsProbe(missing)); + } + assert.throws(() => assertDevToolsProbe(undefined)); +}); + +test('actual probe targets one existing window and preserves transient-open failure evidence', async () => { + const contents = new EventEmitter(); + contents.isDevToolsOpened = () => false; + let calls = 0; + contents.openDevTools = options => { + calls++; + assert.deepEqual(options, { mode: 'detach', activate: false }); + contents.emit('devtools-opened'); + }; + const electron = { BrowserWindow: { getAllWindows: () => [{ id: 1, webContents: contents }] } }; + await assert.rejects(probeDevToolsDisabled(electron, 2), /exact-product-window-required/); + assert.equal(calls, 0); + const result = await probeDevToolsDisabled(electron, 1); + assert.deepEqual(result, { ...negative, openedEvent: true }); + assert.throws(() => assertDevToolsProbe(result)); + assert.equal(calls, 1); + assert.equal(contents.listenerCount('devtools-opened'), 0); +}); + +test('already-open DevTools cannot be hidden or retried into passing evidence', async () => { + const contents = new EventEmitter(); + contents.isDevToolsOpened = () => true; + contents.devToolsWebContents = {}; + contents.openDevTools = () => assert.fail('already open window must not be opened again'); + const electron = { BrowserWindow: { getAllWindows: () => [{ id: 1, webContents: contents }] } }; + const result = await probeDevToolsDisabled(electron, 1); + assert.throws(() => assertDevToolsProbe(result)); + assert.equal(result.beforeOpen, true); + assert.equal(result.beforeContents, true); + assert.equal(result.afterOpen, true); + assert.equal(result.afterContents, true); + assert.equal(contents.listenerCount('devtools-opened'), 0); +}); diff --git a/desktop/localization-ui/windows.ps1 b/desktop/localization-ui/windows.ps1 new file mode 100644 index 00000000..531a1769 --- /dev/null +++ b/desktop/localization-ui/windows.ps1 @@ -0,0 +1,53 @@ +param([Parameter(Mandatory=$true)][ValidateSet('install', 'snapshot', 'cleanup')][string]$Mode) +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) + +if ($Mode -eq 'install') { + if ($env:GITHUB_ACTIONS -ne 'true' -or -not $env:RUNNER_TEMP) { throw 'Dedicated Windows CI runner required' } + if (Test-Path -LiteralPath $env:LOCALIZATION_INSTALL_DIR) { throw 'Install destination must be absent' } + if (Test-Path -LiteralPath $env:LOCALIZATION_USER_DATA) { throw 'Existing real app data is a blocker; never delete it' } + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = $env:LOCALIZATION_INSTALLER + # NSIS /D is last, with no quotes around the value (including Unicode/spaces). + $start.Arguments = '/S /currentuser /D=' + $env:LOCALIZATION_INSTALL_DIR + $start.UseShellExecute = $false + $installer = [Diagnostics.Process]::Start($start) + if (-not $installer.WaitForExit(180000)) { + $installer.Kill() + throw 'Owned installer timed out' + } + if ($installer.ExitCode -ne 0) { throw 'NSIS installation failed' } + @{ exitCode=$installer.ExitCode } | ConvertTo-Json -Compress + exit 0 +} + +$processes = @(Get-CimInstance Win32_Process | Where-Object { + $_.ExecutablePath -and ($_.ExecutablePath -ieq $env:LOCALIZATION_EXE -or $_.ExecutablePath -ieq $env:LOCALIZATION_BACKEND) +} | ForEach-Object { + @{ pid=[int]$_.ProcessId; parentPid=[int]$_.ParentProcessId; path=$_.ExecutablePath; created=$_.CreationDate.ToUniversalTime().ToString('o') } +}) +if ($Mode -eq 'snapshot') { + $ids = @($processes | ForEach-Object { $_.pid }) + $connections = @(Get-NetTCPConnection -ErrorAction SilentlyContinue | Where-Object { $ids -contains [int]$_.OwningProcess }) + $listeners = @($connections | Where-Object { $_.State -eq 'Listen' } | ForEach-Object { + @{ pid=[int]$_.OwningProcess; address=$_.LocalAddress; port=[int]$_.LocalPort } + }) + $outbound = @($connections | Where-Object { + $_.State -in @('Established', 'SynSent') -and $_.RemoteAddress -notin @('127.0.0.1', '::1', '::ffff:127.0.0.1') + } | ForEach-Object { @{ pid=[int]$_.OwningProcess; address=$_.RemoteAddress; port=[int]$_.RemotePort; state=[string]$_.State } }) + @{ processes=$processes; listeners=$listeners; outbound=$outbound } | ConvertTo-Json -Depth 5 -Compress + exit 0 +} + +# Failure containment only. No process-name glob, taskkill /T, profile deletion, +# uninstall, or PID copied from product logs. Revalidate all identity fields. +$stopped = 0 +foreach ($identity in @($env:LOCALIZATION_OWNED_IDENTITIES | ConvertFrom-Json)) { + $match = @($processes | Where-Object { + $_.pid -eq $identity.pid -and $_.parentPid -eq $identity.parentPid -and + $_.path -ieq $identity.path -and $_.created -eq $identity.created + }) + if ($match.Count -eq 1) { Stop-Process -Id $identity.pid -Force -ErrorAction Stop; $stopped++ } +} +@{ stopped=$stopped } | ConvertTo-Json -Compress diff --git a/desktop/main.cjs b/desktop/main.cjs index 74f7848e..efc3546e 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -6,6 +6,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { allowHaruResource, authenticatedHeaders } = require('./haru-protocol.cjs'); const { createHaruShell } = require('./haru.cjs'); +const { createLocaleStore, registerLocaleIPC, applicationMenu, startupErrorText } = require('./locale.cjs'); const { createCapabilities } = require('./capabilities.cjs'); const { RELEASE_POLICY, unavailableReason, productionAdapter, createUpdater, registerUpdaterIPC } = require('./updater.cjs'); const { createInstallSafety } = require('./update-safety.cjs'); @@ -16,6 +17,9 @@ const { isSameOrigin, waitForReady, checkHealth, stopBackend } = require('./life app.setName('OfferPilot Desktop'); app.setPath('userData', path.join(app.getPath('appData'), 'OfferPilot Desktop')); +const localeStore = createLocaleStore(app.getPath('userData')); +const t = localeStore.t; +let disposeLocale = () => {}; let child; let window; let haruShell; @@ -70,7 +74,7 @@ async function start() { child.once('exit', (code, signal) => { log(`Backend exited: code=${code} signal=${signal}\n`); if (!quitting && window) { - dialog.showErrorBox('OfferPilot Desktop', 'The local backend stopped. Close and reopen OfferPilot. Your saved data remains in the desktop data folder.'); + dialog.showErrorBox('OfferPilot Desktop', t('本地服务已停止。请关闭并重新打开 OfferPilot。已保存的数据仍在桌面数据文件夹中。')); app.quit(); } }); @@ -93,7 +97,7 @@ async function start() { haruSession.on('will-download', event => event.preventDefault()); haruSession.webRequest.onBeforeRequest((details, callback) => callback({ cancel: !allowHaruResource(details, ready.origin) })); const { installWindowPolicy, contentSecurityPolicy } = createCapabilities({ - origin: ready.origin, desktopSession, isTrustedContents, dialog, shell, BrowserWindow, + origin: ready.origin, desktopSession, isTrustedContents, dialog, shell, BrowserWindow, t, }); const registerWindow = win => { const contents = win.webContents; @@ -131,13 +135,23 @@ async function start() { haruShell = createHaruShell({ BrowserWindow, Tray, Menu, nativeImage, screen, ipcMain, mainWindow: window, desktopSession: haruSession, origin: ready.origin, userData, registerWindow, - isQuitting: () => quitting || updateLock, quit: () => app.quit(), log, + isQuitting: () => quitting || updateLock, quit: () => app.quit(), log, t, subscribeLocale: localeStore.subscribe, }); + disposeLocale = registerLocaleIPC({ ipcMain, owner: window, getHaruContents: haruShell.getHaruContents, + origin: ready.origin, store: localeStore, isQuitting: () => quitting || updateLock }); + const refreshApplicationMenu = () => Menu.setApplicationMenu(Menu.buildFromTemplate(applicationMenu({ + t, showMain: () => { if (!window.isDestroyed()) { if (window.isMinimized()) window.restore(); window.show(); window.focus(); } }, + quit: () => app.quit(), development: !app.isPackaged, + }))); + refreshApplicationMenu(); + const unsubscribeMenuLocale = localeStore.subscribe(refreshApplicationMenu); + const removeLocaleIPC = disposeLocale; + disposeLocale = () => { unsubscribeMenuLocale(); removeLocaleIPC(); }; const lockUpdates = value => { updateLock = value; for (const win of BrowserWindow.getAllWindows()) if (!win.isDestroyed()) win.setEnabled(!value); }; - const installSafety = createInstallSafety({ ipcMain, window, origin: ready.origin, dialog, + const installSafety = createInstallSafety({ ipcMain, window, origin: ready.origin, dialog, t, lock: () => lockUpdates(true), unlock: () => lockUpdates(false) }); let unavailable = unavailableReason({ packaged: app.isPackaged, platform: process.platform, policy: RELEASE_POLICY }); // The release channel remains disabled until an approved signed distribution @@ -153,7 +167,7 @@ async function start() { recoveringUpdate = true; stopped = true; lockUpdates(false); - try { dialog.showErrorBox('更新未完成', '更新准备或安装启动失败。将尝试重新启动原版本;若未重新打开,请手动启动并核实安装结果。备份与数据目录未被删除。'); } catch { /* A closed OS dialog must not suppress recovery. */ } + try { dialog.showErrorBox(t('更新未完成'), t('更新准备或安装启动失败。将尝试重新启动原版本;若未重新打开,请手动启动并核实安装结果。备份与数据目录未被删除。')); } catch { /* A closed OS dialog must not suppress recovery. */ } try { app.relaunch(); } catch { log('Could not schedule the original application restart.\n'); } app.quit(); }; @@ -177,7 +191,7 @@ async function start() { recoverStoppedVersion(); } else { quitting = false; - dialog.showErrorBox('更新未安装', '无法确认本地服务已停止,已阻止备份和安装。请等待当前操作结束,核对保存结果后重新启动应用。'); + dialog.showErrorBox(t('更新未安装'), t('无法确认本地服务已停止,已阻止备份和安装。请等待当前操作结束,核对保存结果后重新启动应用。')); } throw error; } @@ -187,7 +201,7 @@ async function start() { disposeUpdates = () => { removeUpdateIPC(); installSafety.dispose(); updates.dispose(); }; window.webContents.on('render-process-gone', () => { if (!quitting) { - dialog.showErrorBox('OfferPilot Desktop', 'The application window stopped. Reopen OfferPilot to recover your saved work.'); + dialog.showErrorBox('OfferPilot Desktop', t('应用窗口已停止。请重新打开 OfferPilot,恢复已保存的工作。')); app.quit(); } }); @@ -213,12 +227,13 @@ if (!app.requestSingleInstanceLock()) { if (quitting) return; quitting = true; disposeUpdates(); + disposeLocale(); haruShell?.dispose(); stopBackend(child).finally(() => { stopped = true; app.quit(); }); }); app.whenReady().then(start).catch((error) => { log(`${error.message}\n`); - if (!quitting) dialog.showErrorBox('OfferPilot Desktop could not start', `${error.message}\n\nLog: ${logFile || app.getPath('userData')}\nIf the saved port is occupied, close the conflicting application and retry. Do not delete the data folder.`); + if (!quitting) dialog.showErrorBox(t('OfferPilot Desktop 无法启动'), `${startupErrorText(error, t)}\n\n${t('日志:{path}', { path: logFile || app.getPath('userData') })}\n${t('如果保存的端口被占用,请关闭冲突的应用后重试。请勿删除数据文件夹。')}`); app.quit(); }); } diff --git a/desktop/package.json b/desktop/package.json index c1772a1c..4db60f07 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -23,7 +23,7 @@ "productName": "OfferPilot Desktop", "asar": true, "directories": { "output": "dist" }, - "files": ["main.cjs", "lifecycle.cjs", "capabilities.cjs", "haru.cjs", "haru-protocol.cjs", "preload.cjs", "assets/**", "package.json", "updater.cjs", "update-safety.cjs", "update-backup.cjs", "update-install.cjs", "update-signature.cjs", "update-integrity.cjs"], + "files": ["main.cjs", "locale.cjs", "locale-catalog.cjs", "lifecycle.cjs", "capabilities.cjs", "haru.cjs", "haru-protocol.cjs", "preload.cjs", "assets/**", "package.json", "updater.cjs", "update-safety.cjs", "update-backup.cjs", "update-install.cjs", "update-signature.cjs", "update-integrity.cjs"], "extraResources": [ { "from": "backend-dist/offerpilot-backend", "to": "backend" }, { "from": "../web/dist", "to": "web" }, diff --git a/desktop/preload.cjs b/desktop/preload.cjs index 3412d832..c1d15af0 100644 --- a/desktop/preload.cjs +++ b/desktop/preload.cjs @@ -49,3 +49,17 @@ if (role === 'owner') { replyPrepareInstall: (id, snapshot) => ipcRenderer.send(updates + 'prepare-reply', { id, snapshot }), })); } + +// Locale is a finite preference, never a generic settings/filesystem channel. +// Haru can read and subscribe; only the trusted owner can persist a change. +const localeChannel = 'offerpilot:locale:'; +contextBridge.exposeInMainWorld('offerpilotLocale', Object.freeze({ + getLocale: () => ipcRenderer.invoke(localeChannel + 'get'), + onLocale: listener => { + if (typeof listener !== 'function') return () => {}; + const handler = (_event, value) => listener(value); + ipcRenderer.on(localeChannel + 'changed', handler); + return () => ipcRenderer.removeListener(localeChannel + 'changed', handler); + }, + ...(role === 'owner' ? { setLocale: value => ipcRenderer.invoke(localeChannel + 'set', value) } : {}), +})); diff --git a/desktop/test/capabilities.test.cjs b/desktop/test/capabilities.test.cjs index 5359ce8a..94cbadc3 100644 --- a/desktop/test/capabilities.test.cjs +++ b/desktop/test/capabilities.test.cjs @@ -5,7 +5,7 @@ const { EventEmitter } = require('node:events'); const { createCapabilities, localURL, externalURL, safeFilename, sourceDownload, contentSecurityPolicy } = require('../capabilities.cjs'); const origin = 'http://127.0.0.1:18420'; const flush = () => new Promise(resolve => setImmediate(resolve)); -function fixture() { +function fixture(t) { const session = new EventEmitter(); session.setPermissionCheckHandler = fn => session.check = fn; session.setPermissionRequestHandler = fn => session.request = fn; @@ -20,7 +20,7 @@ function fixture() { showSaveDialogSync(_win, options) { saved.push(options); return answers.shift(); }, }; const capability = createCapabilities({ origin, desktopSession: session, isTrustedContents: c => c === contents, - dialog, shell: { async openExternal(url) { opened.push(url); } }, BrowserWindow: { fromWebContents: () => win } }); + dialog, t, shell: { async openExternal(url) { opened.push(url); } }, BrowserWindow: { fromWebContents: () => win } }); capability.installWindowPolicy(win); const details = { isMainFrame: true, requestingUrl: origin, mediaTypes: ['audio'], mediaType: 'audio' }; const request = (permission = 'media', overrides = {}) => new Promise(resolve => session.request(contents, permission, resolve, { ...details, ...overrides })); @@ -128,3 +128,17 @@ test('clipboard text writes require per-request confirmation and never grant cli assert.equal(await f.request('clipboard-read'),false); assert.equal(await f.request('deprecated-sync-clipboard-read'),false); }); + +test('native capability prompts follow current locale while retaining cancel defaults and scope', async () => { + const { translate } = require('../locale.cjs'); let locale = 'en-US'; + const f = fixture(source => translate(source, locale)); + assert.equal(await f.request(), false); + assert.equal(f.prompts[0].title, 'Allow recording?'); + assert.deepEqual(f.prompts[0].buttons, ['Cancel', 'Allow Microphone']); + assert.equal(f.prompts[0].defaultId, 0); assert.equal(f.prompts[0].cancelId, 0); + assert.match(f.prompts[0].detail, /does not grant access to the camera/); + locale = 'zh-CN'; await f.request(); assert.equal(f.prompts[1].title, '允许录音?'); + locale = 'en-US'; f.download(); assert.equal(f.saved[0].title, 'Save OfferPilot Export'); + f.contents.open({ url: 'https://example.com' }); await flush(); + assert.equal(f.prompts[2].title, 'Open external link?'); assert.equal(f.opened.length, 0); +}); diff --git a/desktop/test/haru.test.cjs b/desktop/test/haru.test.cjs index 9dde061e..ea95d40e 100644 --- a/desktop/test/haru.test.cjs +++ b/desktop/test/haru.test.cjs @@ -7,6 +7,7 @@ const os = require('node:os'); const path = require('node:path'); const { CHANNEL, sanitizeSnapshot, validateRequest, isTrustedFrame, clampBounds, allowHaruResource, authenticatedHeaders } = require('../haru-protocol.cjs'); const { createHaruShell } = require('../haru.cjs'); +const { createLocaleStore } = require('../locale.cjs'); const origin = 'http://127.0.0.1:4040'; const view = { version: 1, taskState: 'idle', conversationId: 7, messages: [{ role: 'assistant', content: 'hello', confirmation_token: 'secret' }], canSend: true, pending: { confirmation_token: 'secret' }, config: { api_key: 'secret' } }; @@ -14,6 +15,8 @@ test('snapshot copies only bounded prose, booleans and theme enum; never credent const value = sanitizeSnapshot({ ...view, error: 'x'.repeat(5000) }); assert.equal(value.messages[0].content, 'hello'); assert.equal(value.error.length, 1200); + assert.equal(sanitizeSnapshot({ ...view, errorKey: 'desktop_send_failed' }).errorKey, 'desktop_send_failed'); + for (const errorKey of ['provider_error', {}, null, 'desktop_send_failed;secret']) assert(!('errorKey' in sanitizeSnapshot({ ...view, errorKey }))); assert(!JSON.stringify(value).includes('secret')); assert(!('pending' in value)); for (const animationLevel of ['full', 'minimal', 'off']) assert.equal(sanitizeSnapshot({ ...view, animationLevel }).animationLevel, animationLevel); @@ -108,7 +111,7 @@ function harness(t, { trayFails = false, preferences, folder: existingFolder } = constructor() { super(); if (trayFails) throw new Error('missing tray'); tray = this; } isDestroyed() { return Boolean(this.destroyed); } destroy() { this.destroyed = true; } - setToolTip() {} + setToolTip(value) { this.tip = value; } setContextMenu(menu) { this.menu = menu; } } class Ipc extends EventEmitter { @@ -125,19 +128,20 @@ function harness(t, { trayFails = false, preferences, folder: existingFolder } = const main = new Window(); const folder = existingFolder || fs.mkdtempSync(path.join(os.tmpdir(), 'haru-shell-')); if (preferences !== undefined) fs.writeFileSync(path.join(folder, 'haru-window.json'), preferences); + const locale = createLocaleStore(folder); const menus = []; const Menu = { buildFromTemplate: items => { const menu = { items, popup: options => { menu.options = options; menus.push(menu); } }; return menu; } }; let quitting = false; let quitCalls = 0; const registered = []; - const shell = createHaruShell({ BrowserWindow: Window, Tray, Menu, nativeImage: { createFromPath: value => value }, screen, ipcMain: ipc, mainWindow: main, desktopSession: {}, origin, userData: folder, registerWindow: win => registered.push(win), isQuitting: () => quitting, quit: () => { quitCalls++; quitting = true; } }); + const shell = createHaruShell({ BrowserWindow: Window, Tray, Menu, nativeImage: { createFromPath: value => value }, screen, ipcMain: ipc, mainWindow: main, desktopSession: {}, origin, userData: folder, registerWindow: win => registered.push(win), isQuitting: () => quitting, quit: () => { quitCalls++; quitting = true; }, t: locale.t, subscribeLocale: locale.subscribe }); const mirror = Window.all[1]; mirror.emit('ready-to-show'); const event = win => ({ sender: win.webContents, senderFrame: win.webContents.mainFrame }); const invoke = (win, name, value) => ipc.handlers.get(CHANNEL + name)(event(win), value); const publish = value => ipc.emit(CHANNEL + 'publish', event(main), value); t.after(() => { quitting = true; shell.dispose(); fs.rmSync(folder, { recursive: true, force: true }); }); - return { folder, menus, tray, main, mirror, ipc, event, invoke, publish, shell, registered, screen, setCursor: point => { cursor = point; }, setQuitting: value => { quitting = value; }, quitCalls: () => quitCalls }; + return { folder, locale, menus, tray, main, mirror, ipc, event, invoke, publish, shell, registered, screen, setCursor: point => { cursor = point; }, setQuitting: value => { quitting = value; }, quitCalls: () => quitCalls }; } test('only owner publishes; only mirror sends; stale epochs fail after reload', async t => { const h = harness(t); @@ -503,3 +507,29 @@ test('repeated close keeps the character visible, collapses geometry, and allows h.setQuitting(true); h.mirror.emit('close', { preventDefault() { assert.fail('must not prevent application shutdown'); } }); }); + + +test('tray and context labels update with locale and preserve pin, collapse and quit actions', t => { + const h = harness(t); + assert.equal(h.tray.menu.items[0].label, '打开 OfferPilot'); + h.locale.setLocale('en-US'); + assert.equal(h.tray.menu.items[0].label, 'Open OfferPilot'); + assert.equal(h.tray.menu.items[1].label, 'Hide Haru'); + assert.match(h.tray.tip, /use the tray menu to quit/); + assert.equal(h.shell.getHaruContents(), h.mirror.webContents); + assert.equal(h.tray.menu.items[2].checked, true); + h.tray.menu.items[2].click(); assert.equal(h.mirror.pinned, false); + h.invoke(h.mirror, 'window', 'context-menu'); + const context = h.menus.at(-1); + assert.equal(context.items[0].label, 'Hide Haru'); + assert.equal(context.items[1].label, 'Keep Haru on Top'); + assert.equal(context.items[1].checked, false); + context.items[1].click(); assert.equal(h.mirror.pinned, true); + h.invoke(h.mirror, 'window', 'expand'); + h.mirror.emit('close', { preventDefault() {} }); + assert.equal(h.invoke(h.mirror, 'state').expanded, false); + assert.equal(h.mirror.isVisible(), true); + context.items[0].click(); assert.equal(h.tray.menu.items[1].label, 'Show Haru'); + h.locale.setLocale('zh-CN'); assert.equal(h.tray.menu.items[1].label, '显示 Haru'); + h.tray.menu.items[4].click(); assert.equal(h.quitCalls(), 1); +}); diff --git a/desktop/test/locale.test.cjs b/desktop/test/locale.test.cjs new file mode 100644 index 00000000..633a359f --- /dev/null +++ b/desktop/test/locale.test.cjs @@ -0,0 +1,91 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const vm = require('node:vm'); +const { CHANNEL, createLocaleStore, registerLocaleIPC, applicationMenu, startupErrorText, translate } = require('../locale.cjs'); +const english = require('../locale-catalog.cjs'); +const folder = t => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-locale-')); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); return dir; }; +test('native locale defaults Chinese, validates strictly, persists and restores on restart', t => { + const dir = folder(t); const store = createLocaleStore(dir); const values = []; store.subscribe(value => values.push(value)); + assert.equal(store.getLocale(), 'zh-CN'); + for (const value of ['en', 'en-GB', 'ZH-CN', {}, null, true, ['en-US']]) assert.equal(store.setLocale(value), null); + assert.equal(store.setLocale('en-US'), 'en-US'); + assert.equal(store.t('复制'), 'Copy'); + assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'desktop-locale.json'))), { locale: 'en-US' }); + assert.equal(createLocaleStore(dir).getLocale(), 'en-US'); + assert.equal(store.setLocale('zh-CN'), 'zh-CN'); + assert.equal(store.t('复制'), '复制'); + assert.deepEqual(values, ['en-US', 'zh-CN']); +}); +test('corrupt or oversized preference safely defaults Chinese and is repaired by explicit selection', t => { + const dir = folder(t); const file = path.join(dir, 'desktop-locale.json'); + for (const value of ['oops', '{"locale":"fr-FR"}', 'x'.repeat(1025)]) { + fs.writeFileSync(file, value); const store = createLocaleStore(dir); + assert.equal(store.getLocale(), 'zh-CN'); + store.setLocale('zh-CN'); + assert.deepEqual(JSON.parse(fs.readFileSync(file)), { locale: 'zh-CN' }); + } +}); +test('failed disk persistence does not change state or broadcast a false success', t => { + const dir = folder(t); const store = createLocaleStore(dir); const values = []; store.subscribe(value => values.push(value)); + fs.mkdirSync(path.join(dir, 'desktop-locale.json')); + assert.throws(() => store.setLocale('en-US'), /无法保存语言/); + assert.equal(store.getLocale(), 'zh-CN'); assert.deepEqual(values, []); + assert.deepEqual(fs.readdirSync(dir), ['desktop-locale.json']); +}); +test('locale IPC isolates owner writes and synchronizes independent trusted windows', t => { + const origin = 'http://127.0.0.1:4040'; + const contents = () => ({ mainFrame: { url: origin + '/' }, isDestroyed: () => false, sent: [], send(channel, value) { this.sent.push({ channel, value }); } }); + const owner = { webContents: contents() }; let mirror = contents(); + const store = createLocaleStore(folder(t)); const handlers = new Map(); let quitting = false; + const dispose = registerLocaleIPC({ ipcMain: { handle: (name, fn) => handlers.set(name, fn), removeHandler: name => handlers.delete(name) }, owner, getHaruContents: () => mirror, origin, store, isQuitting: () => quitting }); + const event = c => ({ sender: c, senderFrame: c.mainFrame }); const get = e => handlers.get(CHANNEL + 'get')(e); const set = (e, value) => handlers.get(CHANNEL + 'set')(e, value); + assert.equal(get(event(mirror)), 'zh-CN'); + assert.equal(set(event(mirror), 'en-US'), null); + assert.equal(set({ sender: owner.webContents, senderFrame: { url: origin + '/' } }, 'en-US'), null); + assert.equal(set(event(contents()), 'en-US'), null); + assert.equal(set(event(owner.webContents), { locale: 'en-US' }), null); + assert.equal(set(event(owner.webContents), 'en-US'), 'en-US'); + assert.deepEqual(mirror.sent, [{ channel: CHANNEL + 'changed', value: 'en-US' }]); + assert.equal(get(event(mirror)), 'en-US'); + const old = mirror; mirror = contents(); + assert.equal(get(event(old)), null); assert.equal(get(event(mirror)), 'en-US'); + mirror.mainFrame.url = 'https://untrusted.example/'; + set(event(owner.webContents), 'zh-CN'); assert.deepEqual(mirror.sent, []); assert.equal(get(event(mirror)), null); + owner.webContents.mainFrame.url = origin + '.evil.test/'; + assert.equal(set(event(owner.webContents), 'en-US'), null); + owner.webContents.mainFrame.url = origin + '/'; quitting = true; + assert.equal(get(event(owner.webContents)), null); assert.equal(set(event(owner.webContents), 'en-US'), null); + dispose(); assert.equal(handlers.size, 0); +}); +test('preload exposes locale writes only to owner, without generic IPC or event objects', () => { + for (const role of ['owner', 'haru']) { + const apis = {}; const listeners = new Map(); const invokes = []; + const ipc = { invoke: (...args) => invokes.push(args), on: (name, fn) => listeners.set(name, fn), removeListener: name => listeners.delete(name) }; + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../preload.cjs'), 'utf8'), { require: () => ({ contextBridge: { exposeInMainWorld: (name, api) => apis[name] = api }, ipcRenderer: ipc }), process: { argv: [`--offerpilot-${role}`] } }); + assert.equal(typeof apis.offerpilotLocale.setLocale, role === 'owner' ? 'function' : 'undefined'); + assert.equal(apis.offerpilotLocale.send, undefined); assert.equal(apis.offerpilotLocale.invoke, undefined); + let received; const off = apis.offerpilotLocale.onLocale(value => received = value); + listeners.get(CHANNEL + 'changed')({ sender: 'private' }, 'en-US'); assert.equal(received, 'en-US'); off(); assert(!listeners.has(CHANNEL + 'changed')); + apis.offerpilotLocale.getLocale(); assert.deepEqual(invokes[0], [CHANNEL + 'get']); + } +}); +test('application menu and startup diagnostics follow locale without changing roles', () => { + const menu = locale => applicationMenu({ t: source => translate(source, locale), showMain() {}, quit() {} }); + assert.equal(menu('zh-CN')[0].label, '文件'); assert.equal(menu('en-US')[0].label, 'File'); + assert.equal(menu('en-US')[1].submenu[0].role, 'undo'); + assert(!JSON.stringify(menu('en-US')).includes('toggleDevTools')); + assert.equal(startupErrorText(new Error('Backend exited before startup (1).'), (s, v) => translate(s, 'en-US', v)), 'The local service exited before startup (1).'); + assert(!startupErrorText(new Error('technical details'), s => translate(s)).includes('technical details')); + for (const [source, translated] of Object.entries(english)) { assert.equal(typeof translated, 'string'); assert(translated.length > 0, source); assert(!/[\u4e00-\u9fff]/.test(translated), source); } +}); + +test('failed observer cannot suppress another window or report a saved value as failed', t => { + const store = createLocaleStore(folder(t)); const seen = []; + store.subscribe(() => { throw Error('window closed'); }); + store.subscribe(locale => seen.push(locale)); + assert.equal(store.setLocale('en-US'), 'en-US'); assert.deepEqual(seen, ['en-US']); +}); diff --git a/desktop/test/update-backup.test.cjs b/desktop/test/update-backup.test.cjs index af812792..b615a38d 100644 --- a/desktop/test/update-backup.test.cjs +++ b/desktop/test/update-backup.test.cjs @@ -5,10 +5,12 @@ test('offline local backup preserves source and config without browser snapshot const base = await fs.mkdtemp(path.join(os.tmpdir(),'update-backup-')); t.after(()=>fs.rm(base,{recursive:true,force:true})); const userData = path.join(base,'OfferPilot Desktop'); await fs.mkdir(path.join(userData,'data'),{recursive:true}); await fs.writeFile(path.join(userData,'data','config.json'),'fixture only'); await fs.writeFile(path.join(userData,'data','data.db'),'fixture sqlite'); + await fs.writeFile(path.join(userData,'desktop-locale.json'),'{"locale":"en-US"}'); await fs.writeFile(path.join(userData,'desktop-port.json'),'{"port":2345}'); await assert.rejects(backupForUpdate({userData,version:'1',backendExited:false})); const backup = await backupForUpdate({userData,version:'1',backendExited:true}); assert.equal(await fs.readFile(path.join(backup,'data','config.json'),'utf8'),'fixture only'); + assert.equal(JSON.parse(await fs.readFile(path.join(backup,'desktop-locale.json'))).locale,'en-US'); assert.equal(await fs.readFile(path.join(userData,'data','config.json'),'utf8'),'fixture only'); assert.equal(JSON.parse(await fs.readFile(path.join(backup,'complete.json'))).browserStorage,'retained-in-place-not-snapshotted'); if(process.platform!=='win32') assert.equal((await fs.stat(path.join(backup,'data','config.json'))).mode&0o077,0); diff --git a/desktop/test/update-safety.test.cjs b/desktop/test/update-safety.test.cjs index d3944c91..f486e696 100644 --- a/desktop/test/update-safety.test.cjs +++ b/desktop/test/update-safety.test.cjs @@ -5,7 +5,7 @@ function fixture(snapshot, options={}) { let requests=0, prompts=0, locked=false; contents.send=(_name,payload)=>{requests++; if(options.noReply)return;queueMicrotask(()=>ipcMain.emit(CHANNEL+'prepare-reply',{sender:contents,senderFrame:contents.mainFrame},{id:payload.id,snapshot:typeof snapshot==='function'?snapshot(requests):snapshot}));}; const window={webContents:contents,isDestroyed:()=>false}; - const safety=createInstallSafety({ipcMain,window,origin:'http://127.0.0.1:1234',timeoutMs:10,dialog:{showMessageBox:async(_w,opts)=>{prompts++;assert.equal(opts.defaultId,0);assert.match(opts.detail,/无法完整自动检测/);options.onPrompt?.(contents);return {response:options.cancel?0:1};}},lock:()=>{locked=true;},unlock:()=>{locked=false;}}); + const safety=createInstallSafety({ipcMain,window,origin:'http://127.0.0.1:1234',timeoutMs:10,t:options.t,dialog:{showMessageBox:async(_w,opts)=>{prompts++;assert.equal(opts.defaultId,0);assert.match(opts.detail,options.t?/cannot all be detected automatically/:/无法完整自动检测/);options.inspect?.(opts);options.onPrompt?.(contents);return {response:options.cancel?0:1};}},lock:()=>{locked=true;},unlock:()=>{locked=false;}}); return {safety,ipcMain,contents,requests:()=>requests,prompts:()=>prompts,locked:()=>locked}; } const safe={ready:true,hasDraft:false,activeRun:false,pendingApproval:false}; @@ -21,3 +21,13 @@ test('navigation during confirmation, malformed, and lost renderer fail closed', const nav=fixture(safe,{onPrompt:c=>c.emit('did-start-navigation',{},'http://127.0.0.1:1234/',false,true)});assert.match(await nav.safety.prepare(),/变化/);nav.safety.dispose(); for(const f of [fixture({ready:true}),fixture(safe,{noReply:true})]){assert.match(await f.safety.prepare(),/无法确认/);assert.equal(f.prompts(),0);f.safety.dispose();} }); + +test('English install confirmation retains warning details and fail-closed second check', async () => { + const { translate } = require('../locale.cjs'); + const f = fixture(n => ({ ...safe, hasDraft: n === 2 }), { t: source => translate(source, 'en-US'), inspect: opts => { + assert.equal(opts.title, 'Install update and restart?'); + assert.equal(opts.buttons[0], 'Cancel'); assert.equal(opts.cancelId, 0); + assert.match(opts.detail, /A backup does not guarantee/); + } }); + assert.match(await f.safety.prepare(), /草稿/); assert.equal(f.locked(), false); f.safety.dispose(); +}); diff --git a/desktop/update-backup.cjs b/desktop/update-backup.cjs index da4c9bd2..f343dae4 100644 --- a/desktop/update-backup.cjs +++ b/desktop/update-backup.cjs @@ -45,7 +45,7 @@ async function backupForUpdate({ userData, version, backendExited, restrictDesti // Electron's live LevelDB is intentionally not copied as an alleged snapshot. // Updates retain the original userData/partition and never migrate its path. await copyTree(path.join(root, 'data'), path.join(destination, 'data')); - for (const filename of ['desktop-port.json', 'haru-window.json']) { + for (const filename of ['desktop-port.json', 'haru-window.json', 'desktop-locale.json']) { try { await copyTree(path.join(root, filename), path.join(destination, filename)); } catch (error) { if (error.code !== 'ENOENT') throw error; } } diff --git a/desktop/update-safety.cjs b/desktop/update-safety.cjs index daeb99aa..5b2e55a6 100644 --- a/desktop/update-safety.cjs +++ b/desktop/update-safety.cjs @@ -1,8 +1,9 @@ 'use strict'; +const { translate } = require('./locale.cjs'); const { randomUUID } = require('node:crypto'); const { isTrustedFrame } = require('./haru-protocol.cjs'); const { CHANNEL } = require('./updater.cjs'); -function createInstallSafety({ ipcMain, window, origin, dialog, timeoutMs = 5000, lock = () => {}, unlock = () => {} }) { +function createInstallSafety({ ipcMain, window, origin, dialog, timeoutMs = 5000, lock = () => {}, unlock = () => {}, t = translate }) { const pending = new Map(); let generation = 0; const invalidate = () => { @@ -43,7 +44,7 @@ function createInstallSafety({ ipcMain, window, origin, dialog, timeoutMs = 5000 const start = generation; const first = unsafe(await request()); if (first) return first; - const response = await dialog.showMessageBox(window, { type: 'question', title: '安装更新并重新启动?', message: '请确认已保存所有编辑、可结束后台任务,并同意关闭应用安装。', detail: '部分页面的未保存编辑及后台任务无法完整自动检测。点击继续表示你已保存所有编辑,并确认可结束后台任务。安装前会在本机备份数据与配置;浏览器存储保留原位。安装过程中请勿关机。安装中断可能需要手动重新安装;备份不保证旧版本能直接读取已迁移的数据。', buttons: ['取消', '已保存且可结束任务,关闭并安装'], defaultId: 0, cancelId: 0, noLink: true }); + const response = await dialog.showMessageBox(window, { type: 'question', title: t('安装更新并重新启动?'), message: t('请确认已保存所有编辑、可结束后台任务,并同意关闭应用安装。'), detail: t('部分页面的未保存编辑及后台任务无法完整自动检测。点击继续表示你已保存所有编辑,并确认可结束后台任务。安装前会在本机备份数据与配置;浏览器存储保留原位。安装过程中请勿关机。安装中断可能需要手动重新安装;备份不保证旧版本能直接读取已迁移的数据。'), buttons: [t('取消'), t('已保存且可结束任务,关闭并安装')], defaultId: 0, cancelId: 0, noLink: true }); if (response.response !== 1) return '已取消安装,可稍后继续。'; if (start !== generation) return '页面状态已变化,请重新检查后再安装。'; lock(); diff --git a/docs/BUGS.md b/docs/BUGS.md index b8b6dedc..7a29b43e 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -629,3 +629,11 @@ U01 模型文字二次确认/备注能力描述、U02 unknown_tool、U05 标签 ## 单条长度 软上限 60 行。复杂多阶段 bug 可超限,需在记录头注明超限原因。 + +## 2026-10-15:邮件最终预览的固定后端提醒未切换语言 + +- 现象:英文界面的最终预览仍可能显示两条中文写入范围/旧提醒清除提示。 +- 根因:前端直接显示后端 `warnings`,合成浏览器 fixture 又返回空 warnings,未覆盖真实响应。 +- 修复:只在展示边界翻译两条确定的后端固定提示,未知文本逐字保留;fixture 按真实字段合并和提醒契约返回数据。 +- 验证:增加双向语言/未知文本保留单测及未编辑提案的 fixture 契约断言;真实浏览器英文审计保留严格中文扫描。 +- 教训:API 来源不代表用户内容;固定 UI 提醒应明确识别,测试不能省略正常响应中的提醒。 diff --git a/docs/architecture/decisions/0014-localize-authored-interface-copy.md b/docs/architecture/decisions/0014-localize-authored-interface-copy.md new file mode 100644 index 00000000..cbf376ed --- /dev/null +++ b/docs/architecture/decisions/0014-localize-authored-interface-copy.md @@ -0,0 +1,44 @@ +# ADR-0014: 在展示边界切换中英文界面 + +- Status: Implemented on feature branch; pending acceptance +- Date: 2026-10-15 +- Decider: 用户要求网页和 Windows 桌面提供设置内语言切换;本记录说明实现边界,发布仍待验收。 + +## Context + +界面中文散落在页面、共享组件、辅助模型、错误提示和 Pilot/Haru 展示中。只切换导航或依赖 DOM 文本替换会遗漏确认框、验证、辅助技术标签,且可能错误翻译用户材料或改变业务身份。 + +网页与独立桌面 Haru 窗口可以复用展示代码,但不应假定 Electron 不同 session partition 共享 localStorage。桌面壳与网页语言实现现整合到最新主线的独立功能分支;源码整合不等于 Windows 同包实装验收通过。 + +## Decision + +1. `web/src/i18n` 持有 `zh-CN` / `en-US`、共享词条、插值和 React 订阅。默认中文。中文源文案也是默认 fallback;确有上下文歧义时使用独立 context key。 +2. `t(source, values?, context?)` 仅用于产品编写的界面文案,不接收简历正文、邮件正文、用户输入、AI 回复、角色名或模型文件。插值按原值保留,由 React 转义。 +3. 分域词条共同形成一个目录。同键不同译文是错误;缺失词条、占位符不一致和原始可见中文由测试/审计发现。目录使用无原型对象,未知输入不会访问 Object.prototype。 +4. 网页偏好保存在当前 origin 的 `offerpilot.locale`,通过 storage 事件同步同 origin 的其他窗口。存储受限时,当前窗口仍可切换,但不保证跨重启保存。 +5. 不以 locale 作为工作区或表单的 React key。组件订阅语言,只有展示投影随语言重算;请求身份、任务所有权、状态枚举、确认 payload 和用户草稿不变。 +6. Ant Design 使用对应 locale;传给会缓存 options 的组件时按渲染生成新展示对象。已存在验证错误可重新验证其字段,但不提交表单。静态确认框原地更新文案,不关闭、不批准、不重建任务。已显示的应用自有 Toast 使用订阅组件更新文本,保留原实例和计时器;来自服务器的未知错误文本原样保留。 +7. 日期/数值按语言展示,时区、币种、金额和选中日期不转换。业务周统计固定已有 Sunday 边界,不随 Day.js 界面 locale 改变。 +8. 需要保留的提示状态保存源文案或可重新投影的语义。`UiTextError` 保存固定界面错误的源文案,允许已有错误同步换语言。来自服务的未知诊断和已有内容不做猜测性翻译。 +9. Windows 集成在最新主线的独立功能分支移植。原生菜单和独立 Haru 窗使用主进程持久偏好及受限、校验枚举/来源的 IPC;只有合法主窗可改偏好。磁盘保存失败不能报告已保存。主网页分支不宣称已完成实装 EXE 验收。 + +## Consequences + +- 语言切换是展示偏好,不是新的业务授权,不触发邮件同步、模型调用、写操作或确认重放。 +- 既有用户/AI 内容可能继续使用原语言。界面语言不保证未来 AI 输出语言;生成语言策略属于独立需求。 +- 本地化不改变恢复记录格式;已审核的创建恢复适配器保留原字节边界,错误在展示层本地化。 +- 所有词条本地打包,不使用付费翻译服务,也不把内容发送到翻译 API。 +- 词条维护需要覆盖新组件;目录完整性、双语行为测试和 mocked 浏览器验收是持续门禁。 + +## Alternatives Considered + +- DOM 扫描/文本替换:拒绝。会碰触用户内容,破坏辅助标签和 React 所有权,也难以保留插值与确认状态。 +- 只在入口切换 Ant Design locale:拒绝。只能覆盖组件库默认文字,不能覆盖产品自己的页面/错误/状态。 +- 完整引入外部 i18n 运行时:当前两种语言不需要新运行时依赖。保留可按语义拆 context key 的本地目录与小型订阅接口。 + +## Related + +- [文档规范](../documentation-rules.md) +- [ADR-0006: 单条岗位录入的原子创建与恢复](./0006-reliable-single-application-intake.md) +- [ADR-0007: 共享动作展示](./0007-project-action-presentation.md) +- [ADR-0010: Runtime 持有执行](./0010-own-pilot-execution-in-runtime.md) diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index e9373b57..be2fe447 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -5,6 +5,11 @@ ## 当前记录 +### 界面语言 + +- [ADR-0014: 在展示边界切换中英文界面](./0014-localize-authored-interface-copy.md) — Implemented on feature branch; pending acceptance + + ### 投递录入 - [ADR-0006: 单条岗位录入的原子创建与恢复](./0006-reliable-single-application-intake.md) — Accepted diff --git a/docs/architecture/desktop-localization-validation.md b/docs/architecture/desktop-localization-validation.md new file mode 100644 index 00000000..40dd25eb --- /dev/null +++ b/docs/architecture/desktop-localization-validation.md @@ -0,0 +1,62 @@ +# Windows 桌面中英文验证包 + +状态:限定范围的独立 CI 构建入口;未签名,不是生产发行版,也不代表完整已安装桌面 UI 或完整发布验收通过。本文描述工作流执行范围,不声明尚未完成的 Windows run 通过。 + +## 来源与隔离 + +[desktop-localization.yml](../../.github/workflows/desktop-localization.yml) 只自动响应 `feat/20261019-localization-integration` 上相关桌面、前端、后端、测试、锁文件或本工作流/说明的 push。另提供 `workflow_dispatch`;新工作流仅在功能分支时,手动入口受 GitHub 默认分支注册限制,不为此自动修改 master。首次构建可由已授权的限定分支 push 启动。`desktop/localization-ui/**` 未纳入 package push 路径:仅修 harness 的提交不会重复冻结/构建;如真实 run 需要重试,再建立明确绑定该新 run、artifact、source SHA 和 installer SHA256 的独立同包重试入口,不预设或使用旧 PIN。 + +本次整合基于 `8fdc4f0aecf9612731bdd0590bbd0966af873279`,逐项移植网页本地化 `f6c19f1a` 至 `02b35bc6` 和桌面 `05fcd54a` 的语言增量。状态消息修复 `e74fcfa7` 与网页 `64a18a76` patch-id 相同,且最新主线已含其行为,不重复应用。保留新主线 Haru X 收起回角色、右键隐藏/置顶、默认置顶与显式 false 偏好。 + +每次运行从当前 checkout 新构建,记录完整 source SHA、ref、run、attempt 和锁文件 SHA256。历史运行与旧 installer 不作为本次通过证据。公开 `v0.1.0-desktop.1` tag 和附件不在本任务修改范围。 + +- 只有一个 Windows job;`contents: read`,没有并发取消或自动替换运行。 +- 不修改或调用旧 [desktop-windows.yml](../../.github/workflows/desktop-windows.yml) 的 12 分片/完整回归链路。 +- 不下载旧 installer、不读取或更新 installed-ui PIN、不安装旧 installed-ui helper、不复用旧 artifact 作为产品来源。 +- 不调用真实 AI、真实邮箱或发布服务,不创建 release,不自动发布或安装更新。 +- 构建依赖及 tokenizer 缓存需要网络;这不是离线构建承诺。Python 回归沿用仓库 loopback-only pytest 防护,冻结 smoke 沿用现有代理防误下载检查,后者不是网络沙箱。 + +## 必须串行通过的检查 + +Windows 2022 x64 runner 使用 Python 3.12、uv 0.12.19、Node 22。锁定安装为 `uv sync --frozen`、`npm ci --prefix web`、`npm ci --prefix desktop`。 + +1. 原生 Node:`npm test --prefix desktop`,包括语言偏好落盘/恢复、异常写入不广播、owner/Haru IPC 权限、可信 frame/origin、preload 白名单、菜单和启动错误翻译,以及现有桌面回归。另执行 `node --test desktop/localization-ui/test/*.test.mjs` 检查新 installed-UI harness 的安全/证据契约。 +2. Renderer:`src/i18n` 全目录,`DesktopHaruWindow`、`useDesktopHaruOwner`、`ConversationControlsLocalization`、`useDesktopUpdateSafety`、`ChatPanel/localization` 与 `tests/localization/mounted.test.tsx`。验证语言权威来源、广播/旧回复竞态、主窗口与 Haru 的控制边界及已选组件中英文行为。Vitest 固定最多两个 worker,保留 JUnit。 +3. Python:`test_desktop.py`、`test_auth_api.py`、`test_static_frontend.py`、`test_desktop_mail_localization_integration.py`、`test_job_mail_api.py`、`test_job_mail_secure_setup.py`,保留 JUnit。组合回归覆盖 desktop token、Web auth、邮件内层 Origin 与桌面外层 Origin 边界;使用隔离临时数据与 fake transport/extractor。 +4. 对上述 Python 测试及 desktop/API/mail 实现执行定向 ruff;对 desktop/API/mail 实现执行 mypy。`npm run build --prefix web` 已包含 `tsc -b` 与生产 Vite 构建,不重复单独运行同一 TypeScript 检查。 +5. 原样调用 [build-backend.py](../../desktop/build-backend.py),固定 PyInstaller 6.16.0、hooks-contrib 2025.9,在 Windows 冻结当前后端。 +6. 原样调用 [smoke-backend.py](../../desktop/smoke-backend.py),检查 `desktop/backend-dist/offerpilot-backend/offerpilot-backend.exe` 与 `web/dist`。 +7. `npm run build:win --prefix desktop -- --config.win.artifactName=...` 沿用 `electron-builder --win nsis --x64 --publish never`,仅由工作流覆盖产物文件名为 `OfferPilot-Desktop-localization-<12 位 SHA>-win-x64-setup.exe`;不修改 package.json。禁用签名自动发现,生成新的未签名 NSIS。 +8. 再对 `desktop/dist/win-unpacked/resources/backend/offerpilot-backend.exe` 与 `desktop/dist/win-unpacked/resources/web` 执行相同 smoke。 +9. 只接受该 SHA 对应的唯一新安装包,生成 SHA256 并复制至证据目录。 +10. 使用新的 `desktop/localization-ui/smoke.mjs` 安装同一新包并运行限定已安装 UI 检查;新包路径、完整 source SHA、同构建 `win-unpacked`、独立证据目录和全新安装目录通过 `LOCALIZATION_*` 环境变量传入。详细范围见下节。 +11. 构建、打包资源 smoke 与 SHA256 成功后,无论 installed-UI 通过、失败或未完成,都生成明确状态 manifest 并保留新实验 artifact,便于对同一字节重试;失败仍使工作流失败。构建或 hash 未成功时不上传 installer。任何失败、跳过、取消或待定结果都不构成验收通过。 + +冻结 smoke 的精确行为沿用 [桌面验证说明](desktop-validation.md#自动-smoke-的证据范围):真实子进程启动、依赖自检、loopback/token/Origin/Host、资源读取、隔离数据写入及重启、换 token、退出与失败清理。它没有执行安装程序或启动 Electron UI。 + +## 产物与证据 + +- 新安装包 artifact:`offerpilot-windows-localization-experimental-<完整 SHA>-`。 +- 新证据 artifact:`windows-localization-evidence-<完整 SHA>-`。 +- 安装包内含新生成的 `OfferPilot-Desktop-localization-<12 位 SHA>-win-x64-setup.exe`、相邻 `.sha256`、`LOCALIZATION-VALIDATION-NOTES.txt` 与 `LOCALIZATION-VALIDATION-STATUS.json`;不沿用旧 artifact 名称。JSON 记录 installer hash、installed UI 的真实 outcome/是否通过及 `releaseReady: false`;artifact 存在不表示 installed UI 通过。 +- 证据位于 `desktop/localization-ci-evidence/`,含来源/锁文件 hash、分阶段 transcript、pytest/Vitest JUnit、最终状态,以及成功生成时的 installer hash 和范围说明;新 installed-UI harness 的截图、动作和 payload 身份证据在其 `installed-ui/` 子目录。 +- 证据上传使用 `always()`;保留 14 天。超时、强制取消或 runner 故障可能截断日志,缺失证据不能当作通过。 +- `result.txt` 是证据上传前的状态快照,最终结论仍应核对 GitHub run/job 的最终状态。 + +## 同包 installed-UI 门禁与验收边界 + +新的独立 harness 安装本次刚构建的 NSIS,并与同构建 `win-unpacked` 比较安装后的可执行文件、app.asar、Web 和后端资源身份。它要求真实 `%APPDATA%/OfferPilot Desktop` 尚不存在,不通过另设 `--user-data-dir` 伪造偏好持久化,也不覆盖已有个人 profile。CI 传入全新临时安装目录,不下载旧包或复用旧 helper/PIN。 + +限定目标为真实安装后 zh/en 切换、完整退出重启后的偏好恢复、owner/Haru 和原生菜单语言同步,以及安全禁用凭据设置时的邮件只读/粘贴/预览与 Origin 边界。使用隔离合成内容,不录入凭据、不提交真实邮件导入、不调用 AI 或邮箱。菜单通过真实 Electron Menu 对象与 Tray 更新观察核对;这不是操作系统菜单的指针交互截图。真实安装 EXE 使用 Playwright Inspector 启动,保留 sandbox/CSP/security 默认值,但尚非未调试启动或 SmartScreen 验收。各动作实际结果以 harness 报告与截图为准;源文件或 CI 配置存在不等于检查已通过。 + +工作流要求 harness 成功退出才将限定 installed UI 标为通过;失败时仍保留已构建且已验证 hash 的同一实验 installer、明确未通过的状态 manifest、UI 报告与截图,便于只重试同包安装验收。保留 artifact 不会把失败的 job 变绿,也不是发布。该限定检查不替代安装/卸载/升级、完整桌面功能、所有菜单和系统对话框等全面验收。完整发布 gate 与真实 AI/邮箱验收仍未运行,不能套用旧 installed-ui artifact 的成功记录。 + +`ui-localization.yml` 是另外的 Web 合成浏览器截图入口;此 Windows job 不依赖或启动它。Mounted/jsdom 测试不等于真实浏览器截图,更不等于 installed Electron UI。 + +此工作流没有运行完整 pytest/前端回归、完整 release gate、真实 AI、真实邮箱、代码签名、更新发布或生产分发。完成实验包不免除 [AGENTS.md §7](../../AGENTS.md#7-验证与-code-review) 与 [Windows 人工验收](desktop-validation.md#windows-人工验收清单) 的相关义务。 + +## 本次源码与验收记录(2026-10-19) + +本次源码检查和真实运行结果以独立 PR、当前 source SHA 的 CI 和证据 artifact 为准。原生桌面、renderer 语言同步及新 Haru 行为有回归测试;另做独立 IPC/会话控制权审查。网页通过真实 Chromium 加合成 API 截图验收;Windows 通过新 NSIS 安装包同包实装验收。两者都不等于真实 AI 或真实邮箱验收。 + +当前云命令环境不允许 Chromium 创建本地 IPC socket,提权启动也被环境初始化错误阻断;因此真实浏览器检查由同源 GitHub Actions Ubuntu runner 执行。不可把该环境错误或旧截图计作通过。完整 PR Windows 回归入口保持不变,结果必须等其终态后另行记录。 diff --git a/tests/test_desktop_mail_localization_integration.py b/tests/test_desktop_mail_localization_integration.py new file mode 100644 index 00000000..159e0f95 --- /dev/null +++ b/tests/test_desktop_mail_localization_integration.py @@ -0,0 +1,119 @@ +"""Offline regressions for the merged desktop and private-mail boundaries.""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from offerpilot.api import create_app +from offerpilot.config import Config, load_config, save_config +from offerpilot.desktop import TOKEN_HEADER, DesktopSecurity, create_desktop_app +from offerpilot.job_mail.model_adapter import OfflineRuleExtractor +from offerpilot.job_mail.transport import FakeIMAPTransport + + +@pytest.mark.parametrize("web_token", ["web-only-token", ""]) +def test_trusted_transport_bypasses_web_login_but_never_mail_origin(tmp_path, web_token): + save_config(tmp_path, Config(auth_enabled=True, auth_token=web_token)) + transport = FakeIMAPTransport() + extractor = OfflineRuleExtractor() + app = create_app( + data_dir=tmp_path, + # Deliberately separate authentication from Origin validation so the + # desktop outer middleware cannot mask a missing inner mail fence. + transport_authenticator=lambda request: True, + job_mail_transport=transport, + job_mail_extractor=extractor, + job_mail_local_setup_enabled=False, + ) + assert app.state.job_mail_service.transport is transport + assert app.state.job_mail_service.extractor is extractor + assert app.state.job_mail_secure_setup.enabled is False + + with TestClient(app) as client: + assert client.get("/api/auth/status").json() == { + "auth_enabled": True, "authenticated": True, + } + assert client.get("/api/job-mail/status").status_code == 200 + same_origin = {"Origin": "http://testserver"} + assert client.get("/api/job-mail/status", headers=same_origin).status_code == 200 + assert client.options("/api/job-mail/imports", headers=same_origin).status_code == 200 + allowed = client.post("/api/job-mail/imports", headers=same_origin, json={ + "subject": "面试邀请", "received_at": "2026-10-09T10:00:00Z", + "body_text": "面试通知,时间待定", + }) + assert allowed.status_code == 200 + assert len(allowed.json()["items"]) == 1 + + invalid_origins = [ + [("Origin", origin)] + for origin in ("https://foreign.example", "null", "", "http://testserver/") + ] + [[("Origin", "http://testserver"), ("Origin", "https://foreign.example")]] + for headers in invalid_origins: + for method, path in ( + ("GET", "/api/job-mail/status"), + ("POST", "/api/job-mail/imports"), + ("POST", "/api/job-mail/disconnect"), + ("OPTIONS", "/api/job-mail/imports"), + ): + response = client.request(method, path, headers=headers) + assert response.status_code == 403 + assert response.json()["error_code"] == "job_mail_origin_denied" + assert client.get("/api/job-mail/suggestions").json()["total"] == 1 + + assert load_config(tmp_path).auth_enabled is True + assert load_config(tmp_path).auth_token == web_token + assert transport.read_calls == [] + + +@pytest.mark.parametrize("web_token,expected_status", [("web-only-token", 401), ("", 503)]) +def test_untrusted_transport_keeps_web_auth_precedence(tmp_path, web_token, expected_status): + save_config(tmp_path, Config(auth_enabled=True, auth_token=web_token)) + with TestClient(create_app( + data_dir=tmp_path, transport_authenticator=lambda request: False, + )) as client: + for headers in ({}, {"Origin": "http://testserver"}, {"Origin": "null"}): + assert client.get("/api/job-mail/status", headers=headers).status_code == expected_status + assert client.get("/api/auth/status").json()["authenticated"] is False + if web_token: + authenticated = {"Authorization": f"Bearer {web_token}"} + assert client.get("/api/job-mail/status", headers=authenticated).status_code == 200 + rejected = client.get("/api/job-mail/status", headers={ + **authenticated, "Origin": "null", + }) + assert rejected.status_code == 403 + assert rejected.json()["error_code"] == "job_mail_origin_denied" + + +@pytest.mark.parametrize("web_token", ["web-only-token", ""]) +def test_desktop_mail_routes_keep_mandatory_outer_security(tmp_path, web_token): + data_dir = tmp_path / "data" + static_dir = tmp_path / "frontend" + static_dir.mkdir() + (static_dir / "index.html").write_text("Desktop mail fixture", encoding="utf-8") + save_config(data_dir, Config(auth_enabled=True, auth_token=web_token)) + security = DesktopSecurity(token="a1" * 32, port=48271) + app = create_desktop_app(data_dir=data_dir, static_dir=static_dir, security=security) + headers = {TOKEN_HEADER: security.token} + + with TestClient(app, base_url=security.origin) as client: + for method in ("GET", "POST", "OPTIONS"): + assert client.request(method, "/api/job-mail/status").status_code == 401 + assert client.get("/api/job-mail/status", headers={ + "X-OfferPilot-Token": web_token, + }).status_code == 401 + assert client.get("/api/job-mail/status", headers=headers).status_code == 200 + assert client.get("/api/job-mail/status", headers={ + **headers, "Origin": security.origin, + }).status_code == 200 + for method, path in ( + ("GET", "/api/job-mail/status"), + ("POST", "/api/job-mail/imports"), + ("OPTIONS", "/api/job-mail/imports"), + ): + denied = client.request(method, path, headers={**headers, "Origin": "null"}) + assert denied.status_code == 403 + assert denied.json() == {"error": "desktop origin forbidden"} + + assert load_config(data_dir).auth_enabled is True + assert load_config(data_dir).auth_token == web_token diff --git a/web/package-lock.json b/web/package-lock.json index cc93ff17..d0253707 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -29,6 +29,7 @@ "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", "jsdom": "^26.1.0", + "playwright-core": "^1.64.1", "typescript": "^5.6.3", "vite": "^5.4.11", "vitest": "^2.1.9" @@ -5745,6 +5746,19 @@ "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", "license": "MIT" }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", diff --git a/web/package.json b/web/package.json index 0eeb6368..fd152c9dd 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run", + "test:localization": "node tests/localization/run.mjs" }, "dependencies": { "@ant-design/icons": "^5.5.1", @@ -31,6 +32,7 @@ "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", "jsdom": "^26.1.0", + "playwright-core": "^1.64.1", "typescript": "^5.6.3", "vite": "^5.4.11", "vitest": "^2.1.9" diff --git a/web/src/components/AISettingsDrawer.tsx b/web/src/components/AISettingsDrawer.tsx index 87cd6260..1f9bcf9f 100644 --- a/web/src/components/AISettingsDrawer.tsx +++ b/web/src/components/AISettingsDrawer.tsx @@ -1,3 +1,6 @@ +import { liveText } from '@/i18n/liveText'; +import { useLocalizedValidation } from './applicationUiText'; +import { t, useLocale } from '@/i18n'; import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { @@ -67,14 +70,14 @@ interface FormValues { const PROVIDER_LABELS: Record = { openai: 'OpenAI', - openai_compatible: 'OpenAI 兼容', + get openai_compatible() { return t("OpenAI 兼容"); }, anthropic: 'Anthropic', openrouter: 'OpenRouter', }; const PROVIDER_OPTIONS = [ { value: 'openai', label: 'OpenAI' }, - { value: 'openai_compatible', label: 'OpenAI 兼容' }, + { value: 'openai_compatible', get label() { return t("OpenAI 兼容"); } }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openrouter', label: 'OpenRouter' }, ]; @@ -84,7 +87,9 @@ const DEFAULT_MODEL = 'gpt-4o'; const PROVIDER_FRAMING_RESERVE = 1_024; export default function AISettingsDrawer({ open, onClose }: Props) { + useLocale(); const [form] = Form.useForm(); + useLocalizedValidation(form); const qc = useQueryClient(); const [providers, setProviders] = useState([]); const [activeProviderId, setActiveProviderId] = useState('default'); @@ -134,7 +139,7 @@ export default function AISettingsDrawer({ open, onClose }: Props) { && !hasValidProviderBudget(provider), ); if (incompleteProvider) { - throw new Error(`请先补全 ${incompleteProvider.label || incompleteProvider.id} 的模型预算配置`); + throw new Error(t("请先补全 {value0} 的模型预算配置", { value0: incompleteProvider.label || incompleteProvider.id })); } const nextActive = nextProviders.find((item) => item.id === nextActiveId) ?? nextProviders[0]; setProviders(nextProviders); @@ -154,7 +159,7 @@ export default function AISettingsDrawer({ open, onClose }: Props) { qc.setQueryData(SETTINGS_QUERY_KEY, settings); qc.invalidateQueries({ queryKey: SETTINGS_QUERY_KEY }); qc.invalidateQueries({ queryKey: ONBOARDING_QUERY_KEY }); - message.success('AI 设置已保存'); + message.success(liveText(() => t("AI 设置已保存"))); setProviders(toEditableProviders(settings)); setActiveProviderId(settings.active_provider_id); setFallbackProviderIds(settings.fallback_provider_ids ?? []); @@ -162,7 +167,7 @@ export default function AISettingsDrawer({ open, onClose }: Props) { onClose(); }, onError: (error) => { - message.error(error instanceof Error ? error.message : 'AI 设置保存失败'); + message.error(error instanceof Error ? error.message : t("AI 设置保存失败")); }, }); @@ -180,14 +185,14 @@ export default function AISettingsDrawer({ open, onClose }: Props) { onSuccess: (result) => { setConnectionResult(result); if (result.ok) { - message.success(result.message || '连接成功'); + message.success(result.message || t("连接成功")); } else { - message.error(result.error || '连接失败'); + message.error(result.error || t("连接失败")); } }, onError: () => { - setConnectionResult({ ok: false, error: '连接失败' }); - message.error('连接失败'); + setConnectionResult({ ok: false, error: "连接失败" }); + message.error(liveText(() => t("连接失败"))); }, }); @@ -198,7 +203,7 @@ export default function AISettingsDrawer({ open, onClose }: Props) { const id = uniqueProviderId('provider', providers); const provider: EditableProvider = { id, - label: '新供应商', + label: t("新供应商"), provider: 'openai', base_url: DEFAULT_BASE_URL, model: DEFAULT_MODEL, @@ -225,7 +230,7 @@ export default function AISettingsDrawer({ open, onClose }: Props) { const nextProvider = formToProvider(values, providers.find((item) => item.id === values.id)); setProviders((current) => upsertProvider(current, nextProvider)); setEditingId(nextProvider.id); - message.success('供应商已更新'); + message.success(liveText(() => t("供应商已更新"))); } function removeProvider(providerId: string) { @@ -243,7 +248,7 @@ export default function AISettingsDrawer({ open, onClose }: Props) { function markDefault(provider: EditableProvider) { if (!hasValidProviderBudget(provider)) { - message.warning('请先补全该供应商的模型预算配置'); + message.warning(liveText(() => t("请先补全该供应商的模型预算配置"))); return; } setActiveProviderId(provider.id); @@ -253,7 +258,7 @@ export default function AISettingsDrawer({ open, onClose }: Props) { if (!open) return null; return ( -
+
+ {t("返回设置")} - AI 设置 - + {t("AI 设置")}
+ {t("保存")}
: } - message={!hasKey ? '尚未配置密钥' : hasBudget ? 'AI 配置已就绪' : '预算配置待补全'} + message={!hasKey ? t("尚未配置密钥") : hasBudget ? t("AI 配置已就绪") : t("预算配置待补全")} description={ !hasKey - ? '配置模型供应商密钥后即可使用 AI 能力。' + ? t("配置模型供应商密钥后即可使用 AI 能力。") : hasBudget - ? '密钥留空保存会保留当前密钥。' - : '请按当前模型的官方规格填写上下文窗口和单次最大输出。' + ? t("密钥留空保存会保留当前密钥。") + : t("请按当前模型的官方规格填写上下文窗口和单次最大输出。") } />
- Provider 列表 - + {t("Provider 列表")} + {t("新增供应商")} ( } onClick={() => editProvider(provider)}> - 编辑供应商 - , + {t("编辑供应商")}, , + {t("设为默认")}, removeProvider(provider.id)} > + {t("删除供应商")} , ]} > @@ -334,11 +331,11 @@ export default function AISettingsDrawer({ open, onClose }: Props) { title={ {provider.label || provider.id} - {provider.id === activeProviderId ? 默认 : null} + {provider.id === activeProviderId ? {t("默认")} : null} {fallbackProviderIds.includes(provider.id) ? Fallback : null} - {provider.has_api_key ? 密钥已配置 : 未配置密钥} + {provider.has_api_key ? {t("密钥已配置")} : {t("未配置密钥")}} {provider.enabled && !hasValidProviderBudget(provider) ? ( - 预算配置待补全 + {t("预算配置待补全")} ) : null} } @@ -358,74 +355,73 @@ export default function AISettingsDrawer({ open, onClose }: Props) { className={workflowStyles.section} > - 编辑供应商 - + {t("编辑供应商")} - + item.id === editingId)} /> - + - + } autoComplete="off" - placeholder={activeProvider?.has_api_key ? '留空则保留当前密钥' : 'sk-...'} + placeholder={activeProvider?.has_api_key ? t("留空则保留当前密钥") : 'sk-...'} /> - - ({ ...option }))} /> - + - + ({ validator(_, value) { if (getFieldValue('enabled') !== true && (!value || value <= 0)) return Promise.resolve(); if (!Number.isInteger(value) || value <= 0) { - return Promise.reject(new Error('请输入大于 0 的整数上下文窗口')); + return Promise.reject(new Error(t("请输入大于 0 的整数上下文窗口"))); } const output = getFieldValue('max_output_tokens'); if (Number.isInteger(output) && output > 0 && value <= output + PROVIDER_FRAMING_RESERVE) { - return Promise.reject(new Error('上下文窗口必须大于最大输出与协议预留之和')); + return Promise.reject(new Error(t("上下文窗口必须大于最大输出与协议预留之和"))); } return Promise.resolve(); }, }), ]} > - + ({ validator(_, value) { if (getFieldValue('enabled') !== true && (!value || value <= 0)) return Promise.resolve(); if (!Number.isInteger(value) || value <= 0) { - return Promise.reject(new Error('请输入大于 0 的整数最大输出')); + return Promise.reject(new Error(t("请输入大于 0 的整数最大输出"))); } const contextWindow = getFieldValue('context_window'); if ( @@ -433,33 +429,33 @@ export default function AISettingsDrawer({ open, onClose }: Props) { && contextWindow > 0 && contextWindow <= value + PROVIDER_FRAMING_RESERVE ) { - return Promise.reject(new Error('单次最大输出必须为上下文和协议预留留出空间')); + return Promise.reject(new Error(t("单次最大输出必须为上下文和协议预留留出空间"))); } return Promise.resolve(); }, }), ]} > - + - + - + - - - + + + - + + + } - - + + } - 本地恢复记录被清除或换设备后,请先检查已有投递。 + {t("本地恢复记录被清除或换设备后,请先检查已有投递。")} ); diff --git a/web/src/components/AddOfferForm.test.tsx b/web/src/components/AddOfferForm.test.tsx index 05338beb..62b15835 100644 --- a/web/src/components/AddOfferForm.test.tsx +++ b/web/src/components/AddOfferForm.test.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { setLocale } from '@/i18n'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { App as AntApp } from 'antd'; @@ -85,6 +86,7 @@ async function selectApplication(optionText: string) { } afterEach(() => { + act(() => setLocale('zh-CN')); act(() => root?.unmount()); host?.remove(); document.body.querySelectorAll('.ant-modal-root').forEach((node) => node.remove()); @@ -149,3 +151,57 @@ it('prefills a new Offer from its application while preserving manually edited f expect(field('company_name').value).toBe('星云数据'); expect(field('position_name').value).toBe('后端工程师'); }, 15_000); + + +it('switches labels and enum options live without resetting edited offer fields', async () => { + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + await act(async () => { + root?.render(); + }); + await selectApplication('星云数据'); + setField('company_name', '用户保留的公司名称'); + const companyInput = field('company_name'); + await act(async () => setLocale('en-US')); + expect(field('company_name')).toBe(companyInput); + expect(companyInput.value).toBe('用户保留的公司名称'); + expect(document.body.textContent).toContain('Monthly salary'); + expect(document.body.textContent).toContain('Company'); + expect(document.body.textContent).not.toContain('月薪(元)'); + await act(async () => setLocale('zh-CN')); + expect(field('company_name')).toBe(companyInput); + expect(companyInput.value).toBe('用户保留的公司名称'); + expect(document.body.textContent).toContain('月薪(元)'); + expect(createOffer).not.toHaveBeenCalled(); + expect(updateOffer).not.toHaveBeenCalled(); + client.clear(); +}, 15_000); + +it('updates existing validation errors on a locale switch without submitting or replacing the form', async () => { + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + await act(async () => { + root?.render(); + }); + const companyInput = field('company_name'); + await act(async () => { + document.body.querySelector('.ant-modal-footer .ant-btn-primary')?.click(); + }); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }); + expect(document.body.textContent).toContain('请输入公司'); + await act(async () => { + setLocale('en-US'); + }); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }); + expect(field('company_name')).toBe(companyInput); + expect(companyInput.value).toBe(''); + expect(document.body.textContent).not.toContain('请输入公司'); + expect(document.body.querySelector('.ant-form-item-explain-error')?.textContent).toMatch(/company/i); + expect(createOffer).not.toHaveBeenCalled(); + expect(updateOffer).not.toHaveBeenCalled(); + client.clear(); +}); diff --git a/web/src/components/AddOfferForm.tsx b/web/src/components/AddOfferForm.tsx index fd1a3d71..c1acbbeb 100644 --- a/web/src/components/AddOfferForm.tsx +++ b/web/src/components/AddOfferForm.tsx @@ -1,3 +1,5 @@ +import { useLocalizedValidation } from './applicationUiText'; +import { t, useLocale } from '@/i18n'; import { useEffect, useRef } from 'react'; import { Alert, Button, Modal, Form, Input, InputNumber, Select, App as AntApp } from 'antd'; import { useMutation, useQueryClient } from '@tanstack/react-query'; @@ -15,13 +17,17 @@ interface Props { requestToken?: string | null; } + + +export default function AddOfferForm({ open, onClose, applications, editing, requestToken }: Props) { const STATUS_OPTIONS = (Object.keys(OFFER_STATUS_LABELS) as OfferStatus[]).map((s) => ({ value: s, label: OFFER_STATUS_LABELS[s], })); -export default function AddOfferForm({ open, onClose, applications, editing, requestToken }: Props) { + useLocale(); const [form] = Form.useForm(); + useLocalizedValidation(form); const { message: toast } = AntApp.useApp(); const qc = useQueryClient(); const historicalReadOnly = Boolean(editing && listOfferBindingState(editing) === 'unbound'); @@ -72,14 +78,14 @@ export default function AddOfferForm({ open, onClose, applications, editing, req ) { applicationOptions.unshift({ value: editingApplicationId, - label: `#${editingApplicationId}(当前绑定投递不可见)`, + label: t("#{editingApplicationId}(当前绑定投递不可见)", { editingApplicationId: editingApplicationId }), }); } const submit = (values: OfferInput) => { if (historicalReadOnly) return; if (!editing && (!Number.isInteger(values.application_id) || Number(values.application_id) <= 0)) { - form.setFields([{ name: 'application_id', errors: ['请选择所属投递'] }]); + form.setFields([{ name: 'application_id', errors: [t("请选择所属投递")] }]); return; } const payload: OfferInput = editing @@ -94,23 +100,23 @@ export default function AddOfferForm({ open, onClose, applications, editing, req return createOffer(values); }, onSuccess: () => { - toast.success(editing ? 'Offer 已更新' : 'Offer 已录入'); + toast.success(editing ? t("Offer 已更新") : t("Offer 已录入")); qc.invalidateQueries({ queryKey: ['offers'] }); onClose(); }, onError: (e: unknown) => { const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error; - toast.error(msg || '保存失败'); + toast.error(msg || t("保存失败")); }, }); return ( form.submit()} - footer={historicalReadOnly ? : undefined} + footer={historicalReadOnly ? : undefined} confirmLoading={mutation.isPending} destroyOnHidden data-request-token={requestToken ?? undefined} @@ -120,8 +126,8 @@ export default function AddOfferForm({ open, onClose, applications, editing, req type="warning" showIcon style={{ marginBottom: 16 }} - message="历史 Offer 仅支持只读查看" - description="该记录没有所属投递,不能编辑、补绑定或进入谈薪准备。" + message={t("历史 Offer 仅支持只读查看")} + description={t("该记录没有所属投递,不能编辑、补绑定或进入谈薪准备。")} /> ) : null}
submit(v as OfferInput)} > - + - + - + - + - + - - + + - + - - + + - + diff --git a/web/src/components/ApplicationCommunicationDraftPanel.tsx b/web/src/components/ApplicationCommunicationDraftPanel.tsx index d59bf88a..ad3437e9 100644 --- a/web/src/components/ApplicationCommunicationDraftPanel.tsx +++ b/web/src/components/ApplicationCommunicationDraftPanel.tsx @@ -1,3 +1,6 @@ +import { liveText } from '@/i18n/liveText'; +import { applicationUiMessage } from '@/components/applicationUiText'; +import { t, useLocale } from '@/i18n'; import { useEffect, useMemo, useState } from 'react'; import { App, Button, Input, Select, Tag } from 'antd'; import { @@ -27,17 +30,17 @@ interface Props { } const RESULT_LABELS: Record = { - advanced: '进入下一阶段', - rejected: '未通过', - withdrawn: '主动退出', - no_response: '暂无回复', - offer_received: '收到 Offer', - other: '其他', + get advanced() { return t("进入下一阶段"); }, + get rejected() { return t("未通过"); }, + get withdrawn() { return t("主动退出"); }, + get no_response() { return t("暂无回复"); }, + get offer_received() { return t("收到 Offer"); }, + get other() { return t("其他"); }, }; function dateLabel(value: string, timezoneOffsetMinutes: number): string { const instant = Date.parse(value); - if (!Number.isFinite(instant)) return '日期未知'; + if (!Number.isFinite(instant)) return t("日期未知"); const local = new Date(instant - timezoneOffsetMinutes * 60_000); const year = local.getUTCFullYear(); const month = String(local.getUTCMonth() + 1).padStart(2, '0'); @@ -46,6 +49,7 @@ function dateLabel(value: string, timezoneOffsetMinutes: number): string { } export default function ApplicationCommunicationDraftPanel({ application, snapshots, outcomes, events }: Props) { + useLocale(); const { message } = App.useApp(); const [kind, setKind] = useState('follow_up'); const [snapshotId, setSnapshotId] = useState(snapshots[0]?.id); @@ -132,7 +136,7 @@ export default function ApplicationCommunicationDraftPanel({ application, snapsh setBody(next.body); setValidationError(''); } catch (error) { - setValidationError(error instanceof Error ? error.message : '无法生成草稿,请检查输入'); + setValidationError(error instanceof Error ? error.message : t("无法生成草稿,请检查输入")); } }; @@ -146,11 +150,11 @@ export default function ApplicationCommunicationDraftPanel({ application, snapsh setCopyStatus(''); try { await navigator.clipboard.writeText(`${subject}\n\n${body}`); - setCopyStatus('完整草稿已复制'); - message.success('完整草稿已复制'); + setCopyStatus(t("完整草稿已复制")); + message.success(liveText(() => t("完整草稿已复制"))); } catch { - setCopyStatus('复制失败,请手动选择文字'); - message.error('复制失败,请手动选择文字'); + setCopyStatus(t("复制失败,请手动选择文字")); + message.error(liveText(() => t("复制失败,请手动选择文字"))); } }; @@ -159,27 +163,27 @@ export default function ApplicationCommunicationDraftPanel({ application, snapsh

COMMUNICATION DRAFT

-

跟进与感谢信

-

使用已冻结的投递档案、当前投递名称和你明确填写的内容;生成后可编辑、复制,不会自动发送。

+

{t(" 跟进与感谢信")}

+

{t("使用已冻结的投递档案、当前投递名称和你明确填写的内容;生成后可编辑、复制,不会自动发送。")}

- }>本地确定性草稿 + }>{t("本地确定性草稿")}
-
+
-
01
确认来源选择本次草稿所依据的事实
- +
01
{t("确认来源")}{t("选择本次草稿所依据的事实")}
+ { const nextOutcome = interviewOutcomes.find((outcome) => outcome.id === value); const linkedEvent = events.find((event) => event.id === nextOutcome?.application_event_id @@ -221,65 +225,65 @@ export default function ApplicationCommunicationDraftPanel({ application, snapsh }} options={interviewOutcomes.map((outcome) => ({ value: outcome.id, label: `${dateLabel(outcome.occurred_at, timezoneOffsetMinutes)} · ${RESULT_LABELS[outcome.result]}` }))} /> - {selectedEvent ?
关联日程第 {selectedEvent.round} 轮面试 · {dateLabel(selectedEvent.scheduled_at, timezoneOffsetMinutes)}
: null} + {selectedEvent ?
{t("关联日程")}{t("第 {round} 轮面试 · {date}", { round: selectedEvent.round, date: dateLabel(selectedEvent.scheduled_at, timezoneOffsetMinutes) })}
: null}
) : null} - + { setRecipientName(event.target.value); invalidateDraft(); }} /> {kind === 'thank_you' ? ( <> - + { setHighlight(event.target.value); invalidateDraft(); }} /> ) : null} - {validationError ?

{validationError}

: null} - + {validationError ?

{applicationUiMessage(validationError)}

: null} +
-
02
编辑与复制内容只保留在当前页面
+
02
{t("编辑与复制")}{t("内容只保留在当前页面")}
{!draft ? (
- 草稿尚未生成 -

确认左侧来源后生成;不会调用 AI,也不会创建业务记录。

+ {t("草稿尚未生成")} +

{t("确认左侧来源后生成;不会调用 AI,也不会创建业务记录。")}

) : (
- 本次草稿使用的来源 + {t(" 本次草稿使用的来源")}
{draft.evidenceLabels.map((label) => {label})}
- - { setSubject(event.target.value); setCopyStatus(''); }} /> - - { setBody(event.target.value); setCopyStatus(''); }} /> + + { setSubject(event.target.value); setCopyStatus(''); }} /> + + { setBody(event.target.value); setCopyStatus(''); }} />
- - + +
)}
-

{copyStatus}

+

{applicationUiMessage(copyStatus)}

); } diff --git a/web/src/components/ApplicationDetail.tsx b/web/src/components/ApplicationDetail.tsx index 25f51494..b8d07989 100644 --- a/web/src/components/ApplicationDetail.tsx +++ b/web/src/components/ApplicationDetail.tsx @@ -1,3 +1,5 @@ +import { liveText } from '@/i18n/liveText'; +import { t, useLocale } from '@/i18n'; import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type KeyboardEvent, type ReactNode } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { @@ -137,9 +139,9 @@ function getScheduleTrigger( } const DETAIL_TABS: Array<{ id: ApplicationDetailTab; label: string }> = [ - { id: 'overview', label: '概览' }, - { id: 'preparation', label: '准备' }, - { id: 'progress', label: '进展' }, + { id: 'overview', get label() { return t("概览"); } }, + { id: 'preparation', get label() { return t("准备"); } }, + { id: 'progress', get label() { return t("进展"); } }, ]; interface ApplicationProgressItem { @@ -151,38 +153,38 @@ interface ApplicationProgressItem { } const EVENT_SUBTYPE_LABELS: Readonly> = { - assessment: '测评', - technical: '技术面试', - behavioral: '行为面试', - phone: '电话沟通', - onsite: '现场面试', - hr: '人事面试', - final: '终面', - screening: '初筛', + get assessment() { return t("测评"); }, + get technical() { return t("技术面试"); }, + get behavioral() { return t("行为面试"); }, + get phone() { return t("电话沟通"); }, + get onsite() { return t("现场面试"); }, + get hr() { return t("人事面试"); }, + get final() { return t("终面"); }, + get screening() { return t("初筛"); }, }; const EVENT_STATUS_LABELS: Readonly> = { - todo: '待处理', - pending: '待确认', - scheduled: '已安排', - in_progress: '进行中', - done: '已完成', - completed: '已完成', - cancelled: '已取消', - deleted: '已取消', - soft_deleted: '已取消', + get todo() { return t("待处理"); }, + get pending() { return t("待确认"); }, + get scheduled() { return t("已安排"); }, + get in_progress() { return t("进行中"); }, + get done() { return t("已完成"); }, + get completed() { return t("已完成"); }, + get cancelled() { return t("已取消"); }, + get deleted() { return t("已取消"); }, + get soft_deleted() { return t("已取消"); }, }; function eventSubtypeLabel(value: string): string { if (!value) return ''; - return EVENT_SUBTYPE_LABELS[value] ?? (/\p{Script=Han}/u.test(value) ? value : '其他环节'); + return EVENT_SUBTYPE_LABELS[value] ?? (/\p{Script=Han}/u.test(value) ? value : t("其他环节")); } function eventStatusLabel(value: string): string { - return EVENT_STATUS_LABELS[value] ?? '状态待确认'; + return EVENT_STATUS_LABELS[value] ?? t("状态待确认"); } -function formatWorkspaceDate(value?: string | null, fallback = '待记录') { +function formatWorkspaceDate(value?: string | null, fallback = t("待记录")) { if (!value) return fallback; const date = dayjs(value); return date.isValid() ? date.format('YYYY-MM-DD HH:mm') : fallback; @@ -199,13 +201,13 @@ function isObjectRow(value: unknown): boolean { } const TASK_COPY: Readonly> = Object.freeze({ - 'application.opportunity_fit': { title: '岗位匹配与风险', description: '确认是否值得继续,以及需要补充的事实。', action: '开始判断' }, - 'application.material_kit': { title: '关联材料', description: '核对本次投递关联的简历与材料记录。', action: '查看投递材料' }, - 'application.interview_prepare': { title: '面试准备', description: '围绕这场面试整理准备信息。', action: '开始准备' }, - 'application.interview_review': { title: '面试复盘', description: '记录或查看这场面试的复盘。', action: '打开复盘' }, - 'application.general_review': { title: '投递复盘', description: '查看不绑定具体事件的投递复盘。', action: '打开复盘' }, - 'application.offer_review': { title: 'Offer 准备', description: '查看归属本次投递的 Offer 与准备信息。', action: '查看准备' }, - 'application.record_outcome': { title: '投递结果', description: '记录本次投递的事实与结果。', action: '打开记录' }, + 'application.opportunity_fit': { get title() { return t("岗位匹配与风险"); }, get description() { return t("确认是否值得继续,以及需要补充的事实。"); }, get action() { return t("开始判断"); } }, + 'application.material_kit': { get title() { return t("关联材料"); }, get description() { return t("核对本次投递关联的简历与材料记录。"); }, get action() { return t("查看投递材料"); } }, + 'application.interview_prepare': { get title() { return t("面试准备"); }, get description() { return t("围绕这场面试整理准备信息。"); }, get action() { return t("开始准备"); } }, + 'application.interview_review': { get title() { return t("面试复盘"); }, get description() { return t("记录或查看这场面试的复盘。"); }, get action() { return t("打开复盘"); } }, + 'application.general_review': { get title() { return t("投递复盘"); }, get description() { return t("查看不绑定具体事件的投递复盘。"); }, get action() { return t("打开复盘"); } }, + 'application.offer_review': { get title() { return t("Offer 准备"); }, get description() { return t("查看归属本次投递的 Offer 与准备信息。"); }, get action() { return t("查看准备"); } }, + 'application.record_outcome': { get title() { return t("投递结果"); }, get description() { return t("记录本次投递的事实与结果。"); }, get action() { return t("打开记录"); } }, }); function taskSource(status: 'loading' | 'error' | 'ready' | 'absent', value?: T): TaskSource { @@ -324,10 +326,10 @@ export function projectApplicationInterviewChoices( const projected = projectApplicationEvent(event, undefined, now); const subtype = typeof event.subtype === 'string' && event.subtype.trim() ? event.subtype.trim().slice(0, 80) - : '面试'; + : t("面试"); const timeLabel = projected.scheduledAtTimestamp === null - ? '时间待确认' - : formatWorkspaceDate(event.scheduled_at, '时间待确认'); + ? t("时间待确认") + : formatWorkspaceDate(event.scheduled_at, t("时间待确认")); const choice = Object.freeze({ eventId, label: `${subtype} · ${timeLabel}`, @@ -453,6 +455,7 @@ interface ApplicationDetailProps { } export default function ApplicationDetail({ initialTab = 'overview', application, open, onClose, taskController, onLaunchTask, onConfirmedFitToMaterial, onTaskSurfaceGuardChange, onOpenOffers, offers, offersLoading = false, offersError = false, onRetryOffers, onMockInterview: _onMockInterview, onAskPilot, onOpenPilotOpportunityFit: _onOpenPilotOpportunityFit, externalTaskBlocked = false, onOpportunityFitOwnerStateChange, onOpportunityFitProjectionChange, opportunityFitOwnerStore, pilotInterviewReviewApplicationId, onPilotInterviewReviewFocusConsumed, pilotInterviewPreparationApplicationId, pilotInterviewPreparationEventId, onPilotInterviewPreparationFocusConsumed, onAttachToPilot, interviewReviewProposalAttempts, onInterviewReviewProposalAttemptChange, reviewReadinessDrafts, onReviewReadinessDraftChange, onOpenReviewStory, onOpenReadinessPractice, onInterviewNoteChanged, interviewKnowledgeCaptureDrafts, onInterviewKnowledgeCaptureDraftChange, onInterviewKnowledgeCaptureNoteChanged, resumes, resumesLoading = false, resumesError = false, taskNow, interviewPreparationAttempts, onInterviewPreparationAttemptChange, interviewPreparationDrafts, onInterviewPreparationDraftChange, interviewPreparationKnowledgeOptions = [], interviewPreparationSelection, offerNegotiationDrafts = {}, onOfferNegotiationDraftChange, onOpenOfferNegotiationPilot, offerNegotiationEntryPoint = 'ui', nextStepSuggestions, nextStepSessionState = null, onSetDisposition, onNextStepNavigate, isNavigationAvailable, onNextStepReadonlyNavigate, isReadonlyNavigationAvailable, applicationJdDraft, onApplicationJdDraftChange }: ApplicationDetailProps) { + const locale = useLocale(); const queryClient = useQueryClient(); const [eventFormOpen, setEventFormOpen] = useState(false); const [materialKitPrefill, setMaterialKitPrefill] = useState<{ @@ -590,7 +593,7 @@ export default function ApplicationDetail({ initialTab = 'overview', application queryClient.invalidateQueries({ queryKey: ['application-jd-history', application?.id] }); onApplicationJdDraftChange?.(application!.id, null); setJdEditorOpen(false); - message.success('\u5c97\u4f4d\u8d44\u6599\u5df2\u4fdd\u5b58'); + message.success(liveText(() => t("岗位资料已保存"))); }, onError: (error: Error & { status?: number; code?: string }) => { if (error.code === 'application_jd_stale_current_version') { @@ -602,16 +605,16 @@ export default function ApplicationDetail({ initialTab = 'overview', application resultUnknown: false, }); }); - message.error('\u5c97\u4f4d\u8d44\u6599\u5df2\u66f4\u65b0\uff0c\u5df2\u5237\u65b0\u5f53\u524d\u7248\u672c\uff0c\u8bf7\u786e\u8ba4\u540e\u518d\u4fdd\u5b58'); + message.error(liveText(() => t("岗位资料已更新,已刷新当前版本,请确认后再保存"))); return; } if (!error.status || error.status >= 500) { onApplicationJdDraftChange?.(application!.id, { resultUnknown: true, pendingOperation: 'save' }); - message.error('\u4fdd\u5b58\u7ed3\u679c\u5f85\u786e\u8ba4\uff0c\u53ef\u4f7f\u7528\u539f\u5c1d\u8bd5\u91cd\u8bd5'); + message.error(liveText(() => t("保存结果待确认,可使用原尝试重试"))); return; } onApplicationJdDraftChange?.(application!.id, { idempotencyKey: null, pendingOperation: null, resultUnknown: false }); - message.error('\u5c97\u4f4d\u8d44\u6599\u4e0d\u80fd\u4fdd\u5b58'); + message.error(liveText(() => t("岗位资料不能保存"))); }, }); @@ -720,7 +723,7 @@ export default function ApplicationDetail({ initialTab = 'overview', application const noteRecords: InterviewNote[] = noteRowsAreObjects ? (notesQuery.data as InterviewNote[]) : []; const canonicalInterviewChoices = useMemo( () => projectApplicationInterviewChoices(allEvents, application?.id ?? 0, resolverNow), - [allEvents, application?.id, resolverNow], + [locale, allEvents, application?.id, resolverNow], ); const interviewStageDataReady = application?.status !== 'interview' || ( @@ -801,10 +804,10 @@ export default function ApplicationDetail({ initialTab = 'overview', application const removeNoteMut = useMutation({ mutationFn: (id: number) => removeNote(id), onSuccess: () => { - message.success('已删除'); + message.success(liveText(() => t("已删除"))); invalidateNotes(); }, - onError: () => message.error('删除失败'), + onError: () => message.error(liveText(() => t("删除失败"))), }); const updateNoteMut = useMutation({ @@ -812,20 +815,20 @@ export default function ApplicationDetail({ initialTab = 'overview', application onSuccess: (_data, variables) => { onInterviewNoteChanged?.(variables.id); onInterviewKnowledgeCaptureNoteChanged?.(variables.id); - message.success('已更新面试复盘'); + message.success(liveText(() => t("已更新面试复盘"))); setEditingNote(null); invalidateNotes(); }, - onError: () => message.error('更新失败'), + onError: () => message.error(liveText(() => t("更新失败"))), }); const createEventNoteMut = useMutation({ mutationFn: (input: CreateNoteInput) => createNote(application!.id, input), onSuccess: () => { - message.success('已保存面试复盘'); + message.success(liveText(() => t("已保存面试复盘"))); invalidateNotes(); }, - onError: () => message.error('保存复盘失败'), + onError: () => message.error(liveText(() => t("保存复盘失败"))), }); const closeDetail = () => { @@ -1154,15 +1157,15 @@ export default function ApplicationDetail({ initialTab = 'overview', application } case 'application.interview_prepare': { const eventId = active.ref.eventId; - if (eventId === undefined) return
请先选择要准备的面试。
; + if (eventId === undefined) return
{t("请先选择要准备的面试。")}
; const preparationTask = applicationTaskResolution.tasks.find( (task) => task.taskId === 'application.interview_prepare' && task.ref.eventId === eventId, ); if (!preparationTask?.executable) { if (resumesLoading || eventsQuery.isLoading || eventsQuery.isFetching || notesQuery.isLoading || notesQuery.isFetching) { - return
正在核对面试资料,请稍候。
; + return
{t("正在核对面试资料,请稍候。")}
; } - return
该面试当前不可准备,请先确认日程状态。
; + return
{t("该面试当前不可准备,请先确认日程状态。")}
; } const lockedSelection = interviewPreparationSelection && interviewPreparationSelection.generation === active.generation @@ -1186,9 +1189,9 @@ export default function ApplicationDetail({ initialTab = 'overview', application : null; if (taskController && !lockedSelection) { if (resumesLoading) { - return
正在核对面试资料,请稍候。
; + return
{t("正在核对面试资料,请稍候。")}
; } - return
请先在面试准备中心选择一份可用简历。
; + return
{t("请先在面试准备中心选择一份可用简历。")}
; } const preparationKey = `${application.id}:${eventId}`; return ( @@ -1229,9 +1232,9 @@ export default function ApplicationDetail({ initialTab = 'overview', application ); if (!reviewTask?.executable) { if (eventsQuery.isLoading || eventsQuery.isFetching || notesQuery.isLoading || notesQuery.isFetching) { - return
正在核对面试资料,请稍候。
; + return
{t("正在核对面试资料,请稍候。")}
; } - return
该面试当前不可复盘,请先确认日程状态。
; + return
{t("该面试当前不可复盘,请先确认日程状态。")}
; } } const note = eventId === undefined @@ -1290,14 +1293,14 @@ export default function ApplicationDetail({ initialTab = 'overview', application ); } case 'application.offer_review': { - if (offersLoading) return
Offer 信息正在读取。
; - if (offersError) return
Offer 信息暂时无法读取。
; - if (offers === undefined) return
Offer 信息尚未加载。
; + if (offersLoading) return
{t("Offer 信息正在读取。")}
; + if (offersError) return
{t("Offer 信息暂时无法读取。")}
; + if (offers === undefined) return
{t("Offer 信息尚未加载。")}
; const linkedOffers = offerRecords.filter((offer) => offer.application_id === application.id); const suggestedOfferId = active.request.hints?.suggestedOfferId; const offer = linkedOffers.find((item) => item.id === suggestedOfferId) ?? (linkedOffers.length === 1 ? linkedOffers[0] : undefined); - if (!offer) return
暂无可聚焦的 Offer,请先确认本次投递的 Offer。
; + if (!offer) return
{t("暂无可聚焦的 Offer,请先确认本次投递的 Offer。")}
; return ( ); default: - return
当前任务暂不可用,请稍后重试。
; + return
{t("当前任务暂不可用,请稍后重试。")}
; } }; @@ -1431,18 +1434,18 @@ export default function ApplicationDetail({ initialTab = 'overview', application const stageDataBlocked = application.status === 'interview' && !interviewStageDataReady; const stageDataHasError = eventsQuery.isError || notesQuery.isError; const stageLabel = stageDataBlocked - ? stageDataHasError ? '面试进展暂不可读' : '面试进展读取中' + ? stageDataHasError ? t("面试进展暂不可读") : t("面试进展读取中") : stage.label; const stagePrimaryActionLabel = stageDataBlocked - ? stageDataHasError ? '重试日程和复盘' : '等待面试进展加载' + ? stageDataHasError ? t("重试日程和复盘") : t("等待面试进展加载") : application.status === 'interview' ? headerPrimaryTask?.taskId === 'application.interview_prepare' - ? '准备本轮面试' + ? t("准备本轮面试") : headerPrimaryTask?.taskId === 'application.interview_review' - ? headerPrimaryTask.reason === 'interview_review_available' ? '查看本轮复盘' : '完成面试复盘' + ? headerPrimaryTask.reason === 'interview_review_available' ? t("查看本轮复盘") : t("完成面试复盘") : headerPrimaryTask - ? TASK_COPY[headerPrimaryTask.taskId]?.action ?? '打开当前任务' - : '暂无可用操作' + ? TASK_COPY[headerPrimaryTask.taskId]?.action ?? t("打开当前任务") + : t("暂无可用操作") : stage.primaryActionLabel; const retryStageData = () => { @@ -1490,9 +1493,9 @@ export default function ApplicationDetail({ initialTab = 'overview', application }; const moreActionItems = [ - ...(onAskPilot ? [{ key: 'haru', label: '让 Haru 帮我', onClick: () => onAskPilot(application, { type: 'application_jd_save' }) }] : []), - { key: 'jd', label: applicationJdQuery.data?.current ? '编辑岗位资料' : '添加岗位资料', onClick: startJdEditor }, - { key: 'schedule', label: '安排日程', onClick: () => openScheduleForm(getScheduleTrigger('more'), 'more') }, + ...(onAskPilot ? [{ key: 'haru', label: t("让 Haru 帮我"), onClick: () => onAskPilot(application, { type: 'application_jd_save' }) }] : []), + { key: 'jd', label: applicationJdQuery.data?.current ? t("编辑岗位资料") : t("添加岗位资料"), onClick: startJdEditor }, + { key: 'schedule', label: t("安排日程"), onClick: () => openScheduleForm(getScheduleTrigger('more'), 'more') }, ]; const linkedOffers = offerRecords.filter((offer) => offer.application_id === application.id); @@ -1506,7 +1509,7 @@ export default function ApplicationDetail({ initialTab = 'overview', application const linkedResume = !materialOwnerMismatch && materialKit ? resumeRecords.find((resume) => resume.id === materialKit.resume_id && !resume.deleted_at) : undefined; - const materialActionLabel = application.status === 'pending' ? '继续准备' : '查看投递材料'; + const materialActionLabel = application.status === 'pending' ? t("继续准备") : t("查看投递材料"); const materialEntryBlocked = externalTaskBlocked || materialKitQuery.isLoading || materialKitQuery.isError || materialOwnerMismatch || Boolean(materialTask && !materialTask.executable) || (!materialTask && applicationTaskResolution.tasks.some((task) => task.availability === 'waiting_confirmation' || task.availability === 'result_unknown')); @@ -1516,14 +1519,14 @@ export default function ApplicationDetail({ initialTab = 'overview', application { id: `application-created-${application.id}`, kind: 'application' as const, - title: '创建投递', + title: t("创建投递"), timestamp: application.created_at, - detail: application.source ? `来源:${application.source}` : undefined, + detail: application.source ? t("来源:{source}", { source: application.source }) : undefined, }, { id: `application-updated-${application.id}`, kind: 'application' as const, - title: `当前阶段:${stageLabel}`, + title: t("当前阶段:{stageLabel}", { stageLabel: stageLabel }), timestamp: application.updated_at, detail: application.notes || undefined, }, @@ -1541,7 +1544,7 @@ export default function ApplicationDetail({ initialTab = 'overview', application ...noteRecords.map((note) => ({ id: `note-${note.id}`, kind: 'note' as const, - title: `面试复盘${note.round ? ` · ${note.round}` : ''}`, + title: t("面试复盘{value1}", { value1: note.round ? ` · ${note.round}` : '' }), timestamp: note.created_at || note.date, detail: note.self_reflection || note.questions || note.difficulty_points || undefined, })), @@ -1551,8 +1554,8 @@ export default function ApplicationDetail({ initialTab = 'overview', application title: `Offer · ${offer.company_name} · ${offer.position_name}`, timestamp: offer.updated_at, detail: [ - `状态:${OFFER_STATUS_LABELS[offer.status]}`, - offer.deadline ? `截止:${formatWorkspaceDate(offer.deadline, '未设置')}` : undefined, + t("状态:{value1}", { value1: OFFER_STATUS_LABELS[offer.status] }), + offer.deadline ? t("截止:{value}", { value: formatWorkspaceDate(offer.deadline, t("未设置")) }) : undefined, ].filter(Boolean).join(' · '), })), ].sort((left, right) => workspaceTimestamp(right.timestamp) - workspaceTimestamp(left.timestamp)); @@ -1591,7 +1594,7 @@ export default function ApplicationDetail({ initialTab = 'overview', application ) : undefined; if (!task?.executable || externalTaskBlocked) { - message.warning('该复盘当前不可用,请先确认事件与复盘资料。'); + message.warning(liveText(() => t("该复盘当前不可用,请先确认事件与复盘资料。"))); return; } const result = launchTask({ @@ -1628,7 +1631,7 @@ export default function ApplicationDetail({ initialTab = 'overview', application <> { setPilotPreparationChooserOpen(false); @@ -1667,11 +1670,11 @@ export default function ApplicationDetail({ initialTab = 'overview', application ))}
- ) :
当前没有可选择的面试,请先安排面试后再开始准备。
} + ) :
{t("当前没有可选择的面试,请先安排面试后再开始准备。")}
} { setPilotReviewChooserOpen(false); @@ -1695,7 +1698,7 @@ export default function ApplicationDetail({ initialTab = 'overview', application && candidate.ref.eventId === choice.eventId, ); if (!task?.executable) { - message.warning('该面试尚无可用复盘,请先确认事件已完成。'); + message.warning(liveText(() => t("该面试尚无可用复盘,请先确认事件已完成。"))); return; } const result = launchTask({ @@ -1718,13 +1721,13 @@ export default function ApplicationDetail({ initialTab = 'overview', application ))} - ) :
当前没有可复盘的面试,请先完成或安排面试后再试。
} + ) :
{t("当前没有可复盘的面试,请先完成或安排面试后再试。")}
}
setJdEditorOpen(false)} @@ -1734,24 +1737,24 @@ export default function ApplicationDetail({ initialTab = 'overview', application value={applicationJdDraft?.jdText ?? applicationJdQuery.data?.current?.jd_text ?? ''} disabled={Boolean(applicationJdDraft?.resultUnknown)} onChange={(event) => onApplicationJdDraftChange?.(application!.id, { jdText: event.target.value })} - placeholder={'\u7c98\u8d34\u5c97\u4f4d\u63cf\u8ff0'} + placeholder={t("粘贴岗位描述")} /> onApplicationJdDraftChange?.(application!.id, { sourceUrl: event.target.value })} - placeholder={'\u6765\u6e90 URL\uff08\u4ec5\u5c55\u793a\uff0c\u4e0d\u4f1a\u8bbf\u95ee\uff09'} + placeholder={t("来源 URL(仅展示,不会访问)")} /> {applicationJdDraft?.resultUnknown && ( - {'\u4fdd\u5b58\u7ed3\u679c\u5f85\u786e\u8ba4\uff0c\u8bf7\u4f7f\u7528\u539f\u5c1d\u8bd5\u91cd\u8bd5\u3002'} + {t("保存结果待确认,请使用原尝试重试。")} )} { setJdHistoryOpen(false); setSelectedJdVersion(null); }} @@ -1766,23 +1769,23 @@ export default function ApplicationDetail({ initialTab = 'overview', application onClick={() => setSelectedJdVersion(version.id)} > - 版本 {version.version_number} - {version.source_kind === 'pilot' ? 'Pilot 保存' : '界面保存'} + {t("版本 ")}{version.version_number} + {version.source_kind === 'pilot' ? t("Pilot 保存") : t("界面保存")} {dayjs(version.created_at).format('YYYY-MM-DD HH:mm:ss')} {version.preview.slice(0, 160)} ))} - {jdHistoryQuery.isError && void jdHistoryQuery.refetch()}>重试} />} - {jdDetailQuery.isError && void jdDetailQuery.refetch()}>重试} />} + {jdHistoryQuery.isError && void jdHistoryQuery.refetch()}>{t("重试")}} />} + {jdDetailQuery.isError && void jdDetailQuery.refetch()}>{t("重试")}} />} {!jdHistoryQuery.isLoading && !jdHistoryQuery.isError && (jdHistoryQuery.data ?? []).length === 0 ? ( - + ) : null} {selectedJdVersion !== null && jdDetailQuery.data && (
-
版本 {jdDetailQuery.data.version_number} · {jdDetailQuery.data.source_kind === 'pilot' ? 'Pilot 保存' : '界面保存'}
-
保存时间:{dayjs(jdDetailQuery.data.created_at).format('YYYY-MM-DD HH:mm:ss')}
-
本版来源:{jdDetailQuery.data.source_url || '未填写'}
+
{t("版本 ")}{jdDetailQuery.data.version_number} · {jdDetailQuery.data.source_kind === 'pilot' ? t("Pilot 保存") : t("界面保存")}
+
{t("保存时间:")}{dayjs(jdDetailQuery.data.created_at).format('YYYY-MM-DD HH:mm:ss')}
+
{t("本版来源:")}{jdDetailQuery.data.source_url || t("未填写")}
{jdDetailQuery.data.jd_text}
)} @@ -1790,9 +1793,7 @@ export default function ApplicationDetail({ initialTab = 'overview', application
- +
@@ -1802,17 +1803,16 @@ export default function ApplicationDetail({ initialTab = 'overview', application <Tag color={stageDataBlocked ? 'orange' : 'green'}>{stageLabel}</Tag> <Text type="secondary"> {application.first_applied_at - ? `投递时间:${formatWorkspaceDate(application.first_applied_at)}` - : `记录时间:${formatWorkspaceDate(application.created_at)}`} + ? t("投递时间:{value}", { value: formatWorkspaceDate(application.first_applied_at) }) + : t("记录时间:{value}", { value: formatWorkspaceDate(application.created_at) })} </Text> - <Text type="secondary"> - 下一步时间:{eventsQuery.isLoading - ? '读取中' + <Text type="secondary">{t("下一步时间:")}{eventsQuery.isLoading + ? t("读取中") : eventsQuery.isError - ? '暂时无法读取' + ? t("暂时无法读取") : upcomingEvent - ? dayjs(upcomingEvent.scheduled_at).format('M 月 D 日 HH:mm') - : '待安排'} + ? dayjs(upcomingEvent.scheduled_at).format(t("M 月 D 日 HH:mm")) + : t("待安排")} </Text> </Space> </div> @@ -1839,15 +1839,13 @@ export default function ApplicationDetail({ initialTab = 'overview', application data-testid="application-more-actions" size="large" icon={<MoreOutlined />} - > - 更多操作 - </Button> + >{t("更多操作")}</Button> </Dropdown> </Space> </div> </div> - <div className={styles.tabList} role="tablist" aria-label="投递详情分段"> + <div className={styles.tabList} role="tablist" aria-label={t("投递详情分段")}> {DETAIL_TABS.map((tab, index) => ( <button key={tab.id} @@ -1876,47 +1874,47 @@ export default function ApplicationDetail({ initialTab = 'overview', application className={styles.tabPanel} > <section className={`${styles.workspaceSection} ${styles.overviewSection}`} aria-labelledby="application-overview-heading"> - <Title id="application-overview-heading" level={4} className={styles.workspaceSectionTitle}>概览 + {t("概览")}
- 当前阶段 + {t("当前阶段")} {stageLabel}
- 下一时间 + {t("下一时间")} {eventsQuery.isLoading - ? '读取中' + ? t("读取中") : eventsQuery.isError - ? '暂时无法读取' + ? t("暂时无法读取") : upcomingEvent ? formatWorkspaceDate(upcomingEvent.scheduled_at) - : '待安排'} + : t("待安排")}
- 最近变化 - {formatWorkspaceDate(application.updated_at, '暂无更新')} - 投递记录已更新 + {t("最近变化")} + {formatWorkspaceDate(application.updated_at, t("暂无更新"))} + {t("投递记录已更新")}
{eventsQuery.isError ? ( - void eventsQuery.refetch()}>重试} /> + void eventsQuery.refetch()}>{t("重试")}} /> ) : null}
- JD 摘要 + {t("JD 摘要")} {applicationJdQuery.isLoading ? : applicationJdQuery.isError ? ( - void applicationJdQuery.refetch()}>重试} /> + void applicationJdQuery.refetch()}>{t("重试")}} /> ) : applicationJdQuery.data?.current ? ( {applicationJdQuery.data.current.jd_text} - ) : 尚未确认岗位描述} + ) : {t("尚未确认岗位描述")}}
- 备注 + {t("备注")} - {application.notes || '暂无补充备注'} + {application.notes || t("暂无补充备注")}
@@ -1948,8 +1946,8 @@ export default function ApplicationDetail({ initialTab = 'overview', application style={{ marginTop: 12 }} type="warning" showIcon - message="有一项助手操作正在等待处理" - description="请先在 Haru / Pilot 中完成确认;当前投递任务不会替换或暴露其他操作的身份。" + message={t("有一项助手操作正在等待处理")} + description={t("请先在 Haru / Pilot 中完成确认;当前投递任务不会替换或暴露其他操作的身份。")} /> ) : null}
@@ -1957,22 +1955,22 @@ export default function ApplicationDetail({ initialTab = 'overview', application
- <FileTextOutlined aria-hidden="true" />岗位描述 + <FileTextOutlined aria-hidden="true" />{t("岗位描述")} {currentJd && !applicationJdQuery.isError && !applicationJdQuery.isLoading ? ( - 当前 JD · 版本 {currentJd.version_number} + {t("当前 JD · 版本 ")}{currentJd.version_number} ) : null}
- +
{applicationJdQuery.isLoading ? : applicationJdQuery.isError ? ( - void applicationJdQuery.refetch()}>重试} /> + void applicationJdQuery.refetch()}>{t("重试")}} /> ) : applicationJdQuery.data === undefined ? ( - 岗位资料状态尚未加载 + {t("岗位资料状态尚未加载")} ) : currentJd ? ( <>
@@ -1980,74 +1978,74 @@ export default function ApplicationDetail({ initialTab = 'overview', application
{longJd ? ( ) : null} {jdSource ? (
- 来源:{jdSource} + {t("来源:")}{jdSource} + message.success(liveText(() => t("来源已复制"))); + } catch { message.error(liveText(() => t("无法复制,请手动选择来源文字"))); } + }}>{t("复制来源")}
) : null} - ) : 尚未确认岗位描述} + ) : {t("尚未确认岗位描述")}}
- 关联材料 + {t("关联材料")}
- {materialKitQuery.isLoading ?
正在读取投递材料
: materialKitQuery.isError ? ( - void materialKitQuery.refetch()}>重试} /> + {materialKitQuery.isLoading ?
{t(" 正在读取投递材料")}
: materialKitQuery.isError ? ( + void materialKitQuery.refetch()}>{t("重试")}} /> ) : materialOwnerMismatch ? ( - void materialKitQuery.refetch()}>重试} /> + void materialKitQuery.refetch()}>{t("重试")}} /> ) : materialKit ? (
- 关联简历 + {t("关联简历")} - {resumesLoading ? '正在读取关联简历' : resumesError ? '关联简历暂时无法读取' - : linkedResume ? linkedResume.title || linkedResume.name || '未命名简历' - : materialKit.resume_id ? '关联简历当前不可用' : '尚未关联简历'} + {resumesLoading ? t("正在读取关联简历") : resumesError ? t("关联简历暂时无法读取") + : linkedResume ? linkedResume.title || linkedResume.name || t("未命名简历") + : materialKit.resume_id ? t("关联简历当前不可用") : t("尚未关联简历")} - 仅表示材料包关联,不代表提交时的简历快照。 + {t("仅表示材料包关联,不代表提交时的简历快照。")}
- {materialKit.status === 'submitted' ? '旧投递标记,缺少证据快照' : materialKit.status === 'ready' ? '材料已就绪' : materialKit.status === 'draft' ? '准备草稿' : '材料状态待核对'} - 最近保存:{formatWorkspaceDate(materialKit.updated_at)} + {materialKit.status === 'submitted' ? t("旧投递标记,缺少证据快照") : materialKit.status === 'ready' ? t("材料已就绪") : materialKit.status === 'draft' ? t("准备草稿") : t("材料状态待核对")} + {t("最近保存:")}{formatWorkspaceDate(materialKit.updated_at)}
{currentJd && materialKit.jd_version_id && materialKit.jd_version_id !== currentJd.id ? ( - 材料使用的 JD 与当前版本不同,请在材料工作区核对来源。 + {t("材料使用的 JD 与当前版本不同,请在材料工作区核对来源。")} ) : null}
) : materialKitQuery.data === undefined ? ( - 投递材料状态尚未加载 + {t("投递材料状态尚未加载")} ) : ( - {application.status === 'pending' ? '尚未保存投递材料。进入准备工作区选择简历,核对岗位要求与提交前检查。' : '尚未保存投递材料。可在材料工作区选择关联简历,补充本次投递的材料记录。'} + {application.status === 'pending' ? t("尚未保存投递材料。进入准备工作区选择简历,核对岗位要求与提交前检查。") : t("尚未保存投递材料。可在材料工作区选择关联简历,补充本次投递的材料记录。")} )} - {materialTask && !materialTask.executable ? 请先完成必要资料读取与核对,再打开材料。 : null} + {materialTask && !materialTask.executable ? {t("请先完成必要资料读取与核对,再打开材料。")} : null}
- {application.status === 'pending' ? '准备任务' : '后续准备'} + {application.status === 'pending' ? t("准备任务") : t("后续准备")}
{remainingPreparationTasks.length > 0 ? remainingPreparationTasks.map((task) => { - const baseCopy = TASK_COPY[task.taskId] ?? { title: '当前任务', description: '当前任务状态已更新。', action: '查看任务' }; - const copy = task.taskId === 'application.interview_prepare' ? { ...baseCopy, action: '准备这场面试' } - : task.taskId === 'application.interview_review' ? { ...baseCopy, action: task.reason === 'interview_review_available' ? '查看复盘' : '开始复盘' } + const baseCopy = TASK_COPY[task.taskId] ?? { title: t("当前任务"), description: t("当前任务状态已更新。"), action: t("查看任务") }; + const copy = task.taskId === 'application.interview_prepare' ? { ...baseCopy, action: t("准备这场面试") } + : task.taskId === 'application.interview_review' ? { ...baseCopy, action: task.reason === 'interview_review_available' ? t("查看复盘") : t("开始复盘") } : baseCopy; const taskEvent = task.ref.eventId === undefined ? undefined : allEvents.find((event) => event.id === task.ref.eventId); const unavailable = externalTaskBlocked || !task.executable || task.availability === 'loading' || task.availability === 'blocked' || task.availability === 'unavailable'; @@ -2060,14 +2058,14 @@ export default function ApplicationDetail({ initialTab = 'overview', application >
{copy.title} - {taskEvent ? {eventSubtypeLabel(taskEvent.subtype)} · {formatWorkspaceDate(taskEvent.scheduled_at, '时间待确认')} : null} + {taskEvent ? {eventSubtypeLabel(taskEvent.subtype)} · {formatWorkspaceDate(taskEvent.scheduled_at, t("时间待确认"))} : null} {copy.description} - {task.availability === 'loading' ? '正在读取状态' - : task.availability === 'unavailable' ? '当前资料暂不可用' - : task.availability === 'blocked' ? '还缺少必要资料' - : task.availability === 'waiting_confirmation' ? '等待确认' - : task.reason === 'result_unknown' ? '结果待确认' : ''} + {task.availability === 'loading' ? t("正在读取状态") + : task.availability === 'unavailable' ? t("当前资料暂不可用") + : task.availability === 'blocked' ? t("还缺少必要资料") + : task.availability === 'waiting_confirmation' ? t("等待确认") + : task.reason === 'result_unknown' ? t("结果待确认") : ''}
- {applicationTaskResolution.hasLoading &&
任务状态正在读取
} + {applicationTaskResolution.hasLoading &&
{t("任务状态正在读取")}
} {applicationTaskResolution.hasUnavailable && !applicationTaskResolution.hasLoading && ( -
部分任务暂不可用,请先检查相关资料
+
{t("部分任务暂不可用,请先检查相关资料")}
)}
-