From b85452b79ad7a071ec994bebb2f7887718f70d33 Mon Sep 17 00:00:00 2001 From: zeroornull Date: Sat, 19 Sep 2026 13:25:12 +0800 Subject: [PATCH] wip: experimental Tauri 2 Windows desktop preview Unsigned internal x64 NSIS only; not an official public desktop release. Co-authored-by: Cursor --- .github/workflows/desktop.yml | 63 + .gitignore | 5 + app/assets/css/web-fonts.css | 9 + app/components.d.ts | 12 - app/components/article/EditArticle.vue | 4 +- app/components/channel-icons/ChannelIcons.vue | 5 +- app/components/dialog/BackupGateDialog.vue | 17 +- app/core/composables/backupImport.ts | 107 + app/core/composables/dictionaryValidation.ts | 81 + .../practice-words/practice-word-session.ts | 22 +- .../composables/remotePracticeValidation.ts | 160 + app/core/composables/settingsValidation.ts | 149 + .../composables/useDataSyncPersistence.ts | 435 +- app/core/composables/useInit.ts | 96 +- app/core/config/env.ts | 8 +- app/core/hooks/event.ts | 6 +- app/core/hooks/export.ts | 7 +- app/core/hooks/sound.ts | 119 +- app/core/hooks/translate.ts | 3 + app/core/platform/accountPersistence.ts | 36 + app/core/platform/accountSync.ts | 249 + app/core/platform/desktop.ts | 116 + app/core/platform/sync.ts | 29 + app/core/utils/http.ts | 31 +- app/core/utils/supabase.ts | 42 +- app/pages/(words)/practice-words/[id].vue | 18 +- app/pages/(words)/words.vue | 26 +- app/pages/index.vue | 9 - app/pages/setting.vue | 189 +- app/plugins/00.desktop.client.ts | 10 + app/plugins/02.init.client.ts | 4 +- nuxt.config.ts | 64 +- package.json | 27 +- pnpm-lock.yaml | 165 + public/list/article.json | 6 +- public/list/recommend_article.json | 6 +- scripts/check-desktop-clean-install.mjs | 418 ++ scripts/check-desktop-resources.mjs | 117 + scripts/check-desktop-test-snapshot.mjs | 58 + scripts/compare-typecheck.mjs | 132 + scripts/desktop-internal.mjs | 45 + scripts/desktop.mjs | 95 + scripts/invoke-setup.ps1 | 81 + scripts/sql/README.md | 40 + scripts/sql/typewords-account-schema.sql | 36 + src-tauri/Cargo.lock | 5174 +++++++++++++++++ src-tauri/Cargo.toml | 22 + src-tauri/README.md | 74 + src-tauri/build.rs | 3 + src-tauri/capabilities/main.json | 60 + src-tauri/icons/icon.ico | Bin 0 -> 20338 bytes src-tauri/icons/icon.png | Bin 0 -> 9025 bytes src-tauri/src/lib.rs | 68 + src-tauri/src/main.rs | 5 + src-tauri/tauri.conf.json | 65 + src-tauri/tauri.internal.conf.json | 18 + tests/desktop/account-sync.test.mjs | 566 ++ tests/desktop/backup-browser-roundtrip.mjs | 37 + tests/desktop/backup-browser-seed.mjs | 31 + .../desktop/backup-cache-validation.test.mjs | 375 ++ tests/desktop/backup-gate.test.mjs | 86 + tests/desktop/backup-history-audit.mjs | 53 + tests/desktop/backup.test.mjs | 126 + tests/desktop/build-config.test.mjs | 157 + tests/desktop/build-gate.test.mjs | 218 + tests/desktop/cdp-port.ps1 | 17 + tests/desktop/clean-install-script.test.mjs | 80 + tests/desktop/close-installed.ps1 | 17 + tests/desktop/desktop-workflow.test.mjs | 114 + tests/desktop/dict-migration-source.mjs | 13 + .../dictionary-validation-fixtures.mjs | 69 + tests/desktop/dictionary-validation.test.mjs | 118 + tests/desktop/fixtures/backup/README.md | 46 + .../fixtures/backup/backup-fixture.json | 512 ++ tests/desktop/fixtures/backup/backup-tone.mp3 | Bin 0 -> 2943 bytes .../desktop/fixtures/typecheck/baseline.json | 74 + tests/desktop/import-safety.test.mjs | 294 + tests/desktop/import-watchers.test.mjs | 22 + tests/desktop/indexeddb-integration.mjs | 95 + tests/desktop/indexeddb-suite.mjs | 880 +++ tests/desktop/init-subscriptions-harness.mjs | 88 + tests/desktop/init-subscriptions.test.mjs | 70 + tests/desktop/internal-installer.test.mjs | 72 + tests/desktop/invoke-setup.test.mjs | 95 + tests/desktop/keyboard-input.test.mjs | 127 + tests/desktop/launch-existing.ps1 | 27 + tests/desktop/launcher.test.mjs | 28 + tests/desktop/migration-declarations.mjs | 20 + tests/desktop/native-adapters.test.mjs | 258 + tests/desktop/native-cache-errors.test.mjs | 24 + tests/desktop/native-doc-v8l.mjs | 193 + tests/desktop/native-enpuz.mjs | 224 + tests/desktop/native-external-observe.ps1 | 250 + tests/desktop/native-external-open.mjs | 251 + tests/desktop/native-failed-upgrade.ps1 | 89 + tests/desktop/native-host.test.mjs | 84 + tests/desktop/native-install-upgrade.mjs | 214 + tests/desktop/native-local-practice.mjs | 103 + tests/desktop/native-local-recovery.mjs | 711 +++ tests/desktop/native-mailto.mjs | 81 + tests/desktop/native-save-dialog.ps1 | 166 + tests/desktop/native-save-with-dialog.mjs | 58 + tests/desktop/native-security.mjs | 174 + tests/desktop/native-source-errors.test.mjs | 30 + tests/desktop/native-youdao.mjs | 297 + tests/desktop/nce-sample.test.mjs | 21 + tests/desktop/practice-reload.test.mjs | 64 + .../remote-practice-validation.test.mjs | 342 ++ tests/desktop/resources-api.test.mjs | 156 + tests/desktop/resources.test.mjs | 100 + tests/desktop/settings-migration-source.mjs | 14 + .../desktop/settings-validation-fixtures.mjs | 97 + tests/desktop/settings-validation.test.mjs | 251 + tests/desktop/sound-network.test.mjs | 230 + tests/desktop/sound-tts.test.mjs | 208 + tests/desktop/sql/account-rls.sql | 132 + tests/desktop/sync-client.test.mjs | 162 + tests/desktop/sync-pull-transaction.test.mjs | 996 ++++ tests/desktop/sync-read-failure.test.mjs | 135 + tests/desktop/sync-settings.test.mjs | 228 + tests/desktop/typecheck-baseline.test.mjs | 106 + 121 files changed, 19487 insertions(+), 335 deletions(-) create mode 100644 .github/workflows/desktop.yml create mode 100644 app/assets/css/web-fonts.css create mode 100644 app/core/composables/backupImport.ts create mode 100644 app/core/composables/dictionaryValidation.ts create mode 100644 app/core/composables/remotePracticeValidation.ts create mode 100644 app/core/composables/settingsValidation.ts create mode 100644 app/core/platform/accountPersistence.ts create mode 100644 app/core/platform/accountSync.ts create mode 100644 app/core/platform/desktop.ts create mode 100644 app/core/platform/sync.ts create mode 100644 app/plugins/00.desktop.client.ts create mode 100644 scripts/check-desktop-clean-install.mjs create mode 100644 scripts/check-desktop-resources.mjs create mode 100644 scripts/check-desktop-test-snapshot.mjs create mode 100644 scripts/compare-typecheck.mjs create mode 100644 scripts/desktop-internal.mjs create mode 100644 scripts/desktop.mjs create mode 100644 scripts/invoke-setup.ps1 create mode 100644 scripts/sql/README.md create mode 100644 scripts/sql/typewords-account-schema.sql create mode 100644 src-tauri/Cargo.lock create mode 100644 src-tauri/Cargo.toml create mode 100644 src-tauri/README.md create mode 100644 src-tauri/build.rs create mode 100644 src-tauri/capabilities/main.json create mode 100644 src-tauri/icons/icon.ico create mode 100644 src-tauri/icons/icon.png create mode 100644 src-tauri/src/lib.rs create mode 100644 src-tauri/src/main.rs create mode 100644 src-tauri/tauri.conf.json create mode 100644 src-tauri/tauri.internal.conf.json create mode 100644 tests/desktop/account-sync.test.mjs create mode 100644 tests/desktop/backup-browser-roundtrip.mjs create mode 100644 tests/desktop/backup-browser-seed.mjs create mode 100644 tests/desktop/backup-cache-validation.test.mjs create mode 100644 tests/desktop/backup-gate.test.mjs create mode 100644 tests/desktop/backup-history-audit.mjs create mode 100644 tests/desktop/backup.test.mjs create mode 100644 tests/desktop/build-config.test.mjs create mode 100644 tests/desktop/build-gate.test.mjs create mode 100644 tests/desktop/cdp-port.ps1 create mode 100644 tests/desktop/clean-install-script.test.mjs create mode 100644 tests/desktop/close-installed.ps1 create mode 100644 tests/desktop/desktop-workflow.test.mjs create mode 100644 tests/desktop/dict-migration-source.mjs create mode 100644 tests/desktop/dictionary-validation-fixtures.mjs create mode 100644 tests/desktop/dictionary-validation.test.mjs create mode 100644 tests/desktop/fixtures/backup/README.md create mode 100644 tests/desktop/fixtures/backup/backup-fixture.json create mode 100644 tests/desktop/fixtures/backup/backup-tone.mp3 create mode 100644 tests/desktop/fixtures/typecheck/baseline.json create mode 100644 tests/desktop/import-safety.test.mjs create mode 100644 tests/desktop/import-watchers.test.mjs create mode 100644 tests/desktop/indexeddb-integration.mjs create mode 100644 tests/desktop/indexeddb-suite.mjs create mode 100644 tests/desktop/init-subscriptions-harness.mjs create mode 100644 tests/desktop/init-subscriptions.test.mjs create mode 100644 tests/desktop/internal-installer.test.mjs create mode 100644 tests/desktop/invoke-setup.test.mjs create mode 100644 tests/desktop/keyboard-input.test.mjs create mode 100644 tests/desktop/launch-existing.ps1 create mode 100644 tests/desktop/launcher.test.mjs create mode 100644 tests/desktop/migration-declarations.mjs create mode 100644 tests/desktop/native-adapters.test.mjs create mode 100644 tests/desktop/native-cache-errors.test.mjs create mode 100644 tests/desktop/native-doc-v8l.mjs create mode 100644 tests/desktop/native-enpuz.mjs create mode 100644 tests/desktop/native-external-observe.ps1 create mode 100644 tests/desktop/native-external-open.mjs create mode 100644 tests/desktop/native-failed-upgrade.ps1 create mode 100644 tests/desktop/native-host.test.mjs create mode 100644 tests/desktop/native-install-upgrade.mjs create mode 100644 tests/desktop/native-local-practice.mjs create mode 100644 tests/desktop/native-local-recovery.mjs create mode 100644 tests/desktop/native-mailto.mjs create mode 100644 tests/desktop/native-save-dialog.ps1 create mode 100644 tests/desktop/native-save-with-dialog.mjs create mode 100644 tests/desktop/native-security.mjs create mode 100644 tests/desktop/native-source-errors.test.mjs create mode 100644 tests/desktop/native-youdao.mjs create mode 100644 tests/desktop/nce-sample.test.mjs create mode 100644 tests/desktop/practice-reload.test.mjs create mode 100644 tests/desktop/remote-practice-validation.test.mjs create mode 100644 tests/desktop/resources-api.test.mjs create mode 100644 tests/desktop/resources.test.mjs create mode 100644 tests/desktop/settings-migration-source.mjs create mode 100644 tests/desktop/settings-validation-fixtures.mjs create mode 100644 tests/desktop/settings-validation.test.mjs create mode 100644 tests/desktop/sound-network.test.mjs create mode 100644 tests/desktop/sound-tts.test.mjs create mode 100644 tests/desktop/sql/account-rls.sql create mode 100644 tests/desktop/sync-client.test.mjs create mode 100644 tests/desktop/sync-pull-transaction.test.mjs create mode 100644 tests/desktop/sync-read-failure.test.mjs create mode 100644 tests/desktop/sync-settings.test.mjs create mode 100644 tests/desktop/typecheck-baseline.test.mjs diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml new file mode 100644 index 000000000..26bb27177 --- /dev/null +++ b/.github/workflows/desktop.yml @@ -0,0 +1,63 @@ +# TypeWords desktop Windows gate. +# This workflow is a static/regression check on windows-latest. +# It does not launch a native WebView. +# It does not build or run an installer, EXE, or NSIS package. +# It does not run typecheck and does not treat typecheck as passing +# (typecheck-green is not claimed; baseline remains exit 2 / 62 diagnostics). +# It does not run test:desktop:clean-install or serial Web/Desktop frontend +# builds; those stay a documented local replay because they copy a tree, +# reinstall from the lockfile, and generate .nuxt/.output. +# Linux or macOS runners are not used as Windows verification. +# CARGO_TARGET_DIR is left unset so cargo writes src-tauri/target. + +name: Desktop + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + desktop-node: + name: Node regression and resources + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 12 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - name: Install from lockfile + run: pnpm install --frozen-lockfile + - name: Desktop Node tests + run: pnpm run test:desktop + - name: Desktop resource audit + run: pnpm run desktop:check-resources + + desktop-rust: + name: Windows Rust locked checks + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - name: Rustfmt + run: cargo fmt --manifest-path src-tauri/Cargo.toml --check + - name: Cargo check locked + run: cargo check --locked --manifest-path src-tauri/Cargo.toml + - name: Cargo test locked + run: cargo test --locked --manifest-path src-tauri/Cargo.toml + - name: Clippy locked + run: cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings diff --git a/.gitignore b/.gitignore index ac84f94e9..372c96f7f 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,8 @@ report.html public/vscode apps/TypeWordsMiniApp *.tsbuildinfo +.ua +docs/plans +# Tauri generated build outputs (keep Cargo.lock) +/src-tauri/target/ +/src-tauri/gen/schemas/ diff --git a/app/assets/css/web-fonts.css b/app/assets/css/web-fonts.css new file mode 100644 index 000000000..beadf67ca --- /dev/null +++ b/app/assets/css/web-fonts.css @@ -0,0 +1,9 @@ +/* Web-only: desktop uses the existing local serif fallback without network fonts. */ +@font-face { + font-family: 'Garamond'; + font-style: italic; + font-weight: 700; + font-display: swap; + src: url(https://fonts.gstatic.com/l/font?kit=XoHg2Y_-T6Oo88RDZSQPp2sshj3I9QTcqzw&skey=509bbab0bec2784f&v=v18) + format('woff2'); +} diff --git a/app/components.d.ts b/app/components.d.ts index f65d26b05..20b5f1b55 100644 --- a/app/components.d.ts +++ b/app/components.d.ts @@ -27,12 +27,10 @@ declare module 'vue' { IconFluentArrowClockwise20Regular: typeof import('~icons/fluent/arrow-clockwise20-regular')['default'] IconFluentArrowDownload20Regular: typeof import('~icons/fluent/arrow-download20-regular')['default'] IconFluentArrowLeft16Regular: typeof import('~icons/fluent/arrow-left16-regular')['default'] - IconFluentArrowMove20Regular: typeof import('~icons/fluent/arrow-move20-regular')['default'] IconFluentArrowRight16Regular: typeof import('~icons/fluent/arrow-right16-regular')['default'] IconFluentArrowShuffle16Regular: typeof import('~icons/fluent/arrow-shuffle16-regular')['default'] IconFluentArrowSort20Regular: typeof import('~icons/fluent/arrow-sort20-regular')['default'] IconFluentArrowSwap20Regular: typeof import('~icons/fluent/arrow-swap20-regular')['default'] - IconFluentArrowUpload20Regular: typeof import('~icons/fluent/arrow-upload20-regular')['default'] IconFluentBook20Regular: typeof import('~icons/fluent/book20-regular')['default'] IconFluentBookLetter20Regular: typeof import('~icons/fluent/book-letter20-regular')['default'] IconFluentBookNumber20Filled: typeof import('~icons/fluent/book-number20-filled')['default'] @@ -45,8 +43,6 @@ declare module 'vue' { IconFluentChevronDown20Regular: typeof import('~icons/fluent/chevron-down20-regular')['default'] IconFluentChevronLeft20Filled: typeof import('~icons/fluent/chevron-left20-filled')['default'] IconFluentChevronLeft28Filled: typeof import('~icons/fluent/chevron-left28-filled')['default'] - IconFluentChevronRight16Regular: typeof import('~icons/fluent/chevron-right16-regular')['default'] - IconFluentChevronRight20Regular: typeof import('~icons/fluent/chevron-right20-regular')['default'] IconFluentChevronUp20Filled: typeof import('~icons/fluent/chevron-up20-filled')['default'] IconFluentClipboardTextEdit20Regular: typeof import('~icons/fluent/clipboard-text-edit20-regular')['default'] IconFluentClock20Regular: typeof import('~icons/fluent/clock20-regular')['default'] @@ -107,14 +103,12 @@ declare module 'vue' { IconMdiSparkles: typeof import('~icons/mdi/sparkles')['default'] IconMdiStar: typeof import('~icons/mdi/star')['default'] IconMdiStarOutline: typeof import('~icons/mdi/star-outline')['default'] - IconPhExportLight: typeof import('~icons/ph/export-light')['default'] IconPhTranslate: typeof import('~icons/ph/translate')['default'] IconRiTwitterFill: typeof import('~icons/ri/twitter-fill')['default'] IconSimpleIconsGithub: typeof import('~icons/simple-icons/github')['default'] IconSimpleIconsWechat: typeof import('~icons/simple-icons/wechat')['default'] IconSimpleIconsXiaohongshu: typeof import('~icons/simple-icons/xiaohongshu')['default'] IconStreamlineColorPenDrawFlat: typeof import('~icons/streamline-color/pen-draw-flat')['default'] - IconSystemUiconsImport: typeof import('~icons/system-uicons/import')['default'] IconTdesignLogoMiniprogram: typeof import('~icons/tdesign/logo-miniprogram')['default'] IconUiwQq: typeof import('~icons/uiw/qq')['default'] RouterLink: typeof import('vue-router')['RouterLink'] @@ -139,12 +133,10 @@ declare global { const IconFluentArrowClockwise20Regular: typeof import('~icons/fluent/arrow-clockwise20-regular')['default'] const IconFluentArrowDownload20Regular: typeof import('~icons/fluent/arrow-download20-regular')['default'] const IconFluentArrowLeft16Regular: typeof import('~icons/fluent/arrow-left16-regular')['default'] - const IconFluentArrowMove20Regular: typeof import('~icons/fluent/arrow-move20-regular')['default'] const IconFluentArrowRight16Regular: typeof import('~icons/fluent/arrow-right16-regular')['default'] const IconFluentArrowShuffle16Regular: typeof import('~icons/fluent/arrow-shuffle16-regular')['default'] const IconFluentArrowSort20Regular: typeof import('~icons/fluent/arrow-sort20-regular')['default'] const IconFluentArrowSwap20Regular: typeof import('~icons/fluent/arrow-swap20-regular')['default'] - const IconFluentArrowUpload20Regular: typeof import('~icons/fluent/arrow-upload20-regular')['default'] const IconFluentBook20Regular: typeof import('~icons/fluent/book20-regular')['default'] const IconFluentBookLetter20Regular: typeof import('~icons/fluent/book-letter20-regular')['default'] const IconFluentBookNumber20Filled: typeof import('~icons/fluent/book-number20-filled')['default'] @@ -157,8 +149,6 @@ declare global { const IconFluentChevronDown20Regular: typeof import('~icons/fluent/chevron-down20-regular')['default'] const IconFluentChevronLeft20Filled: typeof import('~icons/fluent/chevron-left20-filled')['default'] const IconFluentChevronLeft28Filled: typeof import('~icons/fluent/chevron-left28-filled')['default'] - const IconFluentChevronRight16Regular: typeof import('~icons/fluent/chevron-right16-regular')['default'] - const IconFluentChevronRight20Regular: typeof import('~icons/fluent/chevron-right20-regular')['default'] const IconFluentChevronUp20Filled: typeof import('~icons/fluent/chevron-up20-filled')['default'] const IconFluentClipboardTextEdit20Regular: typeof import('~icons/fluent/clipboard-text-edit20-regular')['default'] const IconFluentClock20Regular: typeof import('~icons/fluent/clock20-regular')['default'] @@ -219,14 +209,12 @@ declare global { const IconMdiSparkles: typeof import('~icons/mdi/sparkles')['default'] const IconMdiStar: typeof import('~icons/mdi/star')['default'] const IconMdiStarOutline: typeof import('~icons/mdi/star-outline')['default'] - const IconPhExportLight: typeof import('~icons/ph/export-light')['default'] const IconPhTranslate: typeof import('~icons/ph/translate')['default'] const IconRiTwitterFill: typeof import('~icons/ri/twitter-fill')['default'] const IconSimpleIconsGithub: typeof import('~icons/simple-icons/github')['default'] const IconSimpleIconsWechat: typeof import('~icons/simple-icons/wechat')['default'] const IconSimpleIconsXiaohongshu: typeof import('~icons/simple-icons/xiaohongshu')['default'] const IconStreamlineColorPenDrawFlat: typeof import('~icons/streamline-color/pen-draw-flat')['default'] - const IconSystemUiconsImport: typeof import('~icons/system-uicons/import')['default'] const IconTdesignLogoMiniprogram: typeof import('~icons/tdesign/logo-miniprogram')['default'] const IconUiwQq: typeof import('~icons/uiw/qq')['default'] const RouterLink: typeof import('vue-router')['RouterLink'] diff --git a/app/components/article/EditArticle.vue b/app/components/article/EditArticle.vue index 26b77c272..1ac8da875 100644 --- a/app/components/article/EditArticle.vue +++ b/app/components/article/EditArticle.vue @@ -105,6 +105,9 @@ function splitTranslateText() { //TODO async function startNetworkTranslate() { + if (useRuntimeConfig().public.isDesktop) { + return Toast.warning('桌面百度翻译未启用:需要独立的远端翻译服务;请手动填写译文') + } if (!editArticle.title.trim()) { return Toast.error($t('please_fill_title')) } @@ -801,7 +804,6 @@ function minusStartTime(val: Sentence) { } } - @media (max-width: 768px) { .content { flex-direction: column; diff --git a/app/components/channel-icons/ChannelIcons.vue b/app/components/channel-icons/ChannelIcons.vue index 2cf7314cf..6081f9084 100644 --- a/app/components/channel-icons/ChannelIcons.vue +++ b/app/components/channel-icons/ChannelIcons.vue @@ -5,6 +5,7 @@ import ShareIcon from './ShareIcon.vue' import WeChat from './WeChat.vue' import Github from './Github.vue' import { withAppBaseURL } from '@/core/utils/base-url' +import { EMAIL } from '@/core/config/env.ts' withDefaults( defineProps<{ @@ -54,10 +55,10 @@ const qqQrSrc = withAppBaseURL('/imgs/channel/qq.jpg') diff --git a/app/components/dialog/BackupGateDialog.vue b/app/components/dialog/BackupGateDialog.vue index 561269789..59bda829f 100644 --- a/app/components/dialog/BackupGateDialog.vue +++ b/app/components/dialog/BackupGateDialog.vue @@ -2,26 +2,33 @@ import { watch } from 'vue' import { BaseButton, Dialog } from '@/base' import { useExport } from '@/core/hooks/export' -import { IS_DEV } from '@/core/config/env.ts' import { Toast } from '~/base' const model = defineModel() +const isDesktop = useRuntimeConfig().public.isDesktop const { loading: backupLoading, exportData } = useExport() let backupTriggered = $ref(false) +let backupRequest = 0 watch(model, visible => { - if (!visible) backupTriggered = false + if (!visible) { + backupRequest++ + backupTriggered = false + } }) async function onBackup() { - backupTriggered = true - let disabled = localStorage.getItem('disable360') + const request = ++backupRequest + backupTriggered = false + const disabled = !isDesktop && localStorage.getItem('disable360') if (disabled) { + backupTriggered = true return Toast.success('已跳过导出') } - await exportData('已自动备份数据', 'TypeWords数据备份.zip') + const backup = await exportData('已自动备份数据', 'TypeWords数据备份.zip') + if (request === backupRequest) backupTriggered = !!model.value && backup instanceof Blob } diff --git a/app/core/composables/backupImport.ts b/app/core/composables/backupImport.ts new file mode 100644 index 000000000..ebaaa892f --- /dev/null +++ b/app/core/composables/backupImport.ts @@ -0,0 +1,107 @@ +import { APP_VERSION, EXPORT_DATA_KEY, SAVE_DICT_KEY, SAVE_SETTING_KEY } from '../config/env' +import { checkAndUpgradeSaveDict, checkAndUpgradeSaveSetting } from '../utils' +import { checkAndUpgradePracticeWordCache, PRACTICE_ARTICLE_CACHE, PRACTICE_WORD_CACHE } from '../utils/cache' +import type { BackupData } from '../types' +import { validateRemotePracticeCache } from './remotePracticeValidation' +import { validateStoredSettings } from './settingsValidation' +import { validateStoredDictionary } from './dictionaryValidation' + +export type ImportAudio = Array<{ id: string; file: Blob }> +const record = (v: any): v is Record => v !== null && typeof v === 'object' && !Array.isArray(v) + +function assertEnvelope(value: any, maxVersion: number, name: string, nullable = false) { + if ( + !record(value) || + !Number.isInteger(value.version) || + value.version < 1 || + value.version > maxVersion || + !(record(value.val) || (nullable && value.val === null)) + ) { + throw new Error(`Invalid or unsupported backup field: ${name}`) + } +} + +/** Validate untrusted input before invoking legacy upgrade code (which can create repair snapshots). */ +export async function prepareBackupImport(text: string, audio: ImportAudio): Promise { + const backup = JSON.parse(text) + if (!record(backup) || ![4, EXPORT_DATA_KEY.version].includes(backup.version) || !record(backup.val)) { + throw new Error('Invalid or unsupported backup schema') + } + const data = backup.val + assertEnvelope(data.dict, SAVE_DICT_KEY.version, 'dict') + assertEnvelope(data.setting, SAVE_SETTING_KEY.version, 'setting') + try { + validateStoredDictionary(data.dict.val) + } catch (error) { + throw new Error('Invalid backup dictionary', { cause: error }) + } + try { + validateStoredSettings(data.setting.val) + } catch (error) { + throw new Error('Invalid backup settings', { cause: error }) + } + for (const key of [PRACTICE_WORD_CACHE, PRACTICE_ARTICLE_CACHE]) { + const envelope = data[key.key] + if (envelope == null) continue + assertEnvelope(envelope, key.version, key.key, true) + // ZIP and remote payloads share the same cache format and pre-migration boundary. + try { + validateRemotePracticeCache(envelope.val, key === PRACTICE_WORD_CACHE ? 'word' : 'article', envelope.version) + } catch (error) { + throw new Error(`Invalid backup practice cache: ${key.key}`, { cause: error }) + } + } + const requiredAudio = new Set() + for (const kind of ['word', 'article']) { + const section = data.dict.val[kind] + if (!section.bookList.length) { + throw new Error(`Invalid backup dictionary: ${kind}`) + } + for (const book of section.bookList) { + for (const article of book.articles) { + if (!article.audioSrc && article.audioFileId) { + if (typeof article.audioFileId !== 'string') throw new Error('Invalid audio identifier') + requiredAudio.add(article.audioFileId) + } + } + } + } + const available = new Set() + for (const item of audio) { + if ( + !item || + typeof item.id !== 'string' || + !item.id || + available.has(item.id) || + !(item.file instanceof Blob) || + item.file.size === 0 + ) { + throw new Error('Invalid or duplicate backup audio') + } + available.add(item.id) + } + for (const id of requiredAudio) { + if (!available.has(id)) throw new Error(`Missing backup audio: ${id}`) + } + data.dict.val = await checkAndUpgradeSaveDict(data.dict) + data.setting.val = await checkAndUpgradeSaveSetting(data.setting) + data[PRACTICE_WORD_CACHE.key] = checkAndUpgradePracticeWordCache(data[PRACTICE_WORD_CACHE.key], data.setting.val) + if (backup.version === 4 && data[APP_VERSION.key] != null) data.setting.val.webAppVersion = data[APP_VERSION.key] + return data as BackupData['val'] +} + +/** Read all entries without touching IndexedDB. Missing/corrupt data or audio never partially imports. */ +export async function readBackupZip(zip: any): Promise<{ text: string; audio: ImportAudio }> { + const data = zip.file('data.json') + if (!data) throw new Error('missing_data_json') + const text = await data.async('string') + // Fail malformed JSON before spending time decompressing audio. + JSON.parse(text) + const audio: ImportAudio = [] + for (const filename of Object.keys(zip.files)) { + if (!filename.startsWith('mp3/') || zip.files[filename].dir) continue + if (!/^mp3\/[^/\\]+\.mp3$/.test(filename)) throw new Error('Invalid backup audio path') + audio.push({ id: filename.slice(4, -4), file: await zip.file(filename).async('blob') }) + } + return { text, audio } +} diff --git a/app/core/composables/dictionaryValidation.ts b/app/core/composables/dictionaryValidation.ts new file mode 100644 index 000000000..ec7ee4a86 --- /dev/null +++ b/app/core/composables/dictionaryValidation.ts @@ -0,0 +1,81 @@ +const record = (value: any): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value) + +function assert(condition: boolean, field: string): asserts condition { + if (!condition) throw new Error(`Invalid dictionary: ${field}`) +} + +function validCardDate(value: any): boolean { + if (typeof value === 'string') return value.trim() !== '' && Number.isFinite(Date.parse(value)) + if (typeof value === 'number') return Number.isFinite(value) && Number.isFinite(new Date(value).getTime()) + // Dates can originate in another realm; do not coerce arbitrary objects into dates. + try { + return Number.isFinite(Date.prototype.getTime.call(value)) + } catch { + return false + } +} + +function validateCard(card: any): void { + const field = 'fsrsData.card' + assert(record(card), field) + assert(validCardDate(card.due), `${field}.due`) + if (card.last_review != null) assert(validCardDate(card.last_review), `${field}.last_review`) + assert( + (Number.isInteger(card.state) && card.state >= 0 && card.state <= 3) || + (typeof card.state === 'string' && + ['new', 'learning', 'review', 'relearning'].includes(card.state.toLowerCase())), + `${field}.state` + ) + for (const key of ['stability', 'difficulty', 'elapsed_days', 'scheduled_days']) { + assert(typeof card[key] === 'number' && Number.isFinite(card[key]) && card[key] >= 0, `${field}.${key}`) + } + assert(card.difficulty <= 10, `${field}.difficulty`) + for (const key of ['reps', 'lapses', 'learning_steps']) { + // Pre-learning-step cards remain readable; validation must not rewrite historical records. + if (key === 'learning_steps' && !(key in card)) continue + assert(Number.isSafeInteger(card[key]) && card[key] >= 0, `${field}.${key}`) + } +} + +/** Shared ZIP/remote boundary; preserve legacy identifiers and omitted optional collections. */ +export function validateStoredDictionary(value: any): void { + assert(record(value), 'data') + if ('simpleWords' in value) + assert( + Array.isArray(value.simpleWords) && value.simpleWords.every((word: any) => typeof word === 'string'), + 'simpleWords' + ) + if ('noteData' in value) + assert(record(value.noteData) && Object.values(value.noteData).every(note => typeof note === 'string'), 'noteData') + if ('fsrsData' in value) { + assert(record(value.fsrsData), 'fsrsData') + for (const card of Object.values(value.fsrsData)) validateCard(card) + } + for (const kind of ['word', 'article']) { + const section = value[kind] + assert(record(section) && Array.isArray(section.bookList), kind) + assert( + Number.isSafeInteger(section.studyIndex) && + section.studyIndex >= -1 && + section.studyIndex < section.bookList.length, + `${kind}.studyIndex` + ) + for (const book of section.bookList) { + assert(record(book) && Array.isArray(book.words) && Array.isArray(book.articles), `${kind}.book`) + assert( + book.words.every((word: any) => record(word) && typeof word.word === 'string' && word.word.length > 0), + `${kind}.words` + ) + assert( + book.articles.every( + (article: any) => + record(article) && + typeof article.text === 'string' && + (!('textTranslate' in article) || typeof article.textTranslate === 'string') + ), + `${kind}.articles` + ) + } + } +} diff --git a/app/core/composables/practice-words/practice-word-session.ts b/app/core/composables/practice-words/practice-word-session.ts index 03bb3b1fb..d9e09d176 100644 --- a/app/core/composables/practice-words/practice-word-session.ts +++ b/app/core/composables/practice-words/practice-word-session.ts @@ -10,10 +10,13 @@ import { type LocalCacheResult, type PracticeWordCacheStored, } from '@/core/utils/cache.ts' -import { useDataSyncPersistence } from '@/core/composables/useDataSyncPersistence.ts' +import { RemoteDataReadError, useDataSyncPersistence } from '@/core/composables/useDataSyncPersistence.ts' +import { RemoteDataValidationError } from '@/core/composables/remotePracticeValidation' import { shouldFetchRemote } from '@/core/utils/index.ts' import type { PracticeSessionSnapshot } from './practice-flow-types.ts' +export { RemoteDataValidationError } + export type PracticeData = Omit & { question: Question | null } @@ -173,9 +176,19 @@ export function usePracticeWordPersistence() { } async function load(): Promise { + let remoteReadFailed = false const [local, remote] = await Promise.all([ getPracticeWordCacheLocalWithMeta() as Promise | null>, - dataSync.getRemoteData(SyncDataType.practice_word), + dataSync.getRemoteData(SyncDataType.practice_word).catch(error => { + if (error instanceof RemoteDataReadError) { + remoteReadFailed = true + return null + } + if (error instanceof RemoteDataValidationError && error.unsupportedVersion !== undefined) { + throw new UnsupportedPracticeCacheVersionError(error.unsupportedVersion) + } + throw error + }), ]) let selected: LocalCacheResult | null = local @@ -208,12 +221,13 @@ export function usePracticeWordPersistence() { settingStore ) if (upgraded.val == null) { - await save(null) + if (!remoteReadFailed) await save(null) return null } const restored = restoreCurrentCache(upgraded.val) if (!restored) return null - await save(restored) + // A failed read is not evidence that the old local cache should replace remote progress. + if (!remoteReadFailed) await save(restored) return restored } diff --git a/app/core/composables/remotePracticeValidation.ts b/app/core/composables/remotePracticeValidation.ts new file mode 100644 index 000000000..dd0795c7e --- /dev/null +++ b/app/core/composables/remotePracticeValidation.ts @@ -0,0 +1,160 @@ +export class RemoteDataValidationError extends Error { + constructor( + message: string, + public readonly unsupportedVersion?: number + ) { + super(message) + } +} + +const record = (value: any): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value) +const index = (value: any) => Number.isSafeInteger(value) && value >= 0 +const number = (value: any) => typeof value === 'number' && Number.isFinite(value) && value >= 0 +const strings = (value: any) => Array.isArray(value) && value.every(item => typeof item === 'string') +const wordKeys = (value: any) => strings(value) && value.every((item: string) => item.length > 0) + +function assert(condition: boolean, field: string): asserts condition { + if (!condition) throw new RemoteDataValidationError(`Invalid remote practice cache: ${field}`) +} + +function validateStats(stats: any) { + assert(record(stats), 'statStoreData') + for (const key of ['startDate', 'spend', 'total', 'newWordNumber', 'reviewWordNumber', 'inputWordNumber', 'wrong']) { + assert(number(stats[key]), `statStoreData.${key}`) + } + assert([0, 1, 2, 3, 4, 5, 6, 7, 12, 13].includes(stats.stage), 'statStoreData.stage') + // Timer fields were added after the original caches shipped. + if ('timerPaused' in stats) assert(typeof stats.timerPaused === 'boolean', 'timerPaused') + if ('timerPauseReason' in stats) + assert([null, 'manual', 'auto_visibility', 'auto_idle'].includes(stats.timerPauseReason), 'timerPauseReason') + if ('segments' in stats) { + assert( + Array.isArray(stats.segments) && + stats.segments.every( + (segment: any) => + Array.isArray(segment) && segment.length === 2 && segment.every(number) && segment[1] >= segment[0] + ), + 'segments' + ) + } +} + +function wordId(item: any, compact: boolean): string { + return compact ? item : item.word +} + +function validateQuestion(question: any, currentWord: string | undefined) { + if (question === null) return + assert(record(question) && Array.isArray(question.candidates), 'practiceData.question') + assert(question.candidates.length > 0, 'practiceData.question.candidates') + assert( + index(question.correctIndex) && question.correctIndex < question.candidates.length, + 'practiceData.question.correctIndex' + ) + for (const candidate of question.candidates) { + assert( + record(candidate) && + number(candidate.similarity) && + record(candidate.word) && + typeof candidate.word.word === 'string' && + candidate.word.word.length > 0, + 'practiceData.question.candidates' + ) + } + // A stored question is for the current practice word. Do not retarget or drop it. + assert(typeof currentWord === 'string' && currentWord.length > 0, 'practiceData.question.currentWord') + assert( + question.candidates[question.correctIndex].word.word === currentWord, + 'practiceData.question.correctIndex.word' + ) +} + +function validateSnapshot(snapshot: any, context: { wordCount: number; sessionWordKeys: Set }) { + assert(record(snapshot) && typeof snapshot.flowId === 'string' && snapshot.flowId.length > 0, 'sessionSnapshot') + const cursor = snapshot.cursor + assert(record(cursor), 'cursor') + assert(index(cursor.nodeIndex) && index(cursor.stepIndex), 'cursor.index') + assert(typeof cursor.inWrongWordClear === 'boolean', 'cursor.inWrongWordClear') + assert(cursor.endActionIndex === null || index(cursor.endActionIndex), 'cursor.endActionIndex') + if (cursor.loop !== null) { + assert( + record(cursor.loop) && + index(cursor.loop.startIndex) && + index(cursor.loop.endIndex) && + cursor.loop.endIndex >= cursor.loop.startIndex && + index(cursor.loop.subStepIndex), + 'cursor.loop' + ) + // Restore already rejects endIndex past the live word list; start<=end keeps start in range. + assert(cursor.loop.endIndex < context.wordCount, 'cursor.loop.range') + } + // inWrongWordClear means an onEnd action is active; null is "not in onEnd". + if (cursor.inWrongWordClear) assert(cursor.endActionIndex !== null, 'cursor.endActionIndex.wrongWordClear') + if ('nodeWorkingWordKeys' in snapshot) { + assert(wordKeys(snapshot.nodeWorkingWordKeys), 'nodeWorkingWordKeys') + assert( + snapshot.nodeWorkingWordKeys.every((key: string) => context.sessionWordKeys.has(key)), + 'nodeWorkingWordKeys.unknown' + ) + } +} + +/** Pure boundary check: never normalize, discard malformed entries, or touch local storage. */ +export function validateRemotePracticeCache(value: any, kind: 'word' | 'article', version: number): void { + if (value === null) return + assert(record(value), 'data') + if (kind === 'article') { + assert(record(value.practiceData), 'practiceData') + for (const key of ['sectionIndex', 'sentenceIndex', 'wordIndex']) { + assert(index(value.practiceData[key]), `practiceData.${key}`) + } + validateStats(value.statStoreData) + return + } + const compact = 'taskWordsStr' in value + assert(version === 1 || compact, 'taskWordsStr') + const words = (items: any) => + compact + ? wordKeys(items) + : Array.isArray(items) && + items.every(item => record(item) && typeof item.word === 'string' && item.word.length > 0) + const tasks = compact ? value.taskWordsStr : value.taskWords + assert(record(tasks) && words(tasks.new) && words(tasks.review), 'taskWords') + const hasPractice = 'practiceData' in value + const hasStats = 'statStoreData' in value + assert(hasPractice === hasStats, 'incomplete session') + let wordCount = 0 + const sessionWordKeys = new Set() + if (hasPractice) { + const data = value.practiceData + assert(record(data) && index(data.index), 'practiceData.index') + const wordList = data[compact ? 'wordsStr' : 'words'] + assert(words(wordList), 'practiceData.words') + assert(wordList.length === 0 ? data.index === 0 : data.index < wordList.length, 'practiceData.index.range') + const wrongList = data[compact ? 'wrongWordsStr' : 'wrongWords'] + assert(words(wrongList), 'practiceData.wrongWords') + wordCount = wordList.length + for (const item of [...tasks.new, ...tasks.review, ...wordList, ...wrongList]) { + sessionWordKeys.add(wordId(item, compact)) + } + const currentWord = wordCount === 0 ? undefined : wordId(wordList[data.index], compact) + if ('question' in data) validateQuestion(data.question, currentWord) + for (const key of ['excludeWords', 'allWrongWords']) { + if (key in data) assert(strings(data[key]), `practiceData.${key}`) + } + for (const key of ['wrongTimesMap', 'ratingMap']) { + if (key in data) { + assert(record(data[key]) && Object.values(data[key]).every(number), `practiceData.${key}`) + } + } + if ('wrongTimes' in data) assert(number(data.wrongTimes), 'practiceData.wrongTimes') + if ('isTypingWrongWord' in data) + assert(typeof data.isTypingWrongWord === 'boolean', 'practiceData.isTypingWrongWord') + validateStats(value.statStoreData) + } + if ('sessionSnapshot' in value) { + assert(hasPractice, 'sessionSnapshot without practiceData') + validateSnapshot(value.sessionSnapshot, { wordCount, sessionWordKeys }) + } +} diff --git a/app/core/composables/settingsValidation.ts b/app/core/composables/settingsValidation.ts new file mode 100644 index 000000000..b5d85e4a2 --- /dev/null +++ b/app/core/composables/settingsValidation.ts @@ -0,0 +1,149 @@ +const record = (value: any): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value) + +// waitTimeForChangeWord is passed to setTimeout; spaceCooldownTime is compared with Date.now() deltas. +// HTML/WebView delays are signed 32-bit. This is not the current UI 10000 ms cap. +const SIGNED_32_TIMER_MAX = 2147483647 + +function validateFsrsGradeLimits(value: Record): void { + const keys = ['fsrsEasyLimit', 'fsrsGoodLimit', 'fsrsHardLimit'] as const + const present: Partial> = {} + for (const key of keys) { + if (!(key in value)) continue + const limit = value[key] + // Wrong-time thresholds; 0 is the current Easy default. Missing companions stay for migration. + if (!Number.isSafeInteger(limit) || limit < 0) { + throw new Error(`Invalid settings ${key}`) + } + present[key] = limit + } + const easy = present.fsrsEasyLimit + const good = present.fsrsGoodLimit + const hard = present.fsrsHardLimit + // Product cascade: Easy if <= easy, else Good if <= good, else Hard if <= hard, else Again. + if (easy !== undefined && good !== undefined && easy > good) { + throw new Error('Invalid settings fsrsEasyLimit') + } + if (good !== undefined && hard !== undefined && good > hard) { + throw new Error('Invalid settings fsrsGoodLimit') + } + if (easy !== undefined && hard !== undefined && easy > hard) { + throw new Error('Invalid settings fsrsEasyLimit') + } +} + +function validateFsrsParameters(parameters: any): void { + const assert = (valid: boolean, field = '') => { + if (!valid) throw new Error(`Invalid settings fsrsParameters${field ? `.${field}` : ''}`) + } + assert(record(parameters)) + if ('request_retention' in parameters) { + const retention = parameters.request_retention + assert( + typeof retention === 'number' && Number.isFinite(retention) && retention > 0 && retention <= 1, + 'request_retention' + ) + } + if ('maximum_interval' in parameters) { + const interval = parameters.maximum_interval + assert(typeof interval === 'number' && Number.isFinite(interval) && interval >= 1, 'maximum_interval') + } + for (const key of ['enable_fuzz', 'enable_short_term']) { + if (key in parameters) assert(typeof parameters[key] === 'boolean', key) + } + if ('w' in parameters) { + const weights = parameters.w + assert(Array.isArray(weights) && [17, 19, 21].includes(weights.length), 'w') + // Iteration also rejects holes; Array.every would silently skip them. + for (const weight of weights) assert(typeof weight === 'number' && Number.isFinite(weight), 'w') + } + for (const key of ['learning_steps', 'relearning_steps']) { + if (!(key in parameters)) continue + assert(Array.isArray(parameters[key]), key) + for (const step of parameters[key]) { + assert(typeof step === 'string' && /^\+?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?[mhd]$/i.test(step), key) + const unit = step.slice(-1) + const numeric = step.slice(0, -1) + // Keep the installed engine's parseInt semantics, including fractional legacy steps. + const minutes = Number.parseInt(numeric, 10) * ({ m: 1, h: 60, d: 1440 }[unit] ?? NaN) + assert(Number.isFinite(Number(numeric)) && Number.isFinite(minutes) && minutes >= 0, key) + } + } +} + +/** Validate settings consumed by migration, practice and audio before any repair side effects. */ +export function validateStoredSettings(value: any): void { + if ( + !record(value) || + !record(value.shortcutKeyMap) || + !Object.values(value.shortcutKeyMap).every(shortcut => typeof shortcut === 'string') + ) { + throw new Error('Invalid settings shortcutKeyMap') + } + // Missing historical fields are supplied by migration, not by this boundary. + for (const key of ['repeatCount', 'repeatCustomCount']) { + if (!(key in value)) continue + // Null is the current default even while the custom-count option is selected. + if (key === 'repeatCustomCount' && value[key] === null) continue + if (!Number.isSafeInteger(value[key]) || value[key] <= 0) { + throw new Error(`Invalid settings ${key}`) + } + } + for (const key of ['waitTimeForChangeWord', 'spaceCooldownTime', 'wordReviewRatio']) { + if (key in value && (typeof value[key] !== 'number' || !Number.isFinite(value[key]) || value[key] < 0)) { + throw new Error(`Invalid settings ${key}`) + } + } + for (const key of ['waitTimeForChangeWord', 'spaceCooldownTime']) { + if (key in value && value[key] > SIGNED_32_TIMER_MAX) { + throw new Error(`Invalid settings ${key}`) + } + } + for (const key of [ + 'wordSoundVolume', + 'sentenceSoundVolume', + 'articleSoundVolume', + 'keyboardSoundVolume', + 'effectSoundVolume', + ]) { + if ( + key in value && + (typeof value[key] !== 'number' || !Number.isFinite(value[key]) || value[key] < 0 || value[key] > 100) + ) { + throw new Error(`Invalid settings ${key}`) + } + } + for (const key of ['wordSoundSpeed', 'sentenceSoundSpeed', 'articleSoundSpeed']) { + if (key in value && (typeof value[key] !== 'number' || !Number.isFinite(value[key]) || value[key] <= 0)) { + throw new Error(`Invalid settings ${key}`) + } + } + validateFsrsGradeLimits(value) + if ('fsrsParameters' in value) validateFsrsParameters(value.fsrsParameters) + // Older versions omit these collections; the existing migrator supplies their defaults. + if ('fontSize' in value) { + if (!record(value.fontSize)) throw new Error('Invalid settings fontSize') + for (const key of [ + 'articleForeignFontSize', + 'articleTranslateFontSize', + 'wordForeignFontSize', + 'wordTranslateFontSize', + ]) { + if ( + key in value.fontSize && + (typeof value.fontSize[key] !== 'number' || !Number.isFinite(value.fontSize[key]) || value.fontSize[key] <= 0) + ) { + throw new Error(`Invalid settings fontSize.${key}`) + } + } + } + if ( + 'ttsVoiceMap' in value && + (!Array.isArray(value.ttsVoiceMap) || + !value.ttsVoiceMap.every( + (entry: any) => record(entry) && typeof entry.key === 'string' && typeof entry.voice === 'string' + )) + ) { + throw new Error('Invalid settings ttsVoiceMap') + } +} diff --git a/app/core/composables/useDataSyncPersistence.ts b/app/core/composables/useDataSyncPersistence.ts index 07d28e6b6..b646e84d5 100644 --- a/app/core/composables/useDataSyncPersistence.ts +++ b/app/core/composables/useDataSyncPersistence.ts @@ -6,6 +6,7 @@ import { shouldFetchRemote, } from '../utils' import { + checkAndUpgradePracticeWordCache, getPracticeArticleCacheLocal, getPracticeArticleCacheLocalWithMeta, getPracticeWordCacheLocal, @@ -31,9 +32,35 @@ import { type BaseState, getDefaultBaseState, getDefaultSettingState, useBaseSto import type { BackupData, SaveData, Snapshot } from '../types/types.ts' import { SyncDataType, CompareResult, DictType } from '../types/enum' import { Supabase } from '../utils/supabase' -import { del, get, set } from 'idb-keyval' +import { del, get, set, setMany } from 'idb-keyval' import type { SupabaseClient } from '@supabase/supabase-js' import { Toast } from '@/base' +import { nextTick, toRaw } from 'vue' +import { RemoteDataValidationError, validateRemotePracticeCache } from './remotePracticeValidation' +import { validateStoredSettings } from './settingsValidation' +import { validateStoredDictionary } from './dictionaryValidation' +import type { AccountSync, AccountSyncScope } from '../platform/accountSync' +import { setManyForAccount } from '../platform/accountPersistence' + +// JSON cloning changes FSRS Date objects into strings; unwrap reactive children individually. +function cloneImportState(value: T): T { + if (value === null || typeof value !== 'object') return value + const raw = toRaw(value) + if (raw instanceof Date) return new Date(raw.getTime()) as T + if (Array.isArray(raw)) return raw.map(cloneImportState) as T + return Object.fromEntries(Object.entries(raw).map(([key, item]) => [key, cloneImportState(item)])) as T +} + +// Serialize writes across composable instances; an import invalidates queued autosaves. +let persistenceQueue: Promise = Promise.resolve() +let importGeneration = 0 +function enqueuePersistence(action: () => Promise, generation?: number): Promise { + const result = persistenceQueue.then(() => + generation === undefined || generation === importGeneration ? action() : undefined + ) + persistenceQueue = result.catch(() => {}) + return result as Promise +} type RemoteMetaRow = { type: SyncDataType @@ -83,9 +110,16 @@ function getPersistKey(type: SyncDataType): string { return type === SyncDataType.dict ? SAVE_DICT_KEY.key : SAVE_SETTING_KEY.key } -function getSyncClient(client?: SupabaseClient | null): SupabaseClient | null { +export class RemoteDataReadError extends Error { + constructor() { + super('远端同步数据读取失败,本地缓存仅在内存中恢复,请检查同步配置后重试') + } +} + +function getSyncClient(client?: SupabaseClient | null, allowReadRetry = false): SupabaseClient | null { + if (!Supabase.isEnabled()) return null if (client) return client - if (!Supabase.check()) return null + if (!Supabase.check(allowReadRetry)) return null return Supabase.getInstance() as SupabaseClient } @@ -124,20 +158,31 @@ async function persistLocalState(type: SyncDataType, val: unknown, updated_at?: ) } -function applyDictData(store: ReturnType, data: unknown) { - store.setState(data as any) +function hydrateDictData(store: ReturnType, scope?: AccountSyncScope) { + const word = store.word + const article = store.article if (store.word.studyIndex >= 3) { if (!store.sdict.custom && !store.sdict.system && !store.sdict.words.length) { - _getDictDataByUrl(store.sdict).then(r => { - store.word.bookList[store.word.studyIndex] = r - }) + const index = word.studyIndex + const dict = store.sdict + _getDictDataByUrl(dict) + .then(r => { + scope?.assertCurrent() + if (store.word === word && word.bookList[index] === dict) word.bookList[index] = r + }) + .catch(error => console.warn('Remote dictionary resource load failed', error)) } } if (store.article.studyIndex >= 1) { if (!store.sbook.custom && !store.sbook.system && !store.sbook.articles.length) { - _getDictDataByUrl(store.sbook, DictType.article).then(r => { - store.article.bookList[store.article.studyIndex] = r - }) + const index = article.studyIndex + const book = store.sbook + _getDictDataByUrl(book, DictType.article) + .then(r => { + scope?.assertCurrent() + if (store.article === article && article.bookList[index] === book) article.bookList[index] = r + }) + .catch(error => console.warn('Remote article resource load failed', error)) } } } @@ -152,7 +197,32 @@ async function fetchServerMeta(types: SyncDataType[], client?: SupabaseClient | Supabase.setStatus('error', error?.message ?? String(error)) return null } - return (data ?? []) as RemoteMetaRow[] + // Only a valid empty array means no remote rows; malformed metadata must never select a push. + const seen = new Set() + if ( + !Array.isArray(data) || + data.some(row => { + if ( + !row || + typeof row !== 'object' || + Array.isArray(row) || + !types.includes(row.type) || + seen.has(row.type) || + (row.data_version != null && (!Number.isInteger(row.data_version) || row.data_version < 1)) || + (row.updated_at != null && + (typeof row.updated_at !== 'string' || + !/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(row.updated_at) || + !Number.isFinite(Date.parse(row.updated_at)))) + ) { + return true + } + seen.add(row.type) + return false + }) + ) { + throw new Error('远端同步元数据无效,请检查数据和读取权限后重试') + } + return data as RemoteMetaRow[] } catch (error) { console.log('sp-error', error) Supabase.setStatus('error', error?.message ?? String(error)) @@ -165,7 +235,7 @@ async function fetchServerDatas( client?: SupabaseClient | null ): Promise { const sb = getSyncClient(client) - if (!sb) return [] + if (!sb) return null console.log('Fetching server data list', types) try { const { data, error } = await sb @@ -175,13 +245,21 @@ async function fetchServerDatas( if (error) { console.log('sp-error', error) Supabase.setStatus('error', error?.message ?? String(error)) - return [] + return null } - return (data ?? []) as RemoteDataRow[] + // Metadata selected these rows for a pull; missing rows are not a successful read. + if ( + !Array.isArray(data) || + data.length !== types.length || + types.some(type => data.filter(row => row?.type === type).length !== 1) + ) { + throw new Error('远端同步数据不完整,请检查读取权限后重试') + } + return data as RemoteDataRow[] } catch (error) { console.log('sp-error', error) Supabase.setStatus('error', error?.message ?? String(error)) - return [] + return null } } @@ -236,30 +314,148 @@ async function applyRemoteDataByType( settingStore: ReturnType ): Promise { if (!row) return + try { + await applyRemoteDataBatch([row], store, settingStore) + } catch (error) { + Supabase.setStatus('error', error?.message ?? String(error)) + throw error + } +} + +function validateRemoteDataRow(row: RemoteDataRow): void { + const cache = row.type === SyncDataType.practice_word || row.type === SyncDataType.practice_article + if ( + !ALL_SYNC_TYPES.includes(row.type) || + !Number.isInteger(row.data_version) || + row.data_version < 1 || + row.data_version > getDataVersion(row.type) || + (!(cache && row.data === null) && (!row.data || typeof row.data !== 'object' || Array.isArray(row.data))) + ) { + throw new RemoteDataValidationError( + 'Invalid or unsupported remote data', + row.data_version > getDataVersion(row.type) ? row.data_version : undefined + ) + } + if (cache) { + validateRemotePracticeCache( + row.data, + row.type === SyncDataType.practice_word ? 'word' : 'article', + row.data_version + ) + } + if (row.type === SyncDataType.setting) { + try { + validateStoredSettings(row.data) + } catch (error) { + throw new RemoteDataValidationError(`Invalid remote settings: ${error instanceof Error ? error.message : error}`) + } + } + if (row.type === SyncDataType.dict) { + try { + validateStoredDictionary(row.data) + } catch (error) { + throw new RemoteDataValidationError( + `Invalid remote dictionary: ${error instanceof Error ? error.message : error}` + ) + } + } +} + +async function applyRemoteDataBatch( + rows: RemoteDataRow[], + store: ReturnType, + settingStore: ReturnType, + scope?: AccountSyncScope +): Promise { + scope?.assertCurrent() + // Validate every envelope before legacy normalizers or live state changes. + const prepared = new Map() + for (const row of rows) { + validateRemoteDataRow(row) + if (prepared.has(row.type)) throw new RemoteDataValidationError('Duplicate remote data type') + prepared.set(row.type, cloneImportState(row.data)) + } const now = new Date().toISOString() - if (type === SyncDataType.setting) { + if (prepared.has(SyncDataType.setting)) { + const row = rows.find(row => row.type === SyncDataType.setting)! const normalized = await checkAndUpgradeSaveSetting({ - val: row.data, + val: prepared.get(row.type), version: row.data_version, }) + scope?.assertCurrent() normalized.load = true normalized._ignoreWatch = true - settingStore.setState(normalized) - await persistLocalState(SyncDataType.setting, normalized, row.updated_at ?? now) - return + prepared.set(row.type, normalized) } - if (type === SyncDataType.dict) { + if (prepared.has(SyncDataType.dict)) { + const row = rows.find(row => row.type === SyncDataType.dict)! const normalized = await checkAndUpgradeSaveDict({ - val: row.data, + val: prepared.get(row.type), version: row.data_version, }) + scope?.assertCurrent() normalized.load = true normalized._ignoreWatch = true - applyDictData(store, normalized) - await persistLocalState(SyncDataType.dict, normalized, row.updated_at ?? now) - return + prepared.set(row.type, normalized) + } + if (prepared.has(SyncDataType.practice_word)) { + const row = rows.find(row => row.type === SyncDataType.practice_word)! + prepared.set( + row.type, + checkAndUpgradePracticeWordCache( + { val: prepared.get(row.type), version: row.data_version }, + prepared.get(SyncDataType.setting) ?? settingStore.$state + ).val + ) + } + const entries: [string, string][] = rows.map(row => [ + row.type === SyncDataType.practice_word + ? PRACTICE_WORD_CACHE.key + : row.type === SyncDataType.practice_article + ? PRACTICE_ARTICLE_CACHE.key + : getPersistKey(row.type), + JSON.stringify({ + val: prepared.get(row.type), + version: getDataVersion(row.type), + updated_at: row.updated_at ?? now, + }), + ]) + const previousDict = cloneImportState(store.$state) + const previousSetting = cloneImportState(settingStore.$state) + // Account pulls publish to memory only after the cancellable transaction commits. + // A stale operation must never restore a snapshot over the next account's state. + if (scope) { + await setManyForAccount(entries, scope) + scope.assertCurrent() + } + try { + scope?.assertCurrent() + if (prepared.has(SyncDataType.dict)) store.setState(prepared.get(SyncDataType.dict)) + scope?.assertCurrent() + if (prepared.has(SyncDataType.setting)) settingStore.setState(prepared.get(SyncDataType.setting)) + if (!scope) await setMany(entries) + } catch (error) { + scope?.assertCurrent() + if (prepared.has(SyncDataType.dict)) store.setState({ ...previousDict, _ignoreWatch: true }) + if (prepared.has(SyncDataType.setting)) { + settingStore.$patch(state => { + for (const key of Object.keys(state)) delete state[key] + Object.assign(state, previousSetting, { _ignoreWatch: true }) + }) + } + await nextTick() + throw error + } + await nextTick() + scope?.assertCurrent() + if (prepared.has(SyncDataType.dict)) { + // Resource availability does not undo a completed local transaction. + try { + hydrateDictData(store, scope) + } catch (error) { + console.warn('Remote dictionary resource load failed', error) + } } - await persistLocalState(type, row.data, row.updated_at ?? now) } function getDictSyncBlockReason(state: BaseState): string | null { @@ -356,20 +552,51 @@ export function useDataSyncPersistence() { const store = useBaseStore() const settingStore = useSettingStore() + async function pullAccountRemoteToLocal(account: AccountSync, scope: AccountSyncScope): Promise { + try { + scope.assertCurrent() + const data = await scope.read(ALL_SYNC_TYPES) + scope.assertCurrent() + if ( + data.length !== ALL_SYNC_TYPES.length || + ALL_SYNC_TYPES.some(type => data.filter(row => row.type === type).length !== 1) + ) { + throw new Error('远端账号数据不完整,不能完成首次拉取') + } + await applyRemoteDataBatch(data as RemoteDataRow[], store, settingStore, scope) + scope.assertCurrent() + account.completeInitialSync(scope) + return true + } catch (error) { + // Do not unlock or update the legacy anonymous client's global status. + scope.assertCurrent() + throw error + } + } + async function pullIfRemoteNewer(type: SyncDataType, client?: SupabaseClient | null): Promise { - const remoteMetas = await fetchServerMeta([type], client) - if (!remoteMetas) return null - const remoteMetaMap = new Map(remoteMetas.map(item => [item.type, item])) - const compareResult = await compareResultByType(type, remoteMetaMap) - console.log('pullIfRemoteNewer-compareResult', CompareResult[compareResult], type) - if (compareResult === CompareResult.RemoteNewer) { - const remoteData = await fetchServerDatas([type], client) - if (remoteData?.length) { - await applyRemoteDataByType(type, remoteData[0], store, settingStore) - return remoteData[0] + try { + const sb = getSyncClient(client, true) + if (!sb) return null + const remoteMetas = await fetchServerMeta([type], sb) + if (!remoteMetas) return null + const remoteMetaMap = new Map(remoteMetas.map(item => [item.type, item])) + const compareResult = await compareResultByType(type, remoteMetaMap) + console.log('pullIfRemoteNewer-compareResult', CompareResult[compareResult], type) + if (compareResult === CompareResult.RemoteNewer) { + const remoteData = await fetchServerDatas([type], sb) + if (remoteData?.length) { + await applyRemoteDataByType(type, remoteData[0], store, settingStore) + // Metadata alone cannot recover a failed payload read or local transaction. + Supabase.setStatus('success') + return remoteData[0] + } } + return null + } catch (error) { + Supabase.setStatus('error', error?.message ?? String(error)) + throw error } - return null } // 同步数据,远程新则拉取(默认),本地新则推送(默认) @@ -396,9 +623,8 @@ export function useDataSyncPersistence() { if (pull.length) { const rows = await fetchServerDatas(pull) - for (const item of rows) { - await applyRemoteDataByType(item.type, item, store, settingStore) - } + if (!rows) return + await applyRemoteDataBatch(rows, store, settingStore) } if (push.length && options?.pushWhenLocalNewer !== false) { @@ -454,8 +680,30 @@ export function useDataSyncPersistence() { } async function getRemoteData(type: SyncDataType, client?: SupabaseClient | null): Promise { - const rows = await fetchServerDatas([type], client) - return rows?.[0] ?? null + let sb: SupabaseClient | null + try { + sb = getSyncClient(client, true) + } catch (error) { + Supabase.setStatus('error', error?.message ?? String(error)) + throw new RemoteDataReadError() + } + if (!sb) { + if (Supabase.getStatus().status === 'error') throw new RemoteDataReadError() + return null + } + const rows = await fetchServerDatas([type], sb) + const row = rows?.[0] + if (!row) throw new RemoteDataReadError() + try { + // This read path historically treats an absent version as v1; batch imports remain strict. + validateRemoteDataRow({ ...row, data_version: row.data_version ?? 1 }) + Supabase.setStatus('success') + return row + } catch (error) { + Supabase.setStatus('error', error?.message ?? String(error)) + // A null result lets the caller migrate/save a local cache over the rejected remote data. + throw error + } } async function getRemoteMeta(type: SyncDataType, client?: SupabaseClient | null): Promise { @@ -463,9 +711,53 @@ export function useDataSyncPersistence() { return rows?.[0] ?? null } - async function forcePushLocalDataToRemote(data: BackupData['val'], client?: SupabaseClient | null): Promise { + async function forcePushLocalDataToRemote( + data: BackupData['val'], + client?: SupabaseClient | null, + audio?: Array<{ id: string; file: Blob }> + ): Promise { let syncResult = true const updated_at = new Date().toISOString() + const entries: [string, any][] = [ + [SAVE_DICT_KEY.key, JSON.stringify({ val: data.dict.val, version: SAVE_DICT_KEY.version, updated_at })], + [SAVE_SETTING_KEY.key, JSON.stringify({ val: data.setting.val, version: SAVE_SETTING_KEY.version, updated_at })], + [ + PRACTICE_WORD_CACHE.key, + JSON.stringify({ + val: (data[PRACTICE_WORD_CACHE.key] as SaveData)?.val ?? null, + version: PRACTICE_WORD_CACHE.version, + updated_at, + }), + ], + [ + PRACTICE_ARTICLE_CACHE.key, + JSON.stringify({ + val: (data[PRACTICE_ARTICLE_CACHE.key] as SaveData)?.val ?? null, + version: PRACTICE_ARTICLE_CACHE.version, + updated_at, + }), + ], + ] + if (audio !== undefined) entries.push([LOCAL_FILE_KEY, audio]) + const previousDict = cloneImportState(store.$state) + const previousSetting = cloneImportState(settingStore.$state) + try { + store.setState({ ...cloneImportState(data.dict.val), load: true, _ignoreWatch: true }) + settingStore.setState({ ...cloneImportState(data.setting.val), load: true, _ignoreWatch: true }) + await setMany(entries) + } catch (error) { + store.setState({ ...previousDict, _ignoreWatch: true }) + settingStore.$patch(state => { + for (const key of Object.keys(state)) delete state[key] + Object.assign(state, previousSetting, { _ignoreWatch: true }) + }) + await nextTick() + // An explicit IndexedDB abort can reject with transaction.error === null. + throw error ?? new Error('Local backup transaction failed') + } + await nextTick() + // Local-only audio cannot be reconstructed by a remote JSON consumer. + if (getDictSyncBlockReason(data.dict.val as BaseState)) return false const sb = getSyncClient(client) if (sb) { const rows: Array<{ type: SyncDataType; data: unknown; data_version: number; updated_at: string }> = [ @@ -499,19 +791,13 @@ export function useDataSyncPersistence() { } else { syncResult = false } - await persistLocalState(SyncDataType.dict, data.dict.val, updated_at) - await persistLocalState(SyncDataType.setting, data.setting.val, updated_at) - //@ts-ignore - await persistLocalState(SyncDataType.practice_word, data?.[PRACTICE_WORD_CACHE.key]?.val ?? null, updated_at) - //@ts-ignore - await persistLocalState(SyncDataType.practice_article, data?.[PRACTICE_ARTICLE_CACHE.key]?.val ?? null, updated_at) return syncResult } async function pullAllRemoteToLocal(client?: SupabaseClient | null): Promise { - const sb = getSyncClient(client) - if (!sb) return false try { + const sb = getSyncClient(client, true) + if (!sb) return false const { data, error } = await (sb as any) .from('typewords_data') .select('type, data, updated_at, data_version') @@ -521,11 +807,16 @@ export function useDataSyncPersistence() { Supabase.setStatus('error', error?.message ?? String(error)) return false } - const rows = (data ?? []) as RemoteDataRow[] - const map = new Map(rows.map(item => [item.type, item])) - for (const type of ALL_SYNC_TYPES) { - await applyRemoteDataByType(type, map.get(type) ?? null, store, settingStore) + if ( + !Array.isArray(data) || + !data.length || + data.some(row => !row || !ALL_SYNC_TYPES.includes(row.type)) || + new Set(data.map(row => row.type)).size !== data.length + ) { + throw new Error('远端没有可用的同步数据或返回数据不完整,请检查读取权限后重试') } + const rows = data as RemoteDataRow[] + await applyRemoteDataBatch(rows, store, settingStore) Supabase.setStatus('success') return true } catch (error) { @@ -594,22 +885,36 @@ export function useDataSyncPersistence() { // @deprecated 大版本5废弃 [APP_VERSION.key]: null, } - store.setState(d) - settingStore.setState(d1) return await forcePushLocalDataToRemote(data) } return { - pullIfRemoteNewer, - saveLocalAndSync, + pullAccountRemoteToLocal: (account: AccountSync) => { + if (!Supabase.isEnabled()) return Promise.reject(new Error('本地桌面版暂不提供云同步')) + // Capture before joining the shared queue, not when the queued job starts. + const scope = account.capture('read') + return enqueuePersistence(() => pullAccountRemoteToLocal(account, scope)) + }, + pullIfRemoteNewer: (...args: Parameters) => + enqueuePersistence(() => pullIfRemoteNewer(...args)), + saveLocalAndSync: (...args: Parameters) => + enqueuePersistence(() => saveLocalAndSync(...args), importGeneration), getRemoteData, getRemoteMeta, - saveDictState, - forcePushLocalDataToRemote, - pullAllRemoteToLocal, + saveDictState: (...args: Parameters) => + enqueuePersistence(() => saveDictState(...args), importGeneration), + forcePushLocalDataToRemote: (...args: Parameters) => { + importGeneration++ + return enqueuePersistence(() => forcePushLocalDataToRemote(...args)) + }, + pullAllRemoteToLocal: (...args: Parameters) => + enqueuePersistence(() => pullAllRemoteToLocal(...args)), getLocalCompactDataByType, - syncData, + syncData: (...args: Parameters) => enqueuePersistence(() => syncData(...args)), getDictSyncBlockReason, - clear, + clear: () => { + importGeneration++ + return enqueuePersistence(clear) + }, } } diff --git a/app/core/composables/useInit.ts b/app/core/composables/useInit.ts index 0cf769c44..29ea6ddf2 100644 --- a/app/core/composables/useInit.ts +++ b/app/core/composables/useInit.ts @@ -5,12 +5,37 @@ import { useBaseStore, useRuntimeStore, useSettingStore } from '../stores' import { Supabase } from '../utils/supabase' import { ensureHashGuardBeforeInit, useDataSyncPersistence } from './useDataSyncPersistence' import { SyncDataType } from '../types' -import type { SubscriptionCallbackMutation } from 'pinia' +import type { StateTree, SubscriptionCallback } from 'pinia' import { onUnmounted } from 'vue' let unsub = null let unsub2 = null +function importAwareAutosave(save: (data: S) => Promise) { + let generation = 0 + let resetting = false + const deferred = debounce((data: S, scheduledGeneration: number) => { + if (scheduledGeneration === generation) return save(data) + }, 1000) + const callback: SubscriptionCallback = (mutation, data) => { + if (resetting) return + if (data._ignoreWatch) { + // Consume import/rollback suppression before a genuine edit can join the debounce window. + generation++ + resetting = true + try { + data._ignoreWatch = false + } finally { + resetting = false + } + return + } + if (mutation.type === 'direct' && mutation.events?.key === '_ignoreWatch') return + deferred(data, generation) + } + return { callback, cancel: () => generation++ } +} + export function useInit() { const store = useBaseStore() const settingStore = useSettingStore() @@ -67,51 +92,44 @@ export function useInit() { } settingStore.load = true store.load = true + // Imported records may contain this runtime-only flag; initial hydration has no subscribers. + store._ignoreWatch = false + settingStore._ignoreWatch = false console.timeEnd('init') initializing = false // 初始化完成,允许保存数据 //等数据全部准备好,再开启监听,避免循环保存-同步 document.addEventListener('visibilitychange', onvisibilitychange) //用 $subscribe 替代 watch - unsub = store.$subscribe( - debounce(async (mutation, data: BaseState) => { - if (fetching || !focus || runtimeStore.globalLoading || restoreFetching) return - if (data._ignoreWatch) { - data._ignoreWatch = false - return - } - if (mutation.type === 'direct' && mutation.events?.key === '_ignoreWatch') { - return - } - console.log('store.$subscribe', mutation, data, data._ignoreWatch) - fetching = true - try { - await dataSync.saveDictState(data) - } finally { - fetching = false - } - }, 1000) - ) + const dictAutosave = importAwareAutosave(async (data: BaseState) => { + if (fetching || !focus || runtimeStore.globalLoading || restoreFetching) return + fetching = true + try { + await dataSync.saveDictState(data) + } finally { + fetching = false + } + }) + const stopDict = store.$subscribe(dictAutosave.callback, { flush: 'sync' }) + unsub = () => { + dictAutosave.cancel() + stopDict() + } - unsub2 = settingStore.$subscribe( - debounce(async (mutation: SubscriptionCallbackMutation, data: SettingState) => { - if (fetching2 || !focus || runtimeStore.globalLoading || restoreFetching) return - console.log('settingStore.$subscribe', mutation, data, data._ignoreWatch) - if (data._ignoreWatch) { - data._ignoreWatch = false - return - } - if (mutation.type === 'direct' && mutation.events?.key === '_ignoreWatch') { - return - } - fetching2 = true - try { - await dataSync.saveLocalAndSync(SyncDataType.setting, data) - } finally { - fetching2 = false - } - }, 1000) - ) + const settingAutosave = importAwareAutosave(async (data: SettingState) => { + if (fetching2 || !focus || runtimeStore.globalLoading || restoreFetching) return + fetching2 = true + try { + await dataSync.saveLocalAndSync(SyncDataType.setting, data) + } finally { + fetching2 = false + } + }) + const stopSetting = settingStore.$subscribe(settingAutosave.callback, { flush: 'sync' }) + unsub2 = () => { + settingAutosave.cancel() + stopSetting() + } runtimeStore.isNew = APP_VERSION.version > Number(settingStore.webAppVersion) // runtimeStore.isNew = true diff --git a/app/core/config/env.ts b/app/core/config/env.ts index 40f26e1a3..e9339f22d 100644 --- a/app/core/config/env.ts +++ b/app/core/config/env.ts @@ -89,10 +89,10 @@ export const TourConfig = { } export const LIB_JS_URL = { - SHEPHERD: `${ENV.LIBS_URL}/Shepherd.14.5.1.mjs.js`, - SNAPDOM: `${ENV.LIBS_URL}/snapdom.min.js`, - JSZIP: `${ENV.LIBS_URL}/jszip.min.js`, - XLSX: `${ENV.LIBS_URL}/xlsx.full.min.js`, + SHEPHERD: `${ENV.LIBS_URL}Shepherd.14.5.1.mjs.js`, + SNAPDOM: `${ENV.LIBS_URL}snapdom.min.js`, + JSZIP: `${ENV.LIBS_URL}jszip.min.js`, + XLSX: `${ENV.LIBS_URL}xlsx.full.min.js`, } export const PronunciationApi = 'https://dict.youdao.com/dictvoice?audio=' export const DefaultShortcutKeyMap = { diff --git a/app/core/hooks/event.ts b/app/core/hooks/event.ts index 13296fbe9..53c852f3b 100644 --- a/app/core/hooks/event.ts +++ b/app/core/hooks/event.ts @@ -205,7 +205,8 @@ export function useEventListener(type: string, listener: EventListenerOrEventLis char = 'Backspace' keyCode = 8 } else { - char = target?.value?.slice(-1) || (event as any).data?.slice(-1) + char = target.value.slice(-1) || event.data?.slice(-1) || '' + if (!char) return keyCode = char === ' ' ? 32 : char.toUpperCase().charCodeAt(0) } if (emitWindowsKeys.has(char)) return @@ -232,7 +233,8 @@ export function useEventListener(type: string, listener: EventListenerOrEventLis // if (e.code in CODE_TO_CHAR && !e.ctrlKey && !e.metaKey) return // return // console.log('windowListener', Date.now(), e) - if (e.key === 'Process') { + // IME editing/confirmation keys belong to the composition, not the practice shortcuts. + if (e.isComposing || e.key === 'Process') { // @ts-ignore // e.key = CODE_TO_CHAR[e.code] //todo 这里不能直接设置值,会报错,后续可以用合成事件优化 diff --git a/app/core/hooks/export.ts b/app/core/hooks/export.ts index 079ce8455..c01d39719 100644 --- a/app/core/hooks/export.ts +++ b/app/core/hooks/export.ts @@ -9,7 +9,7 @@ import { SAVE_SETTING_KEY, } from '../config/env' import { get } from 'idb-keyval' -import saveAs from 'file-saver' +import { saveBackup } from '../platform/desktop' import dayjs from 'dayjs' import { Toast } from '@/base' import { useBaseStore } from '../stores/base' @@ -20,6 +20,7 @@ import { usePracticeArticlePersistence, usePracticeWordPersistence } from '../co import type { BackupData } from '../types' export function useExport() { + const isDesktop = useRuntimeConfig().public.isDesktop const store = useBaseStore() const settingStore = useSettingStore() @@ -63,14 +64,14 @@ export function useExport() { async function exportData( notice = '导出成功!', - fileName = `${APP_NAME}-User-Data-${dayjs().format('YYYY-MM-DD HH-mm-ss')}.zip`, + fileName = `${APP_NAME}-User-Data-${dayjs().format('YYYY-MM-DD HH-mm-ss')}.zip` ) { if (loading.value) return loading.value = true try { const content = await buildExportZip() - saveAs(content, fileName) + if (!(await saveBackup(content, fileName, isDesktop))) return notice && Toast.success(notice) return content } catch (e: any) { diff --git a/app/core/hooks/sound.ts b/app/core/hooks/sound.ts index 31fd01a79..0c67ff348 100644 --- a/app/core/hooks/sound.ts +++ b/app/core/hooks/sound.ts @@ -150,6 +150,7 @@ export function resetActiveWordPlayCount(word: string) { } let cachedWordAudio: HTMLAudioElement | null = null +let clearWordNetworkPlayback: (() => void) | null = null let wordPlaybackGeneration = 0 let ttsPlaybackGeneration = 0 @@ -163,6 +164,8 @@ function getCachedWordAudio(): HTMLAudioElement | null { export function cancelWordPracticeAudio() { wordPlaybackGeneration++ ttsPlaybackGeneration++ + clearWordNetworkPlayback?.() + clearWordNetworkPlayback = null if (typeof speechSynthesis !== 'undefined') { speechSynthesis.pause() speechSynthesis.cancel() @@ -171,6 +174,7 @@ export function cancelWordPracticeAudio() { if (wordAudio) { wordAudio.onended = null wordAudio.onerror = null + wordAudio.onplay = null wordAudio.pause() wordAudio.currentTime = 0 } @@ -213,34 +217,87 @@ export function usePlayWordAudio() { if (settingStore.soundType === 'uk') { url = `${PronunciationApi}${word}&type=1` } - let onended = () => { - if (generation !== wordPlaybackGeneration) return + let completed = false + let fallingBack = false + let waitTimer: ReturnType | undefined + const isCurrent = () => generation === wordPlaybackGeneration && !completed + const cleanup = () => { + clearTimeout(waitTimer) + wordAudio.onended = null + wordAudio.onerror = null + wordAudio.onplay = null + wordAudio.onplaying = null + wordAudio.ontimeupdate = null + if (clearWordNetworkPlayback === cleanup) clearWordNetworkPlayback = null + } + clearWordNetworkPlayback = cleanup + const finish = () => { + if (!isCurrent()) return + completed = true + cleanup() onEnd?.() } - wordAudio.onended = onended - wordAudio.onplay = () => onPlay?.() - wordAudio.onerror = () => { - if (generation !== wordPlaybackGeneration) return + const fallback = () => { + if (!isCurrent() || fallingBack) return + fallingBack = true + cleanup() + wordAudio.pause() const ttsPlay = useTTsPlayAudio() - ttsPlay(word, { rate: playbackRate, onEnd: onended }) + ttsPlay(word, { rate: playbackRate, onEnd: finish }) + } + const waitForProgress = () => { + if (!isCurrent() || fallingBack) return + clearTimeout(waitTimer) + // Bound both initial loading and a network stall, not the clip's duration. + waitTimer = setTimeout(fallback, 8000) + } + let lastTime = 0 + wordAudio.onended = () => { + if (!fallingBack) finish() + } + wordAudio.onplay = () => { + if (isCurrent() && !fallingBack) onPlay?.() + } + wordAudio.onplaying = waitForProgress + wordAudio.ontimeupdate = () => { + if (wordAudio.currentTime > lastTime) { + lastTime = wordAudio.currentTime + waitForProgress() + } } + wordAudio.onerror = fallback wordAudio.src = url wordAudio.volume = settingStore.wordSoundVolume / 100 wordAudio.playbackRate = playbackRate - void wordAudio.play() + waitForProgress() + try { + void wordAudio.play().catch(fallback) + } catch { + fallback() + } } return playAudio } -function getVoicesAsync() { +function getVoicesAsync(): Promise { return new Promise(resolve => { const voices = speechSynthesis.getVoices() if (voices.length) return resolve(voices) - speechSynthesis.onvoiceschanged = () => { - resolve(speechSynthesis.getVoices()) + // Some WebViews never emit voiceschanged when no voice pack is available. + const finish = (available: SpeechSynthesisVoice[]) => { + clearTimeout(timer) + speechSynthesis.removeEventListener('voiceschanged', onVoicesChanged) + resolve(available) + } + const onVoicesChanged = () => { + const available = speechSynthesis.getVoices() + if (available.length) finish(available) } + const timer = setTimeout(() => finish(speechSynthesis.getVoices()), 1500) + speechSynthesis.addEventListener('voiceschanged', onVoicesChanged) + onVoicesChanged() }) } @@ -256,18 +313,35 @@ export function useTTsPlayAudio() { const settingStore = useSettingStore() function play(text: string, options: TTsPlayOptions = {}) { - if (!text || typeof speechSynthesis === 'undefined') return const generation = ++ttsPlaybackGeneration + let completed = false + const finish = () => { + if (completed || generation !== ttsPlaybackGeneration) return + completed = true + options.onEnd?.() + } + if (!text) return finish() + if (typeof speechSynthesis === 'undefined' || typeof SpeechSynthesisUtterance === 'undefined') { + Toast.warning('当前环境暂无可用语音,请继续手动练习') + return finish() + } speechSynthesis.cancel() // 防止 Chrome 队列卡死 let msg = new SpeechSynthesisUtterance(text) msg.rate = options.rate ?? settingStore.wordSoundSpeed msg.volume = options.volume ?? settingStore.wordSoundVolume / 100 msg.pitch = options.pitch ?? 1 msg.lang = options.lang ?? 'en-US' - msg.onend = () => options.onEnd?.() - msg.onerror = () => options.onEnd?.() - getVoicesAsync().then((voices: any[]) => { + msg.onend = finish + msg.onerror = finish + getVoicesAsync().then(voices => { if (generation !== ttsPlaybackGeneration) return + if (!voices.length) { + Toast.warning('当前环境暂无可用语音,请继续手动练习') + finish() + return + } + // cancelWordPracticeAudio pauses the shared synthesis engine. + speechSynthesis.resume() // 优先使用用户在当前浏览器配置的声色 const browserKey = getBrowserKey() const savedVoiceName = settingStore?.ttsVoiceMap?.find(v => v.key === browserKey)?.voice @@ -279,11 +353,18 @@ export function useTTsPlayAudio() { return } } - // 回退:优先找 Emma / US,否则取第一个英文声色 - let voiceList = voices.filter(v => v.lang === 'en-US') - if (voiceList && voiceList.length) { - msg.voice = voiceList.find(v => v.name.includes('US') || v.name.includes('Emma')) ?? voiceList[0] + // Prefer the requested locale, then another locale of the same language. + const language = msg.lang.toLowerCase() + const exactVoices = voices.filter(v => v.lang.toLowerCase() === language) + const voiceList = exactVoices.length + ? exactVoices + : voices.filter(v => v.lang.toLowerCase().split('-')[0] === language.split('-')[0]) + if (!voiceList.length) { + Toast.warning(`当前环境暂无 ${msg.lang} 语音,请继续手动练习`) + finish() + return } + msg.voice = voiceList.find(v => v.name.includes('US') || v.name.includes('Emma')) ?? voiceList[0] speechSynthesis.speak(msg) }) } diff --git a/app/core/hooks/translate.ts b/app/core/hooks/translate.ts index 5a9305d8f..1a83b087f 100644 --- a/app/core/hooks/translate.ts +++ b/app/core/hooks/translate.ts @@ -39,6 +39,9 @@ export async function getNetworkTranslate( allShow: boolean = false, progressCb?: (val: number) => void ) { + // /baidu is a development proxy, not a configured desktop service. + if (useRuntimeConfig().public.isDesktop) return false + let translator: Translator if (translateEngine === TranslateEngine.Baidu) { translator = new Baidu({ diff --git a/app/core/platform/accountPersistence.ts b/app/core/platform/accountPersistence.ts new file mode 100644 index 000000000..10a5a9de7 --- /dev/null +++ b/app/core/platform/accountPersistence.ts @@ -0,0 +1,36 @@ +import { createStore, setMany, type UseStore } from 'idb-keyval' +import type { AccountSyncScope } from './accountSync' + +let accountStore: UseStore | undefined + +// Use the existing idb-keyval database, not a separate account data store. +export async function setManyForAccount( + entries: [IDBValidKey, unknown][], + scope: AccountSyncScope, + store: UseStore = (accountStore ??= createStore('keyval-store', 'keyval')) +): Promise { + scope.assertCurrent() + await setMany(entries, (mode, callback) => + store(mode, objectStore => { + // Opening the database is asynchronous too. + scope.assertCurrent() + const transaction = objectStore.transaction + const abort = () => { + try { + transaction.abort() + } catch { + // A completed transaction cannot be undone; the post-await guard still applies. + } + } + scope.signal.addEventListener('abort', abort, { once: true }) + try { + return Promise.resolve(callback(objectStore)).finally(() => scope.signal.removeEventListener('abort', abort)) + } catch (error) { + abort() + scope.signal.removeEventListener('abort', abort) + throw error + } + }) + ) + scope.assertCurrent() +} diff --git a/app/core/platform/accountSync.ts b/app/core/platform/accountSync.ts new file mode 100644 index 000000000..c06a8f060 --- /dev/null +++ b/app/core/platform/accountSync.ts @@ -0,0 +1,249 @@ +import { createClient, type Session } from '@supabase/supabase-js' +import { validatedSyncUrl } from './sync' + +const SYNC_TYPES = ['dict', 'setting', 'practice_word', 'practice_article'] as const +type SyncType = (typeof SYNC_TYPES)[number] +export type AccountSyncRow = { + type: SyncType + data: unknown + data_version: number + updated_at: string +} +export type AccountSyncMode = 'read' | 'initial-upload' | 'automatic' + +export class StaleAccountSyncError extends Error { + constructor() { + super('同步账号或会话已改变,请重新操作') + this.name = 'StaleAccountSyncError' + } +} + +/** + * Account transport only. Callers must also assertCurrent after their own awaits, + * before applying remote data, committing local transactions or updating UI status. + * Disposing cannot undo a write already accepted by the server. + */ +export function createAccountSync(url: string, key: string, desktop: boolean) { + const projectUrl = validatedSyncUrl(url, desktop) + const authClient = createClient(projectUrl, key) + let session: Session | null = null + let revision = 0 + let operation = 0 + let pendingSignIn: number | null = null + let busy: 'sign-in' | 'sign-out' | null = null + let blocked = false + let disposed = false + let active = false + let lifetime = new AbortController() + const completedTransfers = new WeakSet() + + function advanceRevision() { + revision++ + const previous = lifetime + lifetime = new AbortController() + previous.abort() + } + + function invalidate() { + session = null + active = false + advanceRevision() + } + + function acceptSession(next: Session | null) { + const valid = next?.user?.id && next.access_token && !next.user.is_anonymous ? next : null + if (session?.user.id !== valid?.user.id || session?.access_token !== valid?.access_token) { + if (session?.user.id !== valid?.user.id) active = false + session = valid + advanceRevision() + } else { + session = valid + } + } + + // No async SDK calls in the auth callback. + const { + data: { subscription }, + } = authClient.auth.onAuthStateChange((event, next) => { + if (disposed || blocked) return + if (event === 'INITIAL_SESSION' && (operation > 0 || revision > 0)) return + if (busy) { + if (busy === 'sign-in' && event === 'SIGNED_OUT') { + operation++ + invalidate() + blocked = true + } + return + } + acceptSession(event === 'SIGNED_OUT' ? null : next) + }) + + const initialRevision = revision + const ready = authClient.auth.getSession().then(({ data, error }) => { + if (disposed || busy || blocked || revision !== initialRevision) return + if (error) { + invalidate() + blocked = true + throw error + } + acceptSession(data.session) + }) + // Consumers can await ready for errors; do not create an unhandled rejection on construction. + void ready.catch(() => {}) + + function assertOpen() { + if (disposed) throw new StaleAccountSyncError() + } + + async function signIn(email: string, password: string) { + assertOpen() + if (busy) throw new Error('账号操作尚未结束') + const currentOperation = ++operation + pendingSignIn = currentOperation + busy = 'sign-in' + blocked = false + invalidate() + try { + const { data, error } = await authClient.auth.signInWithPassword({ email, password }) + if (disposed || operation !== currentOperation) throw new StaleAccountSyncError() + if (error) throw error + if (!data.session?.user.id || data.session.user.is_anonymous) throw new Error('需要非匿名账号登录') + acceptSession(data.session) + } catch (error) { + if (!disposed && operation === currentOperation) { + invalidate() + blocked = true + } + throw error + } finally { + if (pendingSignIn === currentOperation) { + pendingSignIn = null + if (busy === 'sign-in') busy = null + } + } + } + + async function signOut() { + assertOpen() + const currentOperation = ++operation + busy = 'sign-out' + blocked = true + // Revoke application access immediately, even if SDK logout waits on a login or fails offline. + invalidate() + try { + const { error } = await authClient.auth.signOut({ scope: 'local' }) + if (disposed || operation !== currentOperation) throw new StaleAccountSyncError() + if (error) throw error + } finally { + if (operation === currentOperation) busy = null + // Remain blocked until an explicit sign-in. A failed logout may retain SDK storage. + } + } + + function capture(mode: AccountSyncMode = 'read') { + assertOpen() + if (!['read', 'initial-upload', 'automatic'].includes(mode)) throw new Error('无效的同步模式') + if (busy || blocked || !session) throw new Error('请先登录同步账号') + if (mode === 'automatic' && !active) throw new Error('请先明确选择首次同步方向') + const capturedRevision = revision + const userId = session.user.id + const token = session.access_token + const assertCurrent = () => { + if (disposed || blocked || busy || capturedRevision !== revision) throw new StaleAccountSyncError() + } + // Do not ask a shared auth client for a token after an await: it may now belong to another user. + const client = createClient(projectUrl, key, { + accessToken: async () => { + assertCurrent() + return token + }, + }) + + function validateTypes(types: readonly SyncType[]) { + if (!types.length || new Set(types).size !== types.length || types.some(type => !SYNC_TYPES.includes(type))) { + throw new Error('无效的同步数据类型') + } + } + + const scope = { + userId, + signal: lifetime.signal, + assertCurrent, + async read(types: readonly SyncType[], metadataOnly = false) { + assertCurrent() + const requestedTypes = [...types] + validateTypes(requestedTypes) + const table = client.from('typewords_data') + const query = metadataOnly + ? table.select('user_id,type,updated_at,data_version') + : table.select('user_id,type,data,updated_at,data_version') + const { data, error } = await query.eq('user_id', userId).in('type', requestedTypes) + assertCurrent() + if (error) throw error + if ( + !Array.isArray(data) || + data.some(row => !row || row.user_id !== userId || !requestedTypes.includes(row.type)) || + new Set(data.map(row => row.type)).size !== data.length + ) { + throw new Error('远端账号数据无效') + } + // Empty/partial data may be inspected, but must never enable automatic writes. + if (!metadataOnly && requestedTypes.length === SYNC_TYPES.length && data.length === requestedTypes.length) { + completedTransfers.add(scope) + } + return data + }, + async upsert(rows: readonly AccountSyncRow[]) { + assertCurrent() + if (mode === 'read') throw new Error('只读同步操作不能上传') + validateTypes(rows.map(row => row.type)) + // Snapshot before the SDK awaits its token; caller mutations cannot change ownership or payload. + const payload = structuredClone(rows).map(row => ({ + type: row.type, + data: row.data, + data_version: row.data_version, + updated_at: row.updated_at, + user_id: userId, + })) + const { error } = await client.from('typewords_data').upsert(payload, { onConflict: 'user_id,type' }) + assertCurrent() + if (error) throw error + if (payload.length === SYNC_TYPES.length) completedTransfers.add(scope) + }, + } + return scope + } + + return { + ready, + signIn, + signOut, + capture, + getState() { + return { + userId: session?.user.id ?? null, + email: session?.user.email ?? null, + busy, + active, + } + }, + // Invoke only after payload validation AND the caller's local commit have succeeded. + completeInitialSync(scope: ReturnType) { + scope.assertCurrent() + if (!completedTransfers.has(scope)) throw new Error('首次同步尚未成功完成') + active = true + }, + dispose() { + if (disposed) return + disposed = true + operation++ + busy = null + invalidate() + subscription.unsubscribe() + void authClient.auth.stopAutoRefresh().catch(() => {}) + }, + } +} + +export type AccountSync = ReturnType +export type AccountSyncScope = ReturnType diff --git a/app/core/platform/desktop.ts b/app/core/platform/desktop.ts new file mode 100644 index 000000000..4157e10a9 --- /dev/null +++ b/app/core/platform/desktop.ts @@ -0,0 +1,116 @@ +/** Save through a native picker only on Desktop; Web retains file-saver. */ +export async function saveBackup(content: Blob, fileName: string, desktop: boolean): Promise { + if (!desktop) { + const { default: saveAs } = await import('file-saver') + saveAs(content, fileName) + return true + } + const { save } = await import('@tauri-apps/plugin-dialog') + const path = await save({ + defaultPath: fileName, + filters: [{ name: 'TypeWords backup', extensions: ['zip'] }], + }) + if (path === null) return false + const { writeFile } = await import('@tauri-apps/plugin-fs') + await writeFile(path, new Uint8Array(await content.arrayBuffer())) + return true +} + +// Existing product destinations only. Keep native capability scope in sync. +export const EXTERNAL_HOSTS = [ + 'github.com', + 'typewords.cc', + '2study.top', + 'enpuz.com', + 'www.youdao.com', + 'v.wjx.cn', + 'www.google.cn', + 'chromewebstore.google.com', + 'microsoftedge.microsoft.com', + 'supabase.com', + 'www.kdocs.cn', + 'x.com', + 'pan.quark.cn', + 'v.v8l.cn', +] as const + +// Product About/feedback mailbox only. Keep native capability scope in sync. +export const EXTERNAL_MAILS = ['zyronon@163.com'] as const + +function mailtoAddress(url: URL): string { + const raw = decodeURIComponent((url.pathname || url.href.replace(/^mailto:/i, '')).replace(/^\//, '')) + return raw.split('?')[0] +} + +export function validatedExternalUrl(value: string): string { + const url = new URL(value) + if (url.protocol === 'mailto:') { + const address = mailtoAddress(url) + if (url.username || url.password || url.port || url.search || url.hash) { + throw new Error('桌面版已阻止不受信任的外部链接') + } + if (!(EXTERNAL_MAILS as readonly string[]).includes(address)) { + throw new Error('桌面版已阻止不受信任的外部链接') + } + return `mailto:${address}` + } + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.port || + !(EXTERNAL_HOSTS as readonly string[]).includes(url.hostname) + ) { + throw new Error('桌面版已阻止不受信任的外部链接') + } + return url.href +} + +export async function openExternal(value: string): Promise { + const url = validatedExternalUrl(value) + const { openUrl } = await import('@tauri-apps/plugin-opener') + await openUrl(url) +} + +/** Desktop-only interception; the native host independently denies remote navigation/popups. */ +export function installDesktopLinks( + win: Window, + doc: Document, + open: (url: string) => Promise = openExternal, + onError: (error: unknown) => void = console.error +): () => void { + const originalOpen = win.open + const navigate = (value: string) => { + try { + const url = new URL(value, win.location.href) + if (url.origin === win.location.origin && url.protocol === win.location.protocol) { + win.location.assign(url.href) + } else { + void open(validatedExternalUrl(url.href)).catch(onError) + } + } catch (error) { + onError(error) + } + } + win.open = ((url?: string | URL) => { + if (url) navigate(String(url)) + return null + }) as typeof win.open + const handleClick = (event: MouseEvent) => { + if (event.defaultPrevented || (event.type === 'auxclick' && event.button !== 1)) return + const anchor = (event.target as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null + if (!anchor) return + const url = new URL(anchor.href, win.location.href) + if (url.origin === win.location.origin && url.protocol === win.location.protocol) return + event.preventDefault() + event.stopImmediatePropagation() + navigate(url.href) + } + doc.addEventListener('click', handleClick, true) + doc.addEventListener('auxclick', handleClick, true) + return () => { + win.open = originalOpen + doc.removeEventListener('click', handleClick, true) + doc.removeEventListener('auxclick', handleClick, true) + } +} diff --git a/app/core/platform/sync.ts b/app/core/platform/sync.ts new file mode 100644 index 000000000..addf6bdbf --- /dev/null +++ b/app/core/platform/sync.ts @@ -0,0 +1,29 @@ +import { createClient } from '@supabase/supabase-js' + +/** Desktop permits hosted project origins only; keep connect-src in sync. */ +export function validatedSyncUrl(value: string, desktop: boolean): string { + if (!desktop) return value + let url: URL + try { + url = new URL(value.trim()) + } catch { + throw new Error('桌面同步需要有效的 HTTPS Supabase 项目地址') + } + if ( + url.protocol !== 'https:' || + !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.supabase\.co$/.test(url.hostname) || + url.username || + url.password || + url.port || + url.pathname !== '/' || + url.search || + url.hash + ) { + throw new Error('桌面同步仅支持 https://<项目>.supabase.co,不支持自托管、代理路径或自定义域名') + } + return url.origin +} + +export function createSyncClient(url: string, key: string, desktop: boolean): ReturnType { + return createClient(validatedSyncUrl(url, desktop), key) +} diff --git a/app/core/utils/http.ts b/app/core/utils/http.ts index f7f54b772..4d4397365 100644 --- a/app/core/utils/http.ts +++ b/app/core/utils/http.ts @@ -1,6 +1,6 @@ import axios from 'axios' import type { AxiosInstance } from 'axios' -import { AppEnv, ENV } from '../config/env.ts' +import { ENV } from '../config/env.ts' import { Toast } from '@/base' export const axiosInstance: AxiosInstance = axios.create({ @@ -9,7 +9,30 @@ export const axiosInstance: AxiosInstance = axios.create({ axiosInstance.interceptors.request.use( config => { - config.baseURL = ENV.API + const runtime = useRuntimeConfig().public + if (runtime.isDesktop) { + // Static desktop builds have no Nitro proxy or implicit localhost API. + let endpoint: URL | undefined + try { + endpoint = new URL(String(runtime.desktopApiBase || '').trim()) + } catch {} + if ( + !endpoint || + endpoint.protocol !== 'https:' || + /^(localhost\.?|127(?:\.\d+){3}|\[::1\])$/i.test(endpoint.hostname) || + endpoint.username || + endpoint.password || + endpoint.search || + endpoint.hash + ) { + throw Object.assign(new Error('桌面在线查询 API 未配置有效的 HTTPS 服务,当前功能未启用'), { + code: 'DESKTOP_API_UNAVAILABLE', + }) + } + config.baseURL = endpoint.href.replace(/\/?$/, '/') + } else { + config.baseURL = ENV.API + } return config }, error => Promise.reject(error) @@ -43,6 +66,10 @@ axiosInstance.interceptors.response.use( }, // 请求出错的处理 error => { + if (error.code === 'DESKTOP_API_UNAVAILABLE') { + Toast.warning(error.message) + return Promise.resolve({ code: 503, msg: error.message, data: null, success: false }) + } if (error.response === undefined && error.status === undefined) { return Promise.resolve({ code: 500, diff --git a/app/core/utils/supabase.ts b/app/core/utils/supabase.ts index 3870bc10f..556538259 100644 --- a/app/core/utils/supabase.ts +++ b/app/core/utils/supabase.ts @@ -1,6 +1,7 @@ import { createClient } from '@supabase/supabase-js' import { Toast } from '@/base' import { useRuntimeStore } from '../stores' +import { createSyncClient, validatedSyncUrl } from '../platform/sync' export const SUPABASE_CONFIG_KEY = 'supabase_config' @@ -53,26 +54,53 @@ export class Supabase { static supabaseKey = '' static errorCount = 0 - /** 是否允许执行同步:仅当 config 存在、url/key 有值且 status === 'success' 时返回 true */ - static check(): boolean { + static isEnabled(): boolean { + return !useRuntimeConfig().public.isDesktop + } + + /** 写入仍要求 success;失败后的只读重试须由调用方显式允许。 */ + static check(allowReadRetry = false): boolean { + if (!this.isEnabled()) return false const c = getConfig() if (!c?.url || !c?.key) return false - if (c.status !== 'success') return false - this.supabaseUrl = c.url - this.supabaseKey = c.key + if (c.status !== 'success' && !(allowReadRetry && c.status === 'error')) return false + try { + validatedSyncUrl(c.url, Boolean(useRuntimeConfig().public.isDesktop)) + } catch (error) { + this.setStatus('error', (error as Error).message) + return false + } return true } static saveConfig(url: string, key: string): void { setConfig({ url, key }) + this.instance = null } static removeConfig(): void { localStorage.removeItem(SUPABASE_CONFIG_KEY) + this.instance = null + this.supabaseUrl = '' + this.supabaseKey = '' } /** 拿到客户端;仅根据 url/key 建连,不依赖 status(供设置页保存配置时验表使用) */ static getInstance(): ReturnType { + if (!this.isEnabled()) throw new Error('本地桌面版暂不提供云同步') + const config = getConfig() + // Never retain a client for credentials replaced or removed in another settings flow. + if (config?.url !== this.supabaseUrl || config?.key !== this.supabaseKey) this.instance = null + if (useRuntimeConfig().public.isDesktop) { + if (!config) throw new Error('尚未配置桌面同步') + const url = validatedSyncUrl(config.url, true) + if (!this.instance) { + this.instance = createSyncClient(url, config.key, true) + this.supabaseUrl = config.url + this.supabaseKey = config.key + } + return this.instance + } if (!Supabase.instance) { const c = getConfig() if (c?.url && c?.key) { @@ -108,6 +136,7 @@ export class Supabase { } static getStatus(): { status: SupabaseStatus; statusMessage?: string } { + if (!this.isEnabled()) return { status: 'idle' } const c = getConfig() return { status: c?.status ?? 'idle', @@ -116,6 +145,7 @@ export class Supabase { } static setStatus(status: SupabaseStatus, statusMessage?: string): void { + if (!this.isEnabled()) return if (status === 'error') { // debugger //如果是请求错误,则可重试3次再报错,因为会有很多误判 @@ -130,6 +160,6 @@ export class Supabase { } const runtimeStore = useRuntimeStore() runtimeStore.isError = status === 'error' - setConfig({ status, statusMessage }) + setConfig({ status, statusMessage: statusMessage ?? (status === 'success' ? '' : undefined) }) } } diff --git a/app/pages/(words)/practice-words/[id].vue b/app/pages/(words)/practice-words/[id].vue index e8e9c61f0..2a1d0cda9 100644 --- a/app/pages/(words)/practice-words/[id].vue +++ b/app/pages/(words)/practice-words/[id].vue @@ -7,7 +7,7 @@ import { useRuntimeStore } from '@/core/stores/runtime.ts' import type { Dict, TaskWords, Word } from '@/core/types/types.ts' import { useStartKeyboardEventListener } from '@/core/hooks/event.ts' import useTheme from '@/core/hooks/theme.ts' -import { _getDictDataByUrl, resourceWrap, shuffle, throttle } from '@/core/utils' +import { _getDictDataByUrl, isDictIdMatch, resourceWrap, shuffle, throttle } from '@/core/utils' import { useRoute, useRouter } from 'vue-router' import Footer from '@/components/word/Footer.vue' import Panel from '@/components/Panel.vue' @@ -26,6 +26,7 @@ import { getDefaultPracticeData, type PracticeData, UnsupportedPracticeCacheVersionError, + RemoteDataValidationError, usePracticeWordPersistence, } from '@/core/composables/practice-words/practice-word-session.ts' import { useDataSyncPersistence } from '@/core/composables/useDataSyncPersistence.ts' @@ -254,10 +255,10 @@ async function loadDict() { let dictId = route.params.id if (dictId) { //先在自己的词典列表里面找,如果没有再在资源列表里面找 - dict = store.word.bookList.find(v => v.id === dictId) + dict = store.word.bookList.find(v => isDictIdMatch(v, dictId)) let r = await fetch(resourceWrap(DICT_LIST.WORD.ALL)) let dict_list = await r.json() - if (!dict) dict = dict_list.flat().find(v => v.id === dictId) as Dict + if (!dict) dict = dict_list.flat().find(v => isDictIdMatch(v, dictId)) as Dict if (dict && dict.id) { //如果是不是自定义词典,就请求数据 if (!dict.custom) dict = await _getDictDataByUrl(dict) @@ -285,8 +286,13 @@ async function initData(initVal?: TaskWords, init: boolean = false) { try { d = await wordPersistence.load() } catch (error) { - if (!(error instanceof UnsupportedPracticeCacheVersionError)) throw error - Toast.error('练习缓存来自更高版本,请升级后再继续') + if (!(error instanceof UnsupportedPracticeCacheVersionError) && !(error instanceof RemoteDataValidationError)) + throw error + Toast.error( + error instanceof UnsupportedPracticeCacheVersionError + ? '练习缓存来自更高版本,请升级后再继续' + : '远端练习缓存损坏,已停止加载,请修复同步数据后重试' + ) await router.push('/words') return } @@ -312,7 +318,7 @@ async function initData(initVal?: TaskWords, init: boolean = false) { // 初始化 Question let dictId: any = route.params.id - let d = store.word.bookList.find(v => v.id === dictId) + let d = store.word.bookList.find(v => isDictIdMatch(v, dictId)) if (!d) d = store.sdict if (!d?.id) return router.push('/words') allWords = shuffle(d.words) diff --git a/app/pages/(words)/words.vue b/app/pages/(words)/words.vue index c2f261b3c..94206dc42 100644 --- a/app/pages/(words)/words.vue +++ b/app/pages/(words)/words.vue @@ -56,6 +56,7 @@ import { WordPracticeMode } from '@/core/types/enum.ts' import { type PracticeWordCache, UnsupportedPracticeCacheVersionError, + RemoteDataValidationError, usePracticeWordPersistence, } from '@/core/composables/practice-words/practice-word-session.ts' import dayjs from 'dayjs' @@ -71,15 +72,22 @@ const { nav } = useNav() const runtimeStore = useRuntimeStore() let loading = $ref(true) let isSaveData = $ref(false) -let unsupportedCacheVersion = false +let blockedPracticeCache = false async function loadPracticeCache() { try { - return await wordPersistence.load() + const cache = await wordPersistence.load() + blockedPracticeCache = false + return cache } catch (error) { - if (!(error instanceof UnsupportedPracticeCacheVersionError)) throw error - unsupportedCacheVersion = true - Toast.error('练习缓存来自更高版本,请升级后再继续') + if (!(error instanceof UnsupportedPracticeCacheVersionError) && !(error instanceof RemoteDataValidationError)) + throw error + blockedPracticeCache = true + Toast.error( + error instanceof UnsupportedPracticeCacheVersionError + ? '练习缓存来自更高版本,请升级后再继续' + : '远端练习缓存损坏,已停止加载,请修复同步数据后重试' + ) return null } } @@ -152,7 +160,7 @@ const reviewWordTip = $computed(() => { }) async function resetCacheData() { - if (unsupportedCacheVersion) return + if (blockedPracticeCache) return isSaveData && flushStatToStore(practiceData.statStoreData) isSaveData = false practiceData.practiceData = null @@ -248,7 +256,7 @@ async function init() { if (d) { practiceData = d isSaveData = true - } else if (!unsupportedCacheVersion) { + } else if (!blockedPracticeCache) { refreshStudyTask() } } @@ -256,8 +264,8 @@ async function init() { } async function startPractice(practiceMode: WordPracticeMode, resetCache: boolean = false): Promise { - if (unsupportedCacheVersion) { - Toast.error('当前客户端无法读取这份练习缓存,请升级后再继续') + if (blockedPracticeCache) { + Toast.error('当前无法读取这份练习缓存,请先修复同步数据或升级客户端后重试') return } if (practiceMode === WordPracticeMode.Custom) { diff --git a/app/pages/index.vue b/app/pages/index.vue index 7335c233b..e88e061d9 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -540,15 +540,6 @@ useSeoMeta({ color: var(--hw-text); } -@font-face { - font-family: 'Garamond'; - font-style: italic; - font-weight: 700; - font-display: swap; - src: url(https://fonts.gstatic.com/l/font?kit=XoHg2Y_-T6Oo88RDZSQPp2sshj3I9QTcqzw&skey=509bbab0bec2784f&v=v18) - format('woff2'); -} - /* Hero 标题艺术字体 */ .hero-title { font-family: Garamond, Georgia, 'Times New Roman', serif; diff --git a/app/pages/setting.vue b/app/pages/setting.vue index a9d4b0e9e..7818cfc93 100644 --- a/app/pages/setting.vue +++ b/app/pages/setting.vue @@ -2,12 +2,13 @@ import { defineAsyncComponent, nextTick, ref, watch } from 'vue' import { useSettingStore } from '@/core/stores/setting' import { getShortcutKey, useEventListener } from '@/core/hooks/event' -import { checkAndUpgradeSaveDict, checkAndUpgradeSaveSetting, cloneDeep, isEmpty, loadJsLib } from '@/core/utils' +import { cloneDeep, loadJsLib } from '@/core/utils' import { BaseButton, BaseInput, BasePage, Form, FormItem, type FormType, PopConfirm, Toast, UploadButton } from '@/base' import { useBaseStore } from '@/core/stores/base' import { APP_NAME, APP_VERSION, + EXPORT_DATA_KEY, BACKUP_INDEX_KEY, DefaultShortcutKeyMap, DictId, @@ -25,13 +26,14 @@ import FsrsSetting from '@/components/setting/FsrsSetting.vue' import ArticleSetting from '@/components/setting/ArticleSetting.vue' import WordSetting from '@/components/setting/WordSetting.vue' import SoundSetting from '@/components/setting/SoundSetting.vue' -import { checkAndUpgradePracticeWordCache, PRACTICE_ARTICLE_CACHE, PRACTICE_WORD_CACHE } from '@/core/utils/cache' +import { PRACTICE_ARTICLE_CACHE, PRACTICE_WORD_CACHE } from '@/core/utils/cache' import { useDataSyncPersistence } from '@/core/composables/useDataSyncPersistence' +import { prepareBackupImport, readBackupZip, type ImportAudio } from '@/core/composables/backupImport' import SettingItem from '@/components/setting/SettingItem.vue' import { Supabase } from '@/core/utils/supabase.ts' import BackupGateDialog from '@/components/dialog/BackupGateDialog.vue' -import { createClient } from '@supabase/supabase-js' +import { createSyncClient } from '@/core/platform/sync' import { useRoute } from 'vue-router' import type { BackupData, Snapshot } from '@/core' @@ -60,7 +62,8 @@ useSeoMeta({ twitterDescription: title, }) -const tabIndex = $ref(Number(route?.query?.index ?? 0)) +const requestedTab = Number(route?.query?.index ?? 0) +const tabIndex = $ref(useRuntimeConfig().public.isDesktop && requestedTab === 6 ? 5 : requestedTab) const settingStore = useSettingStore() const runtimeStore = useRuntimeStore() const store = useBaseStore() @@ -208,108 +211,52 @@ let configLoading = $ref(false) const { loading: exportLoading, exportData, getExportedData } = useExport() -function upgradeImportedPracticeWordCache(data: BackupData['val']) { - data[PRACTICE_WORD_CACHE.key] = checkAndUpgradePracticeWordCache(data[PRACTICE_WORD_CACHE.key], data.setting.val) -} - -async function importJson(str: string) { +async function importJson(str: string, audio?: ImportAudio) { importLoading = true - let obj: BackupData = { - version: -1, - val: { - setting: {}, - dict: {}, - [PRACTICE_WORD_CACHE.key]: null, - [PRACTICE_ARTICLE_CACHE.key]: null, - // @deprecated 大版本5废弃 - [APP_VERSION.key]: null, - }, - } + const previousLoading = runtimeStore.globalLoading + runtimeStore.globalLoading = true try { - debugger - obj = JSON.parse(str) - let data = obj.val - data.dict.val = await checkAndUpgradeSaveDict(data.dict) - data.setting.val = await checkAndUpgradeSaveSetting(data.setting) - upgradeImportedPracticeWordCache(data) - //老版本兼容逻辑 - if (obj.version === 4) { - if (!isEmpty(data?.[APP_VERSION.key])) { - data.setting.val.webAppVersion = data?.[APP_VERSION.key] - } - } - //需在调同步方法前面,同步方法可能报错 - let hasRemote = Supabase.check() - runtimeStore.globalLoading = true - const pushOk = await dataSyncPersistence.forcePushLocalDataToRemote(data) - runtimeStore.globalLoading = false + const data = await prepareBackupImport(str, audio ?? (await get(LOCAL_FILE_KEY)) ?? []) + const hasRemote = Supabase.check() + const pushOk = await dataSyncPersistence.forcePushLocalDataToRemote(data, undefined, audio) if (pushOk) { Toast.success(t('import_success_overwrite_remote')) } else { Toast.success(hasRemote ? t('import_success_push_failed') : t('import_success')) } runtimeStore.isNew = APP_VERSION.version > Number(data.setting?.val?.webAppVersion ?? APP_VERSION.version) - data.setting.val.load = true - settingStore.setState(data.setting.val) - data.dict.val.load = true - store.setState(data.dict.val) showBackupGate = false } catch (err) { - return Toast.error(t('import_failed')) + Toast.error(t('import_failed') + ': ' + ((err as Error)?.message ?? String(err))) } finally { + await nextTick() + runtimeStore.globalLoading = previousLoading importLoading = false } } async function importData(e) { + if (importLoading) return importLoading = true - let file = e.target.files[0] - if (!file) return (importLoading = false) - if (file.name.endsWith('.json')) { - let reader = new FileReader() - reader.onload = function (v) { - let str: any = v.target.result - if (str) { - importJson(str) - } - } - reader.readAsText(file) - } else if (file.name.endsWith('.zip')) { - try { + const file = e.target.files?.[0] + try { + if (!file) return + if (file.name.toLowerCase().endsWith('.json')) { + await importJson(await file.text()) + } else if (file.name.toLowerCase().endsWith('.zip')) { const JSZip = await loadJsLib('JSZip', LIB_JS_URL.JSZIP) - const zip = await JSZip.loadAsync(file) - - const dataFile = zip.file('data.json') - if (!dataFile) { - return Toast.error(t('missing_data_json')) - } - - const mp3Folder = zip.folder('mp3') - if (mp3Folder) { - const records: { id: string; file: Blob }[] = [] - for (const filename in zip.files) { - if (filename.startsWith('mp3/') && filename.endsWith('.mp3')) { - const entry = zip.file(filename) - if (!entry) continue - const blob = await entry.async('blob') - const id = filename.replace(/^mp3\//, '').replace(/\.mp3$/, '') - records.push({ id, file: blob }) - } - } - await set(LOCAL_FILE_KEY, records) - } - - const str = await dataFile.async('string') - await importJson(str) - } catch (e) { - Toast.error(e?.message || e || t('import_failed')) - } finally { - importLoading = false + const zip = await JSZip.loadAsync(file, { checkCRC32: true }) + const { text, audio } = await readBackupZip(zip) + await importJson(text, audio) + } else { + Toast.error(t('unsupported_file_type')) } - } else { - Toast.error(t('unsupported_file_type')) + } catch (err) { + Toast.error(t('import_failed') + ': ' + ((err as Error)?.message ?? String(err))) + } finally { + importLoading = false + e.target.value = '' } - importLoading = false } let showBackupGate = $ref(false) @@ -353,6 +300,7 @@ function openHistoryRestoreGate(item: HistoryBackupMeta) { } function openSupabaseSaveGate() { + if (config.public.isDesktop) return sbFormRef?.validate(valid => { if (!valid) return openGate('supabase_save') @@ -363,68 +311,77 @@ async function restoreHistoryData() { if (!restoreTarget) return if (restoreLoading) return restoreLoading = true + const previousLoading = runtimeStore.globalLoading + runtimeStore.globalLoading = true try { const { data: val }: Snapshot = await get(restoreTarget.key) - debugger - let data: BackupData['val'] = { - setting: JSON.parse(val.setting), - dict: JSON.parse(val.dict), - [PRACTICE_WORD_CACHE.key]: JSON.parse(val[PRACTICE_WORD_CACHE.key]), - [PRACTICE_ARTICLE_CACHE.key]: JSON.parse(val[PRACTICE_ARTICLE_CACHE.key]), - } - data.dict.val = await checkAndUpgradeSaveDict(data.dict) - data.setting.val = await checkAndUpgradeSaveSetting(data.setting) - upgradeImportedPracticeWordCache(data) + const data = await prepareBackupImport( + JSON.stringify({ + version: EXPORT_DATA_KEY.version, + val: { + setting: JSON.parse(val.setting), + dict: JSON.parse(val.dict), + [PRACTICE_WORD_CACHE.key]: JSON.parse(String(val[PRACTICE_WORD_CACHE.key] ?? 'null')), + [PRACTICE_ARTICLE_CACHE.key]: JSON.parse(String(val[PRACTICE_ARTICLE_CACHE.key] ?? 'null')), + }, + }), + (await get(LOCAL_FILE_KEY)) ?? [] + ) //需在调同步方法前面,同步方法可能报错 let hasRemote = Supabase.check() runtimeStore.globalLoading = true const pushOk = await dataSyncPersistence.forcePushLocalDataToRemote(data) - runtimeStore.globalLoading = false if (pushOk) { Toast.success(t('history_restore_success_overwrite_remote')) } else { Toast.success(hasRemote ? t('history_restore_success_push_failed') : t('restore_success_short')) } runtimeStore.isNew = APP_VERSION.version > Number(data.setting?.val?.webAppVersion ?? APP_VERSION.version) - data.setting.val.load = true - settingStore.setState(data.setting.val) - data.dict.val.load = true - store.setState(data.dict.val) showBackupGate = false showHistoryDialog = false } catch (error) { Toast.error(t('restore_failed') + ((error as Error)?.message ?? String(error))) } finally { + await nextTick() + runtimeStore.globalLoading = previousLoading restoreLoading = false } } let tempSbInstance = null +let tempSbConfig: { url: string; key: string } | null = null async function onSbFirstSyncChoice(action: 'push_local' | 'pull_remote') { - if (sbSyncChoiceLoading) return + if (config.public.isDesktop) return false + if (sbSyncChoiceLoading || !tempSbInstance || !tempSbConfig) return false + const client = tempSbInstance + const credentials = tempSbConfig sbSyncChoiceLoading = true try { if (action === 'push_local') { let localData = await getExportedData() - const ok = await dataSyncPersistence.forcePushLocalDataToRemote(localData.val, tempSbInstance) + const ok = await dataSyncPersistence.forcePushLocalDataToRemote(localData.val, client) if (!ok) throw new Error(t('push_local_failed')) Toast.success(t('push_local_success')) } else { - const ok = await dataSyncPersistence.pullAllRemoteToLocal(tempSbInstance) + const ok = await dataSyncPersistence.pullAllRemoteToLocal(client) if (!ok) throw new Error(t('pull_remote_failed')) Toast.success(t('pull_remote_success')) } Supabase.setStatus('success') sbStatus = Supabase.getStatus() - Supabase.saveConfig(sbForm?.url, sbForm?.key) + Supabase.saveConfig(credentials.url, credentials.key) showSbFirstSyncChoiceDialog = false + tempSbInstance = null + tempSbConfig = null + return true } catch (error) { const msg = (error as Error)?.message ?? String(error) Supabase.setStatus('error', msg) sbStatus = Supabase.getStatus() Toast.error(t('sync_error_with_msg') + msg) + return false } finally { sbSyncChoiceLoading = false } @@ -482,11 +439,16 @@ const canSyncToServe = $computed(() => { }) async function doSaveSbConfig() { - if (configLoading) return + if (config.public.isDesktop) return + if (configLoading || sbSyncChoiceLoading || showSbFirstSyncChoiceDialog) return showBackupGate = false configLoading = true - tempSbInstance = createClient(sbForm?.url, sbForm?.key) + tempSbInstance = null + tempSbConfig = null try { + // Keep the verified credentials paired with this client across asynchronous UI edits. + const credentials = { url: sbForm.url, key: sbForm.key } + tempSbInstance = createSyncClient(credentials.url, credentials.key, Boolean(config.public.isDesktop)) // 检测 typewords_data 表是否存在 const { data: existingData, error: checkError } = await tempSbInstance.from('typewords_data').select('type') if (checkError) { @@ -505,7 +467,8 @@ async function doSaveSbConfig() { ] for (const item of defaultData) { if (!existingTypes.includes(item.type)) { - await (tempSbInstance as any).from('typewords_data').insert(item) + const { error } = await (tempSbInstance as any).from('typewords_data').insert(item) + if (error) throw new Error(error.message ?? String(error)) } } const { data: hasVersionData, error: versionError } = await (tempSbInstance as any) @@ -518,14 +481,12 @@ async function doSaveSbConfig() { } const hasRemoteVersionData = Array.isArray(hasVersionData) && hasVersionData.length > 0 + tempSbConfig = credentials if (hasRemoteVersionData) { showSbFirstSyncChoiceDialog = true } else { - Supabase.setStatus('success') - sbStatus = Supabase.getStatus() - await onSbFirstSyncChoice('push_local') + if (!(await onSbFirstSyncChoice('push_local'))) return Toast.success(t('save_success')) - Supabase.saveConfig(sbForm?.url, sbForm?.key) transferOk() } } @@ -598,7 +559,7 @@ function disable360() { {{ $t('data_management') }} -
+
{{ $t('data_sync') }}
@@ -665,7 +626,7 @@ function disable360() {
-
+

过时功能:由于经常同步失败,不再推荐继续使用,请等待官方同步功能

diff --git a/app/plugins/00.desktop.client.ts b/app/plugins/00.desktop.client.ts new file mode 100644 index 000000000..e654ff4c6 --- /dev/null +++ b/app/plugins/00.desktop.client.ts @@ -0,0 +1,10 @@ +import { installDesktopLinks } from '@/core/platform/desktop' +import { Toast } from '@/base' + +export default defineNuxtPlugin(() => { + if (!useRuntimeConfig().public.isDesktop) return + const dispose = installDesktopLinks(window, document, undefined, error => { + Toast.error(error instanceof Error ? error.message : '打开外部链接失败') + }) + if (import.meta.hot) import.meta.hot.dispose(dispose) +}) diff --git a/app/plugins/02.init.client.ts b/app/plugins/02.init.client.ts index 7a853db13..a75cd7845 100644 --- a/app/plugins/02.init.client.ts +++ b/app/plugins/02.init.client.ts @@ -4,7 +4,9 @@ import { ENV } from '@/core/config/env.ts' import { withAppBaseURL } from '@/core/utils/base-url' export default defineNuxtPlugin(async nuxtApp => { + const isDesktop = useRuntimeConfig().public.isDesktop if ( + !isDesktop && !location.href.includes('localhost') && !location.href.includes('192.168') && !location.href.includes('172.16') && @@ -17,7 +19,7 @@ export default defineNuxtPlugin(async nuxtApp => { })() } - if ('serviceWorker' in navigator) { + if (!isDesktop && 'serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker .register(withAppBaseURL('/service-worker.js')) diff --git a/nuxt.config.ts b/nuxt.config.ts index 3c9df9fc4..ab98e4083 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,6 +1,5 @@ // https://nuxt.com/docs/api/configuration/nuxt-config -//@ts-ignore -import { resolve } from 'pathe' +import { resolve } from 'node:path' import Icons from 'unplugin-icons/vite' import Components from 'unplugin-vue-components/vite' import IconsResolver from 'unplugin-icons/resolver' @@ -45,7 +44,8 @@ function toSiteURL(path: string, baseURL: string) { return new URL(withBaseURL(path, baseURL), siteOrigin).toString() } -const appBaseURL = normalizeBaseURL(process.env.NUXT_APP_BASE_URL || '/') +const isDesktop = process.env.TYPEWORDS_TARGET === 'desktop' +const appBaseURL = isDesktop ? '/' : normalizeBaseURL(process.env.NUXT_APP_BASE_URL || '/') export default defineNuxtConfig({ compatibilityDate: '2025-07-15', @@ -61,17 +61,36 @@ export default defineNuxtConfig({ link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }], }, }, - // ssr: false, - routeRules: { - '/words': { ssr: false }, - '/articles': { ssr: false }, - '/setting': { ssr: false }, - '/book/nce1': { prerender: true }, - '/book/nce2': { prerender: true }, - '/book/nce3': { prerender: true }, - '/book/nce4': { prerender: true }, - }, + ...(isDesktop ? { ssr: false, image: { provider: 'none' } } : {}), + routeRules: isDesktop + ? {} + : { + '/words': { ssr: false }, + '/articles': { ssr: false }, + '/setting': { ssr: false }, + '/book/nce1': { prerender: true }, + '/book/nce2': { prerender: true }, + '/book/nce3': { prerender: true }, + '/book/nce4': { prerender: true }, + }, + // Nuxt owns the HTTP listener; Vite strictPort alone does not prevent fallback. + hooks: isDesktop + ? { + async listen(server, listener) { + const address = server.address() + if (!address || typeof address === 'string' || address.port !== 5567) { + console.error('[desktop] Expected port 5567; refusing fallback port') + try { + await listener.close() + } finally { + process.exit(1) + } + } + }, + } + : {}, vite: { + ...(isDesktop ? { server: { strictPort: true, watch: { ignored: ['**/src-tauri/**'] } } } : {}), plugins: [ Components({ resolvers: [ @@ -113,7 +132,7 @@ export default defineNuxtConfig({ strategy: 'no_prefix', }, // CSS - css: ['~/assets/css/main.scss'], + css: ['~/assets/css/main.scss', ...(isDesktop ? [] : ['~/assets/css/web-fonts.css'])], // 别名配置 alias: { '@': resolve(__dirname, 'app'), @@ -130,6 +149,8 @@ export default defineNuxtConfig({ // 运行时配置 runtimeConfig: { public: { + isDesktop, + desktopApiBase: isDesktop ? process.env.TYPEWORDS_DESKTOP_API_BASE || '' : '', apiBase: process.env.API_BASE || 'http://localhost/', origin: process.env.ORIGIN || 'https://typewords.cc', host: process.env.HOST || 'typewords.cc', @@ -162,13 +183,16 @@ export default defineNuxtConfig({ }, nitro: { prerender: { + ...(isDesktop ? { crawlLinks: false, routes: ['/'] } : {}), ignore: appBaseURL === '/' ? [] : [withBaseURL('/manifest.json', appBaseURL)], }, - devProxy: { - '/baidu': { - target: 'https://api.fanyi.baidu.com/api/trans/vip/translate', - changeOrigin: true, - }, - }, + devProxy: isDesktop + ? {} + : { + '/baidu': { + target: 'https://api.fanyi.baidu.com/api/trans/vip/translate', + changeOrigin: true, + }, + }, }, }) diff --git a/package.json b/package.json index 049fff1d8..7d6235b6a 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,18 @@ "generate": "nuxt generate", "preview": "nuxt preview", "i18n:write": "gulp i18nWrite", - "test": "" + "test": "node --test tests/desktop/*.test.mjs", + "test:desktop": "node --test tests/desktop/*.test.mjs", + "test:desktop:idb": "node tests/desktop/indexeddb-integration.mjs", + "test:desktop:clean-install": "node scripts/check-desktop-clean-install.mjs", + "typecheck": "nuxt typecheck", + "typecheck:baseline": "node scripts/compare-typecheck.mjs", + "desktop:frontend:dev": "node scripts/desktop.mjs dev", + "desktop:frontend:build": "node scripts/desktop.mjs build", + "desktop:check-resources": "node scripts/check-desktop-resources.mjs", + "desktop:dev": "tauri dev", + "desktop:build": "tauri build --no-bundle", + "desktop:bundle:internal": "node scripts/desktop-internal.mjs" }, "dependencies": { "@floating-ui/dom": "^1.7.4", @@ -16,7 +27,11 @@ "@nuxt/image": "2.0.0", "@nuxtjs/i18n": "10.2.1", "@pinia/nuxt": "^0.11.3", + "@rrweb/record": "2.0.0-alpha.20", "@supabase/supabase-js": "^2.98.0", + "@tauri-apps/plugin-dialog": "2.7.3", + "@tauri-apps/plugin-fs": "2.5.2", + "@tauri-apps/plugin-opener": "2.5.5", "@vueuse/core": "^14.0.0-alpha.0", "axios": "1.15.0", "compromise": "^14.14.4", @@ -24,26 +39,24 @@ "dayjs": "^1.11.13", "file-saver": "^2.0.5", "idb-keyval": "^6.2.2", - "@rrweb/record": "2.0.0-alpha.20", - "rrweb-player": "2.0.0-alpha.20", "md5": "^2.2.1", "mitt": "^3.0.1", "nanoid": "^5.1.5", + "node-forge": "^1.3.1", "nuxt": "^4.2.1", "pinia": "^3.0.3", "qrcode": "^1.5.4", + "rrweb-player": "2.0.0-alpha.20", "string-comparison": "^1.3.0", "ts-fsrs": "^5.2.3", "vue": "^3.5.25", "vue-i18n": "^11.2.8", "vue-router": "^4.6.3", "vue-virtual-scroller": "2.0.0-beta.8", - "vxe-table": "4.18.10", - "node-forge": "^1.3.1" + "vxe-table": "4.18.10" }, "devDependencies": { "@alicloud/pop-core": "^1.8.0", - "@iconify-json/tdesign": "^1.2.14", "@iconify-json/bx": "^1.2.2", "@iconify-json/clarity": "^1.2.4", "@iconify-json/dinkie-icons": "^1.2.0", @@ -62,7 +75,9 @@ "@iconify-json/streamline": "^1.2.5", "@iconify-json/streamline-color": "^1.2.2", "@iconify-json/system-uicons": "^1.2.4", + "@iconify-json/tdesign": "^1.2.14", "@iconify-json/uiw": "^1.2.3", + "@tauri-apps/cli": "2.11.4", "@types/file-saver": "^2.0.7", "@types/lodash-es": "^4.17.12", "@types/md5": "^2.1.33", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e84375814..427e43f06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,15 @@ importers: '@supabase/supabase-js': specifier: ^2.98.0 version: 2.112.2(@opentelemetry/api@1.9.1) + '@tauri-apps/plugin-dialog': + specifier: 2.7.3 + version: 2.7.3 + '@tauri-apps/plugin-fs': + specifier: 2.5.2 + version: 2.5.2 + '@tauri-apps/plugin-opener': + specifier: 2.5.5 + version: 2.5.5 '@vueuse/core': specifier: ^14.0.0-alpha.0 version: 14.4.0(vue@3.5.41(typescript@5.9.3)) @@ -159,6 +168,9 @@ importers: '@iconify-json/uiw': specifier: ^1.2.3 version: 1.2.4 + '@tauri-apps/cli': + specifier: 2.11.4 + version: 2.11.4 '@types/file-saver': specifier: ^2.0.7 version: 2.0.7 @@ -267,6 +279,96 @@ importers: packages: + '@tauri-apps/api@2.11.1': + resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} + + '@tauri-apps/plugin-dialog@2.7.3': + resolution: {integrity: sha512-CRgE+7TP4tvq9MjBU6f04NLTFIqVMLKHk3hAqlhil00ngK9ACTrXPH3oHpKMProxILodd3YjBoKbMwSI4IEcfA==} + + '@tauri-apps/plugin-fs@2.5.2': + resolution: {integrity: sha512-XXvMSnFiob+G1H+YHCDf+bzWVumseQuEIhzpbOJzevUfL4k0U+sTApZWJHpoLiamggnVPJm5dJ5sDsniRyWxlg==} + + '@tauri-apps/plugin-opener@2.5.5': + resolution: {integrity: sha512-xvzGai5aQds8j8R8RsUK/lW6pGG50YgOYIPLzvkqmkwAj7dfySOD7sGtejRzvVdMmv1EQfKVEFh1MvmDp8QR0g==} + + + '@tauri-apps/cli-darwin-arm64@2.11.4': + resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tauri-apps/cli-darwin-x64@2.11.4': + resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-arm64-musl@2.11.4': + resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-gnu@2.11.4': + resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-musl@2.11.4': + resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@tauri-apps/cli-win32-x64-msvc@2.11.4': + resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tauri-apps/cli@2.11.4': + resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==} + engines: {node: '>= 10'} + hasBin: true + + '@alcalzone/ansi-tokenize@0.3.0': resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} engines: {node: '>=18'} @@ -9058,6 +9160,69 @@ packages: snapshots: + '@tauri-apps/api@2.11.1': {} + + '@tauri-apps/plugin-dialog@2.7.3': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-fs@2.5.2': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-opener@2.5.5': + dependencies: + '@tauri-apps/api': 2.11.1 + + + '@tauri-apps/cli-darwin-arm64@2.11.4': + optional: true + + '@tauri-apps/cli-darwin-x64@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm64-musl@2.11.4': + optional: true + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-x64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-x64-musl@2.11.4': + optional: true + + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + optional: true + + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + optional: true + + '@tauri-apps/cli-win32-x64-msvc@2.11.4': + optional: true + + '@tauri-apps/cli@2.11.4': + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 2.11.4 + '@tauri-apps/cli-darwin-x64': 2.11.4 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4 + '@tauri-apps/cli-linux-arm64-gnu': 2.11.4 + '@tauri-apps/cli-linux-arm64-musl': 2.11.4 + '@tauri-apps/cli-linux-riscv64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-musl': 2.11.4 + '@tauri-apps/cli-win32-arm64-msvc': 2.11.4 + '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 + '@tauri-apps/cli-win32-x64-msvc': 2.11.4 + + '@alcalzone/ansi-tokenize@0.3.0': dependencies: ansi-styles: 6.2.3 diff --git a/public/list/article.json b/public/list/article.json index 1e74abbf1..65c29349b 100644 --- a/public/list/article.json +++ b/public/list/article.json @@ -2,11 +2,11 @@ { "id": 246, "enName": "nce1", - "name": "新概念英语1", - "description": "第一册《英语初阶》讲练基本语音、语调、语法、词法、句法及句型结构,是练好英语基本功的关键。", + "name": "新概念英语1(示例,5篇)", + "description": "本书仅含 5 篇示例文章,非完整教材。第一册《英语初阶》讲练基本语音、语调、语法、词法、句法及句型结构,是练好英语基本功的关键。", "categoryId": 4, "url": "NCE_1.json", - "length": 72, + "length": 5, "language": "en", "translateLanguage": "zh_CN", "version": 1, diff --git a/public/list/recommend_article.json b/public/list/recommend_article.json index 1e74abbf1..65c29349b 100644 --- a/public/list/recommend_article.json +++ b/public/list/recommend_article.json @@ -2,11 +2,11 @@ { "id": 246, "enName": "nce1", - "name": "新概念英语1", - "description": "第一册《英语初阶》讲练基本语音、语调、语法、词法、句法及句型结构,是练好英语基本功的关键。", + "name": "新概念英语1(示例,5篇)", + "description": "本书仅含 5 篇示例文章,非完整教材。第一册《英语初阶》讲练基本语音、语调、语法、词法、句法及句型结构,是练好英语基本功的关键。", "categoryId": 4, "url": "NCE_1.json", - "length": 72, + "length": 5, "language": "en", "translateLanguage": "zh_CN", "version": 1, diff --git a/scripts/check-desktop-clean-install.mjs b/scripts/check-desktop-clean-install.mjs new file mode 100644 index 000000000..a2a18bb8b --- /dev/null +++ b/scripts/check-desktop-clean-install.mjs @@ -0,0 +1,418 @@ +// Independent source tree + lockfile install + regression. +// Does not reuse the originating checkout's node_modules. +// Not a clean Git commit of the dirty worktree, release build, or EXE/NSIS rebuild. +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const SKIP_SOURCE = /^(?:docs\/plans|node_modules|\.git|\.nuxt|\.output|src-tauri\/target)(?:\/|$)/ +const LOCKFILE = 'pnpm-lock.yaml' +const root = fileURLToPath(new URL('../', import.meta.url)) + +export function inside(base, path) { + const rel = relative(base, path) + return rel !== '' && !isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`) +} + +export function isSecretSource(name) { + const base = name.split('/').pop() + if (base === '.env.example') return false + if (base === '.env' || base.startsWith('.env.')) return true + if (/\.(?:pem|pfx|p12)$/i.test(base)) return true + return false +} + +export function parseNodeTestCounts(text) { + const read = label => { + const match = String(text).match(new RegExp(`^(?:# |ℹ |info )?${label} (\\d+)`, 'm')) + return match ? Number(match[1]) : null + } + return { + tests: read('tests'), + pass: read('pass'), + fail: read('fail'), + skipped: read('skipped'), + cancelled: read('cancelled'), + todo: read('todo'), + } +} + +export function listCleanSourceFiles(origin = root) { + const listed = spawnSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { + cwd: origin, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + }) + if (listed.status !== 0) throw new Error(listed.stderr || 'Cannot enumerate source files') + return [...new Set(listed.stdout.split('\0').filter(Boolean))] + .filter(name => !SKIP_SOURCE.test(name) && !isSecretSource(name)) + .sort() +} + +function flag(args, name) { + return args.includes(name) +} + +function opt(args, name) { + const index = args.indexOf(name) + return index >= 0 ? args[index + 1] : undefined +} + +function resolveTool(names) { + for (const name of names) { + const found = + process.platform === 'win32' + ? spawnSync('where.exe', [name], { encoding: 'utf8' }) + : spawnSync('command', ['-v', name], { encoding: 'utf8' }) + if (found.status !== 0) continue + const path = found.stdout.split(/\r?\n/).find(Boolean) + if (path) return { name, path } + } + return null +} + +function runCaptured(command, args, { cwd, env, shell = false }) { + const started = Date.now() + const useShell = shell || /\.(cmd|bat)$/i.test(command) + const result = spawnSync(command, args, { + cwd, + env, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + shell: useShell, + windowsHide: true, + }) + return { + command: [command, ...args].join(' '), + status: result.status, + signal: result.signal, + error: result.error ? result.error.message : null, + stdout: result.stdout || '', + stderr: result.stderr || '', + durationMs: Date.now() - started, + } +} + +function writeLog(path, captured) { + if (!path) return + mkdirSync(dirname(path), { recursive: true }) + writeFileSync( + path, + [ + `$ ${captured.command}`, + `exit ${captured.status}`, + captured.signal ? `signal ${captured.signal}` : '', + captured.error ? `error ${captured.error}` : '', + '', + captured.stdout, + captured.stderr, + ] + .filter(line => line !== undefined) + .join('\n') + ) +} + +export function copyCleanTree(origin, dest) { + assert.ok(!inside(origin, dest), `Destination is inside the origin tree: ${dest}`) + assert.ok(!inside(dest, origin), `Origin is inside the destination tree: ${origin}`) + mkdirSync(dest, { recursive: true }) + const files = [] + for (const name of listCleanSourceFiles(origin)) { + const source = resolve(origin, name) + const target = resolve(dest, name) + assert.ok(inside(origin, source) && inside(dest, target), `Unsafe source path: ${name}`) + if (!existsSync(source)) continue + mkdirSync(dirname(target), { recursive: true }) + copyFileSync(source, target) + const buf = readFileSync(source) + files.push({ + name, + size: buf.length, + sha256: createHash('sha256').update(buf).digest('hex'), + }) + } + const absent = { + 'docs/plans': !existsSync(resolve(dest, 'docs/plans')), + '.nuxt': !existsSync(resolve(dest, '.nuxt')), + '.output': !existsSync(resolve(dest, '.output')), + node_modules: !existsSync(resolve(dest, 'node_modules')), + '.env': !existsSync(resolve(dest, '.env')), + } + assert.equal(absent['docs/plans'], true) + assert.equal(absent['.nuxt'], true) + assert.equal(absent['.output'], true) + assert.equal(absent.node_modules, true) + assert.ok(existsSync(resolve(dest, LOCKFILE)), `Missing ${LOCKFILE}`) + assert.ok(existsSync(resolve(dest, 'package.json')), 'Missing package.json') + return { fileCount: files.length, files, absent } +} + +function cleanEnv(env = process.env) { + const next = { ...env } + delete next.TYPEWORDS_TEST_ROOT + return next +} + +export function buildCleanInstallPlan(args = process.argv.slice(2), origin = root) { + const dest = opt(args, '--dest') + ? resolve(opt(args, '--dest')) + : mkdtempSync(resolve(tmpdir(), 'typewords-clean-install-')) + return { + origin: resolve(origin), + dest, + keep: flag(args, '--keep'), + copyOnly: flag(args, '--copy-only'), + skipResources: flag(args, '--skip-resources'), + skipRust: flag(args, '--skip-rust'), + withDualBuild: flag(args, '--with-dual-build'), + report: opt(args, '--report'), + installLog: opt(args, '--install-log'), + testLog: opt(args, '--test-log'), + resourcesLog: opt(args, '--resources-log'), + rustLog: opt(args, '--rust-log'), + dualBuildLog: opt(args, '--dual-build-log'), + } +} + +function recordGit(origin) { + const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: origin, encoding: 'utf8' }) + const porcelain = spawnSync('git', ['status', '--porcelain'], { cwd: origin, encoding: 'utf8' }) + return { + head: head.status === 0 ? head.stdout.trim() : null, + dirty: porcelain.status === 0 ? porcelain.stdout.split(/\r?\n/).filter(Boolean).length : null, + committed: false, + method: + 'Independent copy of git ls-files --cached --others --exclude-standard from the current worktree; excluded docs/plans, .nuxt, .output, node_modules, .git, src-tauri/target and local env/key files. Not a git clone of HEAD and not a commit of the dirty worktree.', + } +} + +function runPnpm(dest, args, env) { + const pnpm = resolveTool(['pnpm.exe', 'pnpm.cmd', 'pnpm']) + if (!pnpm) throw new Error('pnpm is not on PATH; cannot install from lockfile') + return { + tool: pnpm, + ...runCaptured(pnpm.path, args, { cwd: dest, env }), + } +} + +export function runCleanInstall(plan) { + const env = cleanEnv() + const report = { + createdAt: new Date().toISOString(), + git: recordGit(plan.origin), + origin: plan.origin, + dest: plan.dest, + lockfile: LOCKFILE, + copy: null, + install: null, + test: null, + resources: null, + rust: null, + dualBuild: null, + } + report.copy = copyCleanTree(plan.origin, plan.dest) + if (plan.copyOnly) return report + + const version = runPnpm(plan.dest, ['--version'], env) + const store = runPnpm(plan.dest, ['store', 'path'], env) + const install = runPnpm(plan.dest, ['install', '--frozen-lockfile'], env) + writeLog(plan.installLog, install) + report.install = { + command: 'pnpm install --frozen-lockfile', + tool: version.tool, + pnpmVersion: version.stdout.trim(), + storePath: store.status === 0 ? store.stdout.trim() : null, + reusedOriginNodeModules: false, + status: install.status, + signal: install.status === 0 ? null : install.signal, + error: install.error, + durationMs: install.durationMs, + log: plan.installLog || null, + } + if (install.status !== 0) { + report.install.stderrTail = install.stderr.slice(-4000) + return report + } + + const test = runPnpm(plan.dest, ['run', 'test:desktop'], env) + writeLog(plan.testLog, test) + const counts = parseNodeTestCounts(`${test.stdout}\n${test.stderr}`) + report.test = { + command: 'pnpm run test:desktop', + status: test.status, + signal: test.signal, + error: test.error, + durationMs: test.durationMs, + counts, + log: plan.testLog || null, + } + if (test.status !== 0) return report + + if (!plan.skipResources) { + const resources = runPnpm(plan.dest, ['run', 'desktop:check-resources'], env) + writeLog(plan.resourcesLog, resources) + report.resources = { + command: 'pnpm run desktop:check-resources', + status: resources.status, + signal: resources.signal, + error: resources.error, + durationMs: resources.durationMs, + log: plan.resourcesLog || null, + } + if (resources.status !== 0) return report + } + + if (!plan.skipRust) { + const cargo = resolveTool(['cargo.exe', 'cargo']) + if (!cargo) { + report.rust = { skipped: true, reason: 'cargo is not on PATH' } + } else { + const checks = [ + ['fmt', ['fmt', '--manifest-path', 'src-tauri/Cargo.toml', '--check']], + ['check', ['check', '--locked', '--manifest-path', 'src-tauri/Cargo.toml']], + ['test', ['test', '--locked', '--manifest-path', 'src-tauri/Cargo.toml']], + [ + 'clippy', + ['clippy', '--locked', '--manifest-path', 'src-tauri/Cargo.toml', '--all-targets', '--', '-D', 'warnings'], + ], + ] + const rustEnv = { ...env } + delete rustEnv.CARGO_TARGET_DIR + const results = [] + let rustLog = '' + for (const [name, args] of checks) { + const captured = runCaptured(cargo.path, args, { cwd: plan.dest, env: rustEnv }) + rustLog += `$ ${captured.command}\nexit ${captured.status}\n${captured.stdout}\n${captured.stderr}\n` + results.push({ + name, + command: `cargo ${args.join(' ')}`, + status: captured.status, + signal: captured.signal, + error: captured.error, + durationMs: captured.durationMs, + }) + if (captured.status !== 0) break + } + if (plan.rustLog) { + mkdirSync(dirname(plan.rustLog), { recursive: true }) + writeFileSync(plan.rustLog, rustLog) + } + report.rust = { tool: cargo, results, log: plan.rustLog || null } + } + } + + if (plan.withDualBuild) { + const web = runPnpm(plan.dest, ['run', 'build'], env) + const desktop = web.status === 0 ? runPnpm(plan.dest, ['run', 'desktop:frontend:build'], env) : null + writeLog( + plan.dualBuildLog, + desktop + ? { + command: `${web.command}\n${desktop.command}`, + status: desktop.status, + signal: desktop.signal, + stdout: `${web.stdout}\n${desktop.stdout}`, + stderr: `${web.stderr}\n${desktop.stderr}`, + } + : web + ) + report.dualBuild = { + commands: ['pnpm run build', 'pnpm run desktop:frontend:build'], + web: { status: web.status, durationMs: web.durationMs, error: web.error }, + desktop: desktop ? { status: desktop.status, durationMs: desktop.durationMs, error: desktop.error } : null, + log: plan.dualBuildLog || null, + } + } + + return report +} + +function exitFrom(report) { + if (!report.copy) return 1 + if (report.install && report.install.status !== 0) return report.install.status ?? 1 + if (report.test && report.test.status !== 0) return report.test.status ?? 1 + if (report.resources && report.resources.status !== 0) return report.resources.status ?? 1 + if (report.rust && !report.rust.skipped && report.rust.results.some(item => item.status !== 0)) return 1 + if ( + report.dualBuild && + (report.dualBuild.web.status !== 0 || (report.dualBuild.desktop && report.dualBuild.desktop.status !== 0)) + ) + return 1 + return 0 +} + +const invoked = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url) +if (invoked) { + const plan = buildCleanInstallPlan() + let created = false + try { + if (!existsSync(plan.dest)) { + mkdirSync(dirname(plan.dest), { recursive: true }) + created = true + } else { + assert.equal(readdirSync(plan.dest).length, 0, `Refusing to reuse a non-empty destination: ${plan.dest}`) + } + const report = runCleanInstall(plan) + const json = JSON.stringify( + { + ...report, + copy: { + fileCount: report.copy.fileCount, + absent: report.copy.absent, + files: report.copy.files, + }, + }, + null, + 2 + ) + if (plan.report) { + mkdirSync(dirname(plan.report), { recursive: true }) + writeFileSync(plan.report, json) + } + console.log( + [ + `Clean tree: ${report.copy.fileCount} files at ${plan.dest}`, + `Absent before install: docs/plans=${report.copy.absent['docs/plans']} .nuxt=${report.copy.absent['.nuxt']} .output=${report.copy.absent['.output']} node_modules=${report.copy.absent.node_modules}`, + report.install + ? `Install: ${report.install.command} exit ${report.install.status} (${report.install.durationMs}ms) reusedOriginNodeModules=${report.install.reusedOriginNodeModules}` + : 'Install: skipped (--copy-only)', + report.test + ? `Tests: ${report.test.command} exit ${report.test.status} tests=${report.test.counts.tests} pass=${report.test.counts.pass} fail=${report.test.counts.fail}` + : '', + report.resources ? `Resources: ${report.resources.command} exit ${report.resources.status}` : '', + report.rust + ? report.rust.skipped + ? `Rust: skipped (${report.rust.reason})` + : `Rust: ${report.rust.results.map(item => `${item.name}=${item.status}`).join(' ')}` + : '', + report.dualBuild + ? `Dual-build: web=${report.dualBuild.web.status} desktop=${report.dualBuild.desktop && report.dualBuild.desktop.status}` + : '', + ] + .filter(Boolean) + .join('\n') + ) + process.exitCode = exitFrom(report) + } finally { + if (!plan.keep && (created || plan.dest.includes('typewords-clean-install-'))) { + assert.ok( + inside(resolve(tmpdir()), plan.dest) || !inside(root, plan.dest), + 'Refusing to delete an unexpected tree' + ) + if (!inside(root, plan.dest)) rmSync(plan.dest, { recursive: true, force: true }) + } + } +} diff --git a/scripts/check-desktop-resources.mjs b/scripts/check-desktop-resources.mjs new file mode 100644 index 000000000..44bb08946 --- /dev/null +++ b/scripts/check-desktop-resources.mjs @@ -0,0 +1,117 @@ +import { readFileSync, statSync } from 'node:fs' +import { isAbsolute, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const LIST_FILES = ['word.json', 'recommend_word.json', 'article.json', 'recommend_article.json'] + +// Explicit audited set, not automatic discovery of every source-code asset reference. +export const STATIC_RESOURCES = [ + ...['Shepherd.14.5.1.mjs.js', 'snapdom.min.js', 'jszip.min.js', 'xlsx.full.min.js'].map(name => `/libs/${name}`), + '/sound/beep.wav', + '/sound/correct.wav', + ...Array.from({ length: 4 }, (_, i) => `/sound/key-sounds/jixie/机械${i}.mp3`), + ...['机械键盘1', '机械键盘2', '老式机械键盘', '笔记本键盘'].map(name => `/sound/key-sounds/${name}.mp3`), + '/imgs/mini.png', + '/imgs/empty.svg', +] + +export function checkDesktopResources(root = 'public') { + root = resolve(root) + const errors = [] + const references = new Set() + const counts = [] + const fail = (code, path, message) => errors.push({ code, path, message }) + function localFile(path) { + const file = resolve(root, `.${path}`) + const rel = relative(root, file) + if ( + !path.startsWith('/') || + path.startsWith('//') || + rel === '..' || + rel.startsWith(`..${sep}`) || + isAbsolute(rel) + ) { + fail('INVALID_PATH', path, 'Expected a local path inside the resource root') + return null + } + references.add(path) + try { + if (statSync(file).isFile()) return file + } catch {} + fail('MISSING_FILE', path, 'Required resource is not a file') + return null + } + function readArray(path) { + const file = localFile(path) + if (!file) return null + try { + const value = JSON.parse(readFileSync(file, 'utf8')) + if (!Array.isArray(value)) throw new Error('Expected a JSON array') + return value + } catch (error) { + fail('INVALID_JSON', path, error.message) + return null + } + } + function inspectReferences(value) { + if (!value || typeof value !== 'object') return + for (const [key, child] of Object.entries(value)) { + if (['audioSrc', 'cover', 'src'].includes(key) && typeof child === 'string' && child.startsWith('/')) + localFile(child) + else if (child && typeof child === 'object') inspectReferences(child) + } + } + for (const name of LIST_FILES) { + const listPath = `/list/${name}` + const list = readArray(listPath) + if (!list) continue + const type = name.includes('article') ? 'article' : 'word' + for (const dict of list) { + if ( + !dict || + typeof dict !== 'object' || + dict.type !== type || + typeof dict.language !== 'string' || + !dict.language || + typeof dict.url !== 'string' || + !dict.url || + !Number.isInteger(dict.length) || + dict.length < 0 + ) { + fail('INVALID_DICTIONARY', listPath, 'Expected language, url, matching type and non-negative integer length') + continue + } + inspectReferences(dict) + const dictPath = `/dicts/${dict.language}/${type}/${dict.url}` + const rows = readArray(dictPath) + if (!rows) continue + counts.push({ list: listPath, path: dictPath, declared: dict.length, actual: rows.length }) + if (rows.length !== dict.length) + fail('COUNT_MISMATCH', dictPath, `${listPath}: declared ${dict.length}, actual ${rows.length}`) + rows.forEach((row, index) => { + const required = type === 'word' ? ['word'] : ['title', 'text'] + if (!row || typeof row !== 'object' || required.some(key => typeof row[key] !== 'string')) { + fail('INVALID_ROW', dictPath, `Row ${index} must contain string fields: ${required.join(', ')}`) + } + inspectReferences(row) + }) + } + } + for (const path of STATIC_RESOURCES) localFile(path) + return { + ok: errors.length === 0, + root, + scope: + 'Four catalogues, their dictionaries and local audioSrc/cover/src references, plus the explicit staticResources set; no automatic source-code discovery', + staticResources: STATIC_RESOURCES, + references: [...references], + counts, + errors, + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const report = checkDesktopResources(process.argv[2] || 'public') + console.log(JSON.stringify(report, null, 2)) + process.exitCode = report.ok ? 0 : 1 +} diff --git a/scripts/check-desktop-test-snapshot.mjs b/scripts/check-desktop-test-snapshot.mjs new file mode 100644 index 000000000..40a5ef455 --- /dev/null +++ b/scripts/check-desktop-test-snapshot.mjs @@ -0,0 +1,58 @@ +// Source-only regression check, not a clean dependency installation or release build. +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = fileURLToPath(new URL('../', import.meta.url)) +const listed = spawnSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { + cwd: root, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, +}) +if (listed.status !== 0) throw new Error(listed.stderr || 'Cannot enumerate source files') +const temp = resolve(tmpdir()) +const snapshot = mkdtempSync(resolve(temp, 'typewords-test-snapshot-')) +const inside = (base, path) => { + const rel = relative(base, path) + return rel !== '' && !isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`) +} +try { + let count = 0 + for (const name of new Set(listed.stdout.split('\0').filter(Boolean))) { + if (/^(?:docs\/plans|node_modules|\.git|\.nuxt|\.output|src-tauri\/target)(?:\/|$)/.test(name)) continue + const source = resolve(root, name) + const target = resolve(snapshot, name) + assert.ok(inside(root, source) && inside(snapshot, target), `Unsafe source path: ${name}`) + if (!existsSync(source)) continue // Preserve tracked deletions in the working tree. + mkdirSync(dirname(target), { recursive: true }) + copyFileSync(source, target) + count++ + } + assert.equal(existsSync(resolve(snapshot, 'docs/plans')), false) + assert.equal(existsSync(resolve(snapshot, '.nuxt')), false) + assert.equal(existsSync(resolve(snapshot, '.output')), false) + // Dependencies are intentionally reused; no install scripts or network requests. + symlinkSync( + resolve(root, 'node_modules'), + resolve(snapshot, 'node_modules'), + process.platform === 'win32' ? 'junction' : 'dir' + ) + console.log(`Source snapshot: ${count} files; no docs/plans, .nuxt or .output; existing node_modules reused.`) + const env = { ...process.env } + delete env.TYPEWORDS_TEST_ROOT + const result = spawnSync(process.execPath, ['--test', 'tests/desktop/*.test.mjs'], { + cwd: snapshot, + env, + stdio: 'inherit', + }) + if (result.error) throw result.error + process.exitCode = result.status ?? 1 +} finally { + // Only remove the generated direct child of the resolved OS temp directory. + assert.equal(dirname(snapshot), temp) + assert.ok(inside(temp, snapshot) && snapshot.startsWith(resolve(temp, 'typewords-test-snapshot-'))) + rmSync(snapshot, { recursive: true, force: true }) +} diff --git a/scripts/compare-typecheck.mjs b/scripts/compare-typecheck.mjs new file mode 100644 index 000000000..028c97da9 --- /dev/null +++ b/scripts/compare-typecheck.mjs @@ -0,0 +1,132 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const defaultBaseline = resolve(here, '../tests/desktop/fixtures/typecheck/baseline.json') + +export function extractDiagnostics(text) { + return String(text) + .split(/\r?\n/) + .filter(line => /error TS\d+:/.test(line)) + .map(line => line.replace(/\(\d+,\d+\)/, '(line,column)')) +} + +export function extractPreambleIssues(text) { + const issues = [] + if (String(text).includes('vue-router/volar/sfc-route-blocks')) { + issues.push('vue-router/volar/sfc-route-blocks') + } + return issues +} + +export function difference(left, right) { + const remaining = [...right] + return left.filter(line => { + const index = remaining.indexOf(line) + if (index === -1) return true + remaining.splice(index, 1) + return false + }) +} + +export function loadBaseline(path = defaultBaseline) { + const baseline = JSON.parse(readFileSync(path, 'utf8')) + if (!Array.isArray(baseline.diagnostics)) throw new Error('Typecheck baseline missing diagnostics') + if (!Number.isInteger(baseline.expectedExit)) throw new Error('Typecheck baseline missing expectedExit') + return baseline +} + +export function compareTypecheck({ currentText, baseline, currentExit, baselinePath, currentPath }) { + const current = extractDiagnostics(currentText).sort() + const expected = [...baseline.diagnostics].sort() + const added = difference(current, expected) + const removed = difference(expected, current) + const preamble = extractPreambleIssues(currentText) + return { + baseline: baselinePath, + current: currentPath, + baselineCount: expected.length, + currentCount: current.length, + added, + removed, + currentExit, + expectedExit: baseline.expectedExit, + preamble, + typecheckPassed: currentExit === 0 && current.length === 0, + typecheckFailed: currentExit !== 0, + noNewDiagnostics: added.length === 0, + note: 'Diagnostic headline multiset comparison ignoring source line/column; not a passing typecheck.', + } +} + +export function buildBaseline({ text, expectedExit = 2, source }) { + return { + schema: 'typewords-typecheck-baseline-v1', + expectedExit, + count: extractDiagnostics(text).length, + diagnostics: extractDiagnostics(text).sort(), + preamble: extractPreambleIssues(text), + source, + note: 'Internal no-new-diagnostics baseline. typecheck exit 2 remains failure. Not a passing typecheck.', + } +} + +function parseArgs(argv) { + const out = {} + for (let i = 0; i < argv.length; i++) { + const key = argv[i] + if (!key.startsWith('--')) continue + const name = key.slice(2) + const next = argv[i + 1] + if (next && !next.startsWith('--')) { + out[name] = next + i++ + } else { + out[name] = true + } + } + return out +} + +function runCli(argv = process.argv.slice(2)) { + const args = parseArgs(argv) + const currentPath = args.current + if (!currentPath) { + console.error( + 'Usage: node scripts/compare-typecheck.mjs --current [--baseline ] [--exit ] [--out ] [--write-baseline ]' + ) + process.exitCode = 2 + return + } + const text = readFileSync(currentPath, 'utf8') + const currentExit = args.exit === undefined ? 2 : Number(args.exit) + if (args['write-baseline']) { + const baseline = buildBaseline({ text, expectedExit: currentExit, source: currentPath }) + writeFileSync(args['write-baseline'], `${JSON.stringify(baseline, null, 2)}\n`) + console.log( + JSON.stringify( + { wrote: args['write-baseline'], count: baseline.count, expectedExit: baseline.expectedExit }, + null, + 2 + ) + ) + process.exitCode = 0 + return + } + const baselinePath = resolve(args.baseline || defaultBaseline) + const result = compareTypecheck({ + currentText: text, + baseline: loadBaseline(baselinePath), + currentExit, + baselinePath, + currentPath, + }) + const encoded = JSON.stringify(result, null, 2) + if (args.out) writeFileSync(args.out, `${encoded}\n`) + console.log(encoded) + process.exitCode = result.added.length ? 1 : 0 +} + +const invoked = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url) +if (invoked) runCli() diff --git a/scripts/desktop-internal.mjs b/scripts/desktop-internal.mjs new file mode 100644 index 000000000..1fcf9dff8 --- /dev/null +++ b/scripts/desktop-internal.mjs @@ -0,0 +1,45 @@ +import { spawnSync } from 'node:child_process' +import { createRequire } from 'node:module' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { resolve } from 'node:path' + +const require = createRequire(import.meta.url) +const root = fileURLToPath(new URL('..', import.meta.url)) + +export function internalBuildCommand(platform = process.platform, arch = process.arch) { + if (platform !== 'win32' || arch !== 'x64') { + throw new Error( + 'Internal installers require a native Windows x64 Node/Rust toolchain; run this command in the Windows checkout.' + ) + } + return { + command: process.execPath, + args: [ + require.resolve('@tauri-apps/cli/tauri.js'), + 'build', + '--target', + 'x86_64-pc-windows-msvc', + '--config', + 'src-tauri/tauri.internal.conf.json', + '--bundles', + 'nsis', + '--no-sign', + '--ci', + ], + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const { command, args } = internalBuildCommand() + console.log( + 'Building UNSIGNED internal NSIS installer; clean-machine, upgrade, offline and public-release acceptance remain separate.' + ) + const result = spawnSync(command, args, { cwd: root, stdio: 'inherit' }) + if (result.error) throw result.error + process.exitCode = result.status ?? 1 + } catch (error) { + console.error(error.message) + process.exitCode = 1 + } +} diff --git a/scripts/desktop.mjs b/scripts/desktop.mjs new file mode 100644 index 000000000..47ee0d93d --- /dev/null +++ b/scripts/desktop.mjs @@ -0,0 +1,95 @@ +import { spawn } from 'node:child_process' +import { readFileSync, statSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { checkDesktopResources } from './check-desktop-resources.mjs' + +const root = fileURLToPath(new URL('../', import.meta.url)) +const require = createRequire(import.meta.url) + +export function desktopCommand(mode, env = process.env) { + if (!['dev', 'build'].includes(mode)) throw new Error('Usage: node scripts/desktop.mjs ') + const nuxtRoot = dirname(require.resolve('nuxt/package.json')) + return { + command: process.execPath, + args: [ + resolve(nuxtRoot, 'bin/nuxt.mjs'), + mode === 'build' ? 'generate' : 'dev', + ...(mode === 'dev' ? ['--host', '127.0.0.1', '--port', '5567', '--no-fork'] : []), + ], + options: { + cwd: root, + env: { ...env, TYPEWORDS_TARGET: 'desktop', NUXT_APP_BASE_URL: '/' }, + stdio: 'inherit', + shell: false, + }, + } +} + +function checkDesktopEntry(directory) { + const entry = resolve(directory, 'index.html') + try { + if (statSync(entry).isFile() && readFileSync(entry, 'utf8').trim()) return + } catch (error) { + throw new Error(`[desktop] Invalid desktop SPA entry ${entry}: ${error.message}`) + } + throw new Error(`[desktop] Invalid desktop SPA entry ${entry}: expected a non-empty file`) +} + +export function runDesktop( + mode, + { spawnProcess = spawn, checkResources = checkDesktopResources, checkEntry = checkDesktopEntry } = {} +) { + const { command, args, options } = desktopCommand(mode) + const audit = directory => { + const report = checkResources(resolve(root, directory)) + if (!report.ok) { + throw new Error( + `[desktop] Resource audit failed (${directory})\n` + + report.errors.map(error => `${error.code} ${error.path}: ${error.message}`).join('\n') + ) + } + } + // Reject incomplete inputs before generate can replace an existing output. + if (mode === 'build') audit('public') + const child = spawnProcess(command, args, options) + const onInterrupt = () => child.kill('SIGINT') + const onTerminate = () => child.kill('SIGTERM') + process.on('SIGINT', onInterrupt) + process.on('SIGTERM', onTerminate) + const cleanup = () => { + process.off('SIGINT', onInterrupt) + process.off('SIGTERM', onTerminate) + } + child.once('error', error => { + cleanup() + console.error(error.message) + process.exitCode = 1 + }) + child.once('exit', (code, signal) => { + cleanup() + process.exitCode = code ?? (signal === 'SIGINT' ? 130 : 143) + // Never validate stale output after a failed or interrupted generate. + if (mode === 'build' && code === 0 && !signal) { + try { + audit('.output/public') + checkEntry(resolve(root, '.output/public')) + } catch (error) { + console.error(error.message) + process.exitCode = 1 + } + } + }) + return child +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + if (process.argv.length !== 3) throw new Error('Usage: node scripts/desktop.mjs ') + runDesktop(process.argv[2]) + } catch (error) { + console.error(error.message) + process.exitCode = 1 + } +} diff --git a/scripts/invoke-setup.ps1 b/scripts/invoke-setup.ps1 new file mode 100644 index 000000000..6672f9d49 --- /dev/null +++ b/scripts/invoke-setup.ps1 @@ -0,0 +1,81 @@ +param( + [Parameter(Mandatory = $true)][string] $Setup, + [Parameter(Mandatory = $true)][string] $Dest, + [string] $UninstallKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TypeWords', + [string] $ResultPath, + [int] $TimeoutMs = 180000, + [switch] $DryRun +) +$ErrorActionPreference = 'Stop' + +function ConvertTo-TypeWordsVersion([string] $Value) { + $trimmed = "$Value".Trim() + if ($trimmed -notmatch '^\d+(\.\d+){1,3}$') { + throw "Invalid TypeWords version: $Value" + } + return [version]$trimmed +} + +function Get-TypeWordsSetupVersion([string] $SetupPath) { + $name = [IO.Path]::GetFileName($SetupPath) + if ($name -notmatch '^TypeWords_(\d+\.\d+\.\d+)_x64-setup\.exe$') { + throw "Setup filename must be TypeWords__x64-setup.exe: $name" + } + return $Matches[1] +} + +function Write-TypeWordsSetupResult($Record, [int] $Code) { + $json = $Record | ConvertTo-Json -Depth 6 + if ($ResultPath) { + [IO.File]::WriteAllText($ResultPath, $json, [Text.UTF8Encoding]::new($false)) + } + Write-Output $json + exit $Code +} + +if (!(Test-Path -LiteralPath $Setup)) { throw "Missing installer: $Setup" } + +$setupVersion = Get-TypeWordsSetupVersion $Setup +$displayVersion = $null +if (Test-Path -LiteralPath $UninstallKey) { + $displayVersion = (Get-ItemProperty -LiteralPath $UninstallKey).DisplayVersion +} + +$record = [ordered]@{ + setup = $Setup + dest = $Dest + setupVersion = $setupVersion + displayVersion = $displayVersion + dryRun = [bool]$DryRun + refused = $false + launched = $false + reason = $null + installerExit = $null +} + +if ($displayVersion) { + $installed = ConvertTo-TypeWordsVersion $displayVersion + $incoming = ConvertTo-TypeWordsVersion $setupVersion + if ($installed -gt $incoming) { + $record.refused = $true + $record.reason = "installed DisplayVersion $displayVersion is newer than setup $setupVersion" + Write-TypeWordsSetupResult $record 2 + } +} + +if ($DryRun) { + $record.reason = 'allowed' + Write-TypeWordsSetupResult $record 0 +} + +$process = Start-Process -FilePath $Setup -ArgumentList "/S /NS /D=$Dest" -PassThru +$record.launched = $true +if (-not $process.WaitForExit($TimeoutMs)) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + $record.reason = 'installer timeout' + $record.installerExit = $null + Write-TypeWordsSetupResult $record 1 +} +$record.installerExit = $process.ExitCode +$record.reason = 'launched' +Write-TypeWordsSetupResult $record $process.ExitCode diff --git a/scripts/sql/README.md b/scripts/sql/README.md new file mode 100644 index 000000000..410463b23 --- /dev/null +++ b/scripts/sql/README.md @@ -0,0 +1,40 @@ +# Account Sync Database Contract + +`typewords-account-schema.sql` bootstraps an empty Supabase project. It is not a +legacy-table migration and intentionally fails if `public.typewords_data` exists. +Inspect existing objects and data before applying it. Never drop an existing +table to make this script succeed. + +The account key is `(user_id, type)`. Authenticated clients must supply the +current user's ID on every write and use `onConflict: 'user_id,type'`. Reads +should filter by the same ID. RLS enforces ownership independently of those +client filters. Only SELECT, INSERT and UPDATE are granted; anonymous access and +client DELETE are not supported. The primary key also indexes the ownership +predicate. No privileged RPC, view, trigger, or extra schema is introduced. + +Rows require a positive integer version and finite timestamp supplied by the +client. Dictionary and settings data must be JSON objects; practice caches may +also be null. Application payload validation remains necessary. An empty account +has zero rows, not four unversioned placeholder objects. Initial upload must be +an explicit user choice after login and backup, never a side effect of checking +credentials or signing in. + +The current frontend still uses the old unauthenticated contract and is NOT +compatible yet. Do not enable real learning-data sync until login/logout, +session/account-change guards, all query/upsert paths, and first-sync UI are +adapted and verified. Preserve local IndexedDB and ZIP formats. + +## Verification + +`tests/desktop/sql/account-rls.sql` is an explicit management-SQL integration +test, not part of the default offline Node tests. Run it only in the authorized +test project. It refuses a nonempty table and requires exactly one confirmed, +password-enabled, non-anonymous account. It reads that account's ID internally, +does not return it, and never reads the password hash. + +All synthetic writes occur in a transaction ending in ROLLBACK. The test changes +the transaction-local Postgres role and JWT claims to exercise RLS. This verifies +actual database enforcement, but does NOT verify password login, JWT signature +validation, REST grants/schema-cache behavior, a second real account, or +Web/Desktop UI. After running, independently check that the table is empty and +inspect policies, grants, and Supabase security advisors. diff --git a/scripts/sql/typewords-account-schema.sql b/scripts/sql/typewords-account-schema.sql new file mode 100644 index 000000000..b9bb81d43 --- /dev/null +++ b/scripts/sql/typewords-account-schema.sql @@ -0,0 +1,36 @@ +-- Bootstrap for an EMPTY project, not an upgrade of a legacy shared table. +-- Deliberately fail if the table exists: inspect and migrate existing data separately. +create table public.typewords_data ( + user_id uuid not null references auth.users(id), + type text not null check (type in ('dict', 'setting', 'practice_word', 'practice_article')), + data jsonb, + data_version integer not null check (data_version > 0), + updated_at timestamptz not null check (isfinite(updated_at)), + primary key (user_id, type), + constraint typewords_data_payload check ( + case + when type in ('dict', 'setting') then data is not null and jsonb_typeof(data) = 'object' + else data is null or jsonb_typeof(data) in ('object', 'null') + end + ) +); + +alter table public.typewords_data enable row level security; +alter table public.typewords_data force row level security; + +-- Do not inherit the project's default broad Data API grants. +revoke all on table public.typewords_data from public, anon, authenticated; +grant select, insert, update on table public.typewords_data to authenticated; + +create policy typewords_select_own on public.typewords_data + for select to authenticated + using ((select auth.uid()) = user_id); + +create policy typewords_insert_own on public.typewords_data + for insert to authenticated + with check ((select auth.uid()) = user_id); + +create policy typewords_update_own on public.typewords_data + for update to authenticated + using ((select auth.uid()) = user_id) + with check ((select auth.uid()) = user_id); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock new file mode 100644 index 000000000..995c55f92 --- /dev/null +++ b/src-tauri/Cargo.lock @@ -0,0 +1,5174 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.2", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.5", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.6+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.2", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" +dependencies = [ + "base64 0.23.1", + "indexmap 2.14.2", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.15+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.2", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.2", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.20", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.20", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61854a36651aa48381e5e209f69a01273b77f3f9f91f0c430b1b98d33bd47229" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.20", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de22eef34fd78c0da050e748710edd50bf127e651d02ea1b2bfada1523cc5c51" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.20", + "toml 1.1.6+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.20", + "toml 1.1.6+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.6+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "typewords-desktop" +version = "0.1.3" +dependencies = [ + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-fs", + "tauri-plugin-opener", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.20", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.5", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.5", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.5", + "winnow 1.0.4", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 000000000..4e6677d2e --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "typewords-desktop" +version = "0.1.3" +description = "TypeWords desktop host" +edition = "2021" +rust-version = "1.77.2" + +[lib] +name = "typewords_desktop_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "=2.6.3", features = [] } + +[dependencies] +tauri = { version = "=2.11.5", features = [] } +tauri-plugin-dialog = "=2.7.3" +tauri-plugin-fs = "=2.5.2" +tauri-plugin-opener = "=2.5.5" + +[features] +custom-protocol = ["tauri/custom-protocol"] diff --git a/src-tauri/README.md b/src-tauri/README.md new file mode 100644 index 000000000..e25b6a541 --- /dev/null +++ b/src-tauri/README.md @@ -0,0 +1,74 @@ +# TypeWords Windows 桌面端(开发与内部测试) + +更新:2026-09-19。保留现有 Nuxt/Vue、IndexedDB 和 ZIP 数据格式,使用 Tauri 2 内嵌桌面静态 SPA。宿主只有一个本地窗口,没有自定义 command、通用原生网络代理或更新器;已接入 dialog、fs、opener 插件及内部 NSIS 构建,正式发布验收未完成。 + +**这是实验性开源 Windows 预览,不是正式公开发布。** 安装包是未签名的内部/实验性 Windows x64 NSIS。使用前先导出 ZIP 备份。不要把唯一真实学习数据迁进默认 identifier 目录。不要用旧 setup 覆盖较新安装,升级请走仓库里的 `scripts/invoke-setup.ps1`。Win10 实机、干净机、代码签名和默认个人 profile 均未验证。桌面端云同步延后。 + +**后续工作按[迁移计划](../docs/plans/tauri2-desktop-migration-plan.md)第5.1节 R1–R8执行。** 优先数据安全闭环、真实事务与原生验收,再冻结版本打包。计划/证据目录目前被 Git 忽略,干净检出可能没有这些本地文档。 + +## 当前身份与验收状态 + +2026-09-12 用户授权“按照主流来即可”后,首发确定为 Windows x64,主验收 Windows 11,最低兼容目标 Windows 10 22H2。正式 identifier 沿用 `io.github.zyronon.typewords`。Win10实机验证按用户要求暂缓,非通过;平台约定不等于兼容性通过。原生探测继续使用独立临时 WebView profile,不使用个人学习数据。 + +Windows 生产窗口配置使用 `http://tauri.localhost`(`useHttpsScheme: false`),没有自定义 `dataDirectory`。这两个配置与 identifier 一起影响持久化来源,首次原生数据验收后保持稳定。开发窗口使用 `http://127.0.0.1:5567`,它与生产窗口不是同一个存储来源;数据迁移使用 ZIP,不能用开发窗口数据是否出现判断生产持久化。 + +## 命令 + +在具备 Windows Rust MSVC、C++ Build Tools 和 WebView2 的 Windows shell 中,从仓库根目录运行。原生 `node_modules` 必须由该平台的 pnpm 安装,不能复用 WSL 的 Linux 原生依赖。 + +```powershell +pnpm install --frozen-lockfile +pnpm run test:desktop +# Independent tree + lockfile install + regression (does not reuse this checkout's node_modules). +pnpm run test:desktop:clean-install +# beforeDevCommand 自动启动既有桌面前端。 +pnpm run desktop:dev +# 编译内嵌静态资源的可执行文件,不生成安装包。 +pnpm run desktop:build +# 重建前端/EXE并生成未签名Windows x64 NSIS内部测试包。 +pnpm run desktop:bundle:internal +cargo fmt --manifest-path src-tauri/Cargo.toml --check +cargo check --locked --manifest-path src-tauri/Cargo.toml +cargo test --locked --manifest-path src-tauri/Cargo.toml +cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings +``` + +Web 仍用 `pnpm run dev` / `pnpm run build`。两种前端构建共用 `.nuxt` / `.output`,保持串行。`desktop:build` 在编译前自动运行资源门禁和 `nuxt generate`;不要跳过这一步把 Web SSR 输出打进宿主。 + +以上是命令清单,不是一次性顺序执行脚本,`desktop:dev`会保持交互服务运行。打包输出路径以实际日志为准,显式target构建可能位于`target/x86_64-pc-windows-msvc/`。列出Rust检查命令不表示它们均在最新候选重跑。 + +第三十一批本地回归243/243,但部分测试读取被忽略的本地fixture/历史报告,干净检出可复现仍待修复。`pnpm run typecheck`最近结果仍为第三十批62条既有诊断及vue-router/volar导出问题,退出码2,不是通过。不得把历史证据文件校验称为本轮实时UI测试。 + +独立真实存储测试:`pnpm run test:desktop:idb`。需要可解析的 Playwright 和已安装的 Edge;也可用 `TYPEWORDS_PLAYWRIGHT_MODULE` 指向外部 Playwright 的 `index.mjs`,用 `TYPEWORDS_BROWSER_CHANNEL` 指定其他已安装的浏览器 channel。未将 Playwright 添加为项目依赖,不声明该命令已在干净 CI 环境复现。 + +该入口临时启动仅允许白名单测试文件的 loopback 服务,使用全新浏览器 context,阻止非本源网络请求;结束时关闭浏览器和服务并清理随机命名的测试数据库。四个场景验证真实 IndexedDB/idb-keyval 多键事务 abort、重开、缓存迁移、内存恢复与队列重试;词典/设置迁移器、store 方法和云客户端仍是替身,不替代完整 Pinia watcher、ZIP UI、quota 或原生 WebView 验收。测试音频只是合成 Blob 字节。 + +## 当前边界 + +- `app.security.capabilities` 显式选择 `main`;授权保存对话框、文件写入命令及限定HTTPS目标的opener。没有通用shell/原生HTTP权限。选择路径作用域与未授权请求拒绝仍须T09原生验收。 +- 生产 CSP 允许本地脚本、随包资源、Blob 音频、有道/百度发音目标及`https://*.supabase.co`同步连接。开发 CSP 另外允许 Nuxt/Vite 的内联脚本、eval 和本地 WebSocket。CSP允许连接不证明服务权限或同步成功。 +- Rust独立拒绝远程导航和新窗口,JS侧也校验外链;正常外链应进入系统浏览器,未信任页面不得装入有原生权限的窗口。完整T09仍未执行。 +- 原生T01–T05已有部分输入、播放、路由和恢复证据;物理键盘/系统IME、英语实听、系统断网冷启动等仍待,不以旧证据或编译代替最新候选验收。 +- 桌面同步仅接受托管Supabase域,真实数据安全模型/权限及真服务验收未完成;当前代码按type操作,不能把匿名开放测试表当作真实学习数据安全方案。自定义域不在本轮范围。 +- 桌面在线查询API必须显式构建配置,默认禁用;百度开发代理不进入静态包。未配置同步时本地基础练习应可独立使用。 +- 图标由仓库现有 `public/favicon.ico` 转换,未更换产品视觉资产。 + +第二十八批已重建release EXE,尚未启动原生验收。第二十三批旧0.1.0 NSIS不包含第二十四至二十八批修复;历史0.1.1只是升级测试overlay,不表示源码已升版。打包前统一版本和源码/前端/EXE/NSIS哈希,升级候选版本高于实际测试起点。 + +`tauri.internal.conf.json`是当前用户NSIS安装、禁止降级、`downloadBootstrapper`,不是完整WebView2离线安装包。缺运行时下载成功/失败、干净机及中文用户名仍待验证;已有运行时的离线运行另测。未签名包只称内部测试包。自动更新、其他OS、SQLite及完整离线TTS不属于本轮。 + +## 历史隔离原生探测(不代表当前全部状态) + +第七批直接测试 Windows WebView2 生产资源协议,而非 Chrome 静态服务器。进程级环境变量指定新建临时 WebView profile,启动时核对父进程、实际 `--user-data-dir` 和调试端口;结束时正常关闭窗口并确认该 profile 的 WebView 进程及端口退出。正式配置没有加入调试端口或自定义数据目录。 + +已验证练习页重载、正常退出/重启后的词书/设置/缓存恢复,以及真实例句按钮的无音色/无目标语言结束提示。TTS 音色发现最多等待 1500ms;匹配请求语言,已明确保存的音色仍优先。没有英语音色的设备不宣称英语发音通过;人工听音、输入法、完整 20 词/5 句、开发窗口和安装器仍待验收。 + +脚本、实际 EXE 位置、测试数据目录、已知字体 CSP 问题与回退证据见 [第七批 VERIFICATION](../docs/plans/evidence/tauri2-native-smoke/VERIFICATION.txt)。 + +## 第八批:字体与网络失败(2026-09-12) + +桌面构建不加载 Web 专用 Garamond `@font-face`,沿用 Garamond / Georgia / Times New Roman / serif 系统字体栈;Web 保留原远程字体声明。开发、生产 WebView 首页均实测没有远程字体请求或 font-src 违规,当前机器使用系统 Garamond / Noto Serif SC,不表示每台机器安装相同字体。CSP 字节未变。 + +单词网络播放捕获拒绝与同步异常,8 秒无播放进度后回退 TTS,进度前进时重置等待;结束、回退、取消清理事件与计时器。新 EXE 的20次网络阻断与实际媒体请求悬挂回退已验证,无英语声色时明确提示并继续手动练习。开发窗口通过 cargo dev-profile 宿主连接桌面 Nuxt 服务验证,尚未把该组合探测记成完整 `pnpm desktop:dev` 启动器验收。 + +旧 EXE 的20个唯一词键入与进程级离线重载证据保留版本边界。英语实听、物理输入法、完整断网冷启动、最低版本实机和完整开发/生产矩阵仍待完成。证据见 [第八批 VERIFICATION](../docs/plans/evidence/tauri2-font-offline/VERIFICATION.txt)。 diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 000000000..261851f6b --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build(); +} diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json new file mode 100644 index 000000000..d99098a24 --- /dev/null +++ b/src-tauri/capabilities/main.json @@ -0,0 +1,60 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "main", + "description": "Local main window: user-selected backup save and scoped HTTPS links only.", + "windows": ["main"], + "permissions": [ + "dialog:allow-save", + "fs:allow-write-file", + { + "identifier": "opener:allow-open-url", + "allow": [ + { + "url": "https://github.com/*" + }, + { + "url": "https://typewords.cc/*" + }, + { + "url": "https://2study.top/*" + }, + { + "url": "https://enpuz.com/*" + }, + { + "url": "https://www.youdao.com/*" + }, + { + "url": "https://v.wjx.cn/*" + }, + { + "url": "https://www.google.cn/*" + }, + { + "url": "https://chromewebstore.google.com/*" + }, + { + "url": "https://microsoftedge.microsoft.com/*" + }, + { + "url": "https://supabase.com/*" + }, + { + "url": "https://www.kdocs.cn/*" + }, + { + "url": "https://x.com/*" + }, + { + "url": "https://pan.quark.cn/*" + }, + { + "url": "https://v.v8l.cn/*" + }, + { + "url": "mailto:zyronon@163.com" + } + ] + } + ] +} diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..1305befbbfae14bce13751cace8958d8255883de GIT binary patch literal 20338 zcmeFYWmF{1wk=vf;qERCG;WQ%J2dX@?(WvO)4034ySuwJ4vjQ4TDV@mz0V!vjq%Rg zZ=65(*UcYUxgx3}Dk4|RHRp;<000aC1wcUod>$l#1aJVr?4K)=f7_Ih0Kmg%857gL zZ4+n!fCvTv5ET5koev8D1R(#R7k}m%k^iswTckNu)n+6_D0;_44Z}mNm2?! zq39}c9{4XoBQzQ~bJ8$CIvct-=qhk5H!L?&VHlt&3LTwj-A^DwK@gP&`H+u|3&+cL zraS%iNE#3s!JU65tKIoBVPWb9oV_e6P6gHStF%RM7DR3k_>5qdFpuJm9E!?vXiywB zB750^u@a_Y$YK~_nA3o`vJ@Cu-MmUwUwMTHpqBIz$3}sJJfT9^pkLF2d4Bq(oc|&& z8DP01k z1T=8`YUsl>dZIYGm5SIAB~B1PbDN5oj|mL#y6H|}UNWu+*I(g#GldYeV6OSu?@F2g zol8?ZSJ!lk>)RwIfiqUb3kZe(-mnG`3%=A@HG;pxp%Te$SpIJm1}Lo#A;*9$5aJgTY z=y^_QC|c=TT9@IAS@vs0HY0DI6m%#n2*L4JsBs9prpC}4DPl+BZ=Fahpa^LfBhO*c zhtLR@WjvRav{+`C{G8fsfAC?Lk!4Po%ETZh@z`bBWI6WP`S8y9NOsHCltW_5tP(sMeygF2kw&rQ8x8Pz)aahzQ%hRi2t2V@d z)k>DAch_csJgiWP#VDOyG+gVUF?p$oClfGVb2SK3_8%&ubI~x|odK7xo>eOA)Cs(< zKo``&D5AW|WxW#>bziA~xyHF($i})v?afb8HVf%0>@$VZ)T` zRuI|yVoVVxWbLUNJQ#}xbCl(*k-)!9lA3JkXFES~MuG~OsqHrd*H*D3afEWp^uc3s zcdf_W9y=>JYEy(|QCQKs1vG0x46jal>&O(c-X%%xFP4yV3TTz`HBZuV09u3Uj|*8D zU(5x!m8zA-$kcSIrWMh-8$RPdK4*oPhquEx#$775&v9Ni_iozKxQlK*^YU5}_yLoM z#=F_}CF^hFoFP?T2oAvAK2UVjG+e+b;WN-?y!yGotq^GU7Q~+nldolHM zY6$=*$Hj+R#k8TIYcALPpkXPiJF0u3h6X6-8-pKMUooS>rMZ~SpI|lH+lw543KRWd zThWp3I`3867RlNs-R1xBNZjX-AHX{8PZ&V6L{Qd=aH;)UqYeSTRiiRC79frLWA~Rw zuF^456rhD9K$wb!Ew^nQskLg+X+ME9;>TCbOUd%fde>gJQf#CdOt3Y<$G@_o>?ogq z7tS>>;#qvj=bU{7Wd|2p!YKAlZIIz=!2%-hPV+m7R8pXbDHKwwrXt+heCpBoM#HU} z_ReKGVhrY^A?sdDqOy-i$HX6yBOg+Z5cRpC4Md)Y8ChR!4{NusSm+2^>^q*L1J!== z>|~dxQ--hmq37$$U)kvDzB9@ak86i?nL=0-L~XUw^%k0pYPE66cDqhpw zfg};k6nCu@cdv`P+h;muw!^JUn!kMP-7&7>1Uu& zQNs`HL=2};r4a*BkGnJK2{!p-#oQzTJ!u?*h;iV8i)Oj>`ynVY)0Yrdp@uqRj@gGk zBPm|S^AvyikNW{!EM`gNOy=58PoponR&aq8MpeMmpYunf<@++V*=f&^rH!k$7G_)U zC@?Oj(_Pv1PdcE3C71^EWy9!Vp)&j-WQYF;|WFY=7`~Tb3O; zY~z11f&T!L|1bfjf5D_W_){1F08RT}FqxrkqhpRb>JQ3pe7J+;aZi+46fgKLS`pkL zB`s8Dm0w&z!Uzdjtds;z?g2&`5-!af&=$^{1eXkL$0(K(L_?$?My&)Efkr~kDxE0( z6*~hBty9Wobo-3!7@w%e+0EA6_R@`TGwik4SkwGrD*eNU1vJgkIDmQ=X&>hk=VB*d zSdFbZ_<~D{CE)zS>#$V$Ox9t=H zbG9g)R7f;MtG6Yu!XpPwEHEy>z zQl4mj5KIrWrv6n7*#d0(5Xv1)3Ah*;B-w3Ih@8y=I~bWlIc=vvlHpk-M(GYBmowXM zePh%EQ<(|u8_WLF*ZkkZl^mMttU;!bgKw3}J|@lbMOUv8aF!w)6TrTDy4rtWRW%>#7NGkFEgBvd{EOWXx}sqZEIKS zf>E!&FJe}8B#%80yn8#pp8o;6w;BJY*n$(76c|ylkc#SeZ@?t`v-1^6+O_R$PNIC}x zi9l7zL5uO3r7lRcke?*)BQ@aSy9-5!5^?u}vU41}(~J|;W(H#Y4i4slJ6YA!xbV(s zlIff~t*DZVhDIz}AtF5RF)wP_O6S7HBII^!s9+ZSb2IPk>Zb_%yBOFXEbQgvw9{d~ zw?Mdcg)ue^le*hruuw-`5=_G77z+Hzzc)WYL@r6?G&7Rpd@4;S$*4yZ=b;3j(D}{(Nku z^hOEip-~CxrZd~AW9Dlj?Iu-8plG^6>gZBKMmgVWV1Of6NnNijha0Kk@qlRH{lX!fg&v`qP|>9>$viC^fqu2b zlgq`1nFfKkp=TIUJXw|LP_M_*F1Nst3%@vp66cmN`IJnI_{HgpP!sji4*FIqd(jG9kCp{%Ya?mb#E(M?4$+OW$tmy&L8k0G;LddOg zavE|y^z*D3sGkJueYO!G*4yLo%FhWrPzTR>z5^cDgxww?a8yLz8O$EhdAI&v<4Vdv zRX5BcXNCv5$$*P*2LHZH6=|uF-MVcz*Rk4c*gXD(hgzL7P_=-(?}()vzsBj%VGNg8UiZK-_HJXie~v$XKzKJX5iU$M2F2 zDMrd;_?MO=4P{o2!>b~)bKH|@m{bb-Sz<vYA{tYxq%^8)x%?@Ay`k*Q{}H+K(uweOx3=>Uz$;=pzV5+LFVkfJPG*g zAx97uY`nFH@`yj%SG@yOH%$^VOQW~OT)}_l;;r?SU1gcPAeiNQN*$lLc zV*1V`tyJ?C6`zG&vmAuG7^i+9Z_e6|G`qYxSZ+;-d_YkBxyS)?p2hx*q5nyW2!2)z z{wu~-SUPS40I(MS6=O5Kqjgm=NBpgGM}21wJSAJR7Hm-7m$9G+B`Av>*GIDE#-FE=?PJ)HF4#dmP z{qh|o{?r_Vl6&qr^xFP8|M?e>=}EEAMQfa0B;EzNgo*vc`fPCm?NUg}4LNPx@bW~T zH=th@$@}N&Q+UV&+4y#=K%5e6v|6^BtsB7`0f30>dpdYFMjY7|7L?R(dk!2IT;B@=~LRZ#-R_PW0<62;F>qQ zV-91Rw1q;3^mz$^Wv&@|<%lkUMS;a`=B<33qGs4td%v%(55Ru^bNzcmtmTrP<>#u& zPdFY@BZeV(Vn!V18i%`bSoW-h-wl4L2aU@bZ=2i9fp+wkmf?T|1r7R*oY1}i;^0Va zIJv(qZHCFih?PJ8@}GdltRemG#RKOH@;RtkynaNgF>5iod=1`MDSAB3U?mo4sVHIJ z6>Fg2V>y2b9oZM&`W#&`0NmK2n&ueoEym)y{wKv|NmldHQ9f-)+g%oh`BWnnjYA!o#|N7Bk&< zS|}&>*bH9*+H#t*?JpaK_@q)vY2quF9YdF;0s1-NTre$Nu7ATc$C| zhlR}072N<N&piEd^$x>0q3|#`J;u-CU{? zkUNRzc%W1;AQ-Z}X#WD9MQ7!bS+l}xT{evkDm-8%bTM@e6P~?djWw{MqUVi%Qi4G>g?3@mESQ)Io+Q@W*H*iOKBM& zct)B>m)PH;o%Ur0fJ?`Ii`N!-iwhKZnN1$KA|;A!n_VUE3snMUQN?(;kr8TItFX zR-SL6-<2qL;4YM0TWEtuA+m-VD60H!fe&__@Y}l11zkHJKA@waA8U>s9AS^Brkg(U z+g$`&#j*QqeVWyg#%208!Y?c!Iu_j|)qTuk@>!)vU|?vIGDwu#aB5Cb*aW6gn6bnD zL@L<$gEGjVOHu-MR%d5&&LW_CfF8v>sdrQNcYZ3!WYa+ka@OJf8fmAOE~=9JHZ%i1 z^$-^mYL%U74huQ_PA5f?ZcK6ykj?IPngxn!p%5Mk4y3z} ze8kDTo!WzsWZ;EB*V?o!ut&*&Mn&okvDLHA)`k{WVnG9ga9wqxF*B9IU{M-UQhs)6?50CdcAcKx4+#Dn~oau)Xla5MsE zeY>2(4AfMLuim7)v9<@V`}SfN`m4jGa!$qv-&C;<4ziJ_ow$Fkdm4si@8yIAZ!fD-Loz7B zm`hgCan=g5mX~q3j$ojCkfZMmrVLKk2M;Ex_zT6&pp}u)kX%Pztg&&~(6V#biEZlP zS2ktL18;*%w}*|$S3bw=evV^q!MdA3+eu44GzEoc=jWC*>W+0HH)@B3F7}i0{bizUW>eSd=Ue}~W*AlAc*EWRCQ{}~(fHTpDhUk@AmVhUYe%!hC%d{nAs*(RbT zUpNbqMSB6lKV!;D=Wpqj*O=`wdHvULB4CQ}@B0Ru&_yS6FBJu>p+U@7Sw&tL;@%U%ILiLYdGnDQ;JtH#58r{@El*tdPh1tPuv|A(LjeAWm2i=Yki8%_cMX#bm_ zWqZf#sAi7}9B)eW9%G`Rp$Y4e;}jSv#FxPolMK<-(AE`%`f7-M+4pWlp3pLxbgkD= z*KkOs*DZHyUMj*+@FA(8RRY6O5hSBL`Bmgj3x8?jYgzG#JeoB1 zbtS2Ebz^c}S{~K~loDFd0+3%?qe$^Lilu9mk{RB&ysC zFPHrF;e)e9LaU*Da{R`R_qK?7HxC-UIs1S?#JafORmn7JE8m?XK-s6SHo&mRRz5)im_HpE!H(|D=7+Ka1FX%aDCy-EdSc(R!)$h^vC{)AxdQM~3PF(k>YHqt!h>Tciu-VD;5h=vHw}{+y z(*qr-A{~FAcaH>;faA(I_6w)NV|8rSQS4UEMqlg8$6aTp@;1~^SpN_}T&<_-TVOgG zbeOIynms!GY_FAObV0B3-MuG*^nGltI_D65J_VeUKspc-#WQ?uEBBY_wfiyVz0+`unUU)v zU(0yT^S|8$|8Ve2_=$hPfU(%y6BS@ht2W)DI4@^BY9Qz@MrEqXr@Bp-K>-k3tNSMZ zxYq7jqnm9m6{kvE*Np%2)PHI1V!qVm4Uxydy3_Zo7?-{F@?gRn%G1U@W;<(yM-*5O zjavpY`AqkIZmX-)v<4~sZF&f=7E=Y>(6E1h@6Oi(F(*&}0m|pkI9#r`O%s2923zp= z#YQEC>);JWhHFN9`ueWf3P#Ae3>#fuV_%3;SMArw3cYPVuatL1=Q-I}XJiP|!NXdZ zVW)W`4wr`}ioPhyh(bfn+Tm$ztoFBkG)(!73;~Ns03GC@>K@@Fi_S|1uil(q`!nab ziW8=LZ)K){0PU$F|K&U z0;?kXD1<1bn|k%sGV+~ErNjkF)d|YDT4d*n>-Dz!5e`*Ypqjp#^G=_Nb~P-QgbmV%6TPy!>*QUSo08vhVP z&yH-?>K8tnfzJ>ch0D`zv9n!>HZC~;qHIwPq@?=t-_5~zPeRF*rxfR6I!tSFZFe(k z+iO$KJA#KJi`Bf$Ga=hm8y%Y+JqM9B594^4w2roya?)MYbISn zGBSF(!}~1wYRSQZt}D^fF>bj9+EAjP5XeL%6N2r<+3}fpYq}~(Fq=Cic}}xzO<%D) zPp2cK4M;kQha=hh2m3z_x5)Nidkj0q#boYa*XW%4lRcD;@ZpzVvJkHMz!U9c<}|%O zu3eG<`_QzScnmyShbg(91Xq-vmC;H`%o4LWG<~U847=lOFlvkr8lFFO{gDJI=M}*N zM!pkp=qv2MD(l`yXTUz}UdJS7x_kPZr%wbUn<~M*0=ya`dp&&-sVf4f$7z2!kx^KZ z1rY~|2XjqOaCS%*ETMTug-5*tj13!Fc>8VrP$JL-Cq5`K1qc2g_x1mrsR%B)kSIVQ zQvh^mGJmHkXlTfOKDjg|Q3R)IgF7I6-%(NV?_-H~HfzzXCPxLYD+rV6arR7E%{fxK z2cq+Fvopo018cOijs5CD(!B(x$PT|KGh$iR=wgnCj17tAPc3P0|X z2ys#%AjWF1rkKhz4NlR+Oki;{AbAU^S zL2=L9V0aUK`Age0>efgPoXb{R3;Qb(EgGb-r&hdZW@~fRhMZz`+aD>;XIz<*if>&G z7|27g`|wPsyywpPWm;c0X`~n@Hm(X?Jt|&?ZDOJR4My?qXIWW@QcT(a%wHm;HGY&j zq-{_H$N*hwuFXGVYT-xFO*_k0Oi!NZVpbjVUz%C^H!s z(ZbZKwP%gqYjw6HyYI&po3Ca2Nd;!p;Hi`9CLU*`sXaPvdP5MaG+&0k{;igbHkE%| zkHvf7)jsSeB-((6sAyKGZo#w@imHh;%Br&vt2_uufd>>A$sIrqG=H%UD_{bDp&cmM ziLSSt#3{Yg&~ft$wVs)%r};HI3Rz?(4Re5qe5K>xr=C+&_ovnP^+GTJr@tOkQzl{Z zOdLQJ^R+-X?HicIvPSQ+;wf*0AmC2GiPeO8Z^dA?Z0~+l{OLv+bA^8L>e~4x%UL?D z3wdfOQc@Q4OBEK8|EThdkDYJT6yI#@HwE^#*hnL6Reh}e@DGcj4Q0MTnT=@JyuYv` z=vHoPJTekN0YA*f_JY5#b_H7tG1Oio{=-!sYzPnd%#Yd(zXzIjtGzX5@P%Km2NdtE zzX#0P5E(AoS4OV{2UVVopLNW~wUmr*OUJ;Y_@bxeRz44uaX2#=jQ|~vq}X}F5J;I+ z&_gRiGvxUmthD!9sJ4+t6hHFAS*3ejjxk31bmf!V-Oq4xeMA!Cx)_L8ZmbJ9!ez!D zq4+;E(r5X(C*J1+jMNqrmG6Fup`*^)pqAm{fPfJ+>Y6PDJcWOpFk_1~**4PavuB#% zNpd3Gbv8rH2TnZ7Xu6mP>U^0DHU#@V0XXN^8R+$s(|#pL;zSCM5+#yD5Mn*yAjChPt`dL%Eg_1L9 zfA;WnsFw@fvbxq)Idb^|0ZwLO^L+&@R^e@t!%%P~ah11e@!++VXm=$_`jbypb{>?^ zlyyw1I~=29zCTkAMtca$>k+$-j5c=T^*M;m;iWui>NJpV4tJT=3{nr@b4KJUf(^A) z*51umD&=#kO*LqKn>?yW))eR4yT<0bc-KvS$*F9p763qgRX6|3<5T4)S(dpg>O2pmm~sSHPZ4z z91M6|!o75acs)n(wB|fIZxeKvd@Q3uO2#G76t4%I&At}T-ps5#yf%>lR>SdS2+YN` zV>!ipKHOnU^I(=gd%8sWJ`Gc?t!lVcFuLnkhzDesocT-Ib2eUP(Ww&y z-Kh_A$>|!v`fGPY=q}#_K<}@!MeR8c^eHcyAK1aoq1X@WKKog`{!ga_?G~-*jGEU- z`yNc-&@@5B0WF2qH;xNQAyhr_H=VP~r$QT!{uKG&=(ZeE#SNyx!#;TlXzt~??KK*~ zRG!M)X>I*C?g_n>-?LAQz%SN51=YFJs|{9kT>ste6V5a<}!wx1NMpoS%=H#rp+xmJPUPh;3D7!~4=748b?Wqqr>{FS2=M%Kb0Fa;w z0RmBHvM;YHxRAsomJ2ujkT3;RV|q$lXPZ~@cFzv-2}gKHhb2_SavL6#V(KA(>W~bM;#rQfmL{IiGS`eWQ@o375B8Pqfl^(r=U0!w9!n*}+xJEXZ?WR+d|;Y+b)uAh zF7{-rThMkHacxtx-7RL)2c)I*F9nd9BaEVZ7fOHkc$|H{LhSlO3r%IRggQ%#G%9;< z&wLC&I??nssHAaz^aqNZR`nj=0}^R~E!(?oLzDGKiL|y3GSeasEOL$;V%Yn(?X@ab z^(Q3_a42`E8u_419)bh>w0*>hQ4nt)9aot^K-*^0oGadiQGY}Ou=|;6j-8e`-_U-(YFeAn i04B`AGh-TWY&L%`M9&QgPEQ$hKXHy*6 zNQnFEA!HsA^qw4JWLY!hPP|#^^8G)U1CfjU}hneE!{oI#I&Xfm!;ngRZH2 zC%We~;h%3Ei_hzn%MK3wD{I5t+SRt!%(5DpCWDefB2*tTEzhNwL?rabR{GCn<;0ag z(pKG7``QZN13H2#TlY&{?&W_@P=S3`g8}}Ppwe+F`+TC8%=N!^5Vt(jOk900uOA-V z<<3>RrzYs=2AQWgqXWij8gQ4!@C?WWF$&u`kU74Ad14SdT?#Ipp$&`1Jm|3{P};Rg zB(&9yp*gHkP!$GeryHh)XnIxF)Kob8^}dXjE9*8g1*V@~RPgVtf)J(_RCRr4o_uHQ z1qQZ#Rz0iYncj_OkD=x2gszZm93n-qz?{X3ppWmL*`>=PNI0o}cuQsRN^tuU-<|T2 ztM# z^*y%L8%A9dyNzK_{OEl`;>bb$OAmBI1{??Dqu60+zy3{Gykf~sc6sMKHE#Pw#feP{ zgCz7j8A6nrjVsF;1}RKrDA5HhIt;!d?sMV3oj@PI;?rjDi4%;;ExWEC#sDLF@~+b9 zbCXn#v``P)t+)i46gR1gxicF%vrOQKW{iH+$E6BzKG^rcBJDFHRfD1IJhgrT6+_08 z|5xpfg_sKm&NGxkNd0oVgDNour8G+&(_L+{ADP?mlR+yPf`tY=d!e0tc72%07u}jS zZ|WU4_uQOKSG3E;LpTom6B|}aC%sh~8F!d=7?Ts7-AAc_X{6qP9oxq=>gn!nMEh#8 zpBW9vHAz-1-;7+oRS@*$Z=^G1!b(9-)u)cj6htE)+(x z=$PzpH+x(U*Sg12h3A);k(vIq=>tj?WNMXf(%(g9LO#dVJkLfp*dK9q`0YnHZ4$e_ zkyE!!d2F!OV{%4`C~w7~ARJ394OZ7=`?a{W#-C_TcSHsk?upVRR!Y*P%9)7HpUE?l ze-<<4+;aSp8E)4%rUZ+ZT=q~L#ea()MDuAgKVirn-_j(*%xFqs%UYbbLmlV-wE+~k z`xm1L0kgy$C~?F1qM3lDV0kK}oaQi-FgyeFi+8X zP0`}1b*mizwZF{cAPa`&*Ni|8NVDBMIZiFGOCU4z==7b|*j-~XdYY{U>s?2?`h6cz z+7iawNM>Ta9t1HPR)xJ~yZ^8pM%gN{>E@19Vd%o|pfCj^F>|HKzj53Kq10v9GJ0nI zLPvG(hqOGy3ee&tfhp7y(&-Mnt}tD}Mmhs=R?Qw{hnZ*8Y6@U9cq#XFpIL9V_KYlU zh(OWKM=kcmg9=x}@StkbVLW0oR6z`k4hC_1Z4e7xPkpTBjMB~?y%H-s-TXUVQwMk% zwYods@c{JuLEARi7fyWVzmDPtJ#)0Up{m9YN;Qz6Mo?w zD+;c{?yhbACF}7O90!8+;&zZN>JnbY?78cF4xyAdY5&oa`UuO7VcgL$E))9tY5k6u zqHDbu9Wh`vkBer`39>m9T(miCn8gQc_R90&hTwwPp(8!^c)+)4 z)fK4^Z`igkxSOXJC!mvaxc^mCPB-n>9|n7ExK~DRo!WuOS(2HfDJdeaLWBl8NN6(0iNQw&FO-vMbdA6cnAs}vru(`unZT_H(j5*6&G_(=BI6WD^ zcQFjhVskGB>+!7wY|hLZF8C2?ClhtXR%Z=AlwQEgD*B_bW8ktCDqQdzW(*B^vFC0& z6;+rO686B)bs}uS^uzAjSx1cz4`@AtXz_y8vio^Jnt^bk)x?ZpU7pEf)WMBTJMZY$ znbqq-Vc}+Ti+DBMQ&aqX!4vFa!s-bMVHIa|;3BegqmfkTWZZvMd^R|v>GD#vHmBacUyGzcmL#nA>P&#Z`DBfrc0D+=SWwwqZe zbBPiU{)+uMS`BU~CR2ld4_*YMkvf*D>d-QQQ_jdc?!RJu9JCSSKD(ydfA~df!SJP@ zl+$nF0jnYPfipa$gA#Jo4hydsbDU7@r%$Ui@4puSX-3t};2Su4I^2uLN6ucJAK$q6 z?=MX+G_=WF<}$JN)2p{gh~x;XxTE{lqZV|Wg>za7@uu^`{q^KG?v=$M@_wYHkN`Xu z2UrDqS*K@nD;*a_Ozr$@RtWh2{(cAhLNj;RLDtlX{pvv^ws5@X4vrXrw1fvFy}B1! z?0TDH)z{*in0T46q1X0uUid3OZ)FXAE4eqWfU5Rz zt@8-cUTZVfy9?#5+~)g%Sv$$B;iw|A`fX9GTYPkDH;v46*SHj0^DYNB%Uu-exFA=k zNuu|_W$du;c*7Cd(+8~5nhG}0yE0Krw(n0}b@1P~X1$8!Bp3rY~f$rgB9HGX?HzzQ1uw$Ij#PRaIuk0}CiQ4bF#?Oww zGc{wpL2pzCBqJNMI|_i9s272mL5ST;aw8+dXmeu z!g^0+={)!D+vsl%{?X%EKlY)6%+uRL#6945%2@Mj#Aq!!KC0>CZLQA*JMZDg3E3P! zPWrjplpH(Xek53U*Ej9>a^%&(1aNvvSj930Lr8~`Azh`Xv;0<`$HcCM^ET2RLuXu- zdRi0!?rSL+6iSJ@>#xj0+YujS#>FX4z=q!IMfU{eKi2qrMZ{(4^l{8TJWR1NzG6K$ z4unLpu&#K5&4ZWNVotK~2(u$;4+&D!>nf9-JObiJkeGg8j?P0rz79KjZaZ3pLKgG6 z>-(Ip7V110#n?#0bnGEC1=<&MUPfo_;UC{Q{RN}0>9kIBF|KTQ%ND}9^ygHl&+1~D z7I^KSaetG;jYy*+Wy0eA3>8mDp55NB88KujJo;tVv%S?055Mq2Tv%f|7p)-(2?62- zznigo9$U~Z1t?k<p(9()f%&WSfS9KY(>2*rUX(DdJV=mvdn zx>Mr|MkdhTT{OoU_h3H1eC~|Q1UN2Mw42Ko!ZOubwC$JCsS_k0%2d*RZWS}-U=#Mr zAqB=F26(JZaCNa9e5Zi@`}lml0!fCH(RoLqYMr>NE7M3v=UMf*3f7WDKfJoe zwf1YCgVOwQU_<&-#7B|gy1(&7#7S8LhW6W$a{5&5$LjMe+HOaG1iN)XEk|(QvqlQC zxxDteCvo~ahI)~CmO4#!Y7d>?C}Wqj9W3}E(Rm_7cRx#sIEEudYt3*{P1o+Q{E$g8 zDV*k%oYDPgM#1f6Kf{{Q&)Tc>UlU<-A@uEbGXXq8g^m#Tzjn}F^3zCcq1krwEfZHo zJyKK%(PtkzuO7Y;2r%AN2=`g5JdJ{n-r3VQ9h<(WnhDosPv}{<47Hu9P3+;0RHu&0d8G6!GG_=%~v5L}=WyE+?2rDDjHeb*@2r?Az=5 z#mH$x`tGw{FUQFxuC|LJUg}zBbq6;cK-$Q*!zj@%=$VUp&a&ub*g6^od5qL>?K0l0*p1%TUgGfm?k6;rM1NDz(2A_Oo6Ct{Zr zyzTOMtXsE+Q(j^y4RFkynXVJ$_E%W9XvXx#qD~SsPC8ys3BS5zsjZ2--8Jeb!hy{-zUUrmS{acXLa+!&#fEq;fFOZL@Rc~Q5s35F4*5At;&22en!v_m3b38e^xXZSSoqK?Ou6OZs78; zN1YGG1W6E6J#!bd6S4dTLzxZLhk>%2-m>SpYtpt25#ocFH8}iK~QRK&Glh2PW5qOu<$k+ zFD_OwTr`fn^Avlq%hE8T9rB0sGs@a{9vG9h!?PW-LY=L}C!uo1)Qzi?DLKG3!Rjz#AilO?v znqQOAVKzE}D{MWpqC287O%E*i5pJ^5PYyRhE+Fr+h|@jd?k(_#}|z^K{kj| z&GqD{-Krz#3wllG9-W?O-Io?y&3A9vkW~lh=E)lTpJ}peSv)|ZD0#=momudP+ASzV zLfq3pB)i|rSbdirqpZV~R{*8KaY#wFwH4?IF2W`;gPk&a|7X0Z^o|M~`oTk|g>LdQ zlylO*CQ3sXpusA_d@cf4N^FYX-d(Il?SJwW`9B=eZ7TTsjewyQv8@c!p!rq|E;qZ# zrq++kPTDX#Aou#`th?tPlzKiV(FeXB}mS%56}(-mQ91d>1P8;W-5FgQsSbkm!vVVm@fR(_k=$ASpsT5c9)W;!+LIf51 zm7Y)zRI1{T2ZGdNkiSCYhxB)r&n}ZJlUA(d=^WM@2(0yx%77QD*haH``?&lPx8Bpe zi=7|0gvIQyZwwz=lQ2Age$nj zT$(^*@8O%@_k!kJ1z>^}Uvj@~-VEWi@+q5&%cUhgaF(q)IK++SzY$5WV+~JHr=9Fu z`F2C>Ki%NLBn~uhZt*QL>$V^6+7r}&3RQ#YNiaqvZ9M`cO#zd2l@^3(%}?g(BV7PW zHTXyDGBPQ-|1HzX`9);3I^S0O`2v?ST~}I(A`Nxu@?B2{$=XpVkaCuvLq;NYkp}bPPI)NG+KJI}#ZgZL zk6C8aPrOmb(X_*B`Y-O)l{c4XXRmhFeI=+H8rQ%aWJ|KoSHAeWL7ve&9D5w^6o>&> z)!5@T>XH+JzY;B6bq~%~kTr9*_(wQaqZOxMWX!#?I+&&-i#6pkatAmL>@k0pbyR59 zxjRDBvATV;BB(ot7Pj-xA7RX?yg4ZeKpgxcw2LA?_AG2Ko{`<;Xxq=8VSbuy3Fwk` z%(o}xpLmRy5mVQ-FuXeo2;*3KS~yjVP0!pv4=ZUma7-~iex7E6sb*;0N_<>vv6EZY7&XtwN=ZtT5{yh^Tdnn{ISKVBeNSA*! z{JbpyO^Rn?xM4yDd-vE@uQ%&7xmt&yow#Yo@t<^Ix8mBiU)f;X=pQ#m={_~-M5>y) zhmA}&Nw}4|FBgknsz1FqUWK|Cgh9}dA&H>877FSLa){5#3@ZP%VEQMOeCopo(Pa{5 z2hDvoZkVru{1`Tz#3sf)+>lq%;IQ}xKHOxWq!@_YG$&7m7yx^OW1lN-t6mmzb@)-{ zFo|y5Wo?<#%gsOApgCU-Q(|duxY>^F{^)vQ5 zA%SD7KD;;yZw;v930>{P&Nxm-KptU_zQB}BYPhZ`U9BR9`23lN;}Fm@1l4^_MSZ@R z56&u*pGb#WWB^2qCi3xuZ7{dyBH_KbvL3-O5dm{`Rgb*oKQ|WeK{|J#1Y80%7+bvg zR7Ca<5?+MKQ54%*_TJV0*YM|mn4&g}=)s8#Szr#)N0rMSXIIalKNXPpToC`WI z`{N!wJUxP?Rz_oTLh&3n)Y>)OzTx)O~Kb4e%C%Rz3tw? z66`DE#XP0E@CS>n#io7|1_x&0)+^Ej-$rBW<=R7u~zf&Z@|$N3$19Z>IKY2ni<>nYqzd27D6OT_6`Gy5bF4L$UkuhXipB5bPqrEom| z-PNy`ZFfB|zTZmahEIR&Daf9}{2-pw=J{p-^aCvQ)ghpl`^*0dgA080&;LHHNIoUa z>UAfHn<+cS93Q-Mn6EunA(M)JhRH1W%~_TX#zLqD(FseZgoQmGN6+y&>)x+8+Dz=!@ZDF_E&z6&P_t_XVC#H;Pog{h!ji|rlEn7)}FGPDf;Gi19SUoyV2lsj$cYx2E7N$9AL5kPS^6Aa& ze#Ba9c+Eb6_>CFwBbbh+T>$JVK9RE9Uq)KfJ?=D28CW{YXXz}TOJ5gV9KYKdaNqhm zcde~*-}*XRMxrY_lM-_N#MKPq1ucCA!~HMTIrG~)`1`k(xO`c*`R`LTi!lD)9`M-* zNBLj(jZh06eR(q}1wUSCSq-34jSBsOlHo0jGhDPJ%NYl!n33K2_THSJ)zbNwv0-#g7MhWMiFAolzw1$&~a9#NTtY?zr-&LwxUv3WbcJkec`^fBdtK)E!~q znBU`Z!Qu?(9g*S8gVW3@j2HUW0&fyt*!Lvy0k9{{P}n69;yd2_qG>Gd$+E3tx%HVE zKYVhGd)L<(C|f*VD5e{$1lt${VWaKemlOW}*JYmE7V@17dnu(SHw5B7h-U{v-gny& z_qYH(J^1>7sEy-}{(i{s)=XrUxjF1yJx`K_B0RTk%In`;b+fQdD|@meCL85 z4(#y=T(q;%M2ZXl!yD_I|NQ|rj@SvU^2Y_g7DA=s$fkq~7H7D8S&lzCGSfUKabHY4 z-=mW*pbq*dyXn{f80+A9p(5jV+tyuc;%35iCl~m^lVf~+%^1I0TVt@|$ft$07u}uO z(P>x@qnf~~mukG}>pS@VAN6wlywrq_5QHvz?^~a#@~5{AF*s)F%T8RC-#5|EIb3nf zD;cg>mgW6N=Qv^h_;`F^qjO<8tkAdW8ExOf8#d0!Z*SuXtk0C%pf;hXjj zzr`yQKljimfAz~@@+l$ZwSPxl)0lWQSh3VCT)HgFJ(thmrni=uUo_2QYNR{HiM}00K8i${gH~jYfGwI1lbKC816psePLAJSUx#^)%zWC@Uqcuk< z6RlI&-@@ccGzOkF@WYGxIO~x1)CA&wh0i@S$~AWm)05eGgUxmjqJSB$IXr<2kIZuQ z8}ppln0niddnLq&XSnKE{!iQ3T(+c5=PXEzbz}`7>%+Q%kbnM71);l_iN?Lq@s2ig z3x-dfQRKcq>F0{&S!y8+Rb6x8%)Sa8WIU)@xbO!9-1)-(*8~FFQuyzfn8NRw=(7)u zI*y&2;>Q>Fam)F=oVXy>yyLen;OZ7(w0$A}`t%s<212ra=M{eFz#0HVC%YEFykb-f zD5YgeC%m*%>y9`8_pYzA^4<~dUR$M*Hu&EDZGB|~YM~=#gdbhp$65Q>ClJ5p?;jrJ z>f49u$x2&SO~!pE17nU^Im0z498l9NTZX>MR&fp9GK$H%li4`nI(*Yp|Sm|4Oj;mPpDhR z#XlI}!I$dz=C#$L;==#+H5IPDeV9_43%_rokQx}Zy#3HL_gvA>M@}g;_dwX`X+K>i zeij4{{^Zs{e!n#!m+H*I4;@+qhE**cZ8+r<-jjKJzmb*{c+ zgnQRk$!DU+knL-A+mmRaQ(x9_+loGpJ0SI{CJV)d|Naei{@`0Xq7FCHzL||iV5nkQ z*z0k_n~S{f=o}D@^LIm!{+`6mqWJTV1&(*$I>h&$7>kBUJGO*%cmP0jPzOdGryi8z zZ{Jen%!AWY{gEczO%ILo(fdcJ2H<=95dsEAs*Xi{9>0Fi3})rbUYk;JK&*K=;H+=$ zV5sIud+jUyj%fY;6-VXx+*?a~T$t0%LI*#3a*WSBILd=B)#=Sj$JbgrJ^*0+LiP-t zv>?s7ho?DuPKr4NLopR~OihPNDPG}MObfq!zRHL08fo^7@9RnOO;n3I{oph|zPOJ< zO7^(AEe?il70Vgl+`;d*2J~dwJI*h_dtaa9+7t7HE*iD)y0j)2UvndM$Ck2X_2xQ1 zdAiC2>+5)4bPhYc@I#jb0F3X}Gg5Q7u<`siZ~8st(GeBKQd+2oj+)*70utAv&RLw{ zCztd!AF>(0Z@i?p7CO%P-T-&KP@9k$esQ=BG`802)^>iY&_%~B48VBNd5h0sH|UyH zS3K9d)Ywz5keYslAI3MK9IiQPVSDb5fsHoi_~}zsK6-z7w+_{~>i?l%4RhDCRr)7r zdarfSLsq-FhksX|L_0Y3<)Z~)@z7CMEcE7tmG_MBZ_iev zkpVm2>38$v6~6dzh2DapK7m!&{hja7iMsik?79F@`_2LRv~cCELp-%T#4}NWeEj8r zYi=JVpYGh@Dm5`Q020KyXy@VW70Y{nI!HBiRO*f^ZyjW)=1A?wh=b}*9lIJ(O^nb& zPfmDXZH?>i9-(ew^-Fd7C%EdbI#bt-0;u*fjfp}KJ+n~n{K=#>fP}IxD)^w){v^`? zNH8tXUANVWQmRb^g&7?Ad z({_MT)0(p&o;Z=4xKXTO;~b+1*3#=70!itUYj0Zt(58<&S5-@b2um0aH9l#4`Wy%A>Vc0$cN|L=f+0!N>h5*L2hbvbbS4=bH1e{k;KghBP4&W4XkuGf=v z(KOy5jJ|<350vT%&bdaJ3;`rvFjAc9J7*sysGe26cFuLIbY|DW4|{t?L!*;`mA~=} zy+^-GaDRynj+bo7U;|tjf(@c^MWxi#5Cg_XJRij1>XpF3DiQg{$aC9n<_n9f&9c0w zm+$q>cYdK1KePE%Qkm1J)!yvH9SSmEprA_$m6`$~IOm3d&4PO#<3C`0|7SxV=zFO7 eHG3)i0RJDx-QXzMsjznd000033$u9A+#5+r}x7Z>}qGT)E$IgU8CCVDoR48jIDI`k@ zAH~?pn!RSoGFc;e&eZq1oABj?d(vdbV@MP-I0ZZ<*Dw2s5Fsj$;F$+v#Gq#Aaw;{`M9j(tRudnacs{ zw=@nvWoh2{wE1({m8AhTHcZ^P8pHOxMM50_(hE(IZTuwbQN`63z9Il9rp~(J;;6|% zSe$Xz$?X6{Apod~$&25+7K9@_rdn0`Nn>!HD));&>FI3Rs^3$me?>884srfI+#YbD zrN40DT0A!Zw43!G?|ypG{%G3x?cos-WQ$t` zfPCCDqTqAr^soB$$D0wiSnVS7l~1l1!=t#P6b!58ovhKh7|7s70lkcVW$URyr z<|9?uBLHoYF#nj4l#100gYr_QImLeQpHndtQ`#ggICDxTX`{+LMkYLHV)~vRY>@iT zYV5u^+DBkSnKA>#7j?hqVO)|=%mRRJ%2xAvoF@MT4+O}RPXs4vwEw9Tk9Emw5B6;j zG)D(L2>{sC-qo$=|ipY+6)h& z3##wL<2SqYyD2#%0xBHv(u?E!%%3rC%?TchRdaB&juy20-b&1d9G_G-38divoFHW0!)hlhlnRhBY-|8WqmnBAo z->s;<&k?M}{*Ts>ty) z9p=Tsi4E<}E38Vbh{ArQ-Y6CT8ns&C`Z*yt1A#@5D@pS3P7Z%lSK3B1@)1nTaZJM){kUp&b5|ABR-MXhj%Z8ZK-o+3)PU?a}V(nK`B`8YsvKZmVuS@`#922w)d-`^5ozH zbzV5Uf49;xr42RmYoZ|46oU(K`K%&m_mZqtmddeqh>Rj%OpS5u)U%csGrZASJYjEl zkTS10IvvMF(?&44Dc-Cti)pfo7-hqYXfO^xpU@|37lnJ2Od~&KCpg_3h-#dSyyVKx z<&+5!E0wvQaodr7_puoUWFVMzP+Rmc#pFKL<_bXE_Z+6k^n3vNY-KVCM)~k{G;;U1 znK&*dSqu&@a+tEtwfy(sZin8Sddm?ffs(DVKHuM?N71S{iBv)Xo)~=h z8x?7iDuVI9D(zMu62nI2$KYr(Cx|bzysS_`b^>J~20!T1kCfjANXVIx(2qrdU+FXJ z#)>+}pfC#*&YRNDD8PrG%i^ORfYk#7&uG%4h<_?Tu9EaQmfZBC~H;atE=;^@ZdmrF3J3D@ru7Z$5WS}3)a5IRb+OmaO58rLT(s=C3 zK&NFKyaa8rR1Ud^oQqJM)rLa>zC1!GI}TsBMMOt=6iLw|gZCnOZkhAINE`+H1krBO zAh3sr1dI!HCVHwD{DVSrLe>0}<^h2Or2XE?SOt^?E*;!2wAD|Zg`}~9AU1D;luZoQ zt7nB9bb7jV!@wy;1nDK8$1n7Hvs~0bFlG1Xx>J9aqQoNv0-v#PQY4sHIBdDjDl00U zoBS5q1oywoYdv-z-#&_zY4Q-rtQK8_*zWZirwfu$4h;jvbvPX55Ldy<3BI%jtWy96 zM>*~7)GJ?Mhj<<)1DxVky2>bWlpDbinw4`yW7yD|T{>*dBX1wv@h`8<3f&#Fnx`$pV2#+F!H}HFV-aR<2-aq_& zk2IEn5Sn0%{;56EY4afN90o(xDZSOB$v~1~0pccQ_cU{q!x{v$6Uh8UqK%eMr%q2H zNq6^?ioO0k+}fWZ>+imPTG|la^nN8rdoZQ{%ui&RD3sLMbdLMunxEu3ffKc_O{DUe zk3OOO%|0Any5RHWB|R=iq4APXPQ-F!Erx+)-Htfpvuye<`59-TVX+ChjMsdG%GG1} zH0Sk?5%x$V(T@6RN>OT$V#D`rWYmnyLq|hRz*m)lFbem3$mP_FN{|NsNmebt>nAc? z=m3x(7AJ^2#4n1-C@T*)jemvn$COk1rT|q!Zcm#gCdMOQh-&(IX=Rng>ul@ZXE?mn z9`n!CfV|gTZtk2z^J5)1@S~YPWcSQm$oDHkdu4TA`Cx{eTlexI$6wxZC`P4>RxA01 zsiqazZ`}e?{q>*C<{Wd;g0-rz{b|y)*9^Db)+Wg`Nt9!9NS9XodZu#uCCf&~izZ2{ zaV-+d&crJ@jrR!QmDWet2mYZbhX;o_2S|`EKUvxKPuFDN&LUasG3;84;CvAjG$Xlv{mUh^cQnuKKN5?F&Cq-R1(47<2sjpH(NUY!yQ}oYxTen5-_G zSMMG0`Hbao;W*BZ9LINYJJ||4P7L_6On!gn_@5T5EYZd^={nxrU6@>gSMW`w+;|r6 zlPXOaGmZ1bCt-q|*=BD^>v&dT&fbnnIYj2ITb6dZNy8I`!Q?g)3v_{LL_U$WP_Pi> zxi=cn)fhnX%`_o9qPO{YXP6 zdj&wFNUvlxSL5rZrmhm;_}ho{TT}RlEwE(g5OA%5Z!6J>W;tf)Lj&%tb+@ z)6W5btPqCVIXeEyBu8(`C%Fyy1Z>=HGj)?0cbH`X)UB4McID(mE`(N`LYNk{uJf!| zZo&E$Ky}36$f+h5o;{u9AX`FRtRysnO}Z`(dgSd027nkM(>ecJ{2%Q@Mrfq~P-ur< zLMkui^Xpm@Xb5gwV}}%lww^GO+Wjp&aQqBeg+M~5UxftbaGAIJ$z#=vIF_gGLK_OZ zCH2Twu-606?e?yvPhVZF`UtefqPXU^9RvzrBb)LZJ&XI3ogm^%B%y!9$;Yr!@Vu`P zNYV(#CBXOZo6vG3h3+`YThBcqj*^SH%(cHdV0c@2an1T<&}YGR=2B2T36+C*i^G)z zGeJVho}RTB@^?G|?Cpj)p)wut9)Xe&@CE}1@wO;zmTB>;nr z26!I<>H5FLfA`_vOYrYw_}3Erw?(AIGu$#l*-sm-4RcUcp=Fg6(jIK9!2uiSx2bAu zp~I677~lc9mV=D34l{^D>vW)$0oU@Hu*i4^bq9gW1;OUQ;|;u!10s7f%Uxfknql}! zXG5D{%##z$aC?|e^euZKrUgP!GFYU-K$`9rsk)-%?pOBWMB}m&c^izlav-L^GyK%Q z>_xJcRw#_gLMDEr(Cn$J#~?U?7zpBzhbf&)OG~H_^Flm~ zj%MFA0htRbDlx3((clt)CRg&+y-3CdbrlFI_djNRc@12;LD&PV9crpMww%~XlRz-L zEA|>ePA8zpMb{IJ#1m+@`}RUlE+LMd>osD5u>|F7Y0mrFcSnr!#n5UCyFwfrjDOHW z-dQ&+(gD9X%6slFiYErgVP$GiXbc8QS(NM5elt9!eWfhjZ!7jW2_cw4_mJwe7JS*3 z4nzjx1aOWOYB8aavMSPV)8twf)DDBnBLwyuxJ8{2X>1>G+vpGO@O?Mu;-)$sLMBNlj~fG z{23bl7)#57%67quv=-R`BEAa{nR60D^n*aS3nxESBlh8M?&zy7O~$T-ti?k@<<%aU z30QV%Fc2oA6Y}9uKvhFGcJJl=B$T+z;$cbl0x%Lo$@k$z4_})eMwCFGn zXu$5h0lB3UHS_zoilkf5#V8z=JBN~;D{c8Z;7Ay$a-7?#!^=P}zQx5@{aF20uWGjq z>ig&RsVxNC2|P+Xk2fU>DW6|sOu>tKdwAg2m*Gn=Kq+tghMiR6&TiGa=5(5v{w>eU zde}UDEIKv8;7z6Cu?;Q%+u)u+T0+`B9XR9TalH(MqAHg;XhS4JJ~~g;E9L8+(d=Ii z(pALa^$UasFN7uuNWKqQ`x32D@NC;?y;ltDpg1~mE!anCx5!woW=};O#b{|)#n$czyL$X}FV=m*;k#)SUZ?WKH=K?6`4}A+qOh4_DCIP4a51*3WFFlp z{hW67L9xF`H^3z)*nZmBD%j-Un(jqZ3xl(D3NcbTC_)sQIcIF8)@ZN-!8o4F4M8xn z`!z%gDnc!4^Y3`JOg)C@vbgvmcCgjS^gg!VfQ+`@z5%c5$&E8p9i}~dd}~C&j5tcs z{bFj+cqFvg!#sgT3+wH&u1J{9{c9Y3~ZYukdFRkS3YJL+m*N+ z=qI0NQ*r8f>^w6f9Lbcm^(hP-)<2@jxOvyh>Zl5Q-bUAzA73S;(-XwBzC*gjaJ-{p zE^u!)K=?DY6 zgRE2gO)s>EEX_`#&jkXr{4N)-#ZLyc1(r!^KZdH36D;JFUQvr;@db&k-`*+JW7D7s zvF**V6i%lPWiJT%#IThWmJD)IPD-AGfh&6Y55pJmC`9*jjpNQy8Z--2ZncsYWwQ71<8Mq*j`^uOlCJ%4)o%9O zj(IPsmFZU&c%X(?Sda0}Vh0YNlt4I{fd7%U>UL$Gl>Vlx9gYHaWgMpk`RzyEPWXB@ zRgJCZMO4_{%$l)wRY0U-ylngr1=tKJPk2j+j6Lv{5GubkV?7Cb;-v8QW0W=LJTsj} zgYZ+w(^Z1u(`x#ulR@Tfd)%)`8&-~z zBEL#iB{kjlcO`y@vo-U^XazCcntmkh2+xj*OZ@P%iqA_@>mPBH12WBWIy9Dnr>}b+ z;w#lx#-F&`=L?k&LHj=T;3y{dsA!Cq;?_!-8@a!rJsi$a;Fd4se~Es!4|k-V%e-a* zjm)?**gdJbqp@ktQ)V6zNy>s1YW{pHIX- z>?UR*<4Rc1b37xz*}ZTsVDyjxWLf02j!3Z+Ueo%o@!DKD-<(?dGfX3?Dl_`v@UVaE zeqj?oVS+bpIviJ*?C8F9%`#HE)vqnOl(FzZ25C?&olRS}_i%hvS5?ZT6(P4~zpU~( zuKQZ@WIT6OW4p=%p73Rx=%QuI*Dd`jlm4UtHLvy1=xRJSKanIOu19-t%W<`0@~vk} zBwLXJR5pgKbsMQz?>bdA&rG*}{(eRG=A3oMm0HP{#sQZ&N9!R??D6NB%*fY%e$5D` z-8w$}y!FSJRTy+lREaL_B6!pPP)m#Z-}l29y59E{l}{MB!ex-7^oPY7YqXf`{$&l# zg!riAFRGA>$pE1+ijLV2MeBzF5(-xDx0Kk(z2HP4A(V97eO41DRGDd-5!)f+7*!2G zUKpr$2UxwSt>wMJ$Ha#Q1<~B`y)l_DIdP8a&LCZmDpBbO*xPbT?;nbyDe7@}km$JIsh;GROdc4_QCwT#Y^4pCT!njU7M+nbr$4@qGb86+Q&2dqf&2q zF}G(3&L0sx@3++Pp~$K43JUBbK#k`sYcg#q`H(i76kqB&8I>Z2(uFjhu}RtbEZI zxmtU-Yh*Lx`EHoJHY)oyF}^0<{bb8I9XMo8B{4BYr8nxphuiRgXip47PJSX@x5bF( z$rH!4MBuT=|L3;^MboW4Pgy+rH$Fvb8%CFCPyecd+g6r2E|$lN$sIcdm*sDP9X(dy z#raXj?kSu*dgt}Tl%3gRi_!|0N(*@a%5C3_9y_mz?$d$W*zX2^&5>LyIwqD^2MX}; zPP)ZY3YyC2|5TbsTGeJ~^N(COlm7WcFf_alKc*`|cZE0d_iqN;TwS=6&gYB5vwx7L z*HmuB(p>)ghZV6Pj@Mg12dNt68m literal 0 HcmV?d00001 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 000000000..f713dbc52 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,68 @@ +use tauri::webview::NewWindowResponse; + +// Navigation never promotes a remote document into the privileged main window. +fn local_navigation(url: &tauri::Url) -> bool { + if !url.username().is_empty() || url.password().is_some() || url.port().is_some() { + #[cfg(debug_assertions)] + return url.origin().ascii_serialization() == "http://127.0.0.1:5567" + && url.username().is_empty() + && url.password().is_none(); + #[cfg(not(debug_assertions))] + return false; + } + matches!( + (url.scheme(), url.host_str()), + ("tauri", Some("localhost")) | ("http", Some("tauri.localhost")) + ) +} + +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_fs::init()) + // JS routes approved links; do not let the opener auto-open every popup. + .plugin( + tauri_plugin_opener::Builder::new() + .open_js_links_on_click(false) + .build(), + ) + .setup(|app| { + tauri::WebviewWindowBuilder::from_config(app, &app.config().app.windows[0])? + .on_navigation(local_navigation) + .on_new_window(|_, _| NewWindowResponse::Deny) + .build()?; + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running TypeWords desktop"); +} + +#[cfg(test)] +mod tests { + use super::local_navigation; + + #[test] + fn main_window_keeps_remote_documents_out() { + for url in ["http://tauri.localhost/words", "tauri://localhost/setting"] { + assert!(local_navigation(&url.parse().unwrap()), "{url}"); + } + for url in [ + "https://github.com/zyronon/TypeWords", + "https://tauri.localhost/", + "http://tauri.localhost.evil.test/", + "http://tauri.localhost:5567/", + "http://user@tauri.localhost/", + "file:///tmp/index.html", + "data:text/html,hello", + ] { + assert!(!local_navigation(&url.parse().unwrap()), "{url}"); + } + assert_eq!( + local_navigation(&"http://127.0.0.1:5567/".parse().unwrap()), + cfg!(debug_assertions) + ); + assert!(!local_navigation( + &"http://127.0.0.1:5568/".parse().unwrap() + )); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 000000000..e4bb2412c --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + typewords_desktop_lib::run(); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 000000000..cd8612648 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "TypeWords", + "version": "0.1.3", + "identifier": "io.github.zyronon.typewords", + "build": { + "beforeDevCommand": "pnpm run desktop:frontend:dev", + "beforeBuildCommand": "pnpm run desktop:frontend:build", + "devUrl": "http://127.0.0.1:5567", + "frontendDist": "../.output/public" + }, + "app": { + "withGlobalTauri": false, + "windows": [ + { + "label": "main", + "title": "TypeWords", + "url": "index.html", + "width": 1200, + "height": 800, + "minWidth": 800, + "minHeight": 600, + "useHttpsScheme": false, + "create": false + } + ], + "security": { + "capabilities": [ + "main" + ], + "csp": { + "default-src": "'self'", + "base-uri": "'self'", + "object-src": "'none'", + "frame-src": "'none'", + "form-action": "'none'", + "script-src": "'self'", + "style-src": "'self' 'unsafe-inline'", + "img-src": "'self' data: blob:", + "font-src": "'self' data:", + "media-src": "'self' blob: https://dict.youdao.com https://fanyi.baidu.com", + "connect-src": "'self' ipc: http://ipc.localhost https://dict.youdao.com https://fanyi.baidu.com https://*.supabase.co" + }, + "devCsp": { + "default-src": "'self'", + "base-uri": "'self'", + "object-src": "'none'", + "frame-src": "'none'", + "form-action": "'none'", + "script-src": "'self' 'unsafe-inline' 'unsafe-eval'", + "style-src": "'self' 'unsafe-inline'", + "img-src": "'self' data: blob:", + "font-src": "'self' data:", + "media-src": "'self' blob: https://dict.youdao.com https://fanyi.baidu.com", + "connect-src": "'self' ipc: http://ipc.localhost https://dict.youdao.com https://fanyi.baidu.com https://*.supabase.co http://127.0.0.1:5567 ws://127.0.0.1:5567 ws://127.0.0.1:24678 ws://localhost:24678" + } + } + }, + "bundle": { + "active": false, + "icon": [ + "icons/icon.ico" + ] + } +} diff --git a/src-tauri/tauri.internal.conf.json b/src-tauri/tauri.internal.conf.json new file mode 100644 index 000000000..d5752e59a --- /dev/null +++ b/src-tauri/tauri.internal.conf.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "active": true, + "targets": ["nsis"], + "createUpdaterArtifacts": false, + "shortDescription": "TypeWords internal testing build (unsigned)", + "windows": { + "allowDowngrades": false, + "webviewInstallMode": { "type": "downloadBootstrapper", "silent": true }, + "nsis": { + "installMode": "currentUser", + "languages": ["SimpChinese", "English"], + "displayLanguageSelector": true + } + } + } +} diff --git a/tests/desktop/account-sync.test.mjs b/tests/desktop/account-sync.test.mjs new file mode 100644 index 000000000..e31f5d84b --- /dev/null +++ b/tests/desktop/account-sync.test.mjs @@ -0,0 +1,566 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { runInNewContext } from 'node:vm' +import test from 'node:test' +import ts from 'typescript' +import { createClient } from '@supabase/supabase-js' + +const root = process.env.TYPEWORDS_TEST_ROOT || process.cwd() +const types = ['dict', 'setting', 'practice_word', 'practice_article'] +const userA = '00000000-0000-4000-8000-000000000001' +const userB = '00000000-0000-4000-8000-000000000002' +const rows = () => + types.map(type => ({ + type, + data: { fixture: true }, + data_version: 1, + updated_at: '2026-09-16T00:00:00Z', + })) +const deferred = () => { + let resolve, reject + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} +function session(id = userA, token = `synthetic-token-${id}`) { + return { access_token: token, user: { id, email: `${id}@example.invalid`, is_anonymous: false } } +} +function load(path, modules) { + const exports = {} + runInNewContext( + ts.transpileModule(readFileSync(resolve(root, path), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText, + { + exports, + URL, + structuredClone, + AbortController, + require(name) { + assert.ok(name in modules, `Unexpected import: ${name}`) + return modules[name] + }, + } + ) + return exports +} +function harness(options = {}) { + let callback, + unsubscribed = false, + stopped = false + const requests = [] + const authCalls = [] + const auth = { + onAuthStateChange(fn) { + callback = fn + return { + data: { + subscription: { + unsubscribe() { + unsubscribed = true + }, + }, + }, + } + }, + async getSession() { + return options.initial ? await options.initial.promise : { data: { session: null }, error: null } + }, + async signInWithPassword(credentials) { + authCalls.push({ method: 'login', ...credentials }) + if (options.login) return await options.login.promise + callback('SIGNED_IN', session()) + return { data: { session: session() }, error: null } + }, + async signOut(args) { + authCalls.push({ method: 'logout', ...args }) + if (options.logout) return await options.logout.promise + callback('SIGNED_OUT', null) + return { error: null } + }, + async stopAutoRefresh() { + stopped = true + }, + } + const sdk = { + createClient(url, key, config) { + if (!config?.accessToken) return { auth } + return createClient(url, key, { + ...config, + global: { + fetch: async (url, init) => { + const request = { + url: new URL(url), + method: init.method, + headers: new Headers(init.headers), + body: init.body ? JSON.parse(init.body) : null, + } + requests.push(request) + if (options.response) return await options.response(request) + if (request.method === 'POST') return new Response(null, { status: 201 }) + const uid = request.url.searchParams.get('user_id').slice(3) + const selected = request.url.searchParams.get('type').slice(4, -1).split(',') + return Response.json( + rows() + .filter(row => selected.includes(row.type)) + .map(row => ({ ...row, user_id: uid })) + ) + }, + }, + }) + }, + } + const platform = load('app/core/platform/sync.ts', { '@supabase/supabase-js': sdk }) + const api = load('app/core/platform/accountSync.ts', { '@supabase/supabase-js': sdk, './sync': platform }) + const manager = api.createAccountSync('https://synthetic.supabase.co', 'sb_publishable_synthetic', true) + return { + manager, + requests, + authCalls, + emit: (event, value) => callback(event, value), + cleanupState: () => ({ unsubscribed, stopped }), + } +} +async function signedIn(t, options) { + const h = harness(options) + t.after(() => h.manager.dispose()) + await h.manager.ready + await h.manager.signIn('fixture@example.invalid', 'not-a-real-password') + return h +} + +test('account scope abort signal follows token, account, logout and disposal lifetime', async t => { + const h = await signedIn(t) + let scope = h.manager.capture() + const first = scope.signal + h.emit('SIGNED_IN', session()) + assert.equal(first.aborted, false) + h.emit('TOKEN_REFRESHED', session(userA, 'replacement-token')) + assert.equal(first.aborted, true) + scope = h.manager.capture() + h.emit('SIGNED_IN', session(userB)) + assert.equal(scope.signal.aborted, true) + scope = h.manager.capture() + await h.manager.signOut() + assert.equal(scope.signal.aborted, true) + await h.manager.signIn('fixture@example.invalid', 'synthetic') + scope = h.manager.capture() + h.manager.dispose() + assert.equal(scope.signal.aborted, true) +}) + +test('account invalidation observers see the new identity, never the retired token', async t => { + const h = await signedIn(t) + let replacement + h.manager.capture().signal.addEventListener('abort', () => { + replacement = h.manager.capture() + }) + h.emit('SIGNED_IN', session(userB)) + assert.equal(replacement.userId, userB) + assert.equal(replacement.signal.aborted, false) + await replacement.read(['dict']) + assert.equal(h.requests[0].headers.get('authorization'), `Bearer synthetic-token-${userB}`) +}) + +test('account login does not authorize automatic writes or expose tokens/passwords', async t => { + const h = await signedIn(t) + assert.equal(h.manager.getState().userId, userA) + assert.equal(h.manager.getState().active, false) + assert.throws(() => h.manager.capture('automatic'), /首次同步/) + assert.equal(h.requests.length, 0) + const state = JSON.stringify(h.manager.getState()) + assert.ok(!state.includes('synthetic-token')) + assert.ok(!state.includes('not-a-real-password')) + await assert.rejects(h.manager.capture().upsert(rows()), /只读/) + assert.equal(h.requests.length, 0) +}) + +test('installed SDK reads use a fixed subject filter and fixed bearer token', async t => { + const h = await signedIn(t) + const scope = h.manager.capture() + const result = await scope.read(types) + assert.equal(result.length, 4) + assert.equal(h.requests[0].url.pathname, '/rest/v1/typewords_data') + assert.equal(h.requests[0].url.searchParams.get('user_id'), `eq.${userA}`) + assert.equal(h.requests[0].headers.get('authorization'), `Bearer synthetic-token-${userA}`) + assert.throws(() => h.manager.capture('automatic'), /首次同步/) + h.manager.completeInitialSync(scope) + assert.equal(h.manager.getState().active, true) +}) + +test('installed SDK upsert uses account conflict key and overrides injected ownership', async t => { + const h = await signedIn(t) + const data = rows().map(row => ({ ...row, user_id: userB })) + const scope = h.manager.capture('initial-upload') + const upload = scope.upsert(data) + data[0].data.fixture = false + data[0].user_id = userB + await upload + const request = h.requests[0] + assert.equal(request.method, 'POST') + assert.equal(request.url.searchParams.get('on_conflict'), 'user_id,type') + assert.ok(request.body.every(row => row.user_id === userA)) + assert.equal(request.body[0].data.fixture, true) + assert.equal(h.manager.getState().active, false) + h.manager.completeInitialSync(scope) + await h.manager.capture('automatic').upsert([rows()[0]]) +}) + +test('empty, partial and metadata reads cannot complete initial sync', async t => { + for (const result of [[], [{ ...rows()[0], user_id: userA }]]) { + const h = await signedIn(t, { response: async () => Response.json(result) }) + const scope = h.manager.capture() + await scope.read(types) + assert.throws(() => h.manager.completeInitialSync(scope), /尚未成功/) + } + const h = await signedIn(t) + const metadata = h.manager.capture() + await metadata.read(types, true) + assert.throws(() => h.manager.completeInitialSync(metadata), /尚未成功/) + const partial = h.manager.capture('initial-upload') + await partial.upsert([rows()[0]]) + assert.throws(() => h.manager.completeInitialSync(partial), /尚未成功/) +}) + +test('malformed and foreign account responses are rejected', async t => { + for (const result of [ + null, + {}, + [null], + [{ ...rows()[0], user_id: userB }], + [ + { ...rows()[0], user_id: userA }, + { ...rows()[0], user_id: userA }, + ], + [{ ...rows()[0], user_id: userA, type: 'unexpected' }], + ]) { + const h = await signedIn(t, { response: async () => Response.json(result) }) + const scope = h.manager.capture() + await assert.rejects(scope.read(types), /远端账号数据无效/) + assert.throws(() => h.manager.completeInitialSync(scope), /尚未成功/) + } +}) + +test('wrong or duplicate types fail before making a network request', async t => { + const h = await signedIn(t) + assert.throws(() => h.manager.capture('unexpected'), /无效/) + for (const invalid of [[], ['dict', 'dict'], ['unknown']]) { + await assert.rejects(h.manager.capture().read(invalid), /无效/) + await assert.rejects( + h.manager.capture('initial-upload').upsert(invalid.map(type => ({ ...rows()[0], type }))), + /无效/ + ) + } + assert.equal(h.requests.length, 0) +}) + +test('same-user SIGNED_IN preserves scope; refresh invalidates old tokens without resetting direction', async t => { + const h = await signedIn(t) + const scope = h.manager.capture() + await scope.read(types) + h.manager.completeInitialSync(scope) + h.emit('SIGNED_IN', session()) + scope.assertCurrent() + assert.equal(h.manager.getState().active, true) + h.emit('TOKEN_REFRESHED', session(userA, 'refreshed-synthetic-token')) + assert.throws(scope.assertCurrent, { name: 'StaleAccountSyncError' }) + await h.manager.capture('automatic').read(types) + assert.equal(h.requests.at(-1).headers.get('authorization'), 'Bearer refreshed-synthetic-token') +}) + +test('account switch revokes old scopes and requires a new initial direction', async t => { + const h = await signedIn(t) + const old = h.manager.capture() + await old.read(types) + h.manager.completeInitialSync(old) + h.emit('SIGNED_IN', session(userB)) + assert.equal(h.manager.getState().userId, userB) + assert.equal(h.manager.getState().active, false) + assert.throws(() => h.manager.completeInitialSync(old), { name: 'StaleAccountSyncError' }) + await assert.rejects(old.read(types), { name: 'StaleAccountSyncError' }) + await h.manager.capture().read(types) + assert.equal(h.requests.at(-1).url.searchParams.get('user_id'), `eq.${userB}`) +}) + +test('a scope invalidated before SDK token resolution cannot send under another account', async t => { + const h = await signedIn(t) + const write = h.manager.capture('initial-upload').upsert(rows()) + h.emit('SIGNED_IN', session(userB)) + await assert.rejects(write, { name: 'StaleAccountSyncError' }) + assert.equal(h.requests.length, 0) +}) + +for (const method of ['read', 'upsert']) { + test(`late ${method} response is rejected after an account switch`, async t => { + const started = deferred(), + response = deferred() + const h = await signedIn(t, { + response: async () => { + started.resolve() + return await response.promise + }, + }) + const scope = h.manager.capture('initial-upload') + const request = method === 'read' ? scope.read(types) : scope.upsert(rows()) + await started.promise + h.emit('SIGNED_IN', session(userB)) + response.resolve( + method === 'read' + ? Response.json(rows().map(row => ({ ...row, user_id: userA }))) + : new Response(null, { status: 201 }) + ) + await assert.rejects(request, { name: 'StaleAccountSyncError' }) + assert.equal(h.manager.getState().userId, userB) + assert.equal(h.manager.getState().active, false) + assert.equal(h.requests[0].headers.get('authorization'), `Bearer synthetic-token-${userA}`) + }) +} + +test('logout immediately invalidates in-flight scopes and stays blocked on failure', async t => { + const logout = deferred() + const h = await signedIn(t, { logout }) + const scope = h.manager.capture() + const done = h.manager.signOut() + assert.throws(scope.assertCurrent, { name: 'StaleAccountSyncError' }) + assert.throws(() => h.manager.capture(), /登录/) + h.emit('TOKEN_REFRESHED', session()) + assert.equal(h.manager.getState().userId, null) + logout.resolve({ error: new Error('offline logout') }) + await assert.rejects(done, /offline logout/) + h.emit('SIGNED_IN', session()) + assert.equal(h.manager.getState().userId, null) + assert.equal(h.manager.getState().busy, null) + assert.equal(h.authCalls.at(-1).scope, 'local') +}) + +test('logout cancels a delayed login result without resurrecting its account', async t => { + const login = deferred() + const h = harness({ login }) + t.after(() => h.manager.dispose()) + await h.manager.ready + const pending = h.manager.signIn('fixture@example.invalid', 'synthetic') + await h.manager.signOut() + h.emit('SIGNED_IN', session()) + login.resolve({ data: { session: session() }, error: null }) + await assert.rejects(pending, { name: 'StaleAccountSyncError' }) + assert.equal(h.manager.getState().userId, null) + assert.equal(h.manager.getState().busy, null) +}) + +test('external signout during login invalidates its result and releases busy state', async t => { + const login = deferred() + const h = harness({ login }) + t.after(() => h.manager.dispose()) + await h.manager.ready + const pending = h.manager.signIn('fixture@example.invalid', 'synthetic') + h.emit('SIGNED_OUT', null) + login.resolve({ data: { session: session() }, error: null }) + await assert.rejects(pending, { name: 'StaleAccountSyncError' }) + assert.equal(h.manager.getState().busy, null) + assert.equal(h.manager.getState().userId, null) +}) + +test('late initial session cannot overwrite a subsequent login', async t => { + const initial = deferred() + const h = harness({ initial }) + t.after(() => h.manager.dispose()) + await h.manager.signIn('fixture@example.invalid', 'synthetic') + initial.resolve({ data: { session: session(userB) }, error: null }) + await h.manager.ready + assert.equal(h.manager.getState().userId, userA) +}) + +test('late INITIAL_SESSION event cannot replace a completed explicit login', async t => { + const h = await signedIn(t) + const scope = h.manager.capture() + h.emit('INITIAL_SESSION', session(userB)) + assert.equal(h.manager.getState().userId, userA) + scope.assertCurrent() +}) + +test('failed login blocks stale stored events but a new explicit login can recover', async t => { + const login = deferred() + const options = { login } + const h = harness(options) + t.after(() => h.manager.dispose()) + await h.manager.ready + const pending = h.manager.signIn('fixture@example.invalid', 'synthetic') + await assert.rejects(h.manager.signIn('second@example.invalid', 'synthetic'), /尚未结束/) + login.resolve({ data: { session: null }, error: new Error('invalid credentials') }) + await assert.rejects(pending, /invalid credentials/) + h.emit('SIGNED_IN', session()) + assert.equal(h.manager.getState().userId, null) + assert.equal(h.manager.getState().busy, null) + options.login = null + await h.manager.signIn('fixture@example.invalid', 'synthetic') + assert.equal(h.manager.getState().userId, userA) + assert.equal(h.manager.getState().active, false) +}) + +test('successful logout allows a later explicit login, still without upload authorization', async t => { + const h = await signedIn(t) + await h.manager.signOut() + await h.manager.signIn('fixture@example.invalid', 'synthetic') + assert.equal(h.manager.getState().userId, userA) + assert.equal(h.manager.getState().active, false) +}) + +test('disposing during login rejects its late result and cannot revive the manager', async t => { + const login = deferred() + const h = harness({ login }) + t.after(() => h.manager.dispose()) + await h.manager.ready + const pending = h.manager.signIn('fixture@example.invalid', 'synthetic') + h.manager.dispose() + login.resolve({ data: { session: session() }, error: null }) + await assert.rejects(pending, { name: 'StaleAccountSyncError' }) + assert.equal(h.manager.getState().userId, null) + assert.throws(() => h.manager.capture(), { name: 'StaleAccountSyncError' }) +}) + +test('restored sessions require initial direction; anonymous sessions are rejected', async t => { + const initial = deferred() + const h = harness({ initial }) + t.after(() => h.manager.dispose()) + initial.resolve({ data: { session: session() }, error: null }) + await h.manager.ready + assert.throws(() => h.manager.capture('automatic'), /首次同步/) + h.emit('SIGNED_IN', { ...session(), user: { id: userB, is_anonymous: true } }) + assert.equal(h.manager.getState().userId, null) + assert.throws(() => h.manager.capture(), /登录/) +}) + +test('disposed/project-replaced managers cannot accept events, complete sync, or send requests', async t => { + const h = await signedIn(t) + const scope = h.manager.capture() + await scope.read(types) + h.manager.dispose() + h.manager.dispose() + h.emit('SIGNED_IN', session(userB)) + assert.equal(h.manager.getState().userId, null) + assert.deepEqual(h.cleanupState(), { unsubscribed: true, stopped: true }) + assert.throws(() => h.manager.completeInitialSync(scope), { name: 'StaleAccountSyncError' }) + await assert.rejects(scope.read(types), { name: 'StaleAccountSyncError' }) +}) + +test('failed REST writes do not enable automatic synchronization', async t => { + const h = await signedIn(t, { + response: async () => Response.json({ code: '42501', message: 'synthetic RLS rejection' }, { status: 403 }), + }) + const scope = h.manager.capture('initial-upload') + await assert.rejects(scope.upsert(rows()), { code: '42501', message: 'synthetic RLS rejection' }) + assert.throws(() => h.manager.completeInitialSync(scope), /尚未成功/) + assert.equal(h.manager.getState().active, false) +}) + +test('caller mutations cannot turn a partial transfer into completed initial sync', async t => { + const started = deferred(), + response = deferred() + const h = await signedIn(t, { + response: async () => { + started.resolve() + return await response.promise + }, + }) + const scope = h.manager.capture('initial-upload') + const partial = [rows()[0]] + const request = scope.upsert(partial) + await started.promise + partial.push(...rows().slice(1)) + response.resolve(new Response(null, { status: 201 })) + await request + assert.throws(() => h.manager.completeInitialSync(scope), /尚未成功/) + assert.equal(h.requests[0].body.length, 1) +}) + +test('scope from another project cannot authorize this project', async t => { + const first = await signedIn(t), + second = await signedIn(t) + const scope = first.manager.capture() + await scope.read(types) + assert.throws(() => second.manager.completeInitialSync(scope), /尚未成功/) + assert.equal(second.manager.getState().active, false) +}) + +test('installed Auth and REST SDKs complete a synthetic password login/read/upload/local logout', async t => { + const requests = [] + const expires = Math.floor(Date.now() / 1000) + 3600 + const encode = value => Buffer.from(JSON.stringify(value)).toString('base64url') + const token = `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode({ + sub: userA, + aud: 'authenticated', + role: 'authenticated', + exp: expires, + iat: expires - 3600, + iss: 'https://synthetic.supabase.co/auth/v1', + })}.synthetic-signature` + const sdk = { + createClient(url, key, config = {}) { + return createClient(url, key, { + ...config, + auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false }, + global: { + fetch: async (url, init) => { + const parsed = new URL(url) + requests.push({ url: parsed, method: init.method, headers: new Headers(init.headers) }) + if (parsed.pathname === '/auth/v1/token') { + assert.equal(parsed.searchParams.get('grant_type'), 'password') + assert.deepEqual(JSON.parse(init.body), { + email: 'fixture@example.invalid', + password: 'synthetic-password', + gotrue_meta_security: {}, + }) + return Response.json({ + access_token: token, + refresh_token: 'synthetic-refresh-token', + token_type: 'bearer', + expires_in: 3600, + expires_at: expires, + user: { + id: userA, + email: 'fixture@example.invalid', + is_anonymous: false, + app_metadata: {}, + user_metadata: {}, + }, + }) + } + if (parsed.pathname === '/auth/v1/logout') { + assert.equal(parsed.searchParams.get('scope'), 'local') + return new Response(null, { status: 204 }) + } + assert.equal(parsed.pathname, '/rest/v1/typewords_data') + assert.equal(new Headers(init.headers).get('authorization'), `Bearer ${token}`) + if (init.method === 'POST') { + assert.ok(JSON.parse(init.body).every(row => row.user_id === userA)) + return new Response(null, { status: 201 }) + } + return Response.json(rows().map(row => ({ ...row, user_id: userA }))) + }, + }, + }) + }, + } + const platform = load('app/core/platform/sync.ts', { '@supabase/supabase-js': sdk }) + const api = load('app/core/platform/accountSync.ts', { '@supabase/supabase-js': sdk, './sync': platform }) + const manager = api.createAccountSync('https://synthetic.supabase.co', 'sb_publishable_synthetic', true) + t.after(() => manager.dispose()) + await manager.ready + await manager.signIn('fixture@example.invalid', 'synthetic-password') + assert.equal(manager.getState().userId, userA) + const scope = manager.capture('initial-upload') + await scope.read(types) + await scope.upsert(rows()) + manager.completeInitialSync(scope) + await manager.signOut() + assert.equal(manager.getState().userId, null) + assert.equal(manager.getState().active, false) + assert.throws(scope.assertCurrent, { name: 'StaleAccountSyncError' }) + assert.deepEqual( + requests.map(request => request.url.pathname), + ['/auth/v1/token', '/rest/v1/typewords_data', '/rest/v1/typewords_data', '/auth/v1/logout'] + ) +}) diff --git a/tests/desktop/backup-browser-roundtrip.mjs b/tests/desktop/backup-browser-roundtrip.mjs new file mode 100644 index 000000000..5b3e426b6 --- /dev/null +++ b/tests/desktop/backup-browser-roundtrip.mjs @@ -0,0 +1,37 @@ +// Return scripts for Chrome DevTools evaluate_script in a NEW isolated context. +// 1. Open /setting, run importScript(zipBase64). +// 2. Reload /setting, run exportScript(), save returned ZIP as decoded bytes. +// Real UI handlers perform export/import; only the file picker and download are captured. +export function importScript(zipBase64) { + return `async () => { + const config=JSON.parse(localStorage.getItem('supabase_config')||'{}'); + if(config.url||config.key)throw Error('Use a clean isolated browser context'); + const find=text=>[...document.querySelectorAll('*')].filter(x=>x.childElementCount===0&&x.textContent.trim()===text).at(-1); + document.querySelectorAll('.tab')[5].click();await new Promise(r=>setTimeout(r,100)); + find('Import Data Restore').click();await new Promise(r=>setTimeout(r,100)); + find('数据备份').click(); + for(let i=0;i<100&&!document.querySelector('input[type=file]');i++)await new Promise(r=>setTimeout(r,100)); + const input=document.querySelector('input[type=file]');if(!input)throw Error('Backup gate did not open'); + const dt=new DataTransfer();dt.items.add(new File([Uint8Array.from(atob('${zipBase64}'),c=>c.charCodeAt(0))],'backup.zip',{type:'application/zip'}));input.files=dt.files;input.dispatchEvent(new Event('change',{bubbles:true})); + const p=document.querySelector('#__nuxt').__vue_app__.config.globalProperties.$pinia; + for(let i=0;i<100&&p._s.get('base').noteData.fixture!=='ZIP semantic round-trip';i++)await new Promise(r=>setTimeout(r,100)); + if(p._s.get('base').noteData.fixture!=='ZIP semantic round-trip')throw Error('Import not restored'); + return {imported:true,volume:p._s.get('setting').wordSoundVolume,text:document.body.innerText.slice(-180)}; + }` +} +export function exportScript() { + return `async () => { + const p=document.querySelector('#__nuxt').__vue_app__.config.globalProperties.$pinia; + if(p._s.get('base').noteData.fixture!=='ZIP semantic round-trip')throw Error('Reload lost data'); + const r=indexedDB.open('keyval-store');const db=await new Promise((s,j)=>{r.onsuccess=()=>s(r.result);r.onerror=j}); + const get=k=>new Promise(s=>{let r=db.transaction('keyval').objectStore('keyval').get(k);r.onsuccess=()=>s(r.result)}); + const record=(await get('typing-word-files')).find(x=>x.id==='backup-tone'); + const ac=new AudioContext();const decoded=await ac.decodeAudioData(await record.file.arrayBuffer());const duration=decoded.duration;await ac.close(); + const blobs=[];const original=URL.createObjectURL.bind(URL);URL.createObjectURL=b=>{if(b.type==='application/zip')blobs.push(b);return original(b)}; + document.querySelectorAll('.tab')[5].click();await new Promise(r=>setTimeout(r,100)); + [...document.querySelectorAll('*')].find(x=>x.childElementCount===0&&x.textContent.trim()==='Export Data Backup (ZIP)').click(); + for(let i=0;i<100&&!blobs.length;i++)await new Promise(r=>setTimeout(r,100));URL.createObjectURL=original; + if(!blobs.length)throw Error('Export did not produce ZIP'); + return {afterReload:true,zip:btoa(String.fromCharCode(...new Uint8Array(await blobs[0].arrayBuffer()))),audioBytes:record.file.size,audioDuration:duration,wordCache:await get('PracticeSaveWord'),articleCache:await get('PracticeSaveArticle'),sync:localStorage.getItem('supabase_config'),userAgent:navigator.userAgent,libs:performance.getEntriesByType('resource').filter(x=>x.name.includes('jszip')).map(x=>x.name)}; + }` +} diff --git a/tests/desktop/backup-browser-seed.mjs b/tests/desktop/backup-browser-seed.mjs new file mode 100644 index 000000000..26ed21929 --- /dev/null +++ b/tests/desktop/backup-browser-seed.mjs @@ -0,0 +1,31 @@ +// Run returned function in a NEW isolated browser context at /setting; never a personal profile. +import { readFileSync } from 'node:fs' +export function backupSeedScript() { + const audio = readFileSync(new URL('./fixtures/backup/backup-tone.mp3', import.meta.url)).toString('base64') + return `async () => { + if(localStorage.getItem('supabase_config')) throw Error('Sync configuration present: use clean context'); + const pinia=document.querySelector('#__nuxt').__vue_app__.config.globalProperties.$pinia; + const base=pinia._s.get('base'), setting=pinia._s.get('setting'); + const clone=x=>JSON.parse(JSON.stringify(x)); + const template=clone(base.word.bookList[0]); + const word={id:'backup-word',custom:true,word:'fixture',phonetic0:'',phonetic1:'',trans:['隔离测试'],sentences:[],phrases:[],synos:[],relWords:{root:'',rels:[]},etymology:[]}; + const builtin={...clone(template),...(await fetch('/list/recommend_word.json').then(r=>r.json()))[0],system:false,custom:false,lastLearnIndex:7,words:[],articles:[]}; + const custom={...clone(template),id:'backup-custom',enName:'backup-custom',name:'Backup isolated words',system:false,custom:true,length:1,words:[word],articles:[]}; + const article={id:'backup-article',title:'Audio fixture',titleTranslate:'音频测试',text:'This is a fixture.',textTranslate:'这是隔离测试。',newWords:[],sections:[],audioSrc:'',audioFileId:'backup-tone',lrcPosition:[],questions:[],nameList:[]}; + base.word.bookList.push(builtin,custom);base.word.studyIndex=base.word.bookList.length-1; + base.article.bookList.push({...clone(template),id:'backup-articles',enName:'backup-articles',name:'Backup isolated articles',type:'article',system:false,custom:true,length:1,articles:[article],words:[]});base.article.studyIndex=base.article.bookList.length-1; + base.noteData={fixture:'ZIP semantic round-trip'};setting.wordSoundVolume=.37;setting.articleSoundSpeed=1.25;setting.theme='dark'; + const stat={startDate:1789084800000,spend:1250,total:3,wrong:1,newWordNumber:1,reviewWordNumber:2,segments:[],stage:0,inputWordNumber:3}; + const wp={taskWordsStr:{new:['fixture'],review:[]},practiceData:{wordsStr:['fixture'],wrongWordsStr:[],index:0,question:null},statStoreData:stat,sessionSnapshot:{flowId:'system',cursor:{nodeIndex:0,stepIndex:0,inWrongWordClear:false,loop:null,endActionIndex:null}}}; + const ap={practiceData:{sectionIndex:0,sentenceIndex:0,wordIndex:1},statStoreData:stat}; + const r=indexedDB.open('keyval-store');const db=await new Promise((s,j)=>{r.onsuccess=()=>s(r.result);r.onerror=j}); + const bytes=Uint8Array.from(atob('${audio}'),c=>c.charCodeAt(0)); + const tx=db.transaction('keyval','readwrite');const os=tx.objectStore('keyval'); + os.put([{id:'backup-tone',file:new Blob([bytes],{type:'audio/mpeg'})}],'typing-word-files'); + os.put(JSON.stringify({version:2,val:wp}),'PracticeSaveWord');os.put(JSON.stringify({version:1,val:ap}),'PracticeSaveArticle'); + await new Promise((s,j)=>{tx.oncomplete=s;tx.onerror=j}); + window.__backupBlobs=[];const create=URL.createObjectURL.bind(URL);URL.createObjectURL=b=>{if(b.type==='application/zip')window.__backupBlobs.push(b);return create(b)}; + document.querySelectorAll('.tab')[5].click(); + return {isolation:'new context; no sync config',audioBytes:bytes.length,builtin: builtin.enName,custom:custom.id,wordCache:wp,articleCache:ap}; + }` +} diff --git a/tests/desktop/backup-cache-validation.test.mjs b/tests/desktop/backup-cache-validation.test.mjs new file mode 100644 index 000000000..4ef41d200 --- /dev/null +++ b/tests/desktop/backup-cache-validation.test.mjs @@ -0,0 +1,375 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { runInNewContext } from 'node:vm' +import test from 'node:test' +import ts from 'typescript' +import { invalidSettings } from './settings-validation-fixtures.mjs' +import { dictionaryFixture, invalidDictionaries } from './dictionary-validation-fixtures.mjs' + +const root = process.env.TYPEWORDS_TEST_ROOT || process.cwd() +function load(path, modules = {}) { + const exports = {} + runInNewContext( + ts.transpileModule(readFileSync(resolve(root, path), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText, + { + exports, + Blob, + require(name) { + assert.ok(Object.hasOwn(modules, name), `Unexpected import: ${name}`) + return modules[name] + }, + } + ) + return exports +} +const stats = () => ({ + stage: 0, + startDate: 1, + spend: 0, + total: 1, + newWordNumber: 1, + reviewWordNumber: 0, + inputWordNumber: 0, + wrong: 0, +}) +const word = () => ({ + taskWordsStr: { new: ['hello'], review: [] }, + practiceData: { index: 0, wordsStr: ['hello'], wrongWordsStr: [] }, + statStoreData: stats(), + sessionSnapshot: { + flowId: 'fixture', + cursor: { nodeIndex: 0, stepIndex: 0, inWrongWordClear: false, loop: null, endActionIndex: null }, + }, +}) +const article = () => ({ + practiceData: { sectionIndex: 0, sentenceIndex: 0, wordIndex: 0 }, + statStoreData: stats(), +}) + +for (const [label, mutate] of invalidDictionaries) { + test(`ZIP dictionary boundary rejects before any migration: ${label}`, async () => { + const h = harness() + h.backup.val.dict.val = dictionaryFixture() + mutate(h.backup.val.dict.val) + await assert.rejects(h.prepareBackupImport(JSON.stringify(h.backup), h.audio), /Invalid backup/) + assert.deepEqual(h.events, []) + h.backup.val.dict.val = dictionaryFixture() + await h.prepareBackupImport(JSON.stringify(h.backup), h.audio) + assert.deepEqual(h.events, ['dict', 'setting', 'word']) + }) +} +function harness(realUpgrade = false) { + const events = [] + const cache = load('app/core/utils/cache.ts', { + '../types/enum': load('app/core/types/enum.ts'), + 'idb-keyval': { + get() { + assert.fail('Preflight must not read storage') + }, + set() { + assert.fail('Preflight must not write storage') + }, + }, + }) + const helper = load('app/core/composables/backupImport.ts', { + '../config/env': { + APP_VERSION: { key: 'app' }, + EXPORT_DATA_KEY: { version: 5 }, + SAVE_DICT_KEY: { version: 4 }, + SAVE_SETTING_KEY: { version: 23 }, + }, + '../utils': { + checkAndUpgradeSaveDict: async value => { + events.push('dict') + return value.val + }, + checkAndUpgradeSaveSetting: async value => { + events.push('setting') + return value.val + }, + }, + '../utils/cache': { + PRACTICE_WORD_CACHE: { key: 'PracticeSaveWord', version: 2 }, + PRACTICE_ARTICLE_CACHE: { key: 'PracticeSaveArticle', version: 1 }, + checkAndUpgradePracticeWordCache: (value, context) => { + events.push('word') + return realUpgrade ? cache.checkAndUpgradePracticeWordCache(value, context) : value + }, + }, + './remotePracticeValidation': load('app/core/composables/remotePracticeValidation.ts'), + './settingsValidation': load('app/core/composables/settingsValidation.ts'), + './dictionaryValidation': load('app/core/composables/dictionaryValidation.ts'), + }) + const backup = { + version: 5, + val: { + dict: { + version: 4, + val: { + word: { studyIndex: 0, bookList: [{ words: [{ word: 'hello' }], articles: [] }] }, + article: { + studyIndex: 0, + bookList: [{ words: [], articles: [{ text: 'Hello.', audioFileId: 'tone' }] }], + }, + }, + }, + setting: { version: 23, val: { shortcutKeyMap: {} } }, + PracticeSaveWord: { version: 2, val: word() }, + PracticeSaveArticle: { version: 1, val: article() }, + }, + } + const audio = [{ id: 'tone', file: new Blob(['synthetic audio bytes']) }] + return { ...helper, events, backup, audio } +} + +for (const [name, mutate] of invalidSettings) { + test(`ZIP rejects ${name} before any migrator and accepts a corrected retry`, async () => { + const h = harness() + mutate(h.backup.val.setting.val) + await assert.rejects(h.prepareBackupImport(JSON.stringify(h.backup), h.audio), /Invalid backup/) + assert.deepEqual(h.events, []) + h.backup.val.setting.val = { shortcutKeyMap: {} } + await h.prepareBackupImport(JSON.stringify(h.backup), h.audio) + assert.deepEqual(h.events, ['dict', 'setting', 'word']) + }) +} + +for (const [name, mutate] of [ + [ + 'word list', + b => { + b.PracticeSaveWord.val.taskWordsStr.new = [42] + }, + ], + [ + 'empty word identifier', + b => { + b.PracticeSaveWord.val.taskWordsStr.new = [''] + }, + ], + [ + 'word position', + b => { + b.PracticeSaveWord.val.practiceData.index = -1 + }, + ], + [ + 'word index past list', + b => { + b.PracticeSaveWord.val.practiceData.index = 3 + }, + ], + [ + 'word question candidates', + b => { + b.PracticeSaveWord.val.practiceData.question = { candidates: [], correctIndex: 0 } + }, + ], + [ + 'word question correct word', + b => { + b.PracticeSaveWord.val.practiceData.question = { + candidates: [ + { word: { word: 'hello' }, similarity: 1 }, + { word: { word: 'world' }, similarity: 0.2 }, + ], + correctIndex: 1, + } + }, + ], + [ + 'word loop range', + b => { + b.PracticeSaveWord.val.sessionSnapshot.cursor.loop = { startIndex: 0, endIndex: 6, subStepIndex: 1 } + }, + ], + [ + 'word working keys', + b => { + b.PracticeSaveWord.val.sessionSnapshot.nodeWorkingWordKeys = ['missing'] + }, + ], + [ + 'word wrong-word-clear cursor', + b => { + b.PracticeSaveWord.val.sessionSnapshot.cursor.inWrongWordClear = true + b.PracticeSaveWord.val.sessionSnapshot.cursor.endActionIndex = null + }, + ], + [ + 'statistics pair', + b => { + delete b.PracticeSaveWord.val.statStoreData + }, + ], + [ + 'timer segment', + b => { + b.PracticeSaveWord.val.statStoreData.segments = [[4, 1]] + }, + ], + [ + 'session cursor', + b => { + b.PracticeSaveWord.val.sessionSnapshot.cursor.nodeIndex = 0.5 + }, + ], + [ + 'article position', + b => { + b.PracticeSaveArticle.val.practiceData.wordIndex = '1' + }, + ], + [ + 'article statistics', + b => { + b.PracticeSaveArticle.val.statStoreData.wrong = -1 + }, + ], +]) { + test(`ZIP rejects damaged ${name} before all migration and write side effects`, async () => { + const h = harness() + mutate(h.backup.val) + await assert.rejects(async () => { + await h.prepareBackupImport(JSON.stringify(h.backup), h.audio) + h.events.push('write') + }, /Invalid backup practice cache/) + assert.deepEqual(h.events, []) + }) +} + +for (const version of [4, 5]) { + test(`v${version} ZIP preserves current caches and audio references`, async () => { + const h = harness() + h.backup.version = version + const text = JSON.stringify(h.backup) + const result = await h.prepareBackupImport(text, h.audio) + assert.deepEqual(JSON.parse(JSON.stringify(result.PracticeSaveWord)), h.backup.val.PracticeSaveWord) + assert.deepEqual(JSON.parse(JSON.stringify(result.PracticeSaveArticle)), h.backup.val.PracticeSaveArticle) + assert.equal(result.dict.val.article.bookList[0].articles[0].audioFileId, 'tone') + assert.equal(JSON.stringify(h.backup), text) + assert.deepEqual(h.events, ['dict', 'setting', 'word']) + }) +} + +test('ZIP accepts legacy full-word and compact caches without timer or session fields', async () => { + for (const compact of [false, true]) { + const h = harness() + const value = h.backup.val.PracticeSaveWord + value.version = 1 + delete value.val.sessionSnapshot + if (!compact) { + value.val.taskWords = { new: [{ word: 'hello' }], review: [] } + delete value.val.taskWordsStr + value.val.practiceData.words = [{ word: 'hello' }] + value.val.practiceData.wrongWords = [] + delete value.val.practiceData.wordsStr + delete value.val.practiceData.wrongWordsStr + } + await h.prepareBackupImport(JSON.stringify(h.backup), h.audio) + assert.deepEqual(h.events, ['dict', 'setting', 'word']) + } +}) + +test('ZIP preserves absent, null-envelope and null-payload cache compatibility', async () => { + for (const state of ['absent', 'null', 'null-payload', 'tasks-only']) { + const h = harness() + for (const key of ['PracticeSaveWord', 'PracticeSaveArticle']) { + if (state === 'absent') delete h.backup.val[key] + else if (state === 'null') h.backup.val[key] = null + else h.backup.val[key].val = null + } + if (state === 'tasks-only') h.backup.val.PracticeSaveWord.val = { taskWordsStr: { new: [], review: [] } } + await h.prepareBackupImport(JSON.stringify(h.backup), h.audio) + assert.deepEqual(h.events, ['dict', 'setting', 'word']) + } +}) + +test('ZIP rejects invalid legacy words and supports retry with repaired input', async () => { + const h = harness() + h.backup.val.PracticeSaveWord = { version: 1, val: { taskWords: { new: [{}], review: [] } } } + await assert.rejects(h.prepareBackupImport(JSON.stringify(h.backup), h.audio), /Invalid backup practice cache/) + assert.deepEqual(h.events, []) + h.backup.val.PracticeSaveWord.val.taskWords.new = [{ word: 'hello' }] + await h.prepareBackupImport(JSON.stringify(h.backup), h.audio) + assert.deepEqual(h.events, ['dict', 'setting', 'word']) +}) + +test('ZIP preflight runs the actual legacy cache migration without storage access', async () => { + for (const compact of [false, true]) { + const h = harness(true) + const envelope = h.backup.val.PracticeSaveWord + envelope.version = 1 + delete envelope.val.sessionSnapshot + if (!compact) { + envelope.val.taskWords = { new: [{ word: 'hello' }], review: [] } + delete envelope.val.taskWordsStr + envelope.val.practiceData.words = [{ word: 'hello' }] + envelope.val.practiceData.wrongWords = [] + delete envelope.val.practiceData.wordsStr + delete envelope.val.practiceData.wrongWordsStr + } + const result = await h.prepareBackupImport(JSON.stringify(h.backup), h.audio) + assert.equal(result.PracticeSaveWord.version, 2) + assert.deepEqual(Array.from(result.PracticeSaveWord.val.taskWordsStr.new), ['hello']) + assert.deepEqual(Array.from(result.PracticeSaveWord.val.practiceData.wordsStr), ['hello']) + assert.ok(result.PracticeSaveWord.val.sessionSnapshot.flowId) + assert.deepEqual(h.events, ['dict', 'setting', 'word']) + } +}) + +test('setting import failure restores loading and never commits or reports success; repaired retry commits', async () => { + const h = harness(true) + const source = readFileSync(resolve(root, 'app/pages/setting.vue'), 'utf8') + const start = source.indexOf('async function importJson(') + const end = source.indexOf('\nasync function ', start + 1) + assert.ok(start >= 0 && end > start) + const sandbox = { + exports: {}, + importLoading: false, + showBackupGate: true, + runtimeStore: { globalLoading: false }, + prepareBackupImport: h.prepareBackupImport, + Supabase: { + check: () => { + h.events.push('check-sync') + return true + }, + }, + dataSyncPersistence: { + forcePushLocalDataToRemote: async () => { + h.events.push('commit') + return true + }, + }, + Toast: { + error: () => h.events.push('error'), + success: () => h.events.push('success'), + }, + APP_VERSION: { version: 5 }, + t: value => value, + nextTick: async () => {}, + } + // Only the import handler runs; page setup and unrelated actions are excluded. + const body = source.slice(start, source.indexOf('\n}', start) + 2) + runInNewContext( + ts.transpileModule(body + '\nexports.importJson = importJson', { + compilerOptions: { target: ts.ScriptTarget.ES2022 }, + }).outputText, + sandbox + ) + h.backup.val.PracticeSaveArticle.val.practiceData.wordIndex = -1 + await sandbox.exports.importJson(JSON.stringify(h.backup), h.audio) + assert.deepEqual(h.events, ['error']) + assert.equal(sandbox.importLoading, false) + assert.equal(sandbox.runtimeStore.globalLoading, false) + assert.equal(sandbox.showBackupGate, true) + h.events.length = 0 + h.backup.val.PracticeSaveArticle.val.practiceData.wordIndex = 0 + await sandbox.exports.importJson(JSON.stringify(h.backup), h.audio) + assert.deepEqual(h.events, ['dict', 'setting', 'word', 'check-sync', 'commit', 'success']) + assert.equal(sandbox.showBackupGate, false) +}) diff --git a/tests/desktop/backup-gate.test.mjs b/tests/desktop/backup-gate.test.mjs new file mode 100644 index 000000000..11637eb05 --- /dev/null +++ b/tests/desktop/backup-gate.test.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { runInNewContext } from 'node:vm' +import test from 'node:test' +import ts from 'typescript' +const root = process.env.TYPEWORDS_TEST_ROOT || process.cwd() +const source = readFileSync(resolve(root, 'app/components/dialog/BackupGateDialog.vue'), 'utf8') +const body = source.slice(source.indexOf('async function onBackup()'), source.indexOf('')) +function harness(exportData, isDesktop = true, disabled = null) { + const sandbox = { + backupTriggered: false, + backupRequest: 0, + exportData, + isDesktop, + model: { value: true }, + Blob, + localStorage: { getItem: () => disabled }, + Toast: { success() {}, error() {} }, + exports: {}, + } + runInNewContext( + ts.transpileModule(body + '\nexports.onBackup = onBackup', { compilerOptions: { target: ts.ScriptTarget.ES2022 } }) + .outputText, + sandbox + ) + return sandbox +} +test('backup gate stays closed while pending and after native cancel', async () => { + let finish + const h = harness(() => new Promise(r => (finish = r))) + const pending = h.exports.onBackup() + assert.equal(h.backupTriggered, false) + finish(undefined) + await pending + assert.equal(h.backupTriggered, false) +}) +test('backup gate opens only after successful backup Blob', async () => { + const h = harness(async () => new Blob(['zip'])) + await h.exports.onBackup() + assert.equal(h.backupTriggered, true) +}) +test('backup gate remains closed on export errors even after earlier success', async () => { + const h = harness(async () => { + throw Error('disk full') + }) + h.backupTriggered = true + await h.exports.onBackup().catch(() => {}) + assert.equal(h.backupTriggered, false) +}) +test('desktop never bypasses backup through legacy disable360 setting', async () => { + let calls = 0 + const h = harness( + async () => { + calls++ + return undefined + }, + true, + '1' + ) + await h.exports.onBackup() + assert.equal(calls, 1) + assert.equal(h.backupTriggered, false) +}) +test('Web retains existing explicit legacy disable360 escape hatch', async () => { + const h = harness( + async () => { + throw Error('not reached') + }, + false, + '1' + ) + await h.exports.onBackup() + assert.equal(h.backupTriggered, true) +}) +test('closing dialog during export does not unlock a later opening', async () => { + let finish + const h = harness(() => new Promise(r => (finish = r))) + const pending = h.exports.onBackup() + h.model.value = false + h.backupRequest++ // Closing watch invalidates the old export completion. + h.model.value = true + finish(new Blob(['zip'])) + await pending + assert.equal(h.backupTriggered, false) +}) diff --git a/tests/desktop/backup-history-audit.mjs b/tests/desktop/backup-history-audit.mjs new file mode 100644 index 000000000..89de9c34d --- /dev/null +++ b/tests/desktop/backup-history-audit.mjs @@ -0,0 +1,53 @@ +// Explicit historical evidence audit. Does not execute current browser or native UI. +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { resolve } from 'node:path' +import test from 'node:test' + +if (!process.argv[2]) + throw new Error('Usage: node tests/desktop/backup-history-audit.mjs ') +const evidence = resolve(process.argv[2]) +const require = createRequire(import.meta.url) +const JSZip = require('../../public/libs/jszip.min.js') +const tone = readFileSync(resolve(evidence, 'backup-tone.mp3')) + +test('historical Web -> desktop browser artifacts retain practice fields and audio', async () => { + const web = await JSZip.loadAsync(readFileSync(resolve(evidence, 'backup-web.zip'))) + const desktop = await JSZip.loadAsync(readFileSync(resolve(evidence, 'backup-desktop.zip'))) + const first = JSON.parse(await web.file('data.json').async('string')) + const second = JSON.parse(await desktop.file('data.json').async('string')) + assert.deepEqual(second.val.PracticeSaveWord, first.val.PracticeSaveWord) + assert.deepEqual(second.val.PracticeSaveArticle, first.val.PracticeSaveArticle) + assert.equal(second.val.setting.val.wordSoundVolume, 0.37) + assert.equal(second.val.setting.val.articleSoundSpeed, 1.25) + assert.equal(second.val.dict.val.word.bookList.find(b => b.enName === 'cet4').lastLearnIndex, 7) + assert.deepEqual( + second.val.dict.val.word.bookList.find(b => b.id === 'backup-custom').words, + first.val.dict.val.word.bookList.find(b => b.id === 'backup-custom').words + ) + assert.deepEqual( + second.val.dict.val.article.bookList.find(b => b.id === 'backup-articles').articles, + first.val.dict.val.article.bookList.find(b => b.id === 'backup-articles').articles + ) + assert.deepEqual(await desktop.file('mp3/backup-tone.mp3').async('nodebuffer'), tone) +}) + +test('historical desktop -> fresh Web artifacts and reload reports remain consistent', async () => { + const desktop = await JSZip.loadAsync(readFileSync(resolve(evidence, 'backup-desktop.zip'))) + const web = await JSZip.loadAsync(readFileSync(resolve(evidence, 'backup-web-restored.zip'))) + assert.deepEqual( + JSON.parse(await web.file('data.json').async('string')), + JSON.parse(await desktop.file('data.json').async('string')) + ) + assert.deepEqual(await web.file('mp3/backup-tone.mp3').async('nodebuffer'), tone) + for (const name of ['desktop', 'web-restored']) { + const report = JSON.parse(readFileSync(resolve(evidence, `backup-${name}-browser.json`))) + assert.equal(report.afterReload, true) + assert.equal(report.audioDuration, 0.3) + const sync = JSON.parse(report.sync) + assert.equal(sync.url, '') + assert.equal(sync.key, '') + assert.ok(report.libs.every(url => !url.includes('/libs//'))) + } +}) diff --git a/tests/desktop/backup.test.mjs b/tests/desktop/backup.test.mjs new file mode 100644 index 000000000..a80a2b59f --- /dev/null +++ b/tests/desktop/backup.test.mjs @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { resolve } from 'node:path' +import { runInNewContext } from 'node:vm' +import test from 'node:test' +import ts from 'typescript' + +const root = process.cwd() +const fixtures = new URL('./fixtures/backup/', import.meta.url) +const require = createRequire(import.meta.url) +const JSZip = require(resolve(root, 'public/libs/jszip.min.js')) +const fixture = JSON.parse(readFileSync(new URL('backup-fixture.json', fixtures))) +const tone = readFileSync(new URL('backup-tone.mp3', fixtures)) +const constants = { + EXPORT_DATA_KEY: { version: 5 }, + SAVE_SETTING_KEY: { version: 23 }, + SAVE_DICT_KEY: { version: 4 }, + LOCAL_FILE_KEY: 'typing-word-files', + LIB_JS_URL: { JSZIP: '/libs/jszip.min.js' }, + PRACTICE_WORD_CACHE: { key: 'PracticeSaveWord', version: 2 }, + PRACTICE_ARTICLE_CACHE: { key: 'PracticeSaveArticle', version: 1 }, +} +function evaluate(source, globals) { + const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText + return runInNewContext(js, globals) +} + +async function exportFixture(data = fixture, audio = tone) { + const exports = {} + evaluate(readFileSync(resolve(root, 'app/core/hooks/export.ts'), 'utf8'), { + exports, + useRuntimeConfig: () => ({ public: { isDesktop: false } }), + Blob, + require(name) { + if (name === '../config/env' || name === '../utils/cache') return constants + if (name === 'vue') return { ref: value => ({ value }) } + if (name === '../utils') return { loadJsLib: async () => JSZip, shakeCommonDict: structuredClone } + if (name === 'idb-keyval') + return { + get: async key => { + assert.equal(key, constants.LOCAL_FILE_KEY) + return [{ id: 'backup-tone', file: audio }] + }, + } + if (name === '../stores/base') return { useBaseStore: () => ({ $state: data.val.dict.val }) } + if (name === '../stores/setting') return { useSettingStore: () => ({ $state: data.val.setting.val }) } + if (name.includes('usePracticePersistence')) + return { + usePracticeWordPersistence: () => ({ getLocalDataCompact: async () => data.val.PracticeSaveWord.val }), + usePracticeArticlePersistence: () => ({ + getLocalDataCompact: async () => data.val.PracticeSaveArticle.val, + }), + } + return {} + }, + }) + return Buffer.from( + await exports + .useExport() + .buildExportZip() + .then(b => b.arrayBuffer()) + ) +} + +test('actual application exporter produces compatible ZIP with custom audio and practice fields', async () => { + const zip = await JSZip.loadAsync(await exportFixture()) + assert.deepEqual(JSON.parse(await zip.file('data.json').async('string')), fixture) + assert.deepEqual(await zip.file('mp3/backup-tone.mp3').async('nodebuffer'), tone) +}) + +test('actual setting.vue ZIP import handler stages audio bytes and JSON without early writes', async () => { + const source = readFileSync(resolve(root, 'app/pages/setting.vue'), 'utf8') + const body = source.slice(source.indexOf('async function importData(e)'), source.indexOf('\nlet showBackupGate')) + let data, records + const helper = {} + evaluate(readFileSync(resolve(root, 'app/core/composables/backupImport.ts'), 'utf8'), { + exports: helper, + require: () => ({}), + Blob, + }) + const sandbox = { + exports: {}, + readBackupZip: helper.readBackupZip, + importLoading: false, + ...constants, + loadJsLib: async () => JSZip, + set: async (key, value) => { + assert.fail('Import handler must not write audio before JSON validation') + }, + importJson: async (value, audio) => { + data = JSON.parse(value) + records = audio + }, + Toast: { + error: value => { + throw Error(value) + }, + }, + t: value => value, + } + evaluate(body + '\nexports.importData = importData', sandbox) + const file = await exportFixture() + file.name = 'synthetic-backup.zip' + await sandbox.exports.importData({ target: { files: [file] } }) + assert.deepEqual(data, fixture) + assert.equal(records[0].id, 'backup-tone') + assert.deepEqual(Buffer.from(await records[0].file.arrayBuffer()), tone) + assert.equal(sandbox.importLoading, false) +}) + +test('synthetic ZIP read and re-export preserves data and bytes with stub stores (not browser UI)', async () => { + const helper = {} + evaluate(readFileSync(resolve(root, 'app/core/composables/backupImport.ts'), 'utf8'), { + exports: helper, + require: () => ({}), + Blob, + }) + const first = await JSZip.loadAsync(await exportFixture()) + const staged = await helper.readBackupZip(first) + const data = JSON.parse(staged.text) + const audio = Buffer.from(await staged.audio[0].file.arrayBuffer()) + const second = await JSZip.loadAsync(await exportFixture(data, audio)) + assert.deepEqual(JSON.parse(await second.file('data.json').async('string')), fixture) + assert.deepEqual(await second.file('mp3/backup-tone.mp3').async('nodebuffer'), tone) +}) diff --git a/tests/desktop/build-config.test.mjs b/tests/desktop/build-config.test.mjs new file mode 100644 index 000000000..e5e9360f0 --- /dev/null +++ b/tests/desktop/build-config.test.mjs @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import test from 'node:test' +import ts from 'typescript' + +// Evaluate the real config; replace build plugin factories, not branch logic. +const root = process.env.TYPEWORDS_TEST_ROOT || fileURLToPath(new URL('../../', import.meta.url)) + +function config(env = {}, raw = false, exit = () => {}) { + const source = readFileSync(resolve(root, 'nuxt.config.ts'), 'utf8') + const js = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS }, + }).outputText + const exports = {} + runInNewContext(js, { + exports, + __dirname: process.cwd(), + URL, + process: { env, exit }, + console: { error() {} }, + require(name) { + if (name === 'pathe' || name === 'node:path') return { resolve } + if (name === 'child_process') return { execSync: () => 'fixture' } + if (name === 'nuxt/config') return { defineNuxtConfig: value => value } + return { default: () => ({}) } + }, + }) + return raw ? exports.default : JSON.parse(JSON.stringify(exports.default)) +} + +test('Web retains mixed rendering, base path, proxy and API default', () => { + const web = config({ NUXT_APP_BASE_URL: '/typing' }) + assert.notEqual(web.ssr, false) + assert.equal(web.app.baseURL, '/typing/') + assert.equal(web.routeRules['/words'].ssr, false) + assert.equal(web.routeRules['/book/nce1'].prerender, true) + assert.equal(web.runtimeConfig.public.apiBase, 'http://localhost/') + assert.ok(web.nitro.devProxy['/baidu']) +}) + +test('Desktop is root-based SPA with explicit static-only rendering', () => { + const desktop = config({ TYPEWORDS_TARGET: 'desktop', NUXT_APP_BASE_URL: '/web-only/' }) + assert.equal(desktop.ssr, false) + assert.equal(desktop.app.baseURL, '/') + assert.deepEqual(desktop.routeRules, {}) + assert.equal(desktop.nitro.prerender.crawlLinks, false) + assert.deepEqual(desktop.nitro.prerender.routes, ['/']) + assert.equal(desktop.image.provider, 'none') + assert.deepEqual(desktop.nitro.devProxy, {}) + assert.equal(desktop.vite.server.strictPort, true) + assert.equal(desktop.devServer.port, 5567) + assert.equal(desktop.runtimeConfig.public.isDesktop, true) +}) + +test('Unknown build target does not enable desktop', () => { + assert.notEqual(config({ TYPEWORDS_TARGET: 'Desktop' }).ssr, false) +}) + +function initialize(isDesktop) { + const source = readFileSync(resolve(root, 'app/plugins/02.init.client.ts'), 'utf8') + const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText + const events = [] + let plugin + runInNewContext(js, { + exports: {}, + console: { log() {} }, + require: () => ({ ENV: { LIBS_URL: '/libs/' }, withAppBaseURL: value => value }), + defineNuxtPlugin: callback => { + plugin = callback + }, + useRuntimeConfig: () => ({ public: { isDesktop } }), + location: { href: 'https://example.com/' }, + navigator: { serviceWorker: {} }, + window: { addEventListener: name => events.push(name) }, + document: { createElement: () => ({}), head: { appendChild: () => events.push('script') } }, + }) + return plugin({ vueApp: { use: () => events.push('plugin') } }).then(() => events) +} + +test('Desktop skips website script and SW but retains Vue plugin', async () => { + assert.deepEqual(await initialize(true), ['plugin']) +}) + +test('Web retains website initialization', async () => { + assert.deepEqual(await initialize(false), ['script', 'load', 'plugin']) +}) + +test('Desktop rejects an actual fallback listener and closes it before exiting', async () => { + const events = [] + const desktop = config({ TYPEWORDS_TARGET: 'desktop' }, true, code => events.push(`exit:${code}`)) + await desktop.hooks.listen({ address: () => ({ port: 3000 }) }, { close: async () => events.push('closed') }) + assert.deepEqual(events, ['closed', 'exit:1']) +}) + +test('Desktop accepts the actual expected listener', async () => { + const desktop = config({ TYPEWORDS_TARGET: 'desktop' }, true, () => assert.fail('unexpected exit')) + await desktop.hooks.listen({ address: () => ({ port: 5567 }) }, { close: () => assert.fail('unexpected close') }) +}) + +test('Desktop exits even when an invalid listener fails to close', async () => { + const exits = [] + const desktop = config({ TYPEWORDS_TARGET: 'desktop' }, true, code => exits.push(code)) + await assert.rejects( + desktop.hooks.listen( + { address: () => null }, + { + close: async () => { + throw new Error('close failed') + }, + } + ), + /close failed/ + ) + assert.deepEqual(exits, [1]) +}) + +test('Desktop API is explicitly configured independently from Web API_BASE', () => { + assert.equal( + config({ TYPEWORDS_TARGET: 'desktop', API_BASE: 'http://localhost/' }).runtimeConfig.public.desktopApiBase, + '' + ) + assert.equal( + config({ TYPEWORDS_TARGET: 'desktop', TYPEWORDS_DESKTOP_API_BASE: 'https://api.example.test/' }).runtimeConfig + .public.desktopApiBase, + 'https://api.example.test/' + ) + assert.equal( + config({ TYPEWORDS_DESKTOP_API_BASE: 'https://api.example.test/' }).runtimeConfig.public.desktopApiBase, + '' + ) +}) + +test('Desktop watcher ignores Rust build output without changing Web watcher configuration', () => { + const desktop = config({ TYPEWORDS_TARGET: 'desktop' }) + assert.deepEqual(desktop.vite.server.watch.ignored, ['**/src-tauri/**']) + assert.equal(config().vite.server, undefined) +}) + +test('Desktop excludes remote font declarations while Web retains the original face', () => { + const desktop = config({ TYPEWORDS_TARGET: 'desktop' }) + const web = config({}) + const remoteSheet = '~/assets/css/web-fonts.css' + assert.ok(web.css.includes(remoteSheet), 'Web must retain its original Garamond face') + assert.ok(!desktop.css.includes(remoteSheet), 'Desktop must not load network font CSS') + const home = readFileSync(resolve(root, 'app/pages/index.vue'), 'utf8') + const desktopStyles = desktop.css.map(path => readFileSync(resolve(root, 'app', path.replace(/^~\//, '')), 'utf8')) + assert.doesNotMatch([home, ...desktopStyles].join('\n'), /fonts\.(?:gstatic|googleapis)\.com/) + assert.match(home, /font-family: Garamond, Georgia, 'Times New Roman', serif/) + const face = readFileSync(resolve(root, 'app/assets/css/web-fonts.css'), 'utf8') + assert.match(face, /font-family: 'Garamond'/) + assert.match(face, /font-style: italic/) + assert.match(face, /font-weight: 700/) + assert.match(face, /https:\/\/fonts\.gstatic\.com/) +}) diff --git a/tests/desktop/build-gate.test.mjs b/tests/desktop/build-gate.test.mjs new file mode 100644 index 000000000..93182ba72 --- /dev/null +++ b/tests/desktop/build-gate.test.mjs @@ -0,0 +1,218 @@ +import assert from 'node:assert/strict' +import { EventEmitter } from 'node:events' +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { STATIC_RESOURCES, LIST_FILES } from '../../scripts/check-desktop-resources.mjs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import test from 'node:test' + +const root = resolve(process.env.TYPEWORDS_TEST_ROOT || '.') +const { runDesktop } = await import(pathToFileURL(resolve(root, 'scripts/desktop.mjs'))) + +function harness(t, { reports = [], spawnError } = {}) { + // Fail before invoking the old launcher: baseline must never start a real Nuxt process. + assert.match(runDesktop.toString(), /checkResources/, 'launcher must wire resource validation') + const previousExitCode = process.exitCode + const interrupts = process.listenerCount('SIGINT') + const terminations = process.listenerCount('SIGTERM') + const errors = [] + t.mock.method(console, 'error', message => errors.push(message)) + const child = new EventEmitter() + const calls = [] + const entries = [] + let spawned = 0 + child.kill = signal => calls.push(signal) + t.after(() => { + child.emit('exit', 1, null) + process.exitCode = previousExitCode + assert.equal(process.listenerCount('SIGINT'), interrupts) + assert.equal(process.listenerCount('SIGTERM'), terminations) + }) + return { + child, + calls, + entries, + errors, + get spawned() { + return spawned + }, + run(mode = 'build') { + return runDesktop(mode, { + spawnProcess() { + spawned++ + if (spawnError) throw spawnError + return child + }, + checkResources(path) { + calls.push(path) + const report = reports.shift() + if (report instanceof Error) throw report + return report || { ok: true, errors: [] } + }, + checkEntry(path) { + entries.push(path) + }, + }) + }, + } +} +const invalid = { + ok: false, + errors: [{ code: 'COUNT_MISMATCH', path: '/dicts/en/article/NCE_1.json', message: 'declared 72, actual 5' }], +} + +test('source audit failure prevents Nuxt launch', t => { + const h = harness(t, { reports: [invalid] }) + assert.throws(() => h.run(), /COUNT_MISMATCH.*declared 72, actual 5/s) + assert.equal(h.spawned, 0) + assert.deepEqual(h.calls, [resolve(root, 'public')]) +}) +test('successful generate audits output before returning success', t => { + const h = harness(t) + assert.equal(h.run(), h.child) + assert.deepEqual(h.calls, [resolve(root, 'public')]) + h.child.emit('exit', 0, null) + assert.deepEqual(h.calls, [resolve(root, 'public'), resolve(root, '.output/public')]) + assert.equal(process.exitCode, 0) + assert.deepEqual(h.entries, [resolve(root, '.output/public')]) +}) +test('invalid generated resources turn Nuxt success into build failure', t => { + const h = harness(t, { reports: [{ ok: true }, invalid] }) + h.run() + h.child.emit('exit', 0, null) + assert.equal(process.exitCode, 1) + assert.match(h.errors.join('\n'), /COUNT_MISMATCH/) +}) +test('failed generate preserves exit status and does not audit stale output', t => { + const h = harness(t) + h.run() + h.child.emit('exit', 7, null) + assert.equal(process.exitCode, 7) + assert.equal(h.calls.length, 1) + assert.deepEqual(h.entries, []) +}) +test('desktop dev bypasses build audits', t => { + const h = harness(t, { reports: [invalid] }) + h.run('dev') + h.child.emit('exit', 0, null) + assert.deepEqual(h.calls, []) + assert.deepEqual(h.entries, []) + assert.equal(process.exitCode, 0) +}) +test('interrupt forwards to child, preserves status and skips output audit', t => { + const h = harness(t) + h.run() + process.emit('SIGINT') + assert.equal(h.calls.at(-1), 'SIGINT') + h.child.emit('exit', null, 'SIGINT') + assert.equal(process.exitCode, 130) + assert.equal(h.calls.length, 2) +}) +test('spawn error returns failure without inspecting old output', t => { + const h = harness(t) + h.run() + h.child.emit('error', new Error('spawn failed')) + assert.equal(process.exitCode, 1) + assert.deepEqual(h.errors, ['spawn failed']) + assert.equal(h.calls.length, 1) +}) +test('output audit exceptions fail the build without uncaught callback errors', t => { + const h = harness(t, { reports: [{ ok: true }, new Error('audit read failed')] }) + h.run() + assert.doesNotThrow(() => h.child.emit('exit', 0, null)) + assert.equal(process.exitCode, 1) + assert.deepEqual(h.errors, ['audit read failed']) +}) + +// Exercise the actual launcher CLI with an explicitly fake Nuxt generator. +// These tests prove orchestration and exit codes, not framework compilation. +function cliFixture(t) { + const dir = mkdtempSync(resolve(tmpdir(), 'typewords-build-gate-')) + t.after(() => rmSync(dir, { recursive: true, force: true })) + const put = (path, content) => { + const file = resolve(dir, path) + mkdirSync(resolve(file, '..'), { recursive: true }) + writeFileSync(file, content) + } + put('package.json', '{"type":"module"}') + put('node_modules/nuxt/package.json', '{"name":"nuxt","type":"module"}') + put( + 'node_modules/nuxt/bin/nuxt.mjs', + ` + import { cpSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' + writeFileSync('generator-ran', 'yes') + if (process.env.FIXTURE_NUXT_FAILURE) process.exit(7) + mkdirSync('.output', { recursive: true }) + cpSync('public', '.output/public', { recursive: true }) + writeFileSync('.output/public/index.html', 'fixture') + if (process.env.FIXTURE_MISSING_OUTPUT) rmSync('.output/public/sound/beep.wav') + const entry = '.output/public/index.html' + if (process.env.FIXTURE_ENTRY === 'missing') rmSync(entry) + if (process.env.FIXTURE_ENTRY === 'empty') writeFileSync(entry, '') + if (process.env.FIXTURE_ENTRY === 'whitespace') writeFileSync(entry, ' ') + if (process.env.FIXTURE_ENTRY === 'directory') { rmSync(entry); mkdirSync(entry) } + ` + ) + for (const name of LIST_FILES) put(`public/list/${name}`, '[]') + for (const path of STATIC_RESOURCES) put(`public${path}`, 'fixture') + mkdirSync(resolve(dir, 'scripts')) + cpSync(resolve(root, 'scripts/desktop.mjs'), resolve(dir, 'scripts/desktop.mjs')) + cpSync(resolve('scripts/check-desktop-resources.mjs'), resolve(dir, 'scripts/check-desktop-resources.mjs')) + return { + dir, + put, + run(env = {}) { + return spawnSync(process.execPath, [resolve(dir, 'scripts/desktop.mjs'), 'build'], { + cwd: tmpdir(), + encoding: 'utf8', + timeout: 10000, + env: { ...process.env, ...env }, + }) + }, + } +} +test('real CLI succeeds with a complete fixture and uses project root rather than caller cwd', t => { + const f = cliFixture(t) + const result = f.run() + assert.equal(result.status, 0, result.stderr) + assert.equal(readFileSync(resolve(f.dir, '.output/public/index.html'), 'utf8'), 'fixture') +}) +test('real CLI blocks missing source before generation and preserves existing output', t => { + const f = cliFixture(t) + f.put('.output/public/index.html', 'previous output') + rmSync(resolve(f.dir, 'public/sound/beep.wav')) + const result = f.run() + assert.equal(result.status, 1) + assert.match(result.stderr, /MISSING_FILE.*beep.wav/) + assert.equal(readFileSync(resolve(f.dir, '.output/public/index.html'), 'utf8'), 'previous output') + assert.throws(() => readFileSync(resolve(f.dir, 'generator-ran')), { code: 'ENOENT' }) +}) +test('real CLI fails when generator succeeds but drops a required resource', t => { + const f = cliFixture(t) + const result = f.run({ FIXTURE_MISSING_OUTPUT: '1' }) + assert.equal(result.status, 1) + assert.match(result.stderr, /Resource audit failed \(\.output\/public\)/) + assert.match(result.stderr, /MISSING_FILE.*beep.wav/) +}) +test('real CLI preserves generator failure without auditing absent output', t => { + const f = cliFixture(t) + mkdirSync(resolve(f.dir, '.output'), { recursive: true }) + cpSync(resolve(f.dir, 'public'), resolve(f.dir, '.output/public'), { recursive: true }) + f.put('.output/public/index.html', 'previous complete output') + const result = f.run({ FIXTURE_NUXT_FAILURE: '1' }) + assert.equal(result.status, 7) + assert.equal(readFileSync(resolve(f.dir, '.output/public/index.html'), 'utf8'), 'previous complete output') + assert.doesNotMatch(result.stderr, /Resource audit failed/) +}) + +for (const entry of ['missing', 'empty', 'whitespace', 'directory']) { + test(`real CLI rejects ${entry} SPA entry after successful generation`, t => { + const f = cliFixture(t) + const result = f.run({ FIXTURE_ENTRY: entry }) + assert.equal(readFileSync(resolve(f.dir, 'generator-ran'), 'utf8'), 'yes') + assert.equal(result.status, 1, result.stderr) + assert.match(result.stderr, /Invalid desktop SPA entry.*index\.html/) + }) +} diff --git a/tests/desktop/cdp-port.ps1 b/tests/desktop/cdp-port.ps1 new file mode 100644 index 000000000..1702b82ce --- /dev/null +++ b/tests/desktop/cdp-port.ps1 @@ -0,0 +1,17 @@ +function Test-TypeWordsCdp([int] $Port) { + try { + $null = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/json/version" -UseBasicParsing -TimeoutSec 1 + return $true + } catch { + return $false + } +} + +function Wait-TypeWordsCdp([int] $Port, [bool] $Open, [int] $Seconds = 20) { + $deadline = (Get-Date).AddSeconds($Seconds) + do { + if ((Test-TypeWordsCdp $Port) -eq $Open) { return $true } + Start-Sleep -Milliseconds 200 + } while ((Get-Date) -lt $deadline) + return $false +} diff --git a/tests/desktop/clean-install-script.test.mjs b/tests/desktop/clean-install-script.test.mjs new file mode 100644 index 000000000..1f51a1584 --- /dev/null +++ b/tests/desktop/clean-install-script.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import test from 'node:test' + +const root = resolve(process.env.TYPEWORDS_TEST_ROOT || process.cwd()) +const script = resolve(root, 'scripts/check-desktop-clean-install.mjs') +const snapshot = resolve(root, 'scripts/check-desktop-test-snapshot.mjs') +const { SKIP_SOURCE, copyCleanTree, isSecretSource, parseNodeTestCounts } = await import(pathToFileURL(script)) + +test('clean-install script installs from the lockfile and does not reuse origin node_modules', () => { + const source = readFileSync(script, 'utf8') + const snapshotSource = readFileSync(snapshot, 'utf8') + assert.match(source, /pnpm install --frozen-lockfile/) + assert.match(source, /reusedOriginNodeModules: false/) + assert.match(source, /pnpm\.exe/) + assert.match(source, /\(cmd\|bat\)/) + assert.doesNotMatch(source, /symlinkSync/) + assert.match(snapshotSource, /symlinkSync/) + assert.match(snapshotSource, /existing node_modules reused/) +}) + +test('clean source filters drop plans, caches, and local env or key files', () => { + assert.equal(SKIP_SOURCE.test('docs/plans/tauri2-desktop-migration-plan.md'), true) + assert.equal(SKIP_SOURCE.test('.nuxt/index.mjs'), true) + assert.equal(SKIP_SOURCE.test('.output/public/index.html'), true) + assert.equal(SKIP_SOURCE.test('node_modules/nuxt/package.json'), true) + assert.equal(SKIP_SOURCE.test('app/pages/setting.vue'), false) + assert.equal(isSecretSource('.env'), true) + assert.equal(isSecretSource('.env.local'), true) + assert.equal(isSecretSource('.env.example'), false) + assert.equal(isSecretSource('certs/dev.pem'), true) + assert.equal(isSecretSource('package.json'), false) +}) + +test('node test summary parser reads official # and ℹ count lines', () => { + assert.deepEqual(parseNodeTestCounts('# tests 655\n# pass 655\n# fail 0\n# skipped 0\n'), { + tests: 655, + pass: 655, + fail: 0, + skipped: 0, + cancelled: null, + todo: null, + }) + assert.deepEqual(parseNodeTestCounts('ℹ tests 659\nℹ pass 659\nℹ fail 0\nℹ skipped 0\n'), { + tests: 659, + pass: 659, + fail: 0, + skipped: 0, + cancelled: null, + todo: null, + }) +}) + +test( + 'copy-only tree has the lockfile and no docs/plans, .nuxt, .output, or node_modules', + { skip: !existsSync(join(root, '.git')) }, + () => { + const dest = mkdtempSync(join(tmpdir(), 'tw-clean-copy-')) + try { + const copy = copyCleanTree(root, dest) + assert.ok(copy.fileCount > 400, `expected a full source copy, got ${copy.fileCount}`) + assert.equal(copy.absent['docs/plans'], true) + assert.equal(copy.absent['.nuxt'], true) + assert.equal(copy.absent['.output'], true) + assert.equal(copy.absent.node_modules, true) + assert.equal(existsSync(join(dest, 'pnpm-lock.yaml')), true) + assert.equal(existsSync(join(dest, 'package.json')), true) + assert.equal(existsSync(join(dest, 'node_modules')), false) + assert.equal(existsSync(join(dest, 'docs/plans')), false) + assert.equal(existsSync(join(dest, '.nuxt')), false) + assert.equal(existsSync(join(dest, '.output')), false) + assert.ok(copy.files.some(file => file.name === 'pnpm-lock.yaml' && file.sha256)) + } finally { + rmSync(dest, { recursive: true, force: true }) + } + } +) diff --git a/tests/desktop/close-installed.ps1 b/tests/desktop/close-installed.ps1 new file mode 100644 index 000000000..d0f102e38 --- /dev/null +++ b/tests/desktop/close-installed.ps1 @@ -0,0 +1,17 @@ +param([Parameter(Mandatory = $true)][string] $Stage) +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'cdp-port.ps1') +$launch = Get-Content -LiteralPath (Join-Path $Stage 'launch.json') -Raw | ConvertFrom-Json +$process = Get-Process -Id $launch.pid -ErrorAction Stop +if (!$process.CloseMainWindow()) { throw 'Normal close failed' } +if (!$process.WaitForExit(15000)) { throw 'Process did not exit normally' } +if (Get-Process -Id $launch.pid -ErrorAction SilentlyContinue) { throw 'Process still running' } +if (-not (Wait-TypeWordsCdp $launch.port $false 20)) { + throw "CDP $($launch.port) still answered after a normal close" +} +@{ + closedPid = $launch.pid + exitCode = $process.ExitCode + profileRetained = [bool](Test-Path -LiteralPath (Join-Path $launch.profile 'EBWebView')) +} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Stage 'closed.json') -Encoding utf8 +Write-Output "Normally closed $($launch.pid); isolated profile retained=$([bool](Test-Path -LiteralPath (Join-Path $launch.profile 'EBWebView')))" diff --git a/tests/desktop/desktop-workflow.test.mjs b/tests/desktop/desktop-workflow.test.mjs new file mode 100644 index 000000000..f124c50bd --- /dev/null +++ b/tests/desktop/desktop-workflow.test.mjs @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict' +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import test from 'node:test' + +const root = resolve(process.env.TYPEWORDS_TEST_ROOT || process.cwd()) +const workflowPath = resolve(root, '.github/workflows/desktop.yml') +const packagePath = resolve(root, 'package.json') + +const ALLOWED_RUNS = [ + 'pnpm install --frozen-lockfile', + 'pnpm run test:desktop', + 'pnpm run desktop:check-resources', + 'cargo fmt --manifest-path src-tauri/Cargo.toml --check', + 'cargo check --locked --manifest-path src-tauri/Cargo.toml', + 'cargo test --locked --manifest-path src-tauri/Cargo.toml', + 'cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings', +] + +const FORBIDDEN_IN_RUNS = [ + /typecheck/, + /test:desktop:clean-install/, + /desktop:frontend:build/, + /desktop:build/, + /desktop:bundle/, + /desktop:dev/, + /\btauri (?:build|dev|bundle)\b/, + /nsis/i, + /\bubuntu\b/, + /\bmacos\b/, +] + +function extractRunCommands(source) { + const commands = [] + const lines = source.split(/\r?\n/) + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (/^\s*#/.test(line)) continue + const oneLine = line.match(/^(\s+)run:\s+(\S.*)$/) + if (oneLine) { + commands.push(oneLine[2].trim()) + continue + } + const block = line.match(/^(\s+)run:\s*[|>]-?\s*$/) + if (!block) continue + const indent = block[1].length + const parts = [] + for (let j = i + 1; j < lines.length; j++) { + if (lines[j].trim() === '' || /^\s*#/.test(lines[j])) continue + const nextIndent = lines[j].match(/^(\s*)/)[1].length + if (nextIndent <= indent) break + parts.push(lines[j].trim()) + i = j + } + if (parts.length) commands.push(parts.join(' ')) + } + return commands +} + +function pnpmRunNames(commands) { + return commands.flatMap(command => [...command.matchAll(/\bpnpm run ([^\s]+)/g)].map(match => match[1])) +} + +test('desktop workflow exists and every job uses windows-latest', () => { + assert.equal(existsSync(workflowPath), true, 'expected .github/workflows/desktop.yml') + const source = readFileSync(workflowPath, 'utf8') + const runsOn = [...source.matchAll(/^\s*runs-on:\s*(\S+)/gm)].map(match => match[1]) + assert.ok(runsOn.length >= 2, `expected node and rust jobs, got runs-on=${runsOn.join(',')}`) + assert.ok( + runsOn.every(value => value === 'windows-latest'), + `desktop jobs must use windows-latest, got ${runsOn.join(',')}` + ) + assert.doesNotMatch(source, /^\s*runs-on:\s*(ubuntu|macos)/m) + assert.match(source, /^\s+desktop-node:/m) + assert.match(source, /^\s+desktop-rust:/m) +}) + +test('desktop workflow run steps only cite real lockfile, test, resource, and locked Rust commands', () => { + const source = readFileSync(workflowPath, 'utf8') + const scripts = JSON.parse(readFileSync(packagePath, 'utf8')).scripts + const commands = extractRunCommands(source) + assert.deepEqual([...commands].sort(), [...ALLOWED_RUNS].sort()) + for (const name of pnpmRunNames(commands)) { + assert.equal(typeof scripts[name], 'string', `pnpm run ${name} is not a package.json script`) + assert.notEqual(scripts[name].trim(), '', `pnpm run ${name} must not be an empty script`) + } + assert.equal(typeof scripts['test:desktop'], 'string') + assert.equal(typeof scripts['desktop:check-resources'], 'string') + assert.match(scripts['test:desktop'], /tests\/desktop\/\*\.test\.mjs/) + assert.match(scripts['desktop:check-resources'], /check-desktop-resources\.mjs/) + for (const command of commands) { + for (const forbidden of FORBIDDEN_IN_RUNS) { + assert.doesNotMatch(command, forbidden) + } + } +}) + +test('desktop workflow is honest about WebView, installer, typecheck, and the clean-install split', () => { + const source = readFileSync(workflowPath, 'utf8') + assert.match(source, /native WebView/) + assert.match(source, /installer/) + assert.match(source, /NSIS/) + assert.match(source, /typecheck-green/) + assert.match(source, /does not treat typecheck as passing/) + assert.match(source, /exit 2 \/ 62/) + assert.match(source, /test:desktop:clean-install/) + assert.match(source, /not used as Windows verification/) + const commands = extractRunCommands(source).join('\n') + assert.doesNotMatch(commands, /typecheck/) + assert.doesNotMatch(commands, /test:desktop:clean-install/) + assert.doesNotMatch(commands, /desktop:frontend:build/) + assert.doesNotMatch(commands, /desktop:build/) + assert.doesNotMatch(commands, /desktop:bundle/) +}) diff --git a/tests/desktop/dict-migration-source.mjs b/tests/desktop/dict-migration-source.mjs new file mode 100644 index 000000000..225f40b41 --- /dev/null +++ b/tests/desktop/dict-migration-source.mjs @@ -0,0 +1,13 @@ +import { declarations } from './migration-declarations.mjs' + +export function dictMigrationSource(root) { + return [ + "import { DictType } from '../types/enum'", + "import { shallowReactive } from 'vue'", + "import { saveHashSnapshot } from '../composables/useDataSyncPersistence'", + declarations(root, 'app/core/config/env.ts', ['DictId', 'SAVE_DICT_KEY']), + declarations(root, 'app/core/types/func.ts', ['getDefaultDict']), + declarations(root, 'app/core/stores/base.ts', ['getDefaultBaseState']), + declarations(root, 'app/core/utils/index.ts', ['checkRiskKey', 'normalizeStoredDict', 'checkAndUpgradeSaveDict']), + ].join('\n') +} diff --git a/tests/desktop/dictionary-validation-fixtures.mjs b/tests/desktop/dictionary-validation-fixtures.mjs new file mode 100644 index 000000000..336847e22 --- /dev/null +++ b/tests/desktop/dictionary-validation-fixtures.mjs @@ -0,0 +1,69 @@ +export const fsrsCardFixture = () => ({ + due: '2026-09-16T00:00:00.000Z', + stability: 2, + difficulty: 5, + elapsed_days: 1, + scheduled_days: 2, + reps: 2, + lapses: 0, + state: 2, + last_review: '2026-09-14T00:00:00.000Z', +}) + +export const dictionaryFixture = () => ({ + word: { studyIndex: 0, bookList: [{ id: 42, en_name: 'legacy', words: [{ word: 'hello' }], articles: [] }] }, + article: { studyIndex: -1, bookList: [{ words: [], articles: [{ text: 'Hello.', audioFileId: 'tone' }] }] }, + simpleWords: ['a'], + noteData: { hello: 'A note' }, + fsrsData: { hello: fsrsCardFixture() }, +}) + +export const invalidDictionaries = [ + ['missing section', value => delete value.word], + ['null book list', value => (value.word.bookList = null)], + ['invalid selection', value => (value.word.studyIndex = 1)], + ['fractional selection', value => (value.word.studyIndex = 0.5)], + ['null book', value => (value.word.bookList[0] = null)], + ['invalid words', value => (value.word.bookList[0].words = {})], + ['invalid word entry', value => (value.word.bookList[0].words = [{ word: 42 }])], + ['empty word identifier', value => (value.word.bookList[0].words = [{ word: '' }])], + ['invalid articles', value => (value.article.bookList[0].articles = null)], + ['invalid article entry', value => (value.article.bookList[0].articles = [{ text: null }])], + ['invalid translation', value => (value.article.bookList[0].articles = [{ text: 'Hello.', textTranslate: [] }])], + ['invalid simple words', value => (value.simpleWords = [42])], + ['invalid notes', value => (value.noteData = { hello: null })], + ['invalid FSRS container', value => (value.fsrsData = [])], + ...[ + ['null card', () => null], + ['array card', () => []], + [ + 'missing due', + card => { + delete card.due + return card + }, + ], + ['invalid due', card => ({ ...card, due: 'not-a-date' })], + ['null due', card => ({ ...card, due: null })], + ['object due', card => ({ ...card, due: {} })], + ['invalid last review', card => ({ ...card, last_review: 'invalid' })], + ['invalid state', card => ({ ...card, state: 4 })], + ['fractional state', card => ({ ...card, state: 1.5 })], + ['unknown state name', card => ({ ...card, state: 'Unknown' })], + ['negative stability', card => ({ ...card, stability: -1 })], + [ + 'missing stability', + card => { + delete card.stability + return card + }, + ], + ['string difficulty', card => ({ ...card, difficulty: '5' })], + ['excessive difficulty', card => ({ ...card, difficulty: 11 })], + ['negative elapsed days', card => ({ ...card, elapsed_days: -1 })], + ['null scheduled days', card => ({ ...card, scheduled_days: null })], + ['fractional reps', card => ({ ...card, reps: 1.5 })], + ['negative lapses', card => ({ ...card, lapses: -1 })], + ['invalid learning steps', card => ({ ...card, learning_steps: '1' })], + ].map(([label, mutate]) => [`FSRS ${label}`, value => (value.fsrsData = { hello: mutate(fsrsCardFixture()) })]), +] diff --git a/tests/desktop/dictionary-validation.test.mjs b/tests/desktop/dictionary-validation.test.mjs new file mode 100644 index 000000000..1105567a9 --- /dev/null +++ b/tests/desktop/dictionary-validation.test.mjs @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { runInNewContext } from 'node:vm' +import test from 'node:test' +import ts from 'typescript' +import { dictionaryFixture, fsrsCardFixture } from './dictionary-validation-fixtures.mjs' +import { createEmptyCard, fsrs, Rating } from 'ts-fsrs' + +const exports = {} +runInNewContext( + ts.transpileModule( + readFileSync( + resolve(process.env.TYPEWORDS_TEST_ROOT || process.cwd(), 'app/core/composables/dictionaryValidation.ts'), + 'utf8' + ), + { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } } + ).outputText, + { exports } +) + +test('dictionary boundary preserves legacy identifiers, omitted optional maps and unknown metadata', () => { + const value = dictionaryFixture() + delete value.simpleWords + delete value.noteData + delete value.fsrsData + value.metadata = { historical: true } + const before = JSON.stringify(value) + exports.validateStoredDictionary(value) + assert.equal(JSON.stringify(value), before) +}) + +test('dictionary boundary allows empty unselected sections but rejects impossible selections', () => { + const value = { word: { bookList: [], studyIndex: -1 }, article: { bookList: [], studyIndex: -1 } } + exports.validateStoredDictionary(value) + for (const index of [0, -2, NaN, Infinity, '0', null]) { + value.word.studyIndex = index + assert.throws(() => exports.validateStoredDictionary(value), /studyIndex/) + } +}) + +test('dictionary boundary rejects empty word identifiers without mutation or silent repair', () => { + for (const kind of ['word', 'article']) { + const value = dictionaryFixture() + value[kind].bookList[0].words = [{ word: '' }] + const before = structuredClone(value) + assert.throws(() => exports.validateStoredDictionary(value), /words/) + assert.deepEqual(value, before) + } +}) + +test('dictionary boundary still accepts non-empty historical word identifiers', () => { + const value = dictionaryFixture() + value.word.bookList[0].words = [{ word: 'hello' }, { word: 'a', extra: true }] + const before = structuredClone(value) + exports.validateStoredDictionary(value) + assert.deepEqual(value, before) +}) + +test('FSRS boundary accepts engine-generated cards before and after JSON round trips without mutation', () => { + const engine = fsrs({ enable_fuzz: false }) + const now = new Date('2026-09-15T00:00:00.000Z') + const empty = createEmptyCard(now) + const cards = [empty] + for (const grade of [Rating.Again, Rating.Hard, Rating.Good, Rating.Easy]) { + const first = engine.next(empty, now, grade).card + cards.push(first, engine.next(first, first.due, Rating.Again).card) + } + for (const card of cards) { + for (const candidate of [card, JSON.parse(JSON.stringify(card))]) { + const value = dictionaryFixture() + value.fsrsData.hello = candidate + const before = structuredClone(value) + exports.validateStoredDictionary(value) + assert.deepEqual(value, before) + } + } +}) + +test('FSRS boundary preserves legacy steps, optional review date, supported date/state inputs and metadata', () => { + for (const state of [0, 1, 2, 3, 'New', 'Learning', 'Review', 'Relearning']) { + const value = dictionaryFixture() + value.fsrsData.hello = { ...fsrsCardFixture(), state, due: 0, last_review: null, metadata: { old: true } } + exports.validateStoredDictionary(value) + delete value.fsrsData.hello.last_review + exports.validateStoredDictionary(value) + } +}) + +test('FSRS boundary rejects non-finite numbers, unsafe counters and invalid Date objects', () => { + for (const field of [ + 'stability', + 'difficulty', + 'elapsed_days', + 'scheduled_days', + 'reps', + 'lapses', + 'learning_steps', + ]) { + for (const bad of [NaN, Infinity, -Infinity, null, '1', -1]) { + const value = dictionaryFixture() + value.fsrsData.hello[field] = bad + assert.throws(() => exports.validateStoredDictionary(value), /fsrsData/, `${field}=${bad}`) + } + } + for (const field of ['reps', 'lapses', 'learning_steps']) { + for (const bad of [0.5, Number.MAX_SAFE_INTEGER + 1]) { + const value = dictionaryFixture() + value.fsrsData.hello[field] = bad + assert.throws(() => exports.validateStoredDictionary(value), /fsrsData/) + } + } + for (const due of [new Date(NaN), NaN, Infinity, 9e15, true, [], '']) { + const value = dictionaryFixture() + value.fsrsData.hello.due = due + assert.throws(() => exports.validateStoredDictionary(value), /fsrsData/) + } +}) diff --git a/tests/desktop/fixtures/backup/README.md b/tests/desktop/fixtures/backup/README.md new file mode 100644 index 000000000..cbcb39f5f --- /dev/null +++ b/tests/desktop/fixtures/backup/README.md @@ -0,0 +1,46 @@ +# Synthetic Backup Fixture + +These files contain isolated test data, not a personal learning backup or sync +credentials. They were retained from the synthetic backup scenario on 2026-09-11. +The JSON includes settings v23, dictionary v4, both practice caches, a custom word, +a custom article referencing `backup-tone`, and fixed learning statistics. +The MP3 is a generated 0.3-second test tone, not recorded speech. + +Tests build ZIP files at runtime using the actual exporter with stub stores. +ZIP timestamps are not compared; JSON semantics and audio bytes are compared. +These tests do not prove audio playback, browser reload, or native UI behavior. + +Historical browser ZIPs and reports remain local evidence and are deliberately +excluded from the default test glob. To audit those old artifacts explicitly: + +```sh +node tests/desktop/backup-history-audit.mjs docs/plans/evidence/tauri2-continue +``` + +Missing historical files fail that audit; they never silently skip a default +regression test. + +To run the full default regression suite from a temporary source-only copy: + +```sh +node scripts/check-desktop-test-snapshot.mjs +``` + +The copy includes tracked and non-ignored untracked working-tree files, excludes +local plans and build caches, and reuses this checkout's `node_modules`. It is +removed after the run. This checks independence from local evidence, not a clean +Git checkout, frozen-lockfile installation, or reproducible release build. + +To install from `pnpm-lock.yaml` in an independent tree (no `docs/plans`, +`.nuxt`, `.output`, or reused `node_modules`): + +```sh +pnpm run test:desktop:clean-install +``` + +That is still not a commit of a dirty worktree, signed release, or EXE/NSIS rebuild. + +Original SHA256: + +- `backup-fixture.json`: `ca07d4ea05c94ffa20a5c028ac39ff7b5d95898e93d07d9c779e88416be06d6a` +- `backup-tone.mp3`: `d8b8b1279fd37e8642af60065c3d7906324a725c1169738440d264b2c025d1bb` diff --git a/tests/desktop/fixtures/backup/backup-fixture.json b/tests/desktop/fixtures/backup/backup-fixture.json new file mode 100644 index 000000000..be40c5a9d --- /dev/null +++ b/tests/desktop/fixtures/backup/backup-fixture.json @@ -0,0 +1,512 @@ +{ + "version": 5, + "val": { + "setting": { + "version": 23, + "val": { + "soundType": "us", + "wordSound": true, + "wordSoundVolume": 0.37, + "wordSoundSpeed": 1, + "sentenceSoundVolume": 100, + "sentenceSoundSpeed": 1, + "wordReviewRatio": 3, + "autoAddRandomReviewWhenNoDue": false, + "articleSound": true, + "articleAutoPlayNext": false, + "articleSoundVolume": 100, + "articleSoundSpeed": 1.25, + "keyboardSound": true, + "keyboardSoundVolume": 100, + "keyboardSoundFile": "笔记本键盘", + "effectSound": true, + "effectSoundVolume": 100, + "repeatCount": 1, + "repeatCustomCount": null, + "dictation": false, + "translate": true, + "showNearWord": true, + "ignoreCase": true, + "allowWordTip": true, + "waitTimeForChangeWord": 300, + "spaceCooldownTime": 0, + "fontSize": { + "articleForeignFontSize": 48, + "articleTranslateFontSize": 20, + "wordForeignFontSize": 48, + "wordTranslateFontSize": 20 + }, + "showToolbar": true, + "showPanel": true, + "sideExpand": true, + "theme": "dark", + "shortcutKeyMap": { + "EditArticle": "Ctrl+E", + "PlayWordPronunciation": "Ctrl+P", + "ToggleShowTranslate": "Ctrl+Z", + "ToggleDictation": "Ctrl+I", + "ToggleTheme": "Ctrl+Q", + "ToggleConciseMode": "Ctrl+M", + "ToggleToolbar": "Ctrl+B", + "TogglePanel": "Ctrl+L", + "RandomWrite": "Ctrl+R", + "Previous": "Ctrl+⬅", + "Next": "Ctrl+➡", + "PreviousChapter": "Alt+⬅", + "NextChapter": "Alt+➡", + "ShowWord": "Escape", + "Ignore": "Tab", + "ToggleSimple": "`", + "ToggleCollect": "Enter", + "CollectToDict": "Shift+Enter", + "NextStep": "Shift+➡", + "RepeatChapter": "Ctrl+Enter", + "DictationChapter": "Alt+Enter", + "KnowWord": "1", + "UnknownWord": "2", + "MasteredWord": "3", + "ChooseA": "1", + "ChooseB": "2", + "ChooseC": "3", + "ChooseD": "4", + "SelfTestingChooseA": "Alt+1", + "SelfTestingChooseB": "Alt+2", + "SelfTestingChooseC": "Alt+3", + "SelfTestingChooseD": "Alt+4", + "PlaySentence1": "Ctrl+1", + "PlaySentence2": "Ctrl+2", + "PlaySentence3": "Ctrl+3", + "PlaySentence4": "Ctrl+4", + "PlaySentence5": "Ctrl+5", + "PlaySentence6": "Ctrl+6", + "PlaySentence7": "Ctrl+7", + "PlaySentence8": "Ctrl+8", + "PlaySentence9": "Ctrl+9" + }, + "first": true, + "firstTime": 1789135527098, + "webAppVersion": 4, + "load": true, + "conflictNotice": true, + "showConflictNotice2": true, + "showUsageTips": true, + "ignoreSimpleWord": false, + "wordPracticeMode": 0, + "wordPracticeType": 0, + "autoNextWord": false, + "inputWrongClear": false, + "mobileNavCollapsed": false, + "ignoreSymbol": true, + "practiceSentence": false, + "autoPlayFirstSentence": true, + "fsrsEasyLimit": 0, + "fsrsGoodLimit": 3, + "fsrsHardLimit": 6, + "fsrsParameters": { + "request_retention": 0.9, + "maximum_interval": 36500, + "w": [ + 0.212, + 1.2931, + 2.3065, + 8.2956, + 6.4133, + 0.8334, + 3.0194, + 0.001, + 1.8722, + 0.1666, + 0.796, + 1.4835, + 0.0614, + 0.2629, + 1.6483, + 0.6014, + 1.8729, + 0.5425, + 0.0912, + 0.0658, + 0.1542 + ], + "enable_fuzz": false, + "enable_short_term": true, + "learning_steps": [ + "1m", + "10m" + ], + "relearning_steps": [ + "10m" + ] + }, + "identifyMethod": 0, + "_ignoreWatch": false, + "ttsVoiceMap": [], + "showEtymologyAndRelWords": false, + "showWordQuestion": true + } + }, + "dict": { + "version": 4, + "val": { + "simpleWords": [ + "a", + "an", + "i", + "my", + "me", + "you", + "your", + "he", + "his", + "she", + "her", + "it", + "what", + "who", + "where", + "how", + "when", + "which", + "be", + "am", + "is", + "was", + "are", + "were", + "do", + "did", + "can", + "could", + "will", + "would", + "the", + "that", + "this", + "and", + "not", + "no", + "yes", + "to", + "of", + "for", + "at", + "in" + ], + "load": true, + "word": { + "bookList": [ + { + "id": "wordCollect", + "name": "收藏", + "description": "", + "url": "", + "length": 0, + "category": "", + "tags": [], + "translateLanguage": "", + "type": "word", + "language": "en", + "lastLearnIndex": 0, + "perDayStudyNumber": 20, + "custom": false, + "system": true, + "sourceId": "", + "complete": false, + "createdBy": "", + "enName": "wordCollect", + "category_id": null, + "is_default": false, + "update": false, + "cover": "", + "sync": false, + "words": [], + "articles": [], + "statistics": [] + }, + { + "id": "wordWrong", + "name": "错词", + "description": "", + "url": "", + "length": 0, + "category": "", + "tags": [], + "translateLanguage": "", + "type": "word", + "language": "en", + "lastLearnIndex": 0, + "perDayStudyNumber": 20, + "custom": false, + "system": true, + "sourceId": "", + "complete": false, + "createdBy": "", + "enName": "wordWrong", + "category_id": null, + "is_default": false, + "update": false, + "cover": "", + "sync": false, + "words": [], + "articles": [], + "statistics": [] + }, + { + "id": "wordKnown", + "name": "已掌握", + "description": "已掌握后的单词不会出现在练习中", + "url": "", + "length": 0, + "category": "", + "tags": [], + "translateLanguage": "", + "type": "word", + "language": "en", + "lastLearnIndex": 0, + "perDayStudyNumber": 20, + "custom": false, + "system": true, + "sourceId": "", + "complete": false, + "createdBy": "", + "enName": "wordKnown", + "category_id": null, + "is_default": false, + "update": false, + "cover": "", + "sync": false, + "words": [], + "articles": [], + "statistics": [] + }, + { + "id": 1, + "name": "CET-4", + "description": "大学英语四级词库", + "url": "CET4_T.json", + "length": 2607, + "category": "中国考试", + "tags": [ + "大学英语" + ], + "translateLanguage": "zh_CN", + "type": "word", + "language": "en", + "lastLearnIndex": 7, + "perDayStudyNumber": 20, + "custom": false, + "system": false, + "sourceId": "", + "complete": false, + "createdBy": "", + "enName": "cet4", + "category_id": null, + "is_default": false, + "update": false, + "cover": null, + "sync": false, + "words": [], + "articles": [], + "statistics": [], + "categoryId": 1, + "version": 1, + "isDefault": false, + "recommended": true, + "userId": "00000000-0000-0000-0000-000000000001", + "createdAt": "2026-05-10 03:10:19.294875", + "updatedAt": "2026-06-03 15:27:15.113708", + "hidden": false + }, + { + "id": "backup-custom", + "name": "Backup isolated words", + "description": "", + "url": "", + "length": 1, + "category": "", + "tags": [], + "translateLanguage": "", + "type": "word", + "language": "en", + "lastLearnIndex": 0, + "perDayStudyNumber": 20, + "custom": true, + "system": false, + "sourceId": "", + "complete": false, + "createdBy": "", + "enName": "backup-custom", + "category_id": null, + "is_default": false, + "update": false, + "cover": "", + "sync": false, + "words": [ + { + "id": "backup-word", + "custom": true, + "word": "fixture", + "phonetic0": "", + "phonetic1": "", + "trans": [ + "隔离测试" + ], + "sentences": [], + "phrases": [], + "synos": [], + "relWords": { + "root": "", + "rels": [] + }, + "etymology": [] + } + ], + "articles": [], + "statistics": [] + } + ], + "studyIndex": 4 + }, + "article": { + "bookList": [ + { + "id": "articleCollect", + "name": "收藏", + "description": "", + "url": "", + "length": 0, + "category": "", + "tags": [], + "translateLanguage": "", + "type": "word", + "language": "en", + "lastLearnIndex": 0, + "perDayStudyNumber": 20, + "custom": false, + "system": true, + "sourceId": "", + "complete": false, + "createdBy": "", + "enName": "articleCollect", + "category_id": null, + "is_default": false, + "update": false, + "cover": "", + "sync": false, + "words": [], + "articles": [], + "statistics": [] + }, + { + "id": "backup-articles", + "name": "Backup isolated articles", + "description": "", + "url": "", + "length": 1, + "category": "", + "tags": [], + "translateLanguage": "", + "type": "article", + "language": "en", + "lastLearnIndex": 0, + "perDayStudyNumber": 20, + "custom": true, + "system": false, + "sourceId": "", + "complete": false, + "createdBy": "", + "enName": "backup-articles", + "category_id": null, + "is_default": false, + "update": false, + "cover": "", + "sync": false, + "words": [], + "articles": [ + { + "id": "backup-article", + "title": "Audio fixture", + "titleTranslate": "音频测试", + "text": "This is a fixture.", + "textTranslate": "这是隔离测试。", + "newWords": [], + "sections": [], + "audioSrc": "", + "audioFileId": "backup-tone", + "lrcPosition": [], + "questions": [], + "nameList": [] + } + ], + "statistics": [] + } + ], + "studyIndex": 1 + }, + "dictListVersion": 1, + "fsrsData": {}, + "noteData": { + "fixture": "ZIP semantic round-trip" + }, + "_ignoreWatch": false + } + }, + "PracticeSaveWord": { + "version": 2, + "val": { + "taskWordsStr": { + "new": [ + "fixture" + ], + "review": [] + }, + "practiceData": { + "wordsStr": [ + "fixture" + ], + "wrongWordsStr": [], + "index": 0, + "question": null + }, + "statStoreData": { + "startDate": 1789084800000, + "spend": 1250, + "total": 3, + "wrong": 1, + "newWordNumber": 1, + "reviewWordNumber": 2, + "segments": [] + }, + "sessionSnapshot": { + "flowId": "system", + "cursor": { + "nodeIndex": 0, + "stepIndex": 0, + "inWrongWordClear": false, + "loop": null, + "endActionIndex": null + } + } + } + }, + "PracticeSaveArticle": { + "version": 1, + "val": { + "practiceData": { + "sectionIndex": 0, + "sentenceIndex": 0, + "wordIndex": 1 + }, + "statStoreData": { + "startDate": 1789084800000, + "spend": 1250, + "total": 3, + "wrong": 1, + "newWordNumber": 1, + "reviewWordNumber": 2, + "segments": [] + } + } + } + } +} diff --git a/tests/desktop/fixtures/backup/backup-tone.mp3 b/tests/desktop/fixtures/backup/backup-tone.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..c796eac725bc0ae16eea27da75f987a8f763ce31 GIT binary patch literal 2943 zcmc(h`#)6aAHdJt$9;0y#!_ZbTW#5yW-wUHxJ0=VRvU&Gm#^C@lxZ^;tV?uJ)}(JP z;}!{N@l6boH5JBMMe@Z~GAU9B(>Oa5-`DH=2lRUF`-k&aw2*0=yDtV?yY3P2A zL5X`e04VOYHS)g1g0Qt12Ie9FRo#_KN*fAUDCtD&{-772ht)j*-=6uh(ddX;m4(K? z-mTExHQ_KQ$4=Qbe2^cdcanvH6Dii6DAyCK{ek`Q>vKg(Mrz#2mD3pILqMk#2SEc6 zIDm~b$1;CH1GbtkLc)o+9~if!0qhQLfw0;QC(BeiEsYBsa(apyM3(4?7N!M?z> zs>HLq^1J;a&u%zJ zk&ef-EdUUw)RK~cH;#}|HU%MM;ou9bTofe#k9zG3Z}ym_#iP?b`z8oxwqZI@Ob)3H(IR?PwwFhr|++>{uit0}2V-%^*ZWlLC5>1Vt=L=0>?wyQ6qsC#E_VYF3UVb?5!*49pM@bpR%%StX|IHf7 zZ@hRedhLnN@AEU`ebF>78|h?tCFuM3AnH?UKA|!HMG6YJ{oE?%A7kM`%tHza|~P9 zyBv!4xZqTQItuhw-wwGyeIz4Xb^5~$Js-IzM6bR&1d2q`GuUOHg`f(L3^0A54b5M1 z>asLrBTPxb=P^X`$ggPbf~*TeY|3 zSluYnuba&&l??s4z8Jb~X~HniaV)1TVDN}l%FQO+>*k41;{f$wZg0BPeqN|^#DfUV z8gA-RCZr*69Y^HcP|z-})TRn=^L5GM8ud(+`K?*YQ%|Cl9v(jw?>l zWIL3oXwfG1`|4C%mfP&>^UV&2bZ^#Nmj3C255MHE`AwS`fmY5Ukv(Rn;)WDPjJdS5 zBEI!I-u(&KnMSq z%yA~hf^_$CRk>sf@w#y7ac%iv!gPLw?9Kko=C24tKat}xAVkV_0*24kDzO`iVLawiyri`^aeuu6-mR)+d|_T&}8(>veYnP}&NP3_Zj zu7$zFcPGTlzW%q9_ss)1Bx)UFYtXgiMB zW!pobK(-Q=x%;mj{zYQeuTS3n@>=7>_^e#Whl9F-*QsUd(_q}cUe<*>rU;f#?;8eV zWBIeb2}P~J0&pqa&TePS_UK=JuQ5ONiH5o%SV_j&AgTB)rh0nIVt7nN!i{4Ggy)nG zy@t&tT$&e3TSwi)hQpfY>elP#n<8Ifz|}ezVu@}KgOA(i{5+Ci4?j+v3+^ONrUnu$ zXpT^`9GXt&!H3OXuM~@FqVa+f|5Fn`u08XbX}*&nptMTa30Ss&(3KKU3j}6uEZae3njqNhC5W{JERT|x-W=w%rkCov`QrTH*Pg^ zN5^;^#oNSp&6k0#qd&G;Yy)S&>TJKtXz-c6?byW6E4KguB3-R%cu+4aHNa?Vvobue z8GAeLdG#o&Rt)(#6~`e02DG!i$6}fzeV3(Atv`-D4g`G%EZ&NE0=Jqut#(6m25#qF z=cL+cN*DYjij7KwVUOjizdG?Q!UY-pAmm98_GtZr;u6W4A8I~6R&KLuJxy&B1Y7Q50_VLM^;5M5;N%r#Pkn=0k{4nT*ujnNhJl^kfQZ%6F+xY8z_V<1t>SW+clJ4O@o}K(H!3rV+rIu>Rgk%Q}G6%S-2d-~-#D%b|mSqEr9Mx0+TF5dt zs;{BCc(o_~20LUx=fd4b4e3@DQ27TKWlmGw# literal 0 HcmV?d00001 diff --git a/tests/desktop/fixtures/typecheck/baseline.json b/tests/desktop/fixtures/typecheck/baseline.json new file mode 100644 index 000000000..3f3235a85 --- /dev/null +++ b/tests/desktop/fixtures/typecheck/baseline.json @@ -0,0 +1,74 @@ +{ + "schema": "typewords-typecheck-baseline-v1", + "expectedExit": 2, + "count": 62, + "diagnostics": [ + "app/base/InputNumber.vue(line,column): error TS2339: Property 'value' does not exist on type 'EventTarget'.", + "app/base/Pagination.vue(line,column): error TS2339: Property 'value' does not exist on type 'EventTarget'.", + "app/base/form/FormItem.vue(line,column): error TS2749: 'ref' refers to a value, but is being used as a type here. Did you mean 'typeof ref'?", + "app/components/BaseTable.vue(line,column): error TS2322: Type '{ disabled: boolean; onChange: () => void; modelValue: ComputedVariable | ComputedVariable; size: string; }' is not assignable to type 'IntrinsicAttributes & Partial<{ disabled: boolean; modelValue: boolean; }> & Omit<{ readonly disabled: boolean; readonly modelValue: boolean; readonly onClick?: (...args: any[]) => any; readonly onOnChange?: (...args: any[]) => any; readonly \"onUpdate:modelValue\"?: (...args: any[]) => any; } & VNodeProps & AllowedCo...'.", + "app/components/BaseTable.vue(line,column): error TS2322: Type '{ modelValue: ReactiveVariable | ReactiveVariable; \"onUpdate:modelValue\": (e: any) => any; style: string; }' is not assignable to type 'IntrinsicAttributes & Partial<{ modelValue: boolean; width: string; }> & Omit<{ readonly modelValue?: boolean; readonly width?: string; } & VNodeProps & AllowedComponentProps & ComponentCustomProps, DefaultKeys<...>>'.", + "app/components/BaseTable.vue(line,column): error TS2322: Type '{ modelValue: boolean; onChange: () => void; size: string; }' is not assignable to type 'IntrinsicAttributes & Partial<{ disabled: boolean; modelValue: boolean; }> & Omit<{ readonly disabled: boolean; readonly modelValue: boolean; readonly onClick?: (...args: any[]) => any; readonly onOnChange?: (...args: any[]) => any; readonly \"onUpdate:modelValue\"?: (...args: any[]) => any; } & VNodeProps & AllowedCo...'.", + "app/components/article/QuestionForm.vue(line,column): error TS2314: Generic type 'Array' requires 1 type argument(s).", + "app/components/article/QuestionForm.vue(line,column): error TS2322: Type 'Boolean' is not assignable to type 'boolean'.", + "app/components/article/QuestionForm.vue(line,column): error TS2322: Type 'Boolean' is not assignable to type 'boolean'.", + "app/components/article/QuestionForm.vue(line,column): error TS2322: Type 'boolean' is not assignable to type '(props: LooseRequired) => Boolean'.", + "app/components/article/QuestionForm.vue(line,column): error TS2322: Type 'boolean' is not assignable to type '(props: LooseRequired) => Boolean'.", + "app/components/article/QuestionForm.vue(line,column): error TS2322: Type 'number' is not assignable to type '(props: LooseRequired) => Number'.", + "app/components/article/QuestionForm.vue(line,column): error TS2356: An arithmetic operand must be of type 'any', 'number', 'bigint' or an enum type.", + "app/components/article/QuestionForm.vue(line,column): error TS2365: Operator '<=' cannot be applied to types 'number | { toString: (radix?: number) => string; toFixed: (fractionDigits?: number) => string; toExponential: (fractionDigits?: number) => string; toPrecision: (precision?: number) => string; valueOf: () => number; toLocaleString: { ...; }; }' and 'number'.", + "app/components/article/TypingArticle.vue(line,column): error TS2353: Object literal may only specify known properties, and 'x' does not exist in type 'HTMLElement'.", + "app/components/article/TypingWord.vue(line,column): error TS2322: Type '{ className: ComputedVariable<\"\"> | ComputedVariable<\"highlight-word\">; }' is not assignable to type 'HTMLAttributes & ReservedProps'.", + "app/components/article/TypingWord.vue(line,column): error TS2322: Type '{ className: string; }' is not assignable to type 'HTMLAttributes & ReservedProps'.", + "app/components/article/TypingWord.vue(line,column): error TS2322: Type '{ className: string; }' is not assignable to type 'HTMLAttributes & ReservedProps'.", + "app/components/article/TypingWord.vue(line,column): error TS2322: Type '{ className: string; }' is not assignable to type 'HTMLAttributes & ReservedProps'.", + "app/components/channel-icons/ShareIcon.vue(line,column): error TS2304: Cannot find name 'Toass'.", + "app/components/channel-icons/ShareIcon.vue(line,column): error TS2304: Cannot find name 'Toass'.", + "app/components/dialog/BackupGateDialog.vue(line,column): error TS2322: Type 'unknown' is not assignable to type 'boolean'.", + "app/components/dialog/CollectNotice.vue(line,column): error TS2322: Type 'Timeout' is not assignable to type 'number'.", + "app/components/dialog/ConflictNotice2.vue(line,column): error TS2322: Type 'unknown' is not assignable to type 'boolean'.", + "app/components/dialog/MigrateDialog.vue(line,column): error TS2322: Type 'unknown' is not assignable to type 'boolean'.", + "app/components/list/BaseList.vue(line,column): error TS2322: Type 'undefined[]' is not assignable to type '(props: LooseRequired<__VLS_Props>) => any[]'.", + "app/components/list/WordList.vue(line,column): error TS2322: Type 'undefined[]' is not assignable to type '(props: LooseRequired) => Word[]'.", + "app/components/list/WordList.vue(line,column): error TS2322: Type 'undefined[]' is not assignable to type '(props: LooseRequired) => string[]'.", + "app/components/practice-sentences/TypingArticleWord.vue(line,column): error TS2322: Type '{ className: ComputedVariable<\"\"> | ComputedVariable<\"highlight-word\">; }' is not assignable to type 'HTMLAttributes & ReservedProps'.", + "app/components/practice-sentences/TypingArticleWord.vue(line,column): error TS2322: Type '{ className: string; }' is not assignable to type 'HTMLAttributes & ReservedProps'.", + "app/components/practice-sentences/TypingArticleWord.vue(line,column): error TS2322: Type '{ className: string; }' is not assignable to type 'HTMLAttributes & ReservedProps'.", + "app/components/practice-sentences/TypingArticleWord.vue(line,column): error TS2322: Type '{ className: string; }' is not assignable to type 'HTMLAttributes & ReservedProps'.", + "app/components/practice-sentences/TypingSentence.vue(line,column): error TS2322: Type 'unknown' is not assignable to type 'boolean'.", + "app/components/practice-sentences/TypingSentenceItem.vue(line,column): error TS2315: Type 'Function' is not generic.", + "app/components/word/ChangeLastPracticeIndexDialog.vue(line,column): error TS2322: Type 'unknown' is not assignable to type 'boolean'.", + "app/components/word/Footer.vue(line,column): error TS2322: Type 'PracticeFlowProgressStage[]' is not assignable to type '{ name: string; active?: boolean; percentage: number; ratio: number; children: { active: boolean; name: string; percentage: number; ratio: number; }[]; }[]'.", + "app/components/word/Footer.vue(line,column): error TS2322: Type 'PracticeFlowProgressStage[]' is not assignable to type '{ name: string; active?: boolean; percentage: number; ratio: number; children: { active: boolean; name: string; percentage: number; ratio: number; }[]; }[]'.", + "app/components/word/PracticeSettingDialog.vue(line,column): error TS2322: Type 'unknown' is not assignable to type 'boolean'.", + "app/components/word/PracticeWordListDialog.vue(line,column): error TS2322: Type 'unknown' is not assignable to type 'boolean'.", + "app/components/word/ShufflePracticeSettingDialog.vue(line,column): error TS2322: Type 'ComputedVariable' is not assignable to type 'ReactiveVariable'.", + "app/components/word/ShufflePracticeSettingDialog.vue(line,column): error TS2322: Type 'ComputedVariable' is not assignable to type 'ReactiveVariable'.", + "app/components/word/ShufflePracticeSettingDialog.vue(line,column): error TS2322: Type 'unknown' is not assignable to type 'boolean'.", + "app/components/word/WordCollectPopover.vue(line,column): error TS2339: Property 'reason' does not exist on type '{ ok: true; dict: Dict; } | { ok: false; reason: \"empty\" | \"duplicate\" | \"api\"; }'.", + "app/components/word/WordCollectPopover.vue(line,column): error TS2339: Property 'reason' does not exist on type '{ ok: true; dict: Dict; } | { ok: false; reason: \"empty\" | \"duplicate\" | \"api\"; }'.", + "app/core/composables/practice-words/usePracticeWordSession.ts(line,column): error TS2345: Argument of type 'Rating' is not assignable to parameter of type 'Grade'.", + "app/core/config/env.ts(line,column): error TS2741: Property '[WordPracticeMode.Custom]' is missing in type '{ 1: (WordPracticeStage.FollowWriteNewWord | WordPracticeStage.Complete)[]; 2: (WordPracticeStage.IdentifyNewWord | WordPracticeStage.IdentifyReview | WordPracticeStage.Complete)[]; ... 6 more ...; 8: null; }' but required in type 'Record'.", + "app/core/hooks/article.ts(line,column): error TS2322: Type 'Timeout' is not assignable to type 'number'.", + "app/core/hooks/export.ts(line,column): error TS2339: Property 'val' does not exist on type 'number | SaveData'.", + "app/core/hooks/export.ts(line,column): error TS2339: Property 'val' does not exist on type 'number | SaveData'.", + "app/core/hooks/sound.ts(line,column): error TS2322: Type 'Timeout' is not assignable to type 'number'.", + "app/libs/translate/baidu.ts(line,column): error TS4114: This member must have an 'override' modifier because it overrides a member in the base class 'Translator'.", + "app/pages/(words)/dict-list.vue(line,column): error TS2322: Type 'string | number' is not assignable to type 'string'.", + "app/pages/(words)/dict-list.vue(line,column): error TS2345: Argument of type 'Record[]' is not assignable to parameter of type 'DictResource[]'.", + "app/pages/(words)/dict.vue(line,column): error TS2322: Type '(ids: string[]) => Promise' is not assignable to type '(ids: number[]) => any'.", + "app/pages/(words)/dict.vue(line,column): error TS2322: Type 'string | number' is not assignable to type 'string'.", + "app/pages/(words)/dict.vue(line,column): error TS2322: Type '{ class: string; onClick: () => void; }' is not assignable to type 'IntrinsicAttributes & Partial<{}> & Omit<{} & VNodeProps & AllowedComponentProps & ComponentCustomProps, never>'.", + "app/pages/(words)/dict.vue(line,column): error TS2322: Type '{ className: string; }' is not assignable to type 'HTMLAttributes & ReservedProps'.", + "app/pages/(words)/dict.vue(line,column): error TS2322: Type '{ id: string; loading: ReactiveVariable | ReactiveVariable; onClick: () => Promise; }' is not assignable to type 'IntrinsicAttributes & Partial<{ type: \"text\" | \"primary\" | \"info\" | \"orange\"; size: \"small\" | \"normal\" | \"large\"; }> & Omit<{ readonly keyboard?: string; ... 5 more ...; readonly onClick?: (...args: any[]) => any; } & VNodeProps & AllowedComponentProps & ComponentCustomProps, DefaultKeys<...>>'.", + "app/pages/(words)/dict.vue(line,column): error TS2322: Type '{ word: ({ required: boolean; message: string; trigger: string; max?: unknown; } | { max: number; message: string; trigger: string; required?: unknown; })[]; }' is not assignable to type 'FormRules'.", + "app/pages/(words)/dict.vue(line,column): error TS2339: Property 'validate' does not exist on type '{ [RefType]?: RefTypes.Ref; }'.", + "app/pages/setting.vue(line,column): error TS2345: Argument of type 'string | number' is not assignable to parameter of type 'string'.", + "app/plugins/01.error.client.ts(line,column): error TS2339: Property 'src' does not exist on type 'EventTarget'." + ], + "preamble": [ + "vue-router/volar/sfc-route-blocks" + ], + "source": "docs/plans/evidence/tauri2-install-lifecycle/typecheck-current.log", + "note": "Internal no-new-diagnostics baseline. typecheck exit 2 remains failure. Not a passing typecheck." +} diff --git a/tests/desktop/import-safety.test.mjs b/tests/desktop/import-safety.test.mjs new file mode 100644 index 000000000..122a7778e --- /dev/null +++ b/tests/desktop/import-safety.test.mjs @@ -0,0 +1,294 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { runInNewContext } from 'node:vm' +import test from 'node:test' +import ts from 'typescript' +const root = process.env.TYPEWORDS_TEST_ROOT || process.cwd() +const env = { + SAVE_DICT_KEY: { key: 'dict', version: 4 }, + SAVE_SETTING_KEY: { key: 'setting', version: 23 }, + EXPORT_DATA_KEY: { version: 5 }, + LOCAL_FILE_KEY: 'audio', + APP_VERSION: { key: 'app', version: 5 }, +} +const cache = { + PRACTICE_WORD_CACHE: { key: 'word', version: 2 }, + PRACTICE_ARTICLE_CACHE: { key: 'article', version: 1 }, +} +function load(path, modules) { + const exports = {} + runInNewContext( + ts.transpileModule(readFileSync(resolve(root, path), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText, + { exports, Date, require: n => modules[n] || {}, console, Blob, structuredClone } + ) + return exports +} +function harness(fail = false, options = {}) { + const events = [] + const db = new Map([ + ['dict', 'original'], + ['audio', 'original-audio'], + ]) + const base = { + $state: { old: true }, + setState(v) { + this.$state = v + }, + $patch(fn) { + fn(this.$state) + }, + }, + setting = { + $state: { old: true }, + setState(v) { + this.$state = v + }, + $patch(fn) { + fn(this.$state) + }, + } + const persistence = load('app/core/composables/useDataSyncPersistence.ts', { + '../utils': { cloneDeep: structuredClone, shakeCommonDict: v => v }, + '../utils/cache': { + ...cache, + setPracticeWordCacheLocal: async v => { + events.push('single') + db.set('word', JSON.stringify(v)) + }, + setPracticeArticleCacheLocal: async v => { + events.push('single') + db.set('article', JSON.stringify(v)) + }, + }, + '../config/env': env, + '../stores': { useBaseStore: () => base, useSettingStore: () => setting }, + '../types/enum': { + SyncDataType: { dict: 'dict', setting: 'setting', practice_word: 'word', practice_article: 'article' }, + }, + '../utils/supabase': { + Supabase: { + isEnabled: () => !options.desktop, + check: () => true, + getInstance: () => ({ + from: () => ({ + upsert: async () => { + events.push('remote') + return options.remoteError ? { error: { message: 'offline' } } : {} + }, + }), + }), + setStatus() {}, + }, + }, + 'idb-keyval': { + get: async k => db.get(k), + set: async (k, v) => { + events.push('single') + if (fail) throw Error('quota') + db.set(k, v) + }, + setMany: async entries => { + events.push('commit') + if (options.beforeCommit) await options.beforeCommit() + if (fail) throw Error('quota') + entries.forEach(([k, v]) => db.set(k, v)) + }, + }, + vue: { nextTick: async () => {}, toRaw: v => v }, + }).useDataSyncPersistence() + const data = { + dict: { val: { word: { bookList: [] }, article: { bookList: [] } } }, + setting: { val: { x: 1 } }, + word: { val: { n: 1 } }, + article: null, + } + return { persistence, events, db, base, setting, data } +} +test('local commit precedes remote and uses one transaction for all four records and audio', async () => { + const h = harness() + await h.persistence.forcePushLocalDataToRemote(h.data, undefined, [{ id: 'tone', file: new Blob(['x']) }]) + assert.deepEqual(h.events, ['commit', 'remote']) + assert.equal(h.db.get('audio')[0].id, 'tone') + const rows = ['dict', 'setting', 'word', 'article'].map(k => JSON.parse(h.db.get(k))) + assert.equal(new Set(rows.map(r => r.updated_at)).size, 1) +}) +test('local quota failure leaves remote, storage and stores untouched', async () => { + const h = harness(true) + await assert.rejects(h.persistence.forcePushLocalDataToRemote(h.data), /quota/) + assert.deepEqual(h.events, ['commit']) + assert.equal(h.db.get('dict'), 'original') + assert.equal(h.db.get('audio'), 'original-audio') + assert.deepEqual(JSON.parse(JSON.stringify(h.base.$state)), { old: true, _ignoreWatch: true }) + assert.deepEqual(JSON.parse(JSON.stringify(h.setting.$state)), { old: true, _ignoreWatch: true }) +}) +test('explicit IndexedDB abort with a null error still reports a useful failure after rollback', async () => { + const h = harness(false, { + beforeCommit: async () => { + throw null + }, + }) + await assert.rejects(h.persistence.forcePushLocalDataToRemote(h.data), /Local backup transaction failed/) + assert.deepEqual(h.events, ['commit']) + assert.equal(h.db.get('dict'), 'original') + assert.equal(h.db.get('audio'), 'original-audio') + assert.deepEqual(JSON.parse(JSON.stringify(h.base.$state)), { old: true, _ignoreWatch: true }) + assert.deepEqual(JSON.parse(JSON.stringify(h.setting.$state)), { old: true, _ignoreWatch: true }) +}) +const fixturePath = new URL('./fixtures/backup/backup-fixture.json', import.meta.url) +function validationHarness() { + const events = [] + const helper = load('app/core/composables/backupImport.ts', { + './remotePracticeValidation': load('app/core/composables/remotePracticeValidation.ts', {}), + './settingsValidation': load('app/core/composables/settingsValidation.ts', {}), + './dictionaryValidation': load('app/core/composables/dictionaryValidation.ts', {}), + '../config/env': env, + '../utils/cache': { ...cache, checkAndUpgradePracticeWordCache: v => v }, + '../utils': { + checkAndUpgradeSaveDict: async v => { + events.push('dict-upgrade') + return v.val + }, + checkAndUpgradeSaveSetting: async v => { + events.push('setting-upgrade') + return v.val + }, + }, + }) + const fixture = JSON.parse(readFileSync(fixturePath)) + delete fixture.val.PracticeSaveWord + delete fixture.val.PracticeSaveArticle + return { helper, events, fixture, audio: [{ id: 'backup-tone', file: new Blob(['audio']) }] } +} +test('unknown export and nested versions, invalid shapes and missing audio reject before upgrade side effects', async () => { + for (const mutate of [ + b => (b.version = 999), + b => (b.val.dict.version = 999), + b => (b.val.setting.version = 999), + b => (b.val.word = { version: 999, val: {} }), + b => (b.val.dict.val.word.bookList = null), + b => (b.val.dict.val.article.bookList[0] = null), + b => (b.val.setting.val.shortcutKeyMap = []), + ]) { + const h = validationHarness() + mutate(h.fixture) + await assert.rejects(h.helper.prepareBackupImport(JSON.stringify(h.fixture), h.audio)) + assert.deepEqual(h.events, []) + } + const h = validationHarness() + await assert.rejects(h.helper.prepareBackupImport(JSON.stringify(h.fixture), []), /Missing backup audio/) + assert.deepEqual(h.events, []) +}) +test('valid v4 and v5 backups preserve semantic fields and upgrade only after audio validation', async () => { + for (const version of [4, 5]) { + const h = validationHarness() + h.fixture.version = version + h.fixture.val.app = 4 + const result = await h.helper.prepareBackupImport(JSON.stringify(h.fixture), h.audio) + assert.equal(result.setting.val.wordSoundVolume, 0.37) + assert.equal(result.dict.val.article.bookList.at(-1).articles[0].audioFileId, 'backup-tone') + assert.deepEqual(h.events, ['dict-upgrade', 'setting-upgrade']) + if (version === 4) assert.equal(result.setting.val.webAppVersion, 4) + } +}) +test('ZIP JSON parse and audio read failures never reach a writer', async () => { + const { helper } = validationHarness() + await assert.rejects(helper.readBackupZip({ file: () => null }), /missing_data_json/) + await assert.rejects(helper.readBackupZip({ file: () => ({ async: async () => '{broken' }), files: {} })) + const zip = { + files: { 'mp3/tone.mp3': {} }, + file: n => ({ + async: async () => { + if (n === 'data.json') return '{}' + throw Error('corrupt audio') + }, + }), + } + await assert.rejects(helper.readBackupZip(zip), /corrupt audio/) +}) +test('custom audio backup commits locally but is never pushed as incomplete remote JSON', async () => { + const h = harness() + h.data.dict.val.article.bookList = [{ custom: true, articles: [{ audioFileId: 'tone' }] }] + assert.equal( + await h.persistence.forcePushLocalDataToRemote(h.data, undefined, [{ id: 'tone', file: new Blob(['x']) }]), + false + ) + assert.deepEqual(h.events, ['commit']) +}) +test('remote rejection retains a successful local import without rolling data back', async () => { + const h = harness(false, { remoteError: true }) + assert.equal(await h.persistence.forcePushLocalDataToRemote(h.data), false) + assert.deepEqual(h.events, ['commit', 'remote']) + assert.equal(JSON.parse(h.db.get('setting')).val.x, 1) + assert.equal(h.setting.$state.x, 1) +}) +test('queued pre-import autosave is discarded and imports cannot overlap local commits', async () => { + let release + const gate = new Promise(r => (release = r)) + const h = harness(false, { beforeCommit: () => gate }) + const stale = h.persistence.saveLocalAndSync('setting', { stale: true }) + const imported = h.persistence.forcePushLocalDataToRemote(h.data) + await stale + await new Promise(r => setImmediate(r)) + assert.deepEqual(h.events, ['commit']) + release() + await imported + assert.deepEqual(h.events, ['commit', 'remote']) + assert.equal(JSON.parse(h.db.get('setting')).val.x, 1) +}) +test('nullable practice cache envelopes are valid legacy empty caches', async () => { + const h = validationHarness() + h.fixture.val.word = { version: 1, val: null } + h.fixture.val.article = { version: 1, val: null } + await h.helper.prepareBackupImport(JSON.stringify(h.fixture), h.audio) +}) +test('unselected word and article sentinel indices remain importable', async () => { + const h = validationHarness() + h.fixture.val.dict.val.word.studyIndex = -1 + h.fixture.val.dict.val.article.studyIndex = -1 + const result = await h.helper.prepareBackupImport(JSON.stringify(h.fixture), h.audio) + assert.equal(result.dict.val.article.studyIndex, -1) +}) +test('missing shortcut map rejects before legacy normalizer can save fallback snapshots', async () => { + const h = validationHarness() + delete h.fixture.val.setting.val.shortcutKeyMap + await assert.rejects(h.helper.prepareBackupImport(JSON.stringify(h.fixture), h.audio), /Invalid backup settings/) + assert.deepEqual(h.events, []) +}) +test('quota rollback preserves runtime FSRS Date objects and removes merged imported setting keys', async () => { + const h = harness(true) + const due = new Date('2026-09-12T12:00:00Z') + h.base.$state.fsrsData = { test: { due } } + h.setting.setState = function (v) { + Object.assign(this.$state, v) + } + await assert.rejects(h.persistence.forcePushLocalDataToRemote(h.data), /quota/) + assert.ok(h.base.$state.fsrsData.test.due instanceof Date) + assert.equal(h.base.$state.fsrsData.test.due.getTime(), due.getTime()) + assert.equal('x' in h.setting.$state, false) +}) +test('explicit concurrent imports both execute, rather than reporting a skipped import as success', async () => { + const h = harness() + const first = h.persistence.forcePushLocalDataToRemote(h.data) + const second = h.persistence.forcePushLocalDataToRemote(h.data) + assert.equal(await first, true) + assert.equal(await second, true) + assert.deepEqual(h.events, ['commit', 'remote', 'commit', 'remote']) +}) +test('consumer-critical malformed fields reject before normalization or persistence', async () => { + for (const mutate of [ + b => (b.val.dict.val.simpleWords = null), + b => (b.val.dict.val.fsrsData = null), + b => (b.val.setting.val.fontSize = null), + b => (b.val.dict.val.word.bookList[0].words = [{}]), + b => (b.val.dict.val.article.bookList[0].articles = [{}]), + b => (b.val.setting.val.fsrsParameters = null), + ]) { + const h = validationHarness() + mutate(h.fixture) + await assert.rejects(h.helper.prepareBackupImport(JSON.stringify(h.fixture), h.audio)) + assert.deepEqual(h.events, []) + } +}) diff --git a/tests/desktop/import-watchers.test.mjs b/tests/desktop/import-watchers.test.mjs new file mode 100644 index 000000000..3319a39e5 --- /dev/null +++ b/tests/desktop/import-watchers.test.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { nextTick } from 'vue' +import { harness } from './init-subscriptions-harness.mjs' + +for (const name of ['base', 'setting']) { + test(`${name}: long import consumes ignore flag while busy, next genuine edit persists`, async () => { + const h = harness() + await h.init() + h.runtime.globalLoading = true + h[name].setState({ _ignoreWatch: true, marker: 'imported' }) + await nextTick() + await h.clock.flush() + assert.equal(h.calls.length, 0) + assert.equal(h[name]._ignoreWatch, false, 'busy import must consume its own ignored mutation') + h.runtime.globalLoading = false + h[name].marker = 'user edit' + await nextTick() + await h.clock.flush() + assert.deepEqual(h.calls, [{ store: name, marker: 'user edit' }]) + }) +} diff --git a/tests/desktop/indexeddb-integration.mjs b/tests/desktop/indexeddb-integration.mjs new file mode 100644 index 000000000..dc95d4eba --- /dev/null +++ b/tests/desktop/indexeddb-integration.mjs @@ -0,0 +1,95 @@ +// Standalone browser integration: node tests/desktop/indexeddb-integration.mjs +// Set TYPEWORDS_PLAYWRIGHT_MODULE to a Playwright entry point when it is not installed locally. +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { fileURLToPath, pathToFileURL } from 'node:url' +import ts from 'typescript' +import { settingsMigrationSource } from './settings-migration-source.mjs' +import { dictMigrationSource } from './dict-migration-source.mjs' +import { declarations } from './migration-declarations.mjs' + +const root = new URL('../../', import.meta.url) +const source = path => readFileSync(new URL(path, root), 'utf8') +const transpile = path => + ts.transpileModule(source(path), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText +const modules = { + persistence: transpile('app/core/composables/useDataSyncPersistence.ts'), + account: transpile('app/core/platform/accountSync.ts'), + accountPersistence: transpile('app/core/platform/accountPersistence.ts'), + validation: transpile('app/core/composables/remotePracticeValidation.ts'), + settingsValidation: transpile('app/core/composables/settingsValidation.ts'), + dictionaryValidation: transpile('app/core/composables/dictionaryValidation.ts'), + cache: transpile('app/core/utils/cache.ts'), + enums: transpile('app/core/types/enum.ts'), + session: transpile('app/core/composables/practice-words/practice-word-session.ts'), + supabase: transpile('app/core/utils/supabase.ts'), + init: transpile('app/core/composables/useInit.ts'), + debounce: ts.transpileModule(declarations(fileURLToPath(root), 'app/core/utils/index.ts', ['debounce']), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText, + pinia: readFileSync(new URL('./pinia.prod.cjs', import.meta.resolve('pinia')), 'utf8'), + settingsMigration: ts.transpileModule(settingsMigrationSource(fileURLToPath(root)), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText, + dictMigration: ts.transpileModule(dictMigrationSource(fileURLToPath(root)), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText, +} +const routes = new Map([ + ['/', 'Isolated IndexedDB integration'], + ['/modules.json', JSON.stringify(modules)], + ['/suite.js', source('tests/desktop/indexeddb-suite.mjs')], + ['/settings-fixtures.js', source('tests/desktop/settings-validation-fixtures.mjs')], + ['/dictionary-fixtures.js', source('tests/desktop/dictionary-validation-fixtures.mjs')], + ['/idb.js', readFileSync(fileURLToPath(import.meta.resolve('idb-keyval')), 'utf8')], + ['/vue.js', readFileSync(fileURLToPath(import.meta.resolve('vue/dist/vue.esm-browser.js')), 'utf8')], +]) +const server = createServer((req, res) => { + const body = routes.get(req.url) + res.writeHead(body === undefined ? 404 : 200, { + 'Content-Type': req.url.endsWith('.js') + ? 'text/javascript' + : req.url.endsWith('.json') + ? 'application/json' + : 'text/html', + }) + res.end(body ?? 'Not found') +}) +let browser +let timeout +try { + const { chromium } = await import( + process.env.TYPEWORDS_PLAYWRIGHT_MODULE ? pathToFileURL(process.env.TYPEWORDS_PLAYWRIGHT_MODULE).href : 'playwright' + ) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const origin = `http://127.0.0.1:${server.address().port}` + browser = await chromium.launch({ channel: process.env.TYPEWORDS_BROWSER_CHANNEL || 'msedge', headless: true }) + const context = await browser.newContext() + const external = [] + await context.route('**/*', route => { + if (new URL(route.request().url()).origin === origin) return route.continue() + external.push(route.request().url()) + return route.abort() + }) + const page = await context.newPage() + const errors = [] + page.on('pageerror', error => errors.push(error.message)) + await page.goto(origin) + const report = await Promise.race([ + page.evaluate(async () => (await import('/suite.js')).run()), + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error('IndexedDB integration timed out after 45s')), 45000) + }), + ]) + assert.deepEqual(errors, []) + assert.deepEqual(external, []) + assert.equal(report.cases.length, 19) + console.log(JSON.stringify({ browser: browser.version(), externalRequests: external, ...report }, null, 2)) +} finally { + clearTimeout(timeout) + await browser?.close() + await new Promise(resolve => server.close(resolve)) +} diff --git a/tests/desktop/indexeddb-suite.mjs b/tests/desktop/indexeddb-suite.mjs new file mode 100644 index 000000000..77f51bf24 --- /dev/null +++ b/tests/desktop/indexeddb-suite.mjs @@ -0,0 +1,880 @@ +import * as idb from '/idb.js' +import { reactive, shallowReactive, nextTick, toRaw } from '/vue.js' +import * as vue from '/vue.js' +import { invalidSettings } from '/settings-fixtures.js' +import { invalidDictionaries } from '/dictionary-fixtures.js' + +function assert(condition, message) { + if (!condition) throw new Error(message) +} +const equal = (actual, expected, message) => assert(JSON.stringify(actual) === JSON.stringify(expected), message) +const keys = ['dict', 'setting', 'PracticeSaveWord', 'PracticeSaveArticle', 'audio'] + +export async function run() { + const sources = await (await fetch('/modules.json')).json() + function load(name, modules = {}) { + const exports = {} + new Function('exports', 'require', 'process', sources[name])( + exports, + name => { + assert(Object.hasOwn(modules, name), `Unexpected module: ${name}`) + return modules[name] + }, + { env: { NODE_ENV: 'production' } } + ) + return exports + } + const enums = load('enums') + const validation = load('validation') + const settingsValidation = load('settingsValidation') + const dictionaryValidation = load('dictionaryValidation') + // Production Pinia has no active devtools integration; learning state is isolated per case. + const pinia = load('pinia', { vue, '@vue/devtools-api': {} }) + const cases = [] + for (const mode of [ + 'import', + 'pull', + 'queued-before-import', + 'queued-after-abort', + 'offline-load', + 'offline-clear', + 'settings-boundary', + 'settings-abort', + 'dict-migration', + 'dict-abort', + 'watchers-import', + 'watchers-pull', + 'account-open', + 'account-migration', + 'account-logout', + 'account-refresh', + 'account-switch', + 'account-dispose', + 'desktop-local', + ]) { + const name = `typewords-transaction-test-${crypto.randomUUID()}` + const events = [] + let fault = false + let onSubmitted + let submitted = 0 + let succeeded = 0 + let aborted = 0 + let pushes = 0 + let transactionSettled + let status + let queued + let offline = mode.startsWith('offline-') + let settingsMigrations = 0 + let dictMigrations = 0 + let repairSnapshots = 0 + let invalidateAccount + let beforeAccountOpen + let account + const watcherMode = mode.startsWith('watchers-') + const piniaRoot = watcherMode ? pinia.createPinia() : null + const watcherSaves = [] + // Each operation opens and closes a real connection, including all verification reads. + const store = async (txMode, callback) => { + const request = indexedDB.open(name, 1) + request.onupgradeneeded = () => request.result.createObjectStore('keyval') + const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) + const tx = db.transaction('keyval', txMode) + tx.addEventListener('complete', () => db.close()) + tx.addEventListener('abort', () => db.close()) + const objectStore = tx.objectStore('keyval') + const inject = fault && txMode === 'readwrite' + if (inject) { + fault = false + transactionSettled = new Promise(resolve => { + tx.addEventListener('abort', () => { + aborted++ + resolve() + }) + tx.addEventListener('complete', resolve) + }) + } + const observed = inject + ? new Proxy(objectStore, { + get(target, property) { + if (property === 'put') { + return (...args) => { + submitted++ + const write = target.put(...args) + write.addEventListener('success', () => { + succeeded++ + if (succeeded === 2) { + if (invalidateAccount) invalidateAccount() + else tx.abort() + } + }) + return write + } + } + const value = Reflect.get(target, property, target) + return typeof value === 'function' ? value.bind(target) : value + }, + }) + : objectStore + if (txMode === 'readwrite' && beforeAccountOpen) { + const invalidate = beforeAccountOpen + beforeAccountOpen = null + invalidate() + } + const result = callback(observed) + if (inject) onSubmitted?.() + return result + } + const storage = { + get: key => idb.get(key, store), + set: (key, value) => idb.set(key, value, store), + del: key => idb.del(key, store), + setMany: entries => idb.setMany(entries, store), + } + const oldState = () => ({ + word: { studyIndex: -1, bookList: [] }, + article: { studyIndex: -1, bookList: [] }, + due: new Date('2026-09-15T00:00:00Z'), + marker: 'old', + }) + const makeStore = merge => { + if (watcherMode) { + return pinia.defineStore(merge ? 'setting' : 'base', { + state: () => ({ ...oldState(), _ignoreWatch: false }), + actions: { + init: async () => null, + setState(value) { + this.$patch(state => { + if (!merge) { + for (const key of Object.keys(state)) delete state[key] + } + Object.assign(state, value) + }) + }, + }, + })(piniaRoot) + } + const state = reactive({ value: oldState() }) + return { + get $state() { + return state.value + }, + get word() { + return state.value.word + }, + get article() { + return state.value.article + }, + get sdict() { + return state.value.word.bookList[state.value.word.studyIndex] + }, + get sbook() { + return state.value.article.bookList[state.value.article.studyIndex] + }, + setState(value) { + if (merge) Object.assign(state.value, value) + else state.value = value + }, + $patch(fn) { + fn(state.value) + }, + } + } + const base = makeStore(false) + const setting = makeStore(true) + const cache = load('cache', { '../types/enum': enums, 'idb-keyval': storage }) + const data = { + dict: { val: { ...oldState(), marker: 'new' } }, + setting: { val: { marker: 'new', added: true, shortcutKeyMap: {} } }, + PracticeSaveWord: { val: { taskWordsStr: { new: ['hello'], review: [] } } }, + PracticeSaveArticle: { val: null }, + } + const rows = [ + { type: enums.SyncDataType.dict, data: data.dict.val, data_version: 4 }, + { type: enums.SyncDataType.setting, data: data.setting.val, data_version: 23 }, + { type: enums.SyncDataType.practice_word, data: data.PracticeSaveWord.val, data_version: 1 }, + { type: enums.SyncDataType.practice_article, data: null, data_version: 1 }, + ] + const client = { + from: () => { + if (mode === 'desktop-local') throw new Error('Desktop reached remote client') + return { + select: () => ({ + in: (_, types) => { + const result = offline + ? Promise.reject(new Error('isolated network failure')) + : Promise.resolve({ data: structuredClone(rows.filter(row => types.includes(row.type))) }) + result.not = async () => ({ data: structuredClone(rows) }) + return result + }, + }), + upsert: async incoming => { + pushes++ + events.push('remote') + const saved = JSON.parse(await storage.get('setting')) + if (watcherMode) { + for (const row of incoming) { + const key = + row.type === enums.SyncDataType.practice_word + ? 'PracticeSaveWord' + : row.type === enums.SyncDataType.practice_article + ? 'PracticeSaveArticle' + : row.type + equal(JSON.parse(await storage.get(key)).val, row.data, 'Watcher push preceded local commit') + } + } else { + assert(saved.val.marker === 'new', 'Remote push preceded local commit') + } + return {} + }, + } + }, + } + const config = load('supabase', { + '@supabase/supabase-js': { createClient: () => client }, + '@/base': { Toast: { error() {} } }, + '../stores': { useRuntimeStore: () => ({}) }, + '../platform/sync': { validatedSyncUrl: value => value, createSyncClient: () => client }, + }) + globalThis.useRuntimeConfig = () => ({ public: { isDesktop: mode === 'desktop-local' } }) + config.setConfig({ url: 'https://fixture.supabase.co', key: 'fixture-key', status: 'success', statusMessage: '' }) + const setStatus = config.Supabase.setStatus.bind(config.Supabase) + config.Supabase.setStatus = (value, message) => { + setStatus(value, message) + status = config.Supabase.getStatus().status + } + const settingsMigration = load('settingsMigration', { + '../types/enum': enums, + 'idb-keyval': storage, + '../composables/useDataSyncPersistence': { + saveHashSnapshot: async () => { + repairSnapshots++ + throw new Error('Unexpected legacy repair snapshot') + }, + }, + }) + const dictMigration = load('dictMigration', { + '../types/enum': enums, + vue: { shallowReactive }, + '../composables/useDataSyncPersistence': { + saveHashSnapshot: async () => { + repairSnapshots++ + throw new Error('Unexpected dictionary repair snapshot') + }, + }, + }) + const persistence = load('persistence', { + '../platform/accountPersistence': { + setManyForAccount: (entries, scope) => + load('accountPersistence', { 'idb-keyval': idb }).setManyForAccount(entries, scope, store), + }, + '../utils': { + shakeCommonDict: value => value, + shouldFetchRemote: () => enums.CompareResult.LocalNewer, + checkAndUpgradeSaveDict: async envelope => { + dictMigrations++ + return mode.startsWith('dict-') ? dictMigration.checkAndUpgradeSaveDict(envelope) : envelope.val + }, + checkAndUpgradeSaveSetting: async envelope => { + settingsMigrations++ + if (mode === 'account-migration' && invalidateAccount) { + const invalidate = invalidateAccount + invalidateAccount = null + await Promise.resolve() + invalidate() + } + return mode.startsWith('settings-') || mode.startsWith('dict-') + ? settingsMigration.checkAndUpgradeSaveSetting(envelope) + : envelope.val + }, + }, + '../utils/cache': cache, + '../config/env': { + SAVE_DICT_KEY: { key: 'dict', version: 4 }, + SAVE_SETTING_KEY: { key: 'setting', version: 23 }, + LOCAL_FILE_KEY: 'audio', + }, + '../stores': { useBaseStore: () => base, useSettingStore: () => setting }, + '../types/enum': enums, + '../utils/supabase': config, + 'idb-keyval': storage, + '@/base': {}, + vue: { nextTick, toRaw }, + './remotePracticeValidation': validation, + './settingsValidation': settingsValidation, + './dictionaryValidation': dictionaryValidation, + }) + const api = persistence.useDataSyncPersistence() + let stopInit + if (watcherMode) { + const lifecycle = [] + const init = load('init', { + '../config/env': { APP_VERSION: { version: 5 } }, + '../utils': load('debounce'), + '../stores': { + useBaseStore: () => base, + useSettingStore: () => setting, + useRuntimeStore: () => ({}), + }, + '../utils/supabase': config, + './useDataSyncPersistence': { + ensureHashGuardBeforeInit: async () => {}, + useDataSyncPersistence: () => ({ + ...api, + saveDictState: async value => { + watcherSaves.push('dict') + return api.saveDictState(value) + }, + saveLocalAndSync: async (type, value) => { + watcherSaves.push(type) + return api.saveLocalAndSync(type, value) + }, + }), + }, + '../types': enums, + vue: { onUnmounted: fn => lifecycle.push(fn) }, + }).useInit() + await init() + stopInit = () => { + for (const fn of lifecycle) fn() + pinia.disposePinia(piniaRoot) + } + } + const audio = text => [{ id: 'synthetic-tone', file: new Blob([text], { type: 'audio/mpeg' }) }] + async function snapshot() { + const values = await idb.getMany(keys, store) + values[4] = await Promise.all( + values[4].map(async item => ({ id: item.id, bytes: [...new Uint8Array(await item.file.arrayBuffer())] })) + ) + return values + } + try { + await storage.setMany([ + ...keys.slice(0, 4).map(key => [key, JSON.stringify({ val: `old-${key}` })]), + ['audio', audio('old audio bytes')], + ]) + if (mode === 'desktop-local') { + const beforeConfig = localStorage.getItem(config.SUPABASE_CONFIG_KEY) + const before = await snapshot() + assert(!config.Supabase.check() && !config.Supabase.check(true), 'Desktop enabled retained cloud config') + assert(config.Supabase.getStatus().status === 'idle', 'Desktop exposed stale sync status') + assert((await api.getRemoteData(enums.SyncDataType.dict, client)) === null, 'Explicit client bypassed policy') + assert((await api.getRemoteMeta(enums.SyncDataType.dict, client)) === null, 'Metadata bypassed policy') + assert((await api.pullAllRemoteToLocal(client)) === false, 'Desktop pulled remote records') + assert((await api.pullIfRemoteNewer(enums.SyncDataType.dict, client)) === null, 'Desktop pulled newer data') + await api.syncData({ dict: { val: data.dict.val, version: 4 } }) + let captured = false + await api + .pullAccountRemoteToLocal({ + capture() { + captured = true + }, + }) + .then( + () => { + throw Error('Account pull should reject on desktop') + }, + () => {} + ) + assert(!captured, 'Desktop captured an account session') + equal(await snapshot(), before, 'Desktop remote reads changed disk') + await api.saveLocalAndSync(enums.SyncDataType.setting, { marker: 'local edit' }, { client }) + assert(JSON.parse(await storage.get('setting')).val.marker === 'local edit', 'Desktop local save failed') + assert( + (await api.forcePushLocalDataToRemote(data, client, audio('local imported audio'))) === false, + 'Desktop import reported remote upload' + ) + const imported = await snapshot() + assert(JSON.parse(imported[0]).val.marker === 'new', 'Desktop local import failed') + assert(base.$state.marker === 'new', 'Desktop local import did not hydrate') + equal( + imported[4][0].bytes, + [...new TextEncoder().encode('local imported audio')], + 'Desktop audio import failed' + ) + assert(pushes === 0, 'Desktop performed remote writes') + equal(localStorage.getItem(config.SUPABASE_CONFIG_KEY), beforeConfig, 'Desktop rewrote retained config') + cases.push({ + mode, + retainedConfig: true, + explicitClientBlocked: true, + localSave: true, + localImport: true, + reopened: true, + pushes, + }) + continue + } + if (mode.startsWith('account-')) { + const before = await snapshot() + const memory = JSON.stringify([base.$state, setting.$state]) + let authEvent + const session = (user = 'synthetic-a', token = 'synthetic-token') => ({ + user: { id: user, email: 'fixture@example.invalid', is_anonymous: false }, + access_token: token, + }) + const accountModule = load('account', { + './sync': { validatedSyncUrl: value => value }, + '@supabase/supabase-js': { + createClient: (_url, _key, options) => + options?.accessToken + ? { + from: () => ({ + select: () => ({ + eq: (_field, userId) => ({ + in: async () => { + await options.accessToken() + return { data: structuredClone(rows).map(row => ({ ...row, user_id: userId })) } + }, + }), + }), + }), + } + : { + auth: { + onAuthStateChange: callback => { + authEvent = callback + return { data: { subscription: { unsubscribe() {} } } } + }, + getSession: async () => ({ data: { session: null } }), + signInWithPassword: async () => ({ data: { session: session() } }), + signOut: async () => ({}), + stopAutoRefresh: async () => {}, + }, + }, + }, + }) + account = accountModule.createAccountSync('https://synthetic.supabase.co', 'synthetic-key', true) + await account.ready + await account.signIn('fixture@example.invalid', 'synthetic-password') + config.Supabase.setStatus('idle') + invalidateAccount = () => { + equal(JSON.stringify([base.$state, setting.$state]), memory, 'Speculative account data reached memory') + if (mode === 'account-logout') void account.signOut() + else if (mode === 'account-dispose') account.dispose() + else if (mode === 'account-switch') authEvent('SIGNED_IN', session('synthetic-b', 'other-account-token')) + else authEvent('TOKEN_REFRESHED', session('synthetic-a', 'replacement-token')) + } + if (mode === 'account-open') beforeAccountOpen = invalidateAccount + if (['account-logout', 'account-refresh', 'account-switch', 'account-dispose'].includes(mode)) fault = true + let rejected = false + try { + await api.pullAccountRemoteToLocal(account) + } catch { + rejected = true + } + assert(rejected, 'Stale account pull reported success') + await transactionSettled + equal(await snapshot(), before, 'Stale account transaction changed disk/audio') + equal(JSON.stringify([base.$state, setting.$state]), memory, 'Stale account transaction changed memory') + assert(status === 'idle', 'Stale account request changed UI status') + assert(!account.getState().active && pushes === 0, 'Stale account pull activated or uploaded') + if (mode !== 'account-migration' && mode !== 'account-open') { + assert(submitted === 4 && succeeded === 2 && aborted === 1, 'Account invalidation did not abort real writes') + } + invalidateAccount = null + if (mode !== 'account-dispose') { + await account.signIn('fixture@example.invalid', 'synthetic-password') + assert(await api.pullAccountRemoteToLocal(account), 'Current account retry failed') + assert(account.getState().active && status === 'idle', 'Account pull activation changed legacy status') + account.capture('automatic').assertCurrent() + const retry = await snapshot() + assert(JSON.parse(retry[0]).val.marker === 'new', 'Account retry lost dictionary') + assert(JSON.parse(retry[1]).val.marker === 'new', 'Account retry lost settings') + equal(JSON.parse(retry[2]).val.taskWordsStr.new, ['hello'], 'Account retry lost cache') + assert(JSON.parse(retry[3]).val === null, 'Account retry lost empty article cache') + equal(retry[4], before[4], 'Account retry changed local audio') + } + cases.push({ mode, submitted, succeeded, aborted, pushes, reopened: true, retry: mode !== 'account-dispose' }) + continue + } + if (watcherMode) { + const before = await snapshot() + // A pending pre-import edit must not echo either the failed import or its rollback. + setting.marker = 'pending edit' + await nextTick() + fault = true + if (mode === 'watchers-pull') { + assert((await api.pullAllRemoteToLocal()) === false, 'Watcher pull abort reported success') + } else { + let rejected = false + try { + await api.forcePushLocalDataToRemote(data, client, audio('new audio bytes')) + } catch { + rejected = true + } + assert(rejected, 'Watcher import abort did not reject') + } + await transactionSettled + await new Promise(resolve => setTimeout(resolve, 1150)) + equal(await snapshot(), before, 'Watcher wrote after transaction rollback') + equal(watcherSaves, [], 'Import or rollback scheduled an autosave') + assert(pushes === 0 && aborted === 1 && succeeded === 2, 'Watcher abort/push mismatch') + assert(setting.marker === 'pending edit' && base.marker === 'old', 'Watcher rollback lost memory') + assert(!base._ignoreWatch && !setting._ignoreWatch, 'Rollback suppression not consumed') + if (mode === 'watchers-pull') { + assert(await api.pullAllRemoteToLocal(), 'Watcher pull retry failed') + } else { + assert(await api.forcePushLocalDataToRemote(data, client, audio('new audio bytes')), 'Watcher retry failed') + } + const committed = await snapshot() + // Both edits occur inside the real 1000ms debounce window after successful import. + base.marker = 'dict user edit' + setting.marker = 'setting user edit' + await nextTick() + const deadline = Date.now() + 5000 + let saved + do { + await new Promise(resolve => setTimeout(resolve, 30)) + saved = await snapshot() + } while ( + (JSON.parse(saved[0]).val.marker !== 'dict user edit' || + JSON.parse(saved[1]).val.marker !== 'setting user edit') && + Date.now() < deadline + ) + assert(JSON.parse(saved[0]).val.marker === 'dict user edit', 'First dictionary edit was swallowed') + assert(JSON.parse(saved[1]).val.marker === 'setting user edit', 'First settings edit was swallowed') + equal(saved.slice(2), committed.slice(2), 'Watcher edit changed practice caches or audio') + equal([...watcherSaves].sort(), ['dict', 'setting'], 'Unexpected watcher writes') + // Local commits precede the two explicit user edits' stub remote writes. + const pushDeadline = Date.now() + 2000 + while (pushes < (mode === 'watchers-pull' ? 2 : 3) && Date.now() < pushDeadline) { + await new Promise(resolve => setTimeout(resolve, 20)) + } + assert(pushes === (mode === 'watchers-pull' ? 2 : 3), 'Unexpected watcher remote write count') + cases.push({ + mode, + realPinia: true, + productionPinia: true, + fullInitSubscriptions: true, + debounceMs: 1000, + submitted, + succeeded, + aborted, + pushes, + watcherSaves, + reopened: true, + retry: true, + }) + continue + } + if (mode.startsWith('dict-')) { + const before = await snapshot() + const oldMemory = JSON.stringify(base.$state) + const fixture = () => { + const value = dictMigration.getDefaultBaseState() + delete value.word.bookList[0].system + value.word.bookList.push({ + id: 42, + en_name: 'legacy-custom', + name: 'Migration fixture', + custom: true, + words: [ + { word: 'hello', trans: ['greeting'] }, + { word: 'world', trans: [] }, + ], + articles: [], + length: 99, + lastLearnIndex: 1, + perDayStudyNumber: 7, + statistics: [{ total: 2, correct: 1 }], + }) + value.word.studyIndex = 3 + value.article.bookList.push({ + id: 'custom-article', + custom: true, + words: [], + length: 99, + lastLearnIndex: 0, + articles: [{ text: 'Hello world.', textTranslate: 'Fixture', audioFileId: 'synthetic-tone' }], + }) + value.article.studyIndex = 1 + value.simpleWords = ['a'] + value.noteData = { hello: 'Keep this note' } + value.fsrsData = { + hello: { + due: '2026-09-16T00:00:00.000Z', + stability: 2, + difficulty: 5, + elapsed_days: 1, + scheduled_days: 2, + reps: 2, + lapses: 0, + state: 2, + last_review: '2026-09-14T00:00:00.000Z', + }, + } + return JSON.parse(JSON.stringify(value)) + } + const checkSaved = async () => { + const saved = JSON.parse(await storage.get('dict')) + const value = saved.val + assert(saved.version === 4 && value.load === true, 'Dictionary version/load not normalized') + assert(value.word.studyIndex === 3 && value.article.studyIndex === 1, 'Study selection lost') + assert(value.word.bookList[0].system === true, 'Legacy system book not normalized') + const word = value.word.bookList[3] + assert(word.id === 42 && word.enName === 'legacy-custom', 'Legacy dictionary identity lost') + assert(word.length === 2 && word.lastLearnIndex === 1 && word.perDayStudyNumber === 7, 'Word progress lost') + equal(word.words, fixture().word.bookList[3].words, 'Word contents lost') + equal(word.statistics, fixture().word.bookList[3].statistics, 'Statistics lost') + const article = value.article.bookList[1] + assert(article.enName === 'custom-article' && article.length === 1, 'Article identity/length lost') + equal(article.articles, fixture().article.bookList[1].articles, 'Article/audio reference lost') + for (const key of ['simpleWords', 'noteData', 'fsrsData']) { + equal(value[key], fixture()[key], `Dictionary ${key} lost`) + } + equal(JSON.parse(JSON.stringify(base.$state)), value, 'Committed memory differs from reopened dictionary') + assert(JSON.parse(await storage.get('setting')).val.wordSoundVolume === 37, 'Real setting migration lost') + equal(JSON.parse(await storage.get('PracticeSaveWord')).val.taskWordsStr.new, ['hello'], 'Cache lost') + assert(JSON.parse(await storage.get('PracticeSaveArticle')).val === null, 'Article cache lost') + } + rows[0].data = fixture() + rows[1].data = { shortcutKeyMap: {}, wordSoundVolume: 37 } + if (mode === 'dict-migration') { + for (const [label, mutate] of invalidDictionaries) { + rows[0].data = fixture() + mutate(rows[0].data) + // The fixture has four word books; use an actually out-of-range selection. + if (label === 'invalid selection') rows[0].data.word.studyIndex = 99 + assert((await api.pullAllRemoteToLocal()) === false, `Invalid dictionary accepted: ${label}`) + equal(await snapshot(), before, `Invalid dictionary changed database/audio: ${label}`) + assert(JSON.stringify(base.$state) === oldMemory, `Invalid dictionary changed memory: ${label}`) + assert(dictMigrations === 0 && settingsMigrations === 0, `Invalid dictionary migrated: ${label}`) + assert(pushes === 0 && repairSnapshots === 0, `Invalid dictionary wrote remotely: ${label}`) + assert(status === 'error' && !config.Supabase.check(), `Invalid dictionary unlocked writes: ${label}`) + } + rows[0].data = fixture() + } + if (mode === 'dict-abort') { + rows[0].data_version = 1 + fault = true + assert((await api.pullAllRemoteToLocal()) === false, 'Dictionary abort reported success') + await transactionSettled + assert(submitted === 4 && succeeded === 2 && aborted === 1, 'Expected dictionary transaction abort') + equal(await snapshot(), before, 'Dictionary abort changed persistent data/audio') + equal( + JSON.parse(JSON.stringify(base.$state)), + { ...JSON.parse(oldMemory), _ignoreWatch: true }, + 'Dictionary abort changed old memory beyond the watcher suppression flag' + ) + assert(base.$state.due instanceof Date, 'Dictionary rollback lost Date') + assert(!Object.hasOwn(base.$state, 'noteData'), 'Dictionary rollback retained new fields') + assert(status === 'error' && !config.Supabase.check(), 'Dictionary abort unlocked writes') + } + const versions = mode === 'dict-migration' ? [1, 2, 3, 4] : [1] + for (const version of versions) { + rows[0].data_version = version + rows[0].data = fixture() + const pulled = await api.pullAllRemoteToLocal() + assert(pulled, `Real dictionary v${version} pull failed: ${config.Supabase.getStatus().statusMessage}`) + await checkSaved() + assert(status === 'success' && config.Supabase.getStatus().statusMessage === '', 'Retry status stale') + } + assert(dictMigrations === (mode === 'dict-abort' ? 2 : 4), 'Dictionary migrator not exercised') + assert(settingsMigrations === dictMigrations, 'Real settings migration not exercised') + assert(pushes === 0 && repairSnapshots === 0, 'Dictionary test wrote remote/repair snapshots') + equal((await snapshot())[4], before[4], 'Dictionary migration changed audio bytes') + cases.push({ + mode, + realDictMigrator: true, + invalidCases: mode === 'dict-migration' ? invalidDictionaries.length : 0, + realSettingsMigrator: true, + legacyVersions: versions, + dictMigrations, + settingsMigrations, + repairSnapshots, + submitted, + succeeded, + aborted, + pushes, + reopened: true, + retry: mode === 'dict-abort', + }) + continue + } + if (mode.startsWith('settings-')) { + const before = await snapshot() + const oldMemory = JSON.stringify(setting.$state) + if (mode === 'settings-boundary') { + for (const [label, mutate] of invalidSettings) { + rows[1].data = { shortcutKeyMap: {} } + mutate(rows[1].data) + assert((await api.pullAllRemoteToLocal()) === false, `Accepted ${label}`) + equal(await snapshot(), before, `Invalid ${label} changed database/audio`) + assert(JSON.stringify(setting.$state) === oldMemory, `Invalid ${label} changed memory`) + assert(settingsMigrations === 0 && dictMigrations === 0, `Invalid ${label} reached migration`) + assert(status === 'error' && !config.Supabase.check(), `Invalid ${label} unlocked writes`) + } + for (const version of [1, 17, 19, 23]) { + rows[1].data_version = version + rows[1].data = { shortcutKeyMap: { [enums.ShortcutKey.Next]: 'Alt+N' }, wordSoundVolume: 37 } + assert(await api.pullAllRemoteToLocal(), `Historical settings v${version} failed`) + const saved = JSON.parse(await storage.get('setting')) + assert(saved.version === 23 && saved.val.wordSoundVolume === 37, 'Settings migration lost value') + assert(saved.val.fontSize.wordForeignFontSize === 48, 'Missing legacy font default') + equal(saved.val.ttsVoiceMap, [], 'Missing legacy voice default') + assert( + saved.val.shortcutKeyMap[enums.ShortcutKey.Next] === + (version <= 20 ? settingsMigration.DefaultShortcutKeyMap[enums.ShortcutKey.Next] : 'Alt+N'), + 'Historical shortcut migration changed' + ) + assert(status === 'success' && config.Supabase.getStatus().statusMessage === '', 'Retry status stale') + } + } else { + rows[1].data = { shortcutKeyMap: {}, wordSoundVolume: 37 } + fault = true + assert((await api.pullAllRemoteToLocal()) === false, 'Real settings abort reported success') + await transactionSettled + assert(submitted === 4 && succeeded === 2 && aborted === 1, 'Expected multi-key transaction abort') + equal(await snapshot(), before, 'Real settings abort changed database/audio') + assert(setting.$state.marker === 'old', 'Real settings abort lost old memory') + assert(!Object.hasOwn(setting.$state, 'wordSoundVolume'), 'Real settings abort retained imported values') + assert(setting.$state.due instanceof Date, 'Real settings rollback lost Date') + assert(status === 'error' && !config.Supabase.check(), 'Aborted settings unlocked writes') + assert(await api.pullAllRemoteToLocal(), 'Real settings retry failed') + assert(JSON.parse(await storage.get('setting')).val.wordSoundVolume === 37, 'Retry lost setting') + assert(status === 'success' && config.Supabase.getStatus().statusMessage === '', 'Retry status stale') + } + assert(pushes === 0 && repairSnapshots === 0, 'Settings test wrote remote or repair snapshots') + const after = await snapshot() + equal(after[4], before[4], 'Settings retry changed audio') + cases.push({ + mode, + realSettingsMigrator: true, + settingsMigrations, + dictMigrations, + repairSnapshots, + submitted, + succeeded, + aborted, + pushes, + reopened: true, + retry: true, + invalidCases: mode === 'settings-boundary' ? invalidSettings.length : 0, + legacyVersions: mode === 'settings-boundary' ? [1, 17, 19, 23] : [], + }) + continue + } + if (mode.startsWith('offline-')) { + await storage.set( + cache.PRACTICE_WORD_CACHE.key, + JSON.stringify({ + val: mode === 'offline-clear' ? null : { taskWords: { new: [{ word: 'hello' }], review: [] } }, + version: 1, + updated_at: '2020-01-01T00:00:00Z', + }) + ) + const before = await snapshot() + const session = load('session', { + '@/core/stores/base.ts': { useBaseStore: () => ({ sdict: { words: [{ word: 'hello' }] } }) }, + '@/core/stores/setting.ts': { useSettingStore: () => setting }, + '@/core/types/enum.ts': enums, + '@/core/utils/cache.ts': cache, + '@/core/composables/useDataSyncPersistence.ts': { ...persistence, useDataSyncPersistence: () => api }, + '@/core/composables/remotePracticeValidation': validation, + '@/core/utils/index.ts': { shouldFetchRemote: () => enums.CompareResult.RemoteNewer }, + }).usePracticeWordPersistence() + const restored = await session.load() + equal( + restored, + mode === 'offline-clear' ? null : { taskWords: { new: [{ word: 'hello' }], review: [] } }, + 'Offline legacy cache was not restored in memory' + ) + equal(await snapshot(), before, 'Offline load changed persistent records or audio') + assert(status === 'error' && pushes === 0, 'Offline load hid the error or pushed') + offline = false + rows[2].data_version = 2 + const retry = await session.load() + assert(retry.taskWords.new[0].word === 'hello', 'Read recovery did not restore remote cache') + assert(status === 'success' && pushes === 0, 'Read recovery status/push incorrect') + equal(await snapshot(), before, 'Read recovery implicitly wrote current cache') + cases.push({ mode, reopened: true, retry: true, pushes, legacyVersion: 1, persistedUnchanged: true }) + continue + } + const before = await snapshot() + if (mode === 'queued-before-import') { + queued = api.saveLocalAndSync(enums.SyncDataType.setting, { marker: 'stale' }, { canSyncRemote: false }) + } + if (mode === 'queued-after-abort') { + onSubmitted = () => { + queued = api.saveLocalAndSync(enums.SyncDataType.setting, { marker: 'newer-edit' }, { canSyncRemote: false }) + } + } + fault = true + let rejected = false + if (mode === 'pull') { + assert((await api.pullAllRemoteToLocal()) === false, 'Aborted pull reported success') + assert(status === 'error', 'Aborted pull did not set error status') + } else { + try { + await api.forcePushLocalDataToRemote(data, client, audio('new audio bytes')) + } catch { + rejected = true + } + assert(rejected, 'Aborted import did not reject') + } + await transactionSettled + assert(submitted === (mode === 'pull' ? 4 : 5), 'Not all writes were submitted') + assert(succeeded === 2 && aborted === 1, 'Abort did not follow two real successful requests') + assert(pushes === 0, 'Failed transaction reached remote push') + assert(base.$state.marker === 'old' && setting.$state.marker === 'old', 'In-memory state not restored') + assert(!Object.hasOwn(setting.$state, 'added'), 'Rollback retained an imported setting') + assert(base.$state.due instanceof Date && setting.$state.due instanceof Date, 'Rollback lost Date values') + await queued + const after = await snapshot() + if (mode === 'queued-after-abort') { + assert(JSON.parse(after[1]).val.marker === 'newer-edit', 'Queued newer save was lost') + after[1] = before[1] + } + equal(after, before, 'Reopened database retained partial transaction writes') + events.push('abort-reopened-and-verified') + if (mode === 'pull') { + assert(!config.Supabase.check(), 'Failed pull still authorizes automatic writes') + assert(await api.pullAllRemoteToLocal(), 'Pull retry failed') + assert(status === 'success', 'Pull retry did not clear error') + assert(config.Supabase.getStatus().statusMessage === '', 'Pull retry retained stale error message') + } else { + assert(await api.forcePushLocalDataToRemote(data, client, audio('new audio bytes')), 'Import retry failed') + } + const retry = await snapshot() + assert(JSON.parse(retry[0]).val.marker === 'new', 'Retry lost dictionary') + assert(JSON.parse(retry[1]).val.marker === 'new', 'Retry lost settings') + equal(JSON.parse(retry[2]).val.taskWordsStr.new, ['hello'], 'Retry lost word cache') + assert(JSON.parse(retry[2]).version === 2, 'Cache migration/current version not persisted') + assert(JSON.parse(retry[3]).val === null, 'Retry lost null article cache') + equal( + retry[4][0].bytes, + [...new TextEncoder().encode(mode === 'pull' ? 'old audio bytes' : 'new audio bytes')], + 'Audio bytes not preserved' + ) + assert(pushes === (mode === 'pull' ? 0 : 1), 'Unexpected remote push count') + cases.push({ mode, submitted, succeeded, aborted, pushes, reopened: true, retry: true, events }) + } finally { + account?.dispose() + stopInit?.() + localStorage.removeItem(config.SUPABASE_CONFIG_KEY) + delete globalThis.useRuntimeConfig + await new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(name) + request.onsuccess = resolve + request.onerror = () => reject(request.error) + request.onblocked = () => reject(new Error('Integration database cleanup blocked')) + }) + } + } + return { + cases, + boundaries: [ + 'Real Chromium IndexedDB, installed idb-keyval, Vue reactivity and word-cache migrator', + 'Settings boundary/abort cases run real settings migration, defaults, shortcut map and cloneDeep declarations', + 'Dictionary migration/abort cases run real dictionary and settings migrators with source defaults', + 'Other cases dictionary migrator, non-settings/non-dictionary cases settings migrator, store methods and cloud client are test doubles', + 'Watcher cases execute full useInit subscriptions with production Pinia and real timers; minimal store schemas/actions, migration and cloud edges remain test doubles', + 'Account cases execute real account lifetime, shared persistence queue and cancellable four-key transactions; Auth/REST and store actions remain test doubles', + 'Explicit transaction abort, not quota exhaustion, crash recovery or native WebView UI', + 'Synthetic Blob bytes, not decodable audio or a ZIP/UI end-to-end test', + ], + } +} diff --git a/tests/desktop/init-subscriptions-harness.mjs b/tests/desktop/init-subscriptions-harness.mjs new file mode 100644 index 000000000..43519242d --- /dev/null +++ b/tests/desktop/init-subscriptions-harness.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { runInNewContext } from 'node:vm' +import ts from 'typescript' +import { createPinia, defineStore } from 'pinia' +import { nextTick } from 'vue' +import { declarations } from './migration-declarations.mjs' + +const root = process.env.TYPEWORDS_TEST_ROOT || process.cwd() +const compile = source => + ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText + +export function harness() { + const timers = new Map() + let sequence = 0 + const clock = { + setTimeout(fn) { + timers.set(++sequence, fn) + return sequence + }, + clearTimeout(id) { + timers.delete(id) + }, + async flush() { + const pending = [...timers.values()] + timers.clear() + for (const fn of pending) await fn() + await nextTick() + }, + } + const pinia = createPinia() + const make = id => + defineStore(id, { + state: () => ({ _ignoreWatch: false, marker: 'old', load: false }), + actions: { + init: async () => null, + setState(value) { + this.$patch(value) + }, + }, + })(pinia) + const base = make('base') + const setting = make('setting') + const runtime = { globalLoading: false } + const calls = [] + const cleanup = [] + const load = (source, imports = {}) => { + const exports = {} + runInNewContext(compile(source), { + exports, + require(name) { + assert.ok(Object.hasOwn(imports, name), `Unexpected dependency: ${name}`) + return imports[name] + }, + ...clock, + console: { log() {}, time() {}, timeEnd() {} }, + document: { addEventListener() {}, removeEventListener() {} }, + window: { location: { host: 'isolated.test' } }, + }) + return exports + } + const utils = load(declarations(root, 'app/core/utils/index.ts', ['debounce'])) + const imports = { + '../config/env': { APP_VERSION: { version: 5 } }, + '../utils': utils, + '../stores': { + useBaseStore: () => base, + useSettingStore: () => setting, + useRuntimeStore: () => runtime, + }, + '../utils/supabase': { Supabase: { getStatus: () => ({ status: 'success' }) } }, + './useDataSyncPersistence': { + ensureHashGuardBeforeInit: async () => {}, + useDataSyncPersistence: () => ({ + saveDictState: async data => calls.push({ store: 'base', marker: data.marker }), + saveLocalAndSync: async (_, data) => calls.push({ store: 'setting', marker: data.marker }), + }), + }, + '../types': { SyncDataType: { setting: 'setting' } }, + vue: { onUnmounted: fn => cleanup.push(fn) }, + } + // The full init module is executed; only its Nuxt/environment and persistence edges are replaced. + const init = load(readFileSync(resolve(root, 'app/core/composables/useInit.ts'), 'utf8'), imports).useInit() + return { base, setting, runtime, calls, clock, init, cleanup } +} diff --git a/tests/desktop/init-subscriptions.test.mjs b/tests/desktop/init-subscriptions.test.mjs new file mode 100644 index 000000000..407a9aed2 --- /dev/null +++ b/tests/desktop/init-subscriptions.test.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { nextTick } from 'vue' +import { harness } from './init-subscriptions-harness.mjs' + +for (const name of ['base', 'setting']) { + test(`${name}: hydrated import flags do not swallow the first edit after restart`, async () => { + const h = harness() + h[name].init = async () => { + h[name].setState({ marker: 'restored import', _ignoreWatch: true }) + return null + } + await h.init() + h[name].marker = 'after restart' + await nextTick() + await h.clock.flush() + assert.deepEqual(h.calls, [{ store: name, marker: 'after restart' }]) + }) + + test(`${name}: real Pinia preserves the first edit inside the import debounce window`, async () => { + const h = harness() + await h.init() + h[name].setState({ marker: 'imported', _ignoreWatch: true }) + await nextTick() + h[name].marker = 'user edit' + await nextTick() + await h.clock.flush() + assert.deepEqual(h.calls, [{ store: name, marker: 'user edit' }]) + }) + + test(`${name}: real Pinia import cancels a previously scheduled autosave`, async () => { + const h = harness() + await h.init() + h[name].marker = 'pending edit' + await nextTick() + h[name].setState({ marker: 'imported', _ignoreWatch: true }) + await nextTick() + await h.clock.flush() + await h.clock.flush() + assert.deepEqual(h.calls, []) + assert.equal(h[name]._ignoreWatch, false) + }) + + test(`${name}: reinitialization cancels old subscription timers`, async () => { + const h = harness() + await h.init() + h[name].marker = 'pending edit' + await nextTick() + await h.init() + await h.clock.flush() + assert.deepEqual(h.calls, []) + h[name].marker = 'new edit' + await nextTick() + await h.clock.flush() + assert.deepEqual(h.calls, [{ store: name, marker: 'new edit' }]) + }) + + test(`${name}: busy import suppression does not swallow the next genuine edit`, async () => { + const h = harness() + await h.init() + h.runtime.globalLoading = true + h[name].setState({ marker: 'imported', _ignoreWatch: true }) + await nextTick() + h.runtime.globalLoading = false + h[name].marker = 'after import' + await nextTick() + await h.clock.flush() + assert.deepEqual(h.calls, [{ store: name, marker: 'after import' }]) + }) +} diff --git a/tests/desktop/internal-installer.test.mjs b/tests/desktop/internal-installer.test.mjs new file mode 100644 index 000000000..a6ddc6f9b --- /dev/null +++ b/tests/desktop/internal-installer.test.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import test from 'node:test' +const root = process.env.TYPEWORDS_TEST_ROOT || process.cwd() +test('internal installer keeps stable identity and uses per-user NSIS with explicit online WebView setup', () => { + const base = JSON.parse(readFileSync(resolve(root, 'src-tauri/tauri.conf.json'))) + const config = JSON.parse(readFileSync(resolve(root, 'src-tauri/tauri.internal.conf.json'))) + assert.equal(config.identifier, undefined) + assert.equal(config.app, undefined) + assert.equal(config.version, undefined) + assert.equal(base.identifier, 'io.github.zyronon.typewords') + assert.equal(config.bundle.active, true) + assert.deepEqual(config.bundle.targets, ['nsis']) + assert.equal(config.bundle.windows.nsis.installMode, 'currentUser') + assert.equal(config.bundle.windows.allowDowngrades, false) + assert.deepEqual(config.bundle.windows.webviewInstallMode, { type: 'downloadBootstrapper', silent: true }) + assert.equal(config.bundle.createUpdaterArtifacts, false) +}) +test('failed-upgrade runner refuses 0.1.2 through invoke-setup instead of launching raw NSIS', () => { + const script = readFileSync(resolve(root, 'tests/desktop/native-failed-upgrade.ps1'), 'utf8') + assert.match(script, /invoke-setup\.ps1/) + assert.match(script, /TypeWords_0\.1\.2_x64-setup\.exe/) + assert.match(script, /TypeWords_0\.1\.3_x64-setup\.exe/) + assert.match(script, /allowDowngrades=false/) + assert.match(script, /0\.1\.3/) + assert.match(script, /io\.github\.zyronon\.typewords/) + assert.match(script, /restored/) + assert.match(script, /wrapperBlockedSilentDowngrade/) + assert.doesNotMatch(script, /Start-Process -FilePath \$setup/) + assert.doesNotMatch(script, /Get-NetTCPConnection/) +}) +test('invoke-setup compares DisplayVersion and refuses older setups before Start-Process', () => { + const script = readFileSync(resolve(root, 'scripts/invoke-setup.ps1'), 'utf8') + assert.match(script, /DisplayVersion/) + assert.match(script, /TypeWords__x64-setup\.exe/) + assert.match(script, /installed DisplayVersion/) + assert.match(script, /Write-TypeWordsSetupResult \$record 2/) + assert.match(script, /Start-Process/) + assert.ok(script.indexOf('Write-TypeWordsSetupResult $record 2') < script.indexOf('Start-Process')) + assert.doesNotMatch(script, /Get-NetTCPConnection/) +}) +test('install lifecycle helpers probe CDP over HTTP instead of Get-NetTCPConnection', () => { + const helper = readFileSync(resolve(root, 'tests/desktop/cdp-port.ps1'), 'utf8') + assert.match(helper, /json\/version/) + assert.doesNotMatch(helper, /Get-NetTCPConnection/) + for (const name of ['close-installed.ps1', 'launch-existing.ps1']) { + const script = readFileSync(resolve(root, `tests/desktop/${name}`), 'utf8') + assert.match(script, /cdp-port\.ps1/) + assert.match(script, /Wait-TypeWordsCdp|Test-TypeWordsCdp/) + assert.doesNotMatch(script, /Get-NetTCPConnection/) + } +}) +test('internal build launches native Windows x64 and explicitly disables signing, rejecting other hosts', async () => { + const { internalBuildCommand } = await import(pathToFileURL(resolve(root, 'scripts/desktop-internal.mjs'))) + const cmd = internalBuildCommand('win32', 'x64') + assert.equal(cmd.command, process.execPath) + assert.deepEqual(cmd.args.slice(1), [ + 'build', + '--target', + 'x86_64-pc-windows-msvc', + '--config', + 'src-tauri/tauri.internal.conf.json', + '--bundles', + 'nsis', + '--no-sign', + '--ci', + ]) + assert.throws(() => internalBuildCommand('linux', 'x64'), /Windows x64/) + assert.throws(() => internalBuildCommand('win32', 'arm64'), /Windows x64/) +}) diff --git a/tests/desktop/invoke-setup.test.mjs b/tests/desktop/invoke-setup.test.mjs new file mode 100644 index 000000000..913ccb905 --- /dev/null +++ b/tests/desktop/invoke-setup.test.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import test from 'node:test' + +const root = process.env.TYPEWORDS_TEST_ROOT || process.cwd() +const wrapper = resolve(root, 'scripts/invoke-setup.ps1') +const testKeyRoot = 'HKCU:\\Software\\TypeWordsDesktopTest' +const testKey = `${testKeyRoot}\\Uninstall\\TypeWords` + +function runWrapper(args) { + return spawnSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', wrapper, ...args], { + encoding: 'utf8', + }) +} + +function withTempSetup(fn) { + const dir = mkdtempSync(join(tmpdir(), 'tw-invoke-setup-')) + try { + return fn(dir) + } finally { + rmSync(dir, { recursive: true, force: true }) + spawnSync( + 'powershell', + [ + '-NoProfile', + '-Command', + `Remove-Item -LiteralPath '${testKeyRoot}' -Recurse -Force -ErrorAction SilentlyContinue`, + ], + { encoding: 'utf8' } + ) + } +} + +function setDisplayVersion(version) { + const result = spawnSync( + 'powershell', + [ + '-NoProfile', + '-Command', + `New-Item -Path '${testKey}' -Force | Out-Null; Set-ItemProperty -LiteralPath '${testKey}' -Name DisplayVersion -Value '${version}'`, + ], + { encoding: 'utf8' } + ) + assert.equal(result.status, 0, result.stderr || result.stdout) +} + +test('invoke-setup refuses an older setup when DisplayVersion is newer without launching NSIS', () => { + withTempSetup(dir => { + const older = join(dir, 'TypeWords_0.1.2_x64-setup.exe') + const same = join(dir, 'TypeWords_0.1.3_x64-setup.exe') + writeFileSync(older, 'dummy-setup') + writeFileSync(same, 'dummy-setup') + setDisplayVersion('0.1.3') + const refusePath = join(dir, 'refuse.json') + const allowPath = join(dir, 'allow.json') + const refused = runWrapper([ + '-Setup', + older, + '-Dest', + dir, + '-UninstallKey', + testKey, + '-ResultPath', + refusePath, + '-DryRun', + ]) + assert.equal(refused.status, 2, refused.stderr || refused.stdout) + const refuse = JSON.parse(readFileSync(refusePath, 'utf8')) + assert.equal(refuse.setupVersion, '0.1.2') + assert.equal(refuse.displayVersion, '0.1.3') + assert.equal(refuse.refused, true) + assert.equal(refuse.launched, false) + const allowed = runWrapper([ + '-Setup', + same, + '-Dest', + dir, + '-UninstallKey', + testKey, + '-ResultPath', + allowPath, + '-DryRun', + ]) + assert.equal(allowed.status, 0, allowed.stderr || allowed.stdout) + const allow = JSON.parse(readFileSync(allowPath, 'utf8')) + assert.equal(allow.setupVersion, '0.1.3') + assert.equal(allow.displayVersion, '0.1.3') + assert.equal(allow.refused, false) + assert.equal(allow.launched, false) + assert.equal(allow.dryRun, true) + }) +}) diff --git a/tests/desktop/keyboard-input.test.mjs b/tests/desktop/keyboard-input.test.mjs new file mode 100644 index 000000000..62dd62ea2 --- /dev/null +++ b/tests/desktop/keyboard-input.test.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { runInNewContext } from 'node:vm' +import test from 'node:test' +import ts from 'typescript' + +// Run the actual hook with a minimal DOM/lifecycle boundary, not a copied handler. +function fixture() { + const handlers = new Map() + const received = [] + const windowHandlers = new Map() + const input = { + value: '', focus() {}, remove() {}, + addEventListener(type, fn) { handlers.set(type, fn) }, + removeEventListener(type) { handlers.delete(type) }, + } + const exports = {} + const source = readFileSync(resolve(process.env.TYPEWORDS_TEST_ROOT || process.cwd(), 'app/core/hooks/event.ts'), 'utf8') + runInNewContext(ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText, { + exports, setTimeout() {}, console, + document: { querySelector: () => input, body: { appendChild() {} } }, + window: { + addEventListener(type, fn) { windowHandlers.set(type, fn) }, + removeEventListener(type) { windowHandlers.delete(type) }, + }, + require(name) { + if (name === 'vue') return { onMounted: fn => fn(), onUnmounted() {}, onDeactivated() {} } + if (name === '@/base') return { Toast: { warning() {} } } + if (name === '../utils/eventBus') return { emitter: {} } + if (name === '../stores' || name === '../utils') return {} + throw new Error(`Unexpected dependency: ${name}`) + }, + }) + exports.useEventListener('keydown', e => received.push({ key: e.key, code: e.code })) + return { + input, received, + send: (type, event) => handlers.get(type)({ target: input, ...event }), + key: event => windowHandlers.get('keydown')(event), + } +} + +test('empty input with null data is ignored without throwing or emitting a practice key', () => { + const f = fixture() + f.send('input', { inputType: 'insertText', data: null }) + assert.deepEqual(f.received, []) +}) + +test('empty replacement input is ignored', () => { + const f = fixture() + f.send('input', { inputType: 'insertReplacementText', data: '' }) + assert.deepEqual(f.received, []) +}) + +test('input still accepts value, data fallback and backspace', () => { + for (const [value, data, inputType, key, code] of [ + ['c', null, 'insertText', 'c', 'KeyC'], + ['', 'a', 'insertText', 'a', 'KeyA'], + ['', null, 'deleteContentBackward', 'Backspace', 'Backspace'], + ]) { + const f = fixture() + f.input.value = value + f.send('input', { inputType, data }) + assert.deepEqual(f.received, [{ key, code }]) + } +}) + +test('composition defers input and an empty cancelled composition emits no key', () => { + const f = fixture() + f.send('compositionstart', {}) + f.input.value = 'c' + f.send('input', { inputType: 'insertCompositionText', data: 'c' }) + assert.deepEqual(f.received, []) + f.send('compositionend', { data: '' }) + assert.deepEqual(f.received, []) + f.input.value = '' + f.send('input', { inputType: 'insertCompositionText', data: null }) + assert.deepEqual(f.received, []) +}) + + +test('active composition owns editing and confirmation keys without sending practice keys', () => { + const f = fixture() + f.send('compositionstart', {}) + for (const [key, code] of [['Backspace', 'Backspace'], ['Enter', 'Enter'], [' ', 'Space'], ['a', 'KeyA']]) { + f.key({ key, code, isComposing: true }) + } + assert.deepEqual(f.received, []) + f.send('compositionend', { data: 'ca' }) + assert.deepEqual(f.received, [{ key: 'c', code: 'KeyC' }, { key: 'a', code: 'KeyA' }]) + f.key({ key: 'n', code: 'KeyN', isComposing: false }) + f.key({ key: 'Backspace', code: 'Backspace', isComposing: false }) + assert.deepEqual(f.received.slice(2), [{ key: 'n', code: 'KeyN' }, { key: 'Backspace', code: 'Backspace' }]) +}) + +test('native isComposing key is ignored even without local compositionstart', () => { + const f = fixture() + f.key({ key: 'Backspace', code: 'Backspace', isComposing: true }) + assert.deepEqual(f.received, []) + f.key({ key: 'Backspace', code: 'Backspace', isComposing: false }) + assert.deepEqual(f.received, [{ key: 'Backspace', code: 'Backspace' }]) +}) + +test('cancelled composition resumes ordinary keys and Process remains ignored', () => { + const f = fixture() + f.send('compositionstart', {}) + f.key({ key: 'Backspace', code: 'Backspace', isComposing: true }) + f.send('compositionend', { data: '' }) + f.key({ key: 'Process', code: 'KeyC', isComposing: false }) + assert.deepEqual(f.received, []) + f.key({ key: 'c', code: 'KeyC', isComposing: false }) + assert.deepEqual(f.received, [{ key: 'c', code: 'KeyC' }]) +}) + + +test('native keyboard composition end recovers even when compositionend was not delivered', () => { + const f = fixture() + f.send('compositionstart', {}) + f.key({ key: 'Backspace', code: 'Backspace', isComposing: true }) + assert.deepEqual(f.received, []) + // Blink may delete the entire preedit without dispatching compositionend. + f.key({ key: 'a', code: 'KeyA', isComposing: false }) + f.key({ key: 'Backspace', code: 'Backspace', isComposing: false }) + assert.deepEqual(f.received, [{ key: 'a', code: 'KeyA' }, { key: 'Backspace', code: 'Backspace' }]) +}) diff --git a/tests/desktop/launch-existing.ps1 b/tests/desktop/launch-existing.ps1 new file mode 100644 index 000000000..c191b973a --- /dev/null +++ b/tests/desktop/launch-existing.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory = $true)][string] $Stage) +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'cdp-port.ps1') +$exe = Join-Path $Stage 'installed\typewords-desktop.exe' +$profile = Join-Path $Stage 'isolated-profile' +$previous = Get-Content -LiteralPath (Join-Path $Stage 'launch.json') -Raw | ConvertFrom-Json +if (!(Test-Path -LiteralPath $exe)) { throw "Missing installed EXE: $exe" } +if (!(Test-Path -LiteralPath "$profile\EBWebView")) { throw 'Expected the existing isolated profile' } +if (Get-Process typewords-desktop -ErrorAction SilentlyContinue) { throw 'TypeWords is already running' } +if (Test-TypeWordsCdp $previous.port) { throw "CDP $($previous.port) is occupied" } +$env:WEBVIEW2_USER_DATA_FOLDER = $profile +$env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--remote-debugging-port=$($previous.port) --remote-debugging-address=127.0.0.1" +$process = Start-Process -FilePath $exe -WindowStyle Hidden -PassThru +if (-not (Wait-TypeWordsCdp $previous.port $true 30)) { + throw "Started PID $($process.Id) but CDP $($previous.port) did not open" +} +$record = @{ + pid = $process.Id + exe = $exe + profile = $profile + port = $previous.port + sha256 = (Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash + productVersion = (Get-Item -LiteralPath $exe).VersionInfo.ProductVersion +} +$record | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Stage 'launch.json') -Encoding utf8 +$record | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Stage 'restart.json') -Encoding utf8 +Write-Output "Started existing isolated profile PID=$($process.Id) version=$($record.productVersion)" diff --git a/tests/desktop/launcher.test.mjs b/tests/desktop/launcher.test.mjs new file mode 100644 index 000000000..898a3d537 --- /dev/null +++ b/tests/desktop/launcher.test.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import test from 'node:test' +import { desktopCommand } from '../../scripts/desktop.mjs' + +test('Build launches local Nuxt generate without shell or environment mutation', () => { + const env = { TYPEWORDS_TARGET: 'web', NUXT_APP_BASE_URL: '/web/', PATH: 'preserved' } + const command = desktopCommand('build', env) + assert.equal(command.command, process.execPath) + assert.match(command.args[0], /nuxt\.mjs$/) + assert.equal(command.args[1], 'generate') + assert.equal(command.options.shell, false) + assert.equal(command.options.env.TYPEWORDS_TARGET, 'desktop') + assert.equal(command.options.env.NUXT_APP_BASE_URL, '/') + assert.equal(command.options.env.PATH, 'preserved') + assert.equal(env.TYPEWORDS_TARGET, 'web') +}) + +test('Dev uses the exact loopback host and port', () => { + assert.deepEqual(desktopCommand('dev').args.slice(1), ['dev', '--host', '127.0.0.1', '--port', '5567', '--no-fork']) +}) + +test('Invalid command fails before launching Nuxt', () => { + assert.throws(() => desktopCommand('preview'), /Usage:/) + const result = spawnSync(process.execPath, ['scripts/desktop.mjs', 'invalid'], { encoding: 'utf8' }) + assert.equal(result.status, 1) + assert.match(result.stderr, /Usage:/) +}) diff --git a/tests/desktop/migration-declarations.mjs b/tests/desktop/migration-declarations.mjs new file mode 100644 index 000000000..b1a7a363e --- /dev/null +++ b/tests/desktop/migration-declarations.mjs @@ -0,0 +1,20 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import ts from 'typescript' + +// Extract declarations rather than initialize unrelated Nuxt stores in the browser harness. +export function declarations(root, path, names) { + const file = ts.createSourceFile(path, readFileSync(resolve(root, path), 'utf8'), ts.ScriptTarget.Latest, true) + return names + .map(name => { + const statement = file.statements.find(statement => + ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) + ? statement.name?.text === name + : ts.isVariableStatement(statement) && + statement.declarationList.declarations.some(item => ts.isIdentifier(item.name) && item.name.text === name) + ) + if (!statement) throw new Error(`Missing declaration ${path}:${name}`) + return statement.getText(file) + }) + .join('\n') +} diff --git a/tests/desktop/native-adapters.test.mjs b/tests/desktop/native-adapters.test.mjs new file mode 100644 index 000000000..b7a3b962f --- /dev/null +++ b/tests/desktop/native-adapters.test.mjs @@ -0,0 +1,258 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { runInNewContext } from 'node:vm' +import test from 'node:test' +import ts from 'typescript' + +const root = process.env.TYPEWORDS_TEST_ROOT || process.cwd() +function load(path, modules = {}, globals = {}) { + const exports = {} + const source = readFileSync(resolve(root, path), 'utf8') + runInNewContext( + ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + }).outputText, + { + exports, + URL, + Uint8Array, + console, + ...globals, + require(name) { + if (!(name in modules)) throw new Error(`Unexpected import: ${name}`) + return modules[name] + }, + } + ) + return exports +} +function fixture(options = {}) { + const calls = [] + const api = load('app/core/platform/desktop.ts', { + 'file-saver': { default: (...args) => calls.push(['web', ...args]) }, + '@tauri-apps/plugin-dialog': { + save: async options_ => { + calls.push(['dialog', options_]) + return options.cancel ? null : 'C:\\backup\\用户数据.zip' + }, + }, + '@tauri-apps/plugin-fs': { + writeFile: async (...args) => { + calls.push(['write', ...args]) + if (options.fail) throw new Error('disk full') + }, + }, + '@tauri-apps/plugin-opener': { openUrl: async url => calls.push(['open', url]) }, + }) + return { api, calls } +} + +test('Web save retains file-saver without native commands', async () => { + const { api, calls } = fixture() + const blob = new Blob(['ZIP bytes']) + assert.equal(await api.saveBackup(blob, 'backup.zip', false), true) + assert.deepEqual(calls, [['web', blob, 'backup.zip']]) +}) + +test('Desktop save writes exact bytes only to the user-selected path', async () => { + const { api, calls } = fixture() + const blob = new Blob([new Uint8Array([0, 80, 75, 255])]) + assert.equal(await api.saveBackup(blob, '备份.zip', true), true) + assert.equal(calls[0][1].defaultPath, '备份.zip') + assert.equal(calls[0][1].filters[0].extensions[0], 'zip') + assert.deepEqual(calls[1], ['write', 'C:\\backup\\用户数据.zip', new Uint8Array([0, 80, 75, 255])]) + assert.equal(calls.length, 2) +}) + +test('Cancelled native save neither writes nor reads Blob bytes', async () => { + const { api, calls } = fixture({ cancel: true }) + assert.equal( + await api.saveBackup( + { + arrayBuffer() { + throw new Error('should not read') + }, + }, + 'x.zip', + true + ), + false + ) + assert.equal(calls.length, 1) +}) + +test('Native write failure rejects instead of reporting success', async () => { + const { api } = fixture({ fail: true }) + await assert.rejects(api.saveBackup(new Blob(['zip']), 'x.zip', true), /disk full/) +}) + +test('External opener accepts existing HTTPS destinations and rejects scheme/host confusion', async () => { + const { api, calls } = fixture() + await api.openExternal('https://github.com/zyronon/TypeWords') + await api.openExternal('mailto:zyronon@163.com') + for (const value of [ + 'http://github.com/', + 'javascript:alert(1)', + 'file:///tmp/x', + 'mailto:a@b.com', + 'mailto:zyronon@163.com?cc=evil@example.com', + 'https://github.com.evil.test/', + 'https://user@github.com/', + 'https://github.com:8443/', + 'https://evil.test/?next=https://github.com', + '//github.com/', + ]) { + await assert.rejects(api.openExternal(value)) + } + assert.deepEqual(calls, [ + ['open', 'https://github.com/zyronon/TypeWords'], + ['open', 'mailto:zyronon@163.com'], + ]) +}) + +test('Native allowlist exactly matches client host validation; no broad filesystem permission', () => { + const { api } = fixture() + const capability = JSON.parse(readFileSync(resolve(root, 'src-tauri/capabilities/main.json'), 'utf8')) + assert.equal(capability.remote, undefined) + assert.deepEqual(capability.permissions.slice(0, 2), ['dialog:allow-save', 'fs:allow-write-file']) + assert.equal(capability.permissions.length, 3) + assert.equal(capability.permissions[2].identifier, 'opener:allow-open-url') + assert.deepEqual( + capability.permissions[2].allow.map(item => item.url), + [ + ...Array.from(api.EXTERNAL_HOSTS, host => `https://${host}/*`), + ...Array.from(api.EXTERNAL_MAILS, mail => `mailto:${mail}`), + ] + ) + assert.match(readFileSync(resolve(root, 'app/core/config/env.ts'), 'utf8'), new RegExp(`EMAIL = '${api.EXTERNAL_MAILS[0]}'`)) +}) + +test('Desktop anchors, middle-click and window.open use browser; internal links remain local', async () => { + const { api } = fixture() + const opened = [] + const errors = [] + const listeners = {} + const originalOpen = () => { + throw new Error('native popup must never run') + } + const assigned = [] + const win = { + open: originalOpen, + location: { + href: 'http://tauri.localhost/setting', + origin: 'http://tauri.localhost', + protocol: 'http:', + assign: url => assigned.push(url), + }, + } + const doc = { + addEventListener: (type, fn) => { + listeners[type] = fn + }, + removeEventListener: type => { + delete listeners[type] + }, + } + const dispose = api.installDesktopLinks( + win, + doc, + async url => opened.push(url), + error => errors.push(error) + ) + function click(href, type = 'click', button = 0) { + const event = { + type, + button, + target: { closest: () => ({ href }) }, + preventDefault() { + this.prevented = true + }, + stopImmediatePropagation() {}, + } + listeners[type](event) + return event + } + assert.equal(click('https://www.youdao.com/result?word=test').prevented, true) + assert.equal(click('mailto:zyronon@163.com').prevented, true) + assert.equal(click('https://github.com/zyronon/TypeWords', 'auxclick', 1).prevented, true) + assert.equal(click('http://tauri.localhost/words').prevented, undefined) + assert.equal(click('javascript:alert(1)').prevented, true) + assert.equal(win.open('https://enpuz.com/', '_blank'), null) + win.open('https://evil.test/') + win.open('/words') + assert.deepEqual(opened, [ + 'https://www.youdao.com/result?word=test', + 'mailto:zyronon@163.com', + 'https://github.com/zyronon/TypeWords', + 'https://enpuz.com/', + ]) + assert.equal(errors.length, 2) + assert.deepEqual(assigned, ['http://tauri.localhost/words']) + dispose() + assert.equal(win.open, originalOpen) + assert.deepEqual(listeners, {}) +}) + +test('Export consumer suppresses success on cancel/error and clears loading', async () => { + for (const result of ['cancel', 'error', 'saved']) { + const notices = [] + const calls = [] + class Zip { + file() {} + folder() { + return this + } + async generateAsync() { + return new Blob(['zip']) + } + } + const { useExport } = load( + 'app/core/hooks/export.ts', + { + '../utils': { loadJsLib: async () => Zip, shakeCommonDict: value => value }, + '../config/env': { + EXPORT_DATA_KEY: { version: 1 }, + SAVE_SETTING_KEY: { version: 1 }, + SAVE_DICT_KEY: { version: 1 }, + LIB_JS_URL: { JSZIP: '' }, + }, + 'idb-keyval': { get: async () => [] }, + '../platform/desktop': { + saveBackup: async (...args) => { + calls.push(args) + if (result === 'error') throw new Error('disk full') + return result === 'saved' + }, + }, + dayjs: { default: () => ({ format: () => 'today' }) }, + '@/base': { + Toast: { success: value => notices.push(['success', value]), error: value => notices.push(['error', value]) }, + }, + '../stores/base': { useBaseStore: () => ({ $state: {} }) }, + '../stores/setting': { useSettingStore: () => ({ $state: {} }) }, + vue: { ref: value => ({ value }) }, + '../utils/cache': { + PRACTICE_WORD_CACHE: { key: 'word', version: 1 }, + PRACTICE_ARTICLE_CACHE: { key: 'article', version: 1 }, + }, + '../composables/usePracticePersistence.ts': { + usePracticeWordPersistence: () => ({ getLocalDataCompact: async () => ({}) }), + usePracticeArticlePersistence: () => ({ getLocalDataCompact: async () => ({}) }), + }, + }, + { useRuntimeConfig: () => ({ public: { isDesktop: true } }) } + ) + const exporter = useExport() + await exporter.exportData('saved!', 'backup.zip') + assert.equal(calls[0][2], true) + assert.equal(exporter.loading.value, false) + assert.deepEqual( + notices, + result === 'saved' ? [['success', 'saved!']] : result === 'error' ? [['error', 'disk full']] : [] + ) + } +}) diff --git a/tests/desktop/native-cache-errors.test.mjs b/tests/desktop/native-cache-errors.test.mjs new file mode 100644 index 000000000..9ca24409d --- /dev/null +++ b/tests/desktop/native-cache-errors.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import test from 'node:test' + +const root = resolve(process.env.TYPEWORDS_TEST_ROOT || process.cwd()) + +test('native recovery runner and dialog wrapper list the cache-errors mode', () => { + const runner = readFileSync(resolve(root, 'tests/desktop/native-local-recovery.mjs'), 'utf8') + const wrapper = readFileSync(resolve(root, 'tests/desktop/native-save-with-dialog.mjs'), 'utf8') + assert.match(runner, /cache-errors/) + assert.match(wrapper, /cache-errors/) + assert.match(wrapper, /cache-ui-unlock\.zip/) + assert.ok( + runner.includes("'empty-cache-word'") && + runner.includes("'negative-word-index'") && + runner.includes("'invalid-shortcut-map'") && + runner.includes("'empty-word-booklist'"), + 'cache-errors must keep the four 0.1.3-era reject payloads' + ) + assert.match(runner, /Invalid backup practice cache/) + assert.match(runner, /Invalid backup settings/) + assert.match(runner, /Invalid backup dictionary/) +}) diff --git a/tests/desktop/native-doc-v8l.mjs b/tests/desktop/native-doc-v8l.mjs new file mode 100644 index 000000000..2892624e9 --- /dev/null +++ b/tests/desktop/native-doc-v8l.mjs @@ -0,0 +1,193 @@ +// Explicit installed-WebView check: /doc 经典美/英剧资源合集 → v.v8l.cn. +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const evidence = resolve(process.argv[2] || '') +assert.ok(process.argv[2], 'Supply an evidence directory with an isolated launch.json') +const launch = JSON.parse(readFileSync(resolve(evidence, 'launch.json'), 'utf8').replace(/^\uFEFF/, '')) +assert.equal(resolve(launch.profile), resolve(evidence, 'isolated-profile')) +assert.equal(createHash('sha256').update(readFileSync(launch.exe)).digest('hex'), launch.sha256.toLowerCase()) +assert.ok(process.env.TYPEWORDS_PLAYWRIGHT_MODULE, 'Set TYPEWORDS_PLAYWRIGHT_MODULE') +const { chromium } = await import(pathToFileURL(process.env.TYPEWORDS_PLAYWRIGHT_MODULE).href) +const url = 'https://v.v8l.cn/s/TG3sgVg' +const observeScript = resolve('tests/desktop/native-external-observe.ps1') +const observeFile = resolve(evidence, 'native-doc-v8l-observe.json') +const hostPattern = /v8l\.cn/i +const extraMarkers = ['v8l', 'TG3sgVg'] +const asArray = value => (value == null ? [] : Array.isArray(value) ? value : [value]) +const tabText = tab => `${tab?.name || ''} ${tab?.window || ''}` +const matchingTabs = tabs => + asArray(tabs).filter(tab => { + const text = tabText(tab) + return hostPattern.test(text) || extraMarkers.some(marker => text.includes(marker)) + }) +const report = { sha256: launch.sha256, url, webviewRequests: [], errors: [] } + +function powershell(args) { + return new Promise((resolvePromise, reject) => { + const child = spawn('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ...args], { + windowsHide: true, + }) + const stdout = [] + const stderr = [] + child.stdout.on('data', chunk => stdout.push(chunk)) + child.stderr.on('data', chunk => stderr.push(chunk)) + child.on('error', reject) + child.on('exit', code => { + const result = { + code, + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + } + if (code === 0) resolvePromise(result) + else reject(new Error(`powershell ${args[0]} exited ${code}: ${result.stderr || result.stdout}`)) + }) + }) +} + +function startWatch() { + const child = spawn( + 'powershell.exe', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + observeScript, + '-Mode', + 'Watch', + '-Url', + url, + '-OutFile', + observeFile, + '-Seconds', + '20', + ], + { windowsHide: true } + ) + const stderr = [] + child.stderr.on('data', chunk => stderr.push(chunk)) + const done = new Promise((resolvePromise, reject) => { + child.on('error', reject) + child.on('exit', code => { + if (code === 0) resolvePromise() + else reject(new Error(`observe watch exited ${code}: ${Buffer.concat(stderr).toString('utf8')}`)) + }) + }) + return { child, done } +} + +const browser = await chromium.connectOverCDP(`http://127.0.0.1:${launch.port}`) +const context = browser.contexts()[0] +const page = context.pages().find(item => item.url().startsWith('http://tauri.localhost')) +assert.ok(page, 'Expected a production Tauri page') +const createdPages = [] +const onPage = opened => createdPages.push(opened.url()) +context.on('page', onPage) +let watch +try { + page.on('pageerror', error => report.errors.push(error.message)) + page.on('request', request => { + if (hostPattern.test(request.url())) report.webviewRequests.push(request.url()) + }) + await page.goto('http://tauri.localhost/doc') + await page.waitForFunction(() => { + const pinia = document.querySelector('#__nuxt')?.__vue_app__?.config.globalProperties.$pinia + return pinia?._s.get('base')?.load && pinia?._s.get('setting')?.load + }) + const open = page.locator('.card-white').filter({ hasText: '经典美/英剧资源合集' }).getByText('打开链接') + await open.waitFor({ state: 'visible', timeout: 15000 }) + report.identity = await page.evaluate(() => ({ + origin: location.origin, + href: location.href, + desktop: window.__NUXT__?.config?.public?.isDesktop, + ipc: typeof window.__TAURI_INTERNALS__?.invoke === 'function', + })) + assert.equal(report.identity.desktop, true) + assert.equal(report.identity.ipc, true) + await page.evaluate(() => { + window.__externalDocumentMarker = 'native-doc-v8l' + const internals = window.__TAURI_INTERNALS__ + const originalFetch = window.fetch + const opener = internals.convertFileSrc('plugin:opener|open_url', 'ipc') + window.__externalOpenProbe = { fetches: [] } + window.fetch = async function (input, init) { + const href = String(input) + const response = await originalFetch.call(this, input, init) + if (href === opener || /plugin:opener\|open_url/.test(href)) { + window.__externalOpenProbe.fetches.push({ + url: href, + ok: response.headers.get('Tauri-Response') === 'ok', + }) + } + return response + } + }) + watch = startWatch() + const readyUntil = Date.now() + 8000 + while (Date.now() < readyUntil) { + if (existsSync(observeFile)) { + try { + if (JSON.parse(readFileSync(observeFile, 'utf8').replace(/^\uFEFF/, '')).uiaReady) break + } catch {} + } + await new Promise(resolveWait => setTimeout(resolveWait, 150)) + } + const original = page.url() + await open.click() + await page.waitForFunction(() => window.__externalOpenProbe?.fetches?.some(item => item.ok), null, { + timeout: 10000, + }) + await watch.done + const observe = existsSync(observeFile) ? JSON.parse(readFileSync(observeFile, 'utf8').replace(/^\uFEFF/, '')) : null + const probe = await page.evaluate(() => window.__externalOpenProbe) + report.probe = probe + report.observe = observe + assert.ok(asArray(probe.fetches).some(item => item.ok), 'Doc resource must call the native opener') + assert.equal(page.url(), original) + assert.equal(await page.evaluate(() => window.__externalDocumentMarker), 'native-doc-v8l') + assert.deepEqual(createdPages, []) + assert.deepEqual(report.webviewRequests, []) + assert.deepEqual(report.errors, []) + const processHits = asArray(observe?.processes) + const tabHits = matchingTabs(observe?.newTabs) + const retargetHits = matchingTabs(observe?.retargetedTabs) + const windowHits = asArray(observe?.newWindows) + const existingHits = matchingTabs(observe?.existingMatches) + assert.ok( + processHits.length > 0 || + tabHits.length > 0 || + retargetHits.length > 0 || + windowHits.length > 0 || + existingHits.length > 0, + 'System browser must receive v.v8l.cn; opener IPC success is not enough' + ) + await page.screenshot({ path: resolve(evidence, 'native-doc-v8l.png') }) + report.systemBrowser = { + httpsProgId: observe?.httpsProgId ?? null, + processes: processHits.length, + newTabs: tabHits.length, + retargetedTabs: retargetHits.length, + newWindows: windowHits.length, + existingMatches: existingHits.length, + } + try { + await powershell([observeScript, '-Mode', 'Close', '-Url', url, '-OutFile', observeFile]) + report.observe = JSON.parse(readFileSync(observeFile, 'utf8').replace(/^\uFEFF/, '')) + } catch (error) { + report.closeError = String(error) + } + report.passed = true + console.log( + `PASS: /doc v.v8l.cn opened via opener; system browser evidence processes=${processHits.length} tabs=${tabHits.length} retargeted=${retargetHits.length} windows=${windowHits.length} existing=${existingHits.length}; main document retained.` + ) +} finally { + context.off('page', onPage) + if (watch?.child && watch.child.exitCode == null) watch.child.kill() + writeFileSync(resolve(evidence, 'native-doc-v8l.json'), JSON.stringify(report, null, 2)) + await browser.close() +} diff --git a/tests/desktop/native-enpuz.mjs b/tests/desktop/native-enpuz.mjs new file mode 100644 index 000000000..14ef1425c --- /dev/null +++ b/tests/desktop/native-enpuz.mjs @@ -0,0 +1,224 @@ +// Explicit installed-WebView check: article practice context menu → enpuz.com. +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const evidence = resolve(process.argv[2] || '') +assert.ok(process.argv[2], 'Supply an evidence directory with an isolated launch.json') +const launch = JSON.parse(readFileSync(resolve(evidence, 'launch.json'), 'utf8').replace(/^\uFEFF/, '')) +assert.equal(resolve(launch.profile), resolve(evidence, 'isolated-profile')) +assert.equal(createHash('sha256').update(readFileSync(launch.exe)).digest('hex'), launch.sha256.toLowerCase()) +assert.ok(process.env.TYPEWORDS_PLAYWRIGHT_MODULE, 'Set TYPEWORDS_PLAYWRIGHT_MODULE') +const { chromium } = await import(pathToFileURL(process.env.TYPEWORDS_PLAYWRIGHT_MODULE).href) +const url = 'https://enpuz.com/' +const observeScript = resolve('tests/desktop/native-external-observe.ps1') +const observeFile = resolve(evidence, 'native-enpuz-observe.json') +const hostPattern = /enpuz\.com/i +const extraMarkers = ['enpuz', '英语语法', '语法分析'] +const asArray = value => (value == null ? [] : Array.isArray(value) ? value : [value]) +const tabText = tab => `${tab?.name || ''} ${tab?.window || ''}` +const matchingTabs = tabs => + asArray(tabs).filter(tab => { + const text = tabText(tab) + return hostPattern.test(text) || extraMarkers.some(marker => text.includes(marker)) + }) +const report = { sha256: launch.sha256, url, webviewRequests: [], errors: [] } + +function powershell(args) { + return new Promise((resolvePromise, reject) => { + const child = spawn('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ...args], { + windowsHide: true, + }) + const stdout = [] + const stderr = [] + child.stdout.on('data', chunk => stdout.push(chunk)) + child.stderr.on('data', chunk => stderr.push(chunk)) + child.on('error', reject) + child.on('exit', code => { + const result = { + code, + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + } + if (code === 0) resolvePromise(result) + else reject(new Error(`powershell ${args[0]} exited ${code}: ${result.stderr || result.stdout}`)) + }) + }) +} + +function startWatch() { + const child = spawn( + 'powershell.exe', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + observeScript, + '-Mode', + 'Watch', + '-Url', + url, + '-OutFile', + observeFile, + '-Seconds', + '20', + ], + { windowsHide: true } + ) + const stderr = [] + child.stderr.on('data', chunk => stderr.push(chunk)) + const done = new Promise((resolvePromise, reject) => { + child.on('error', reject) + child.on('exit', code => { + if (code === 0) resolvePromise() + else reject(new Error(`observe watch exited ${code}: ${Buffer.concat(stderr).toString('utf8')}`)) + }) + }) + return { child, done } +} + +async function ready(page) { + await page.waitForFunction(() => { + const pinia = document.querySelector('#__nuxt')?.__vue_app__?.config.globalProperties.$pinia + return pinia?._s.get('base')?.load && pinia?._s.get('setting')?.load + }) +} + +const browser = await chromium.connectOverCDP(`http://127.0.0.1:${launch.port}`) +const context = browser.contexts()[0] +const page = context.pages().find(item => item.url().startsWith('http://tauri.localhost')) +assert.ok(page, 'Expected a production Tauri page') +const createdPages = [] +const onPage = opened => createdPages.push(opened.url()) +context.on('page', onPage) +let watch +try { + page.on('pageerror', error => report.errors.push(error.message)) + page.on('request', request => { + if (hostPattern.test(request.url())) report.webviewRequests.push(request.url()) + }) + await page.goto('http://tauri.localhost/book-list') + await ready(page) + await page.locator('#dict-246').waitFor({ state: 'visible', timeout: 15000 }) + await page.locator('#dict-246').click() + await page.waitForURL(/\/book\/246\b/, { timeout: 15000 }) + await ready(page) + await page.getByText('Excuse me!').first().waitFor({ state: 'visible', timeout: 20000 }) + await page.locator('.dict-actions').getByText('学习', { exact: true }).click() + await page.waitForURL(/\/practice-articles\//, { timeout: 15000 }) + await ready(page) + await page.evaluate(() => { + const pinia = document.querySelector('#__nuxt').__vue_app__.config.globalProperties.$pinia + const setting = pinia._s.get('setting') + if (setting) { + setting.showPanel = false + setting.conflictNotice = false + } + document.querySelectorAll('.modal-root').forEach(node => node.remove()) + }) + await page.waitForTimeout(300) + const word = page.locator('.word-wrap').first() + await word.waitFor({ state: 'visible', timeout: 15000 }) + report.identity = await page.evaluate(() => ({ + origin: location.origin, + href: location.href, + desktop: window.__NUXT__?.config?.public?.isDesktop, + ipc: typeof window.__TAURI_INTERNALS__?.invoke === 'function', + })) + assert.equal(report.identity.desktop, true) + assert.equal(report.identity.ipc, true) + await page.evaluate(() => { + window.__externalDocumentMarker = 'native-enpuz' + const internals = window.__TAURI_INTERNALS__ + const originalFetch = window.fetch + const opener = internals.convertFileSrc('plugin:opener|open_url', 'ipc') + window.__externalOpenProbe = { fetches: [] } + window.fetch = async function (input, init) { + const href = String(input) + const response = await originalFetch.call(this, input, init) + if (href === opener || /plugin:opener\|open_url/.test(href)) { + window.__externalOpenProbe.fetches.push({ + url: href, + ok: response.headers.get('Tauri-Response') === 'ok', + }) + } + return response + } + }) + watch = startWatch() + const readyUntil = Date.now() + 8000 + while (Date.now() < readyUntil) { + if (existsSync(observeFile)) { + try { + if (JSON.parse(readFileSync(observeFile, 'utf8').replace(/^\uFEFF/, '')).uiaReady) break + } catch {} + } + await new Promise(resolveWait => setTimeout(resolveWait, 150)) + } + const original = page.url() + const box = await word.boundingBox() + assert.ok(box, 'Expected an article word box') + await page.mouse.click(box.x + Math.min(8, box.width / 2), box.y + box.height / 2, { button: 'right' }) + const grammar = page.getByText(/语法分析|Grammar analysis/) + await grammar.waitFor({ state: 'visible', timeout: 8000 }) + await grammar.click() + await page.waitForFunction(() => window.__externalOpenProbe?.fetches?.some(item => item.ok), null, { + timeout: 15000, + }) + await watch.done + const observe = existsSync(observeFile) ? JSON.parse(readFileSync(observeFile, 'utf8').replace(/^\uFEFF/, '')) : null + const probe = await page.evaluate(() => window.__externalOpenProbe) + report.probe = probe + report.observe = observe + assert.ok(asArray(probe.fetches).some(item => item.ok), '语法分析 must call the native opener') + assert.match(page.url(), /^http:\/\/tauri\.localhost\//) + report.retainedHref = page.url() + report.openedFrom = original + if (page.url() === original) { + assert.equal(await page.evaluate(() => window.__externalDocumentMarker), 'native-enpuz') + } + assert.deepEqual(createdPages, []) + assert.deepEqual(report.webviewRequests, []) + assert.deepEqual(report.errors, []) + const processHits = asArray(observe?.processes) + const tabHits = matchingTabs(observe?.newTabs) + const retargetHits = matchingTabs(observe?.retargetedTabs) + const windowHits = asArray(observe?.newWindows) + const existingHits = matchingTabs(observe?.existingMatches) + assert.ok( + processHits.length > 0 || + tabHits.length > 0 || + retargetHits.length > 0 || + windowHits.length > 0 || + existingHits.length > 0, + 'System browser must receive enpuz.com; opener IPC success is not enough' + ) + await page.screenshot({ path: resolve(evidence, 'native-enpuz.png') }) + report.systemBrowser = { + httpsProgId: observe?.httpsProgId ?? null, + processes: processHits.length, + newTabs: tabHits.length, + retargetedTabs: retargetHits.length, + newWindows: windowHits.length, + existingMatches: existingHits.length, + } + try { + await powershell([observeScript, '-Mode', 'Close', '-Url', url, '-OutFile', observeFile]) + report.observe = JSON.parse(readFileSync(observeFile, 'utf8').replace(/^\uFEFF/, '')) + } catch (error) { + report.closeError = String(error) + } + report.passed = true + console.log( + `PASS: article 语法分析 opened enpuz.com; system browser evidence processes=${processHits.length} tabs=${tabHits.length} retargeted=${retargetHits.length} windows=${windowHits.length} existing=${existingHits.length}; main document retained.` + ) +} finally { + context.off('page', onPage) + if (watch?.child && watch.child.exitCode == null) watch.child.kill() + writeFileSync(resolve(evidence, 'native-enpuz.json'), JSON.stringify(report, null, 2)) + await browser.close() +} diff --git a/tests/desktop/native-external-observe.ps1 b/tests/desktop/native-external-observe.ps1 new file mode 100644 index 000000000..337d2efaf --- /dev/null +++ b/tests/desktop/native-external-observe.ps1 @@ -0,0 +1,250 @@ +param( + [ValidateSet('Watch', 'Close')] + [string] $Mode = 'Watch', + [Parameter(Mandatory = $true)] + [string] $Url, + [Parameter(Mandatory = $true)] + [string] $OutFile, + [int] $Seconds = 12, + [int] $PollMs = 120 +) + +$ErrorActionPreference = 'Stop' +$uri = [Uri]$Url +$needle = $uri.AbsoluteUri +$markers = New-Object System.Collections.Generic.List[string] +foreach ($item in @($uri.Host, $uri.AbsolutePath.Trim('/'), $needle, 'zyronon/TypeWords', 'github.com/zyronon/TypeWords')) { + if (-not [string]::IsNullOrWhiteSpace($item)) { [void]$markers.Add($item) } +} +if ($uri.Host -match 'wjx\.cn') { + foreach ($item in @('问卷星', '问卷', 'wjx.cn', 'v.wjx.cn', 'ev0W7fv')) { [void]$markers.Add($item) } +} +if ($uri.Host -match 'youdao\.com') { + foreach ($item in @('有道', 'youdao', '词典', 'Youdao', '无法访问')) { [void]$markers.Add($item) } + foreach ($pair in $uri.Query.TrimStart('?').Split('&')) { + if ($pair -like 'word=*' -and $pair.Length -gt 5) { + $word = [Uri]::UnescapeDataString($pair.Substring(5)) + if (-not [string]::IsNullOrWhiteSpace($word)) { [void]$markers.Add($word) } + } + } +} +if ($uri.Host -match 'enpuz\.com') { + foreach ($item in @('enpuz', 'Enpuz', '英语语法', '语法分析', '在线英语')) { [void]$markers.Add($item) } +} +if ($uri.Host -match 'v8l\.cn') { + foreach ($item in @('v8l', 'TG3sgVg')) { [void]$markers.Add($item) } +} +$httpsProgId = $null +try { + $httpsProgId = (Get-ItemProperty -LiteralPath 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice').ProgId +} catch {} + +function Test-UrlMarker([string] $Text) { + if ([string]::IsNullOrWhiteSpace($Text)) { return $false } + foreach ($marker in $markers) { + if ($Text.ToLowerInvariant().Contains($marker.ToLowerInvariant())) { return $true } + } + return $false +} + +function Get-BrowserProcesses { + $found = New-Object System.Collections.Generic.List[object] + foreach ($process in (Get-CimInstance Win32_Process)) { + if (-not $process.CommandLine) { continue } + if ($process.Name -notmatch '^(chrome|msedge|firefox|brave|opera|iexplore)\.exe$') { continue } + if (-not (Test-UrlMarker $process.CommandLine)) { continue } + $found.Add([pscustomobject]@{ + pid = $process.ProcessId + name = $process.Name + commandLine = $process.CommandLine + }) + } + return ,$found.ToArray() +} + +$script:UiaReady = $false +$script:UiaError = $null +$script:UiaRoot = $null +$script:WindowType = $null +$script:TabType = $null +$script:ButtonType = $null +try { + Add-Type -AssemblyName UIAutomationClient + Add-Type -AssemblyName UIAutomationTypes + $script:UiaRoot = [System.Windows.Automation.AutomationElement]::RootElement + $script:WindowType = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Window + ) + $script:TabType = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::TabItem + ) + $script:ButtonType = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Button + ) + $script:UiaReady = $true +} catch { + $script:UiaError = [string]$_ +} + +function Get-TopWindows { + $found = New-Object System.Collections.Generic.List[object] + if (-not $script:UiaReady) { return ,$found.ToArray() } + foreach ($window in $script:UiaRoot.FindAll([System.Windows.Automation.TreeScope]::Children, $script:WindowType)) { + $name = $window.Current.Name + if ([string]::IsNullOrWhiteSpace($name)) { continue } + $found.Add([pscustomobject]@{ + name = $name + runtimeId = ($window.GetRuntimeId() -join ',') + }) + } + return ,$found.ToArray() +} + +function Get-BrowserTabs { + $found = New-Object System.Collections.Generic.List[object] + if (-not $script:UiaReady) { return ,$found.ToArray() } + foreach ($window in $script:UiaRoot.FindAll([System.Windows.Automation.TreeScope]::Children, $script:WindowType)) { + $windowName = $window.Current.Name + if ($windowName -notmatch 'Chrome|Edge|Firefox|Brave|Chromium') { continue } + foreach ($tab in $window.FindAll([System.Windows.Automation.TreeScope]::Descendants, $script:TabType)) { + $found.Add([pscustomobject]@{ + name = $tab.Current.Name + runtimeId = ($tab.GetRuntimeId() -join ',') + window = $windowName + }) + } + } + return ,$found.ToArray() +} + +function Write-Json($Value) { + $utf8 = New-Object System.Text.UTF8Encoding $false + [System.IO.File]::WriteAllText($OutFile, ($Value | ConvertTo-Json -Depth 8), $utf8) +} + +function Close-TabByRuntimeId([string] $RuntimeId) { + if (-not $script:UiaReady -or [string]::IsNullOrWhiteSpace($RuntimeId)) { return $false } + foreach ($window in $script:UiaRoot.FindAll([System.Windows.Automation.TreeScope]::Children, $script:WindowType)) { + foreach ($tab in $window.FindAll([System.Windows.Automation.TreeScope]::Descendants, $script:TabType)) { + if (($tab.GetRuntimeId() -join ',') -ne $RuntimeId) { continue } + foreach ($button in $tab.FindAll([System.Windows.Automation.TreeScope]::Children, $script:ButtonType)) { + if ($button.Current.Name -notmatch 'Close|关闭') { continue } + $pattern = $button.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) + $pattern.Invoke() + return $true + } + return $false + } + } + return $false +} + +if ($Mode -eq 'Close') { + $report = Get-Content -LiteralPath $OutFile -Raw -Encoding utf8 | ConvertFrom-Json + $closed = New-Object System.Collections.Generic.List[object] + foreach ($tab in @($report.newTabs) + @($report.retargetedTabs)) { + if (-not (Test-UrlMarker ([string]$tab.name))) { continue } + $ok = Close-TabByRuntimeId ([string]$tab.runtimeId) + $closed.Add([pscustomobject]@{ runtimeId = $tab.runtimeId; name = $tab.name; closed = $ok }) + } + $report | Add-Member -NotePropertyName closedTabs -NotePropertyValue $closed.ToArray() -Force + Write-Json $report + return +} + +$beforeWindowIds = @{} +$beforeTabIds = @{} +$beforeTabNames = @{} +foreach ($window in (Get-TopWindows)) { $beforeWindowIds[[string]$window.runtimeId] = $true } +foreach ($tab in (Get-BrowserTabs)) { + $id = [string]$tab.runtimeId + $beforeTabIds[$id] = $true + $beforeTabNames[$id] = [string]$tab.name +} +$processes = New-Object System.Collections.Generic.List[object] +$newWindows = New-Object System.Collections.Generic.List[object] +$newTabs = New-Object System.Collections.Generic.List[object] +$retargetedTabs = New-Object System.Collections.Generic.List[object] +$seenProcess = @{} +$seenWindow = @{} +$seenRetarget = @{} +$deadline = (Get-Date).AddSeconds($Seconds) + +function Get-ExistingMatches { + $found = New-Object System.Collections.Generic.List[object] + foreach ($tab in (Get-BrowserTabs)) { + if (Test-UrlMarker $tab.name) { $found.Add($tab) } + } + foreach ($window in (Get-TopWindows)) { + if (Test-UrlMarker $window.name) { $found.Add($window) } + } + return ,$found.ToArray() +} + +function Write-Report { + $chromeWindows = New-Object System.Collections.Generic.List[string] + foreach ($window in (Get-TopWindows)) { + if ($window.name -match 'Chrome|Edge|Firefox|Brave|Chromium') { [void]$chromeWindows.Add($window.name) } + } + Write-Json ([pscustomobject]@{ + url = $needle + httpsProgId = $httpsProgId + uiaReady = $script:UiaReady + uiaError = $script:UiaError + processes = $processes.ToArray() + newWindows = $newWindows.ToArray() + newTabs = $newTabs.ToArray() + retargetedTabs = $retargetedTabs.ToArray() + existingMatches = Get-ExistingMatches + chromeWindows = $chromeWindows.ToArray() + }) +} + +Write-Report +while ((Get-Date) -lt $deadline) { + foreach ($process in (Get-BrowserProcesses)) { + $key = [string]$process.pid + if ($seenProcess.ContainsKey($key)) { continue } + $seenProcess[$key] = $true + $processes.Add($process) + } + foreach ($window in (Get-TopWindows)) { + $id = [string]$window.runtimeId + if ($beforeWindowIds.ContainsKey($id) -or $seenWindow.ContainsKey($id)) { continue } + if (-not (Test-UrlMarker $window.name)) { continue } + $seenWindow[$id] = $true + $newWindows.Add($window) + } + foreach ($tab in (Get-BrowserTabs)) { + $id = [string]$tab.runtimeId + if ($beforeTabIds.ContainsKey($id)) { + $previous = [string]$beforeTabNames[$id] + if ((Test-UrlMarker $tab.name) -and -not (Test-UrlMarker $previous) -and -not $seenRetarget.ContainsKey($id)) { + $seenRetarget[$id] = $true + $retargetedTabs.Add([pscustomobject]@{ + name = $tab.name + previousName = $previous + runtimeId = $id + window = $tab.window + }) + } + continue + } + $existing = $null + foreach ($item in $newTabs) { + if ($item.runtimeId -eq $id) { $existing = $item; break } + } + if ($null -eq $existing) { + $newTabs.Add($tab) + } elseif ((Test-UrlMarker $tab.name) -and -not (Test-UrlMarker $existing.name)) { + $existing.name = $tab.name + $existing.window = $tab.window + } + } + Write-Report + Start-Sleep -Milliseconds $PollMs +} +Write-Report diff --git a/tests/desktop/native-external-open.mjs b/tests/desktop/native-external-open.mjs new file mode 100644 index 000000000..884c9c161 --- /dev/null +++ b/tests/desktop/native-external-open.mjs @@ -0,0 +1,251 @@ +// Explicit release-WebView integration test; not part of the Node unit suite. +// Clicks a real in-app allowlisted link and requires OS evidence that the +// system browser received it. IPC success alone is not enough. +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const evidence = resolve(process.argv[2] || '') +assert.ok(process.argv[2], 'Supply an evidence directory with an isolated launch.json') +const launch = JSON.parse(readFileSync(resolve(evidence, 'launch.json'), 'utf8').replace(/^\uFEFF/, '')) +assert.equal(resolve(launch.profile), resolve(evidence, 'isolated-profile')) +assert.equal(createHash('sha256').update(readFileSync(launch.exe)).digest('hex'), launch.sha256.toLowerCase()) +assert.ok(process.env.TYPEWORDS_PLAYWRIGHT_MODULE, 'Set TYPEWORDS_PLAYWRIGHT_MODULE') +const { chromium } = await import(pathToFileURL(process.env.TYPEWORDS_PLAYWRIGHT_MODULE).href) +const url = process.argv[3] || 'https://github.com/zyronon/TypeWords' +const host = new URL(url).hostname +const stem = + process.argv[3] == null ? 'native-external-open' : `native-external-${host.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '')}` +const observeScript = resolve('tests/desktop/native-external-observe.ps1') +const observeFile = resolve(evidence, `${stem}-observe.json`) +const report = { sha256: launch.sha256, url, webviewRequests: [], errors: [] } +const asArray = value => (value == null ? [] : Array.isArray(value) ? value : [value]) +const hostPattern = new RegExp(host.replace(/\./g, '\\.'), 'i') +const extraMarkers = host.includes('wjx.cn') + ? ['问卷', '问卷星', 'ev0W7fv'] + : host.includes('youdao.com') + ? ['有道', 'youdao', '词典', 'Youdao', new URL(url).searchParams.get('word')].filter(Boolean) + : host.includes('enpuz.com') + ? ['enpuz', '英语语法', '语法分析'] + : host.includes('v8l.cn') + ? ['v8l', 'TG3sgVg'] + : [] +const tabText = tab => `${tab?.name || ''} ${tab?.window || ''}` +const matchingTabs = tabs => + asArray(tabs).filter(tab => { + const text = tabText(tab) + return hostPattern.test(text) || text.includes(url) || extraMarkers.some(marker => text.includes(marker)) + }) + +function powershell(args) { + return new Promise((resolvePromise, reject) => { + const child = spawn('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ...args], { + windowsHide: true, + }) + const stdout = [] + const stderr = [] + child.stdout.on('data', chunk => stdout.push(chunk)) + child.stderr.on('data', chunk => stderr.push(chunk)) + child.on('error', reject) + child.on('exit', code => { + const result = { + code, + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + } + if (code === 0) resolvePromise(result) + else reject(new Error(`powershell ${args[0]} exited ${code}: ${result.stderr || result.stdout}`)) + }) + }) +} + +function startWatch() { + const child = spawn( + 'powershell.exe', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + observeScript, + '-Mode', + 'Watch', + '-Url', + url, + '-OutFile', + observeFile, + '-Seconds', + '12', + ], + { windowsHide: true } + ) + const stderr = [] + child.stderr.on('data', chunk => stderr.push(chunk)) + const done = new Promise((resolvePromise, reject) => { + child.on('error', reject) + child.on('exit', code => { + if (code === 0) resolvePromise() + else reject(new Error(`observe watch exited ${code}: ${Buffer.concat(stderr).toString('utf8')}`)) + }) + }) + return { child, done } +} + +async function connect() { + const deadline = Date.now() + 30000 + let last + while (Date.now() < deadline) { + try { + return await chromium.connectOverCDP(`http://127.0.0.1:${launch.port}`) + } catch (error) { + last = error + await new Promise(resolveWait => setTimeout(resolveWait, 250)) + } + } + throw last +} + +const browser = await connect() +const context = browser.contexts()[0] +const page = context.pages().find(item => item.url().startsWith('http://tauri.localhost')) +assert.ok(page, 'Expected a production Tauri page') +const createdPages = [] +const onPage = opened => createdPages.push(opened.url()) +context.on('page', onPage) +let watch +try { + page.on('pageerror', error => report.errors.push(error.message)) + page.on('request', request => { + if (hostPattern.test(request.url())) report.webviewRequests.push(request.url()) + }) + await page.goto('http://tauri.localhost/setting?index=9') + await page.waitForFunction(() => { + const pinia = document.querySelector('#__nuxt')?.__vue_app__?.config.globalProperties.$pinia + return pinia?._s.get('base')?.load && pinia?._s.get('setting')?.load + }) + const link = page.locator(`a[href="${url}"]`) + await link.waitFor({ state: 'visible' }) + report.identity = await page.evaluate(() => ({ + origin: location.origin, + href: location.href, + desktop: window.__NUXT__?.config?.public?.isDesktop, + ipc: typeof window.__TAURI_INTERNALS__?.invoke === 'function', + ua: navigator.userAgent, + })) + assert.equal(report.identity.desktop, true) + assert.equal(report.identity.ipc, true) + assert.match(report.identity.href, /[?&]index=9\b/) + const marker = 'native-external-document' + await page.evaluate(value => { + window.__externalDocumentMarker = value + const internals = window.__TAURI_INTERNALS__ + const originalInvoke = internals.invoke.bind(internals) + const originalFetch = window.fetch + const opener = internals.convertFileSrc('plugin:opener|open_url', 'ipc') + window.__externalOpenProbe = { invokes: [], fetches: [] } + internals.invoke = async function (command, args, options) { + try { + const value = await originalInvoke(command, args, options) + window.__externalOpenProbe.invokes.push({ command, args, ok: true }) + return value + } catch (error) { + window.__externalOpenProbe.invokes.push({ command, args, ok: false, error: String(error) }) + throw error + } + } + window.fetch = async function (input, init) { + const href = String(input) + const response = await originalFetch.call(this, input, init) + if (href === opener || /plugin:opener\|open_url/.test(href)) { + const raw = await response.clone().text() + let body = raw + try { + body = JSON.parse(raw) + } catch {} + window.__externalOpenProbe.fetches.push({ + url: href, + ok: response.headers.get('Tauri-Response') === 'ok', + body, + }) + } + return response + } + if (window.fetch === originalFetch) throw new Error('Cannot install opener observer') + }, marker) + watch = startWatch() + await new Promise(resolveWait => setTimeout(resolveWait, 400)) + const original = page.url() + await link.click() + await page.waitForFunction( + expected => { + const probe = window.__externalOpenProbe + return ( + probe?.invokes?.some( + item => item.command === 'plugin:opener|open_url' && item.ok && item.args?.url === expected + ) || probe?.fetches?.some(item => item.ok) + ) + }, + url, + { timeout: 10000 } + ) + await watch.done + const observe = existsSync(observeFile) ? JSON.parse(readFileSync(observeFile, 'utf8').replace(/^\uFEFF/, '')) : null + const probe = await page.evaluate(() => window.__externalOpenProbe) + report.probe = probe + report.observe = observe + const invoke = asArray(probe.invokes).find(item => item.command === 'plugin:opener|open_url') + const fetched = asArray(probe.fetches).find(item => item.ok) + assert.ok(invoke || fetched, 'Product click must call the native opener') + if (invoke) { + assert.equal(invoke.args.url, url) + } + assert.equal(page.url(), original) + assert.equal(await page.evaluate(() => window.__externalDocumentMarker), marker) + assert.deepEqual(createdPages, [], 'Host must not open a WebView page for the product link') + assert.deepEqual(report.webviewRequests, [], `${host} must not load inside the privileged WebView`) + assert.deepEqual(report.errors, []) + assert.equal(await page.locator('.message.error').count(), 0) + const processHits = asArray(observe?.processes) + const tabHits = matchingTabs(observe?.newTabs) + const retargetHits = matchingTabs(observe?.retargetedTabs) + const windowHits = asArray(observe?.newWindows) + const existingHits = matchingTabs(observe?.existingMatches) + assert.ok( + processHits.length > 0 || + tabHits.length > 0 || + retargetHits.length > 0 || + windowHits.length > 0 || + existingHits.length > 0, + 'System browser must receive the allowlisted URL; opener IPC success is not enough' + ) + await page.screenshot({ path: resolve(evidence, `${stem}.png`) }) + report.probe = probe + report.observe = observe + report.noWebViewNavigation = true + report.systemBrowser = { + httpsProgId: observe?.httpsProgId ?? null, + processes: processHits.length, + newTabs: tabHits.length, + retargetedTabs: retargetHits.length, + newWindows: windowHits.length, + existingMatches: existingHits.length, + } + try { + await powershell([observeScript, '-Mode', 'Close', '-Url', url, '-OutFile', observeFile]) + report.observe = JSON.parse(readFileSync(observeFile, 'utf8').replace(/^\uFEFF/, '')) + } catch (error) { + report.closeError = String(error) + } + report.passed = true + console.log( + `PASS: settings About ${host} link opened via opener; system browser evidence processes=${processHits.length} tabs=${tabHits.length} retargeted=${retargetHits.length} windows=${windowHits.length}; main document retained.` + ) +} finally { + context.off('page', onPage) + if (watch?.child && watch.child.exitCode == null) watch.child.kill() + writeFileSync(resolve(evidence, `${stem}.json`), JSON.stringify(report, null, 2)) + await browser.close() +} diff --git a/tests/desktop/native-failed-upgrade.ps1 b/tests/desktop/native-failed-upgrade.ps1 new file mode 100644 index 000000000..fa95a3ee7 --- /dev/null +++ b/tests/desktop/native-failed-upgrade.ps1 @@ -0,0 +1,89 @@ +param([Parameter(Mandatory = $true)][string] $Stage) +$ErrorActionPreference = 'Stop' +$repo = Split-Path (Split-Path $PSScriptRoot) +$wrapper = Join-Path $repo 'scripts\invoke-setup.ps1' +$key = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TypeWords' +$dest = Join-Path $Stage 'installed' +$exe = Join-Path $dest 'typewords-desktop.exe' +$setup = Join-Path $Stage 'TypeWords_0.1.2_x64-setup.exe' +$launch = Get-Content -LiteralPath (Join-Path $Stage 'launch.json') -Raw | ConvertFrom-Json +$defaultDir = Join-Path $env:LOCALAPPDATA 'io.github.zyronon.typewords' +$resultPath = Join-Path $Stage 'invoke-setup-012.json' +if (!(Test-Path -LiteralPath $wrapper)) { throw "Missing setup wrapper: $wrapper" } +if (!(Test-Path -LiteralPath $setup)) { throw "Missing 0.1.2 installer: $setup" } +if (Get-Process typewords-desktop -ErrorAction SilentlyContinue) { throw 'TypeWords must be closed before the failed-upgrade installer' } +$registered = Get-ItemProperty $key +if ($registered.DisplayVersion -ne '0.1.3' -or $registered.InstallLocation.Trim('"') -ne $dest) { + throw 'Failed-upgrade identity mismatch; expected installed 0.1.3 at the disposable dest' +} +$before = @{ + sha256 = (Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash + productVersion = (Get-Item -LiteralPath $exe).VersionInfo.ProductVersion + bytes = (Get-Item -LiteralPath $exe).Length + displayVersion = $registered.DisplayVersion +} +if ($before.sha256 -ne $launch.sha256 -or $before.productVersion -ne '0.1.3') { + throw 'Installed EXE is not the recorded 0.1.3 candidate' +} +& $wrapper -Setup $setup -Dest $dest -ResultPath $resultPath +$wrapperExit = $LASTEXITCODE +$wrapperResult = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json +$afterExe = Get-Item -LiteralPath $exe +$after = @{ + sha256 = (Get-FileHash -LiteralPath $afterExe.FullName -Algorithm SHA256).Hash + productVersion = $afterExe.VersionInfo.ProductVersion + bytes = $afterExe.Length + displayVersion = (Get-ItemProperty $key).DisplayVersion + installLocation = (Get-ItemProperty $key).InstallLocation + profileRetained = [bool](Test-Path -LiteralPath (Join-Path $Stage 'isolated-profile\EBWebView')) + defaultIdentifierFiles = @(Get-ChildItem -LiteralPath $defaultDir -Force -Recurse -ErrorAction SilentlyContinue).Count +} +$record = [ordered]@{ + attempted = '0.1.2 installer over installed 0.1.3 through invoke-setup; raw NSIS allowDowngrades=false is insufficient' + installer = $setup + installerBytes = (Get-Item -LiteralPath $setup).Length + installerSha256 = (Get-FileHash -LiteralPath $setup -Algorithm SHA256).Hash + wrapper = $wrapper + wrapperExit = $wrapperExit + refused = [bool]$wrapperResult.refused + launched = [bool]$wrapperResult.launched + reason = $wrapperResult.reason + installerExit = $wrapperResult.installerExit + timedOut = $false + before = $before + after = $after +} +$destIntact = $after.sha256 -eq $before.sha256 -and $after.productVersion -eq '0.1.3' -and $after.displayVersion -eq '0.1.3' -and $after.profileRetained -and $after.defaultIdentifierFiles -eq 0 +$record.destIntact = $destIntact +$record.wrapperBlockedSilentDowngrade = $destIntact -and $record.refused -and -not $record.launched -and $wrapperExit -eq 2 +if (-not $destIntact -and $after.productVersion -eq '0.1.2') { + $restoreSetup = Join-Path $Stage 'TypeWords_0.1.3_x64-setup.exe' + & $wrapper -Setup $restoreSetup -Dest $dest -ResultPath (Join-Path $Stage 'invoke-setup-013-restore.json') + $restoreExit = $LASTEXITCODE + if ($restoreExit -ne 0) { throw '0.1.3 restore installer did not exit 0 after unexpected 0.1.2 downgrade' } + $restoredExe = Get-Item -LiteralPath $exe + $record.restored = @{ + installerExit = $restoreExit + sha256 = (Get-FileHash -LiteralPath $restoredExe.FullName -Algorithm SHA256).Hash + productVersion = $restoredExe.VersionInfo.ProductVersion + displayVersion = (Get-ItemProperty $key).DisplayVersion + profileRetained = [bool](Test-Path -LiteralPath (Join-Path $Stage 'isolated-profile\EBWebView')) + defaultIdentifierFiles = @(Get-ChildItem -LiteralPath $defaultDir -Force -Recurse -ErrorAction SilentlyContinue).Count + } +} +$record | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $Stage 'failed-upgrade.json') -Encoding utf8 +if ($record.wrapperBlockedSilentDowngrade) { + Write-Output 'PASS: invoke-setup refused 0.1.2 over 0.1.3; EXE/registry/profile unchanged' + return +} +if ( + $record.restored -and + $record.restored.productVersion -eq '0.1.3' -and + $record.restored.sha256 -eq $before.sha256 -and + $record.restored.profileRetained -and + $record.restored.defaultIdentifierFiles -eq 0 +) { + Write-Output 'FINDING: wrapper did not block the silent 0.1.2 downgrade; restored 0.1.3; isolated profile and unused default identifier dir kept' + return +} +throw 'Failed-upgrade dest was not left on the recorded 0.1.3 candidate' diff --git a/tests/desktop/native-host.test.mjs b/tests/desktop/native-host.test.mjs new file mode 100644 index 000000000..bb4733e2b --- /dev/null +++ b/tests/desktop/native-host.test.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import test from 'node:test' + +const root = process.env.TYPEWORDS_TEST_ROOT || process.cwd() +const read = path => readFileSync(resolve(root, path), 'utf8') +const config = () => JSON.parse(read('src-tauri/tauri.conf.json')) + +test('native commands reuse the existing desktop frontend and preserve Web commands', () => { + const pkg = JSON.parse(read('package.json')) + assert.equal(pkg.scripts['desktop:dev'], 'tauri dev') + assert.equal(pkg.scripts['desktop:build'], 'tauri build --no-bundle') + assert.equal(pkg.scripts.build, 'nuxt build') + assert.equal(pkg.scripts.dev, 'nuxt dev') + assert.equal(pkg.devDependencies['@tauri-apps/cli'], '2.11.4') +}) + +test('native build consumes the audited static frontend and exact development port', () => { + const { build } = config() + assert.equal(build.beforeDevCommand, 'pnpm run desktop:frontend:dev') + assert.equal(build.beforeBuildCommand, 'pnpm run desktop:frontend:build') + assert.equal(build.devUrl, 'http://127.0.0.1:5567') + assert.equal(build.frontendDist, '../.output/public') +}) + +test('native host uses one local window with no installer or global API', () => { + const { app, bundle } = config() + assert.equal(app.windows.length, 1) + assert.equal(app.windows[0].label, 'main') + assert.equal(app.windows[0].url, 'index.html') + assert.equal(app.withGlobalTauri, false) + assert.equal(bundle.active, false) + assert.equal(bundle.externalBin, undefined) + assert.equal(app.windows[0].dataDirectory, undefined) + assert.equal(app.windows[0].useHttpsScheme, false) +}) + +test('native permissions select local-only save and scoped opener commands', () => { + assert.deepEqual(config().app.security.capabilities, ['main']) + const capability = JSON.parse(read('src-tauri/capabilities/main.json')) + assert.equal(capability.identifier, 'main') + assert.deepEqual(capability.windows, ['main']) + assert.deepEqual(capability.permissions.slice(0, 2), ['dialog:allow-save', 'fs:allow-write-file']) + assert.equal(capability.permissions[2].identifier, 'opener:allow-open-url') + assert.equal(capability.permissions.length, 3) + assert.equal(capability.remote, undefined) +}) + +test('native production CSP is explicit and keeps development exceptions separate', () => { + const { csp, devCsp } = config().app.security + assert.equal(csp['default-src'], "'self'") + assert.equal(csp['script-src'], "'self'") + assert.equal(csp['object-src'], "'none'") + assert.equal(csp['base-uri'], "'self'") + assert.match(csp['media-src'], /blob:/) + assert.match(csp['media-src'], /https:\/\/dict\.youdao\.com/) + assert.doesNotMatch(JSON.stringify(csp), /unsafe-eval|ws:\/\/|https:\/\/\*(?!\.supabase\.co)/) + assert.equal( + csp['connect-src'] + .split(' ') + .filter(value => value.includes('*')) + .join(' '), + 'https://*.supabase.co' + ) + assert.match(devCsp['connect-src'], /https:\/\/\*\.supabase\.co/) + assert.match(devCsp['connect-src'], /ws:\/\/127\.0\.0\.1:24678/) +}) + +test('Rust host pins Tauri 2 and only approved native plugins', () => { + const manifest = read('src-tauri/Cargo.toml') + assert.match(manifest, /tauri = \{ version = "=2\.11\.5"/) + assert.match(manifest, /tauri-build = \{ version = "=2\.6\.3"/) + assert.match(manifest, /custom-protocol = \["tauri\/custom-protocol"\]/) + assert.match(manifest, /tauri-plugin-dialog = "=2\.7\.3"/) + assert.match(manifest, /tauri-plugin-fs = "=2\.5\.2"/) + assert.match(manifest, /tauri-plugin-opener = "=2\.5\.5"/) + assert.doesNotMatch(manifest, /tauri-plugin-(shell|updater)/) + assert.equal(config().app.windows[0].create, false) + assert.match(read('src-tauri/src/lib.rs'), /on_navigation\(local_navigation\)/) + assert.match(read('src-tauri/src/lib.rs'), /NewWindowResponse::Deny/) + assert.match(read('src-tauri/src/lib.rs'), /tauri::generate_context!\(\)/) + assert.match(read('src-tauri/build.rs'), /tauri_build::build\(\)/) +}) diff --git a/tests/desktop/native-install-upgrade.mjs b/tests/desktop/native-install-upgrade.mjs new file mode 100644 index 000000000..b50e422d5 --- /dev/null +++ b/tests/desktop/native-install-upgrade.mjs @@ -0,0 +1,214 @@ +// Explicit installed-candidate runner. Not part of the default Node suite. +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { backupSeedScript } from './backup-browser-seed.mjs' + +const [mode, evidencePath] = process.argv.slice(2) +assert.ok( + ['prepare', 'before-upgrade', 'after-upgrade', 'retain-before', 'retain-after'].includes(mode), + 'Use prepare, before-upgrade, after-upgrade, retain-before or retain-after' +) +assert.ok(evidencePath, 'An explicit evidence directory is required') +const evidence = resolve(evidencePath) +const launchName = existsSync(resolve(evidence, 'restart.json')) ? 'restart.json' : 'launch.json' +const launch = JSON.parse(readFileSync(resolve(evidence, launchName), 'utf8').replace(/^\uFEFF/, '')) +assert.equal(resolve(launch.profile), resolve(evidence, 'isolated-profile')) +assert.equal(createHash('sha256').update(readFileSync(launch.exe)).digest('hex'), launch.sha256.toLowerCase()) +assert.ok(process.env.TYPEWORDS_PLAYWRIGHT_MODULE, 'Set TYPEWORDS_PLAYWRIGHT_MODULE') +const { chromium } = await import(pathToFileURL(process.env.TYPEWORDS_PLAYWRIGHT_MODULE).href) + +async function connect() { + const deadline = Date.now() + 30000 + let last + while (Date.now() < deadline) { + try { + return await chromium.connectOverCDP(`http://127.0.0.1:${launch.port}`) + } catch (error) { + last = error + await new Promise(resolveWait => setTimeout(resolveWait, 250)) + } + } + throw last +} + +const browser = await connect() +const context = browser.contexts()[0] +const deadline = Date.now() + 30000 +let page +while (Date.now() < deadline) { + page = context.pages().find(item => item.url().startsWith('http://tauri.localhost')) + if (page) break + await new Promise(resolveWait => setTimeout(resolveWait, 250)) +} +assert.ok(page, 'Expected the isolated installed Tauri page') +const external = [] +const errors = [] +page.on('pageerror', error => errors.push(error.message)) +await context.route(/^https:\/\//, route => { + external.push(route.request().url()) + return route.abort() +}) +const pause = ms => new Promise(resolveWait => setTimeout(resolveWait, ms)) +async function ready() { + await page.waitForFunction(() => { + const pinia = document.querySelector('#__nuxt')?.__vue_app__?.config.globalProperties.$pinia + return pinia?._s.get('base')?.load && pinia?._s.get('setting')?.load + }) +} +async function snapshot() { + return page.evaluate(async () => { + const request = indexedDB.open('keyval-store') + const db = await new Promise((resolveDb, reject) => { + request.onsuccess = () => resolveDb(request.result) + request.onerror = () => reject(request.error) + }) + const keys = [ + 'typing-word-dict', + 'typing-word-setting', + 'PracticeSaveWord', + 'PracticeSaveArticle', + 'typing-word-files', + ] + const disk = {} + try { + const tx = db.transaction('keyval', 'readonly') + await Promise.all( + keys.map( + key => + new Promise((resolveKey, reject) => { + const get = tx.objectStore('keyval').get(key) + get.onsuccess = () => { + disk[key] = get.result ?? null + resolveKey() + } + get.onerror = () => reject(get.error) + }) + ) + ) + if (disk['typing-word-files']) { + disk['typing-word-files'] = await Promise.all( + disk['typing-word-files'].map(async item => ({ + id: item.id, + bytes: Array.from(new Uint8Array(await item.file.arrayBuffer())), + })) + ) + } + } finally { + db.close() + } + const pinia = document.querySelector('#__nuxt').__vue_app__.config.globalProperties.$pinia + const clean = state => { + const value = JSON.parse(JSON.stringify(state)) + delete value._ignoreWatch + return value + } + return { + disk, + base: clean(pinia._s.get('base').$state), + setting: clean(pinia._s.get('setting').$state), + href: location.href, + } + }) +} +const save = (name, value) => writeFileSync(resolve(evidence, name), JSON.stringify(value, null, 2)) +try { + await page.goto('http://tauri.localhost/setting?index=5') + await ready() + const identity = await page.evaluate(() => ({ + origin: location.origin, + href: location.href, + desktop: window.__NUXT__?.config?.public?.isDesktop, + ipc: typeof window.__TAURI_INTERNALS__?.invoke === 'function', + ua: navigator.userAgent, + })) + assert.equal(identity.origin, 'http://tauri.localhost') + assert.equal(identity.desktop, true) + assert.equal(identity.ipc, true) + if (mode === 'prepare') { + const seeded = await page.evaluate(`(${backupSeedScript()})()`) + assert.equal(seeded.audioBytes, 2943) + await pause(1800) + const baseline = await snapshot() + for (const [key, value] of Object.entries(baseline.disk)) { + assert.notEqual(value, null, `Seed must populate ${key}`) + } + assert.equal(baseline.base.noteData.fixture, 'ZIP semantic round-trip') + assert.equal(baseline.setting.wordSoundVolume, 0.37) + assert.equal(baseline.disk['typing-word-files'][0].bytes.length, 2943) + save('before-upgrade.json', { identity, productVersion: launch.productVersion, baseline, external, errors }) + console.log('PASS: installed 0.1.0 seeded five keys and 2943-byte audio in the isolated profile.') + } else if (mode === 'retain-before') { + const baseline = await snapshot() + for (const [key, value] of Object.entries(baseline.disk)) { + assert.notEqual(value, null, `Retain snapshot must include ${key}`) + } + assert.equal(baseline.disk['typing-word-files'][0].bytes.length, 2943) + save('failed-upgrade-before.json', { + identity, + productVersion: launch.productVersion, + sha256: launch.sha256, + baseline, + external, + errors, + }) + await page.screenshot({ path: resolve(evidence, 'failed-upgrade-before.png') }) + console.log(`PASS: recorded isolated retain snapshot before failed upgrade on ${launch.productVersion}.`) + } else if (mode === 'retain-after') { + const expected = JSON.parse(readFileSync(resolve(evidence, 'failed-upgrade-before.json'), 'utf8')) + const actual = await snapshot() + assert.equal( + launch.productVersion, + expected.productVersion, + 'Failed upgrade must not change the installed product version' + ) + assert.equal(actual.base.noteData.fixture, expected.baseline.base.noteData.fixture) + assert.equal(actual.setting.wordSoundVolume, expected.baseline.setting.wordSoundVolume) + assert.deepEqual(actual.disk['typing-word-files'], expected.baseline.disk['typing-word-files']) + assert.equal( + JSON.parse(actual.disk['typing-word-setting']).val.wordSoundVolume, + expected.baseline.setting.wordSoundVolume + ) + assert.equal( + JSON.parse(actual.disk['typing-word-dict']).val.noteData.fixture, + expected.baseline.base.noteData.fixture + ) + save('failed-upgrade-after.json', { + identity, + productVersion: launch.productVersion, + sha256: launch.sha256, + fiveKeysRetained: true, + audioBytes: actual.disk['typing-word-files'][0].bytes.length, + external, + errors, + }) + await page.screenshot({ path: resolve(evidence, 'failed-upgrade-after.png') }) + console.log( + `PASS: isolated profile retained five keys and audio after failed upgrade; still ${launch.productVersion}.` + ) + } else { + const expected = JSON.parse(readFileSync(resolve(evidence, 'before-upgrade.json'), 'utf8')) + const actual = await snapshot() + assert.equal(actual.base.noteData.fixture, expected.baseline.base.noteData.fixture) + assert.equal(actual.setting.wordSoundVolume, expected.baseline.setting.wordSoundVolume) + assert.deepEqual(actual.disk['typing-word-files'], expected.baseline.disk['typing-word-files']) + assert.equal(JSON.parse(actual.disk['typing-word-setting']).val.wordSoundVolume, 0.37) + assert.equal(JSON.parse(actual.disk['typing-word-dict']).val.noteData.fixture, 'ZIP semantic round-trip') + save(`${mode}.json`, { + identity, + productVersion: launch.productVersion, + fiveKeysRetained: true, + audioBytes: actual.disk['typing-word-files'][0].bytes.length, + external, + errors, + }) + await page.screenshot({ path: resolve(evidence, `${mode}.png`) }) + console.log(`PASS: ${mode} retained five keys and audio after ${launch.productVersion}.`) + } + assert.deepEqual(errors, []) +} finally { + await context.unrouteAll({ behavior: 'wait' }).catch(() => {}) + await browser.close() +} diff --git a/tests/desktop/native-local-practice.mjs b/tests/desktop/native-local-practice.mjs new file mode 100644 index 000000000..1bde7a4be --- /dev/null +++ b/tests/desktop/native-local-practice.mjs @@ -0,0 +1,103 @@ +// Explicit installed-WebView practice check. Not part of the default Node suite. +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const evidence = resolve(process.argv[2] || '') +assert.ok(process.argv[2], 'Supply an evidence directory with an isolated launch.json') +const launch = JSON.parse(readFileSync(resolve(evidence, 'launch.json'), 'utf8').replace(/^\uFEFF/, '')) +assert.equal(resolve(launch.profile), resolve(evidence, 'isolated-profile')) +assert.equal(createHash('sha256').update(readFileSync(launch.exe)).digest('hex'), launch.sha256.toLowerCase()) +assert.ok(process.env.TYPEWORDS_PLAYWRIGHT_MODULE, 'Set TYPEWORDS_PLAYWRIGHT_MODULE') +const { chromium } = await import(pathToFileURL(process.env.TYPEWORDS_PLAYWRIGHT_MODULE).href) +const browser = await chromium.connectOverCDP(`http://127.0.0.1:${launch.port}`) +const context = browser.contexts()[0] +const page = context.pages().find(item => item.url().startsWith('http://tauri.localhost')) +assert.ok(page, 'Expected a production Tauri page') +const report = { sha256: launch.sha256, errors: [], external: [] } +page.on('pageerror', error => report.errors.push(error.message)) +await context.route(/^https:\/\//, route => { + report.external.push(route.request().url()) + return route.abort() +}) + +async function ready() { + await page.waitForFunction(() => { + const pinia = document.querySelector('#__nuxt')?.__vue_app__?.config.globalProperties.$pinia + return pinia?._s.get('base')?.load && pinia?._s.get('setting')?.load + }) +} + +const snapshotPractice = () => + page.evaluate(() => { + const pinia = document.querySelector('#__nuxt').__vue_app__.config.globalProperties.$pinia + const base = pinia._s.get('base') + const practice = pinia._s.get('practice') + return { + origin: location.origin, + href: location.href, + dictId: base.sdict?.id, + dictName: base.sdict?.name, + wordCount: base.sdict?.words?.length ?? 0, + lastLearnIndex: base.sdict?.lastLearnIndex, + currentWord: document.querySelector('.typing-word .letter')?.textContent?.trim() || '', + practiceIndex: (() => { + const data = document.querySelector('.typing-word') + return data ? true : false + })(), + inputWordNumber: practice?.inputWordNumber, + spend: practice?.spend, + } + }) + +try { + await page.goto('http://tauri.localhost/words') + await ready() + const before = await snapshotPractice() + report.before = before + assert.equal(before.origin, 'http://tauri.localhost') + assert.ok(before.wordCount > 0, 'Installed profile must have a dictionary with words') + const start = page.getByText(/^(开始学习|继续学习|开始系统练习|开始自由练习|开始自定义练习|继续自定义练习)/) + await start.first().click() + await page.waitForURL(/\/practice-words\//, { timeout: 15000 }) + await ready() + await page.waitForSelector('.typing-word', { timeout: 15000 }) + const started = await snapshotPractice() + report.started = started + let word = await page.evaluate(() => { + const pinia = document.querySelector('#__nuxt').__vue_app__.config.globalProperties.$pinia + const runtime = pinia._s.get('runtime') + return runtime?.editWord?.word || runtime?.currentWord?.word || '' + }) + if (!word) { + word = await page.locator('.typing-word .letter').first().innerText() + } + word = word.trim() + assert.ok(/^[A-Za-z][A-Za-z' -]*$/.test(word), `Expected a practice word, got ${JSON.stringify(word)}`) + report.typed = word + await page.evaluate(() => { + const pinia = document.querySelector('#__nuxt').__vue_app__.config.globalProperties.$pinia + const setting = pinia._s.get('setting') + if (setting) setting.showPanel = false + }) + await page.waitForTimeout(200) + await page.keyboard.type(word.replace(/[^A-Za-z]/g, ''), { delay: 50 }) + await page.keyboard.press('Space') + await page.waitForTimeout(800) + const after = await snapshotPractice() + report.after = after + assert.ok( + after.inputWordNumber > (started.inputWordNumber ?? 0) || + (await page.locator('.statistics, .stat, text=练习报告, text=再来一次').count()) > 0, + 'Typing the current word must advance practice or finish the session' + ) + assert.deepEqual(report.errors, []) + await page.screenshot({ path: resolve(evidence, 'native-local-practice.png') }) + report.passed = true + console.log(`PASS: installed 0.1.2 typed ${word} in ${started.dictName || started.dictId}; no page errors.`) +} finally { + writeFileSync(resolve(evidence, 'native-local-practice.json'), JSON.stringify(report, null, 2)) + await browser.close() +} diff --git a/tests/desktop/native-local-recovery.mjs b/tests/desktop/native-local-recovery.mjs new file mode 100644 index 000000000..f0b844191 --- /dev/null +++ b/tests/desktop/native-local-recovery.mjs @@ -0,0 +1,711 @@ +// Explicit native integration runner. Never included in the default Node suite. +// The caller must launch the current EXE with a fresh, recorded isolated profile. +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { createRequire } from 'node:module' +import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { backupSeedScript } from './backup-browser-seed.mjs' + +const [mode, evidencePath] = process.argv.slice(2) +assert.ok( + [ + 'prepare', + 'cancelled', + 'gate', + 'save-failure', + 'save-retry', + 'matrix', + 'cache-errors', + 'source-errors', + 'restart', + ].includes(mode), + 'Use prepare, cancelled, gate, save-failure, save-retry, matrix, cache-errors, source-errors or restart and an evidence directory' +) +assert.ok(evidencePath, 'An explicit evidence directory is required') +const evidence = resolve(evidencePath) +const launch = JSON.parse( + readFileSync( + resolve(evidence, existsSync(resolve(evidence, 'restart.json')) ? 'restart.json' : 'launch.json'), + 'utf8' + ).replace(/^\uFEFF/, '') +) +assert.equal(resolve(launch.profile), resolve(evidence, 'isolated-profile')) +assert.equal(createHash('sha256').update(readFileSync(launch.exe)).digest('hex'), launch.sha256.toLowerCase()) +const modulePath = process.env.TYPEWORDS_PLAYWRIGHT_MODULE +assert.ok(modulePath, 'Set TYPEWORDS_PLAYWRIGHT_MODULE to the installed Playwright module') +const { chromium } = await import(pathToFileURL(modulePath).href) +const browser = await chromium.connectOverCDP(`http://127.0.0.1:${launch.port}`) +const context = browser.contexts()[0] +const page = context.pages().find(page => page.url().startsWith('http://tauri.localhost')) +assert.ok(page, 'Expected the isolated production Tauri page') +const external = [] +const errors = [] +page.on('pageerror', error => errors.push(error.message)) +await context.route(/^https:\/\//, route => { + external.push(route.request().url()) + return route.abort() +}) +const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) +async function ready() { + await page.waitForFunction(() => { + const pinia = document.querySelector('#__nuxt')?.__vue_app__?.config.globalProperties.$pinia + return pinia?._s.get('base')?.load && pinia?._s.get('setting')?.load + }) +} +async function snapshot() { + return page.evaluate(async () => { + const request = indexedDB.open('keyval-store') + const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) + const keys = [ + 'typing-word-dict', + 'typing-word-setting', + 'PracticeSaveWord', + 'PracticeSaveArticle', + 'typing-word-files', + ] + const disk = {} + try { + const tx = db.transaction('keyval', 'readonly') + await Promise.all( + keys.map( + key => + new Promise((resolve, reject) => { + const request = tx.objectStore('keyval').get(key) + request.onsuccess = () => { + disk[key] = request.result ?? null + resolve() + } + request.onerror = () => reject(request.error) + }) + ) + ) + if (disk['typing-word-files']) { + disk['typing-word-files'] = await Promise.all( + disk['typing-word-files'].map(async item => ({ + id: item.id, + bytes: Array.from(new Uint8Array(await item.file.arrayBuffer())), + })) + ) + } + } finally { + db.close() + } + const pinia = document.querySelector('#__nuxt').__vue_app__.config.globalProperties.$pinia + const clean = state => { + const value = JSON.parse(JSON.stringify(state)) + delete value._ignoreWatch + return value + } + return { + disk, + base: clean(pinia._s.get('base').$state), + setting: clean(pinia._s.get('setting').$state), + globalLoading: pinia._s.get('runtime').globalLoading, + config: localStorage.getItem('supabase_config'), + } + }) +} +const save = (name, value) => writeFileSync(resolve(evidence, name), JSON.stringify(value, null, 2)) +const readonlyTarget = resolve(evidence, 'selected-readonly.zip') +let ownsReadonlyTarget = false +try { + if (['prepare', 'restart', 'cache-errors', 'source-errors'].includes(mode)) { + await page.goto('http://tauri.localhost/setting?index=5') + } + await ready() + const identity = await page.evaluate(() => ({ + origin: location.origin, + desktop: window.__NUXT__?.config?.public?.isDesktop, + ipc: typeof window.__TAURI_INTERNALS__?.invoke === 'function', + ua: navigator.userAgent, + })) + assert.equal(identity.origin, 'http://tauri.localhost') + assert.equal(identity.desktop, true) + assert.equal(identity.ipc, true) + if (mode === 'prepare') { + await page.goto('http://tauri.localhost/setting?index=5') + await ready() + const seeded = await page.evaluate(`(${backupSeedScript()})()`) + assert.equal(seeded.audioBytes, 2943, 'Synthetic seed must execute, not return a function') + await pause(1800) + await page.evaluate(() => + localStorage.setItem( + 'supabase_config', + JSON.stringify({ + url: 'https://synthetic-local-only.supabase.co', + key: 'synthetic-not-a-secret', + status: 'error', + error: 'synthetic previous failure', + }) + ) + ) + await page.reload() + await ready() + await pause(1600) + const baseline = await snapshot() + for (const [key, value] of Object.entries(baseline.disk)) { + assert.notEqual(value, null, `Seed must populate ${key}`) + } + assert.equal(baseline.base.noteData.fixture, 'ZIP semantic round-trip') + assert.equal(baseline.disk['typing-word-files'][0].bytes.length, seeded.audioBytes) + save('baseline.json', baseline) + await page.getByText(/^(Import Data Restore|导入数据恢复)$/).click() + await page.getByText('数据备份', { exact: true }).last().waitFor() + assert.equal(await page.locator('input[type=file]').count(), 0, 'Native backup must unlock import') + save('prepare.json', { identity, external, errors }) + console.log('Prepared actual stores and audio; backup gate locked. Complete native backup before matrix.') + } else if (mode === 'gate') { + await page.getByText(/^(Import Data Restore|导入数据恢复)$/).click() + await page.getByText('数据备份', { exact: true }).last().waitFor() + assert.equal(await page.locator('input[type=file]').count(), 0) + save('expected-restart.json', await snapshot()) + console.log('Backup gate reopened on current native candidate.') + } else if (mode === 'cancelled') { + assert.equal(await page.locator('input[type=file]').count(), 0) + assert.equal(await page.locator('.message.success').count(), 0) + assert.deepEqual(await snapshot(), JSON.parse(readFileSync(resolve(evidence, 'baseline.json')))) + await page.screenshot({ path: resolve(evidence, 'save-cancelled.png') }) + save('cancelled.json', { identity, gateLocked: true, dataUnchanged: true, successToast: false, external, errors }) + console.log('PASS: cancelled native save keeps gate locked, with no success or data changes.') + } else if (mode === 'save-failure' || mode === 'save-retry') { + const failing = mode === 'save-failure' + const target = failing ? readonlyTarget : resolve(evidence, 'retry-backup.zip') + assert.equal(existsSync(target), false, 'Never overwrite an existing evidence file') + const baseline = JSON.parse(readFileSync(resolve(evidence, 'baseline.json'))) + assert.deepEqual(await snapshot(), baseline) + assert.equal(await page.locator('input[type=file]').count(), 0) + if (!page.url().includes('/setting')) { + await page.goto('http://tauri.localhost/setting?index=5') + await ready() + assert.deepEqual(await snapshot(), baseline) + } + if ((await page.getByText('数据备份', { exact: true }).count()) < 2) { + await page + .getByText(/^(Import Data Restore|导入数据恢复)$/) + .first() + .click() + await page.getByText('数据备份', { exact: true }).last().waitFor() + } + // Observe real IPC and pause only the write, after the real picker grants its path. + // No dialog, permission result, filesystem call or error is replaced. + await page.evaluate(failing => { + const originalFetch = window.fetch + const urls = new Map( + ['plugin:dialog|save', 'plugin:fs|write_file'].map(command => [ + window.__TAURI_INTERNALS__.convertFileSrc(command, 'ipc'), + command, + ]) + ) + window.__nativeSaveProbe = { calls: [], release: !failing } + window.__nativeSaveRestore = () => { + window.fetch = originalFetch + } + const observedFetch = async function (url, options) { + const command = urls.get(String(url)) + if (!command) return originalFetch.call(this, url, options) + const state = window.__nativeSaveProbe + const call = { command } + state.calls.push(call) + if (command === 'plugin:fs|write_file') { + call.path = decodeURIComponent(new Headers(options.headers).get('path')) + state.waiting = true + const deadline = Date.now() + 30000 + while (!state.release) { + if (Date.now() > deadline) throw new Error('TEST write release timeout') + await new Promise(resolve => setTimeout(resolve, 25)) + } + } + const response = await originalFetch.call(this, url, options) + call.ok = response.headers.get('Tauri-Response') === 'ok' + const raw = await response.clone().text() + let value = raw + try { + value = JSON.parse(raw) + } catch {} + if (command === 'plugin:dialog|save' && call.ok) call.path = value + if (!call.ok) call.error = String(value) + return response + } + window.fetch = observedFetch + if (window.fetch !== observedFetch) throw new Error('Cannot install native save observer') + }, failing) + await page.getByText('数据备份', { exact: true }).last().click() + console.log(`Select this exact new path in the native Save dialog: ${target}`) + await page.waitForFunction(() => window.__nativeSaveProbe.waiting, null, { timeout: 600000 }) + const selected = await page.evaluate(() => window.__nativeSaveProbe.calls) + assert.equal(resolve(selected.find(call => call.command === 'plugin:dialog|save').path), target) + assert.equal(resolve(selected.find(call => call.command === 'plugin:fs|write_file').path), target) + const sentinel = 'Synthetic selected target: failed write must preserve these bytes.' + if (failing) { + writeFileSync(target, sentinel, { flag: 'wx' }) + ownsReadonlyTarget = true + chmodSync(target, 0o444) + await page.evaluate(() => (window.__nativeSaveProbe.release = true)) + } + const toast = page.locator(failing ? '.message.error' : '.message.success') + await toast.waitFor() + const text = await toast.innerText() + const probe = await page.evaluate(() => window.__nativeSaveProbe) + assert.equal(probe.calls.length, 2) + assert.equal(probe.calls[0].ok, true, 'Real picker returned a selected path') + assert.equal(probe.calls[1].ok, !failing) + if (failing) { + assert.match(probe.calls[1].error, /os error 5|access.*denied|拒绝访问/i) + assert.ok(text.includes(probe.calls[1].error), 'UI must display the actual native write failure') + assert.equal(readFileSync(target, 'utf8'), sentinel) + assert.equal(await page.locator('input[type=file]').count(), 0) + assert.equal(await page.locator('.message.success').count(), 0) + } else { + await page.locator('input[type=file]').waitFor({ state: 'attached' }) + const require = createRequire(import.meta.url) + const JSZip = require(resolve('public/libs/jszip.min.js')) + const zip = await JSZip.loadAsync(readFileSync(target)) + const backup = JSON.parse(await zip.file('data.json').async('string')) + assert.equal(backup.val.setting.val.wordSoundVolume, baseline.setting.wordSoundVolume) + assert.deepEqual(backup.val.dict.val.noteData, baseline.base.noteData) + for (const audio of baseline.disk['typing-word-files']) { + assert.deepEqual([...(await zip.file(`mp3/${audio.id}.mp3`).async('uint8array'))], audio.bytes) + } + } + await pause(1600) + assert.deepEqual(await snapshot(), baseline, 'Save failure/retry must preserve disk, audio and actual stores') + assert.deepEqual(errors, []) + assert.deepEqual(external, []) + await page.screenshot({ path: resolve(evidence, `${mode}.png`) }) + save(`${mode}.json`, { identity, probe, text, dataUnchanged: true, gateLocked: failing, external, errors }) + console.log( + `PASS: real native ${failing ? 'read-only write failure keeps gate locked' : 'save retry unlocks gate and produces a readable ZIP with original audio'}.` + ) + } else if (mode === 'matrix') { + await page.locator('input[type=file]').waitFor({ state: 'attached' }) + const before = await snapshot() + const baseline = JSON.parse( + readFileSync( + resolve( + evidence, + existsSync(resolve(evidence, 'expected-restart.json')) ? 'expected-restart.json' : 'baseline.json' + ) + ) + ) + assert.deepEqual(before, baseline, 'Native backup must not alter learning data') + const require = createRequire(import.meta.url) + const JSZip = require(resolve('public/libs/jszip.min.js')) + const fixture = JSON.parse(readFileSync(new URL('./fixtures/backup/backup-fixture.json', import.meta.url))) + // The historical round-trip fixture predates complete synthetic statistics. + for (const key of ['PracticeSaveWord', 'PracticeSaveArticle']) { + Object.assign(fixture.val[key].val.statStoreData, { stage: 0, inputWordNumber: 3 }) + } + fixture.val.dict.val.noteData.fixture = 'Native local recovery retry' + fixture.val.setting.val.wordSoundVolume = 61 + const audio = readFileSync(new URL('./fixtures/backup/backup-tone.mp3', import.meta.url)) + const zip = async (data, withAudio = true) => { + const archive = new JSZip().file('data.json', JSON.stringify(data)) + if (withAudio) archive.file('mp3/backup-tone.mp3', audio) + return archive.generateAsync({ type: 'nodebuffer' }) + } + const valid = await zip(fixture) + const cases = [ + ['corrupt-zip', Buffer.from('not a zip'), "Can't find end of central directory"], + ['unsupported-version', await zip({ ...fixture, version: 999 }), 'Invalid or unsupported backup schema'], + ['missing-audio', await zip(fixture, false), 'Missing backup audio'], + ] + const results = [] + for (const [name, buffer, message] of cases) { + await page.locator('input[type=file]').setInputFiles({ name: `${name}.zip`, mimeType: 'application/zip', buffer }) + const toast = page.locator('.message.error') + await toast.waitFor() + const text = await toast.innerText() + assert.ok(text.includes(message), `${name}: expected ${message}, received ${text}`) + await pause(1600) + assert.deepEqual(await snapshot(), before, `${name}: disk, audio and actual store state unchanged`) + assert.equal(await page.locator('.message.success').count(), 0, `${name}: no false success`) + results.push({ name, text, unchanged: true }) + save('matrix-partial.json', results) + await toast.waitFor({ state: 'detached' }) + } + await page.evaluate(() => { + const original = IDBObjectStore.prototype.put + window.__nativeAbortRestore = () => { + IDBObjectStore.prototype.put = original + } + window.__nativeAbort = { puts: 0, successes: 0, aborts: 0 } + IDBObjectStore.prototype.put = function (...args) { + const result = original.apply(this, args) + const state = window.__nativeAbort + state.puts++ + result.addEventListener( + 'success', + () => { + state.successes++ + if (state.successes === 2) { + this.transaction.addEventListener('abort', () => state.aborts++, { once: true }) + this.transaction.abort() + IDBObjectStore.prototype.put = original + } + }, + { once: true } + ) + return result + } + }) + await page + .locator('input[type=file]') + .setInputFiles({ name: 'valid.zip', mimeType: 'application/zip', buffer: valid }) + await page.locator('.message.error').waitFor() + const abortText = await page.locator('.message.error').innerText() + assert.ok(abortText.includes('Local backup transaction failed'), abortText) + await pause(1800) + const abort = await page.evaluate(() => window.__nativeAbort) + assert.deepEqual(abort, { puts: 5, successes: 2, aborts: 1 }) + assert.deepEqual( + await snapshot(), + before, + 'Real transaction abort restores actual memory and all five persisted keys' + ) + assert.equal(await page.locator('.message.success').count(), 0) + await page.screenshot({ path: resolve(evidence, 'transaction-abort.png') }) + results.push({ name: 'transaction-abort', text: abortText, ...abort, unchanged: true }) + await page.locator('.message.error').waitFor({ state: 'detached' }) + await page + .locator('input[type=file]') + .setInputFiles({ name: 'valid.zip', mimeType: 'application/zip', buffer: valid }) + await page.locator('.message.success').waitFor() + const successText = await page.locator('.message.success').innerText() + const after = await snapshot() + assert.equal(after.base.noteData.fixture, 'Native local recovery retry') + assert.equal(after.setting.wordSoundVolume, 61) + assert.equal(after.globalLoading, false) + assert.equal(after.config, before.config) + assert.deepEqual(after.disk['typing-word-files'], [{ id: 'backup-tone', bytes: [...audio] }]) + assert.equal(JSON.parse(after.disk['typing-word-setting']).val.wordSoundVolume, 61) + assert.equal(JSON.parse(after.disk['typing-word-dict']).val.noteData.fixture, 'Native local recovery retry') + assert.equal(await page.locator('input[type=file]').count(), 0, 'Successful import closes backup gate') + // An edit inside the debounce window must persist through the real subscriptions. + await page.evaluate(() => { + const p = document.querySelector('#__nuxt').__vue_app__.config.globalProperties.$pinia + p._s.get('setting').wordSoundVolume = 62 + p._s.get('base').noteData.fixture = 'Native local recovery edited' + }) + await pause(1600) + const edited = await snapshot() + assert.equal(JSON.parse(edited.disk['typing-word-setting']).val.wordSoundVolume, 62) + assert.equal(JSON.parse(edited.disk['typing-word-dict']).val.noteData.fixture, 'Native local recovery edited') + save('expected-restart.json', edited) + assert.deepEqual(external, [], 'No external request, including retained sync config') + assert.deepEqual(errors, []) + save('matrix.json', { identity, results, successText, editedAfterRetry: true, external, errors }) + await page.screenshot({ path: resolve(evidence, 'retry-success.png') }) + console.log('PASS: 3 invalid ZIP cases, real five-key abort, retry, actual store autosave and local-only config.') + } else if (mode === 'cache-errors') { + if (!page.url().includes('/setting')) { + await page.goto('http://tauri.localhost/setting?index=5') + await ready() + } + const before = await snapshot() + assert.ok(before.disk['typing-word-files']?.[0]?.bytes?.length === 2943, 'Isolated profile must keep custom audio') + assert.equal(await page.locator('input[type=file]').count(), 0, 'Start with the backup gate closed') + const unlockTarget = resolve(evidence, 'cache-ui-unlock.zip') + assert.equal(existsSync(unlockTarget), false, 'Never overwrite an existing evidence file') + if ((await page.getByText('数据备份', { exact: true }).count()) < 2) { + await page + .getByText(/^(Import Data Restore|导入数据恢复)$/) + .first() + .click() + await page.getByText('数据备份', { exact: true }).last().waitFor() + } + await page.evaluate(() => { + const originalFetch = window.fetch + const urls = new Map( + ['plugin:dialog|save', 'plugin:fs|write_file'].map(command => [ + window.__TAURI_INTERNALS__.convertFileSrc(command, 'ipc'), + command, + ]) + ) + window.__nativeSaveProbe = { calls: [] } + window.__nativeSaveRestore = () => { + window.fetch = originalFetch + } + const observedFetch = async function (url, options) { + const command = urls.get(String(url)) + if (!command) return originalFetch.call(this, url, options) + const call = { command } + window.__nativeSaveProbe.calls.push(call) + const response = await originalFetch.call(this, url, options) + call.ok = response.headers.get('Tauri-Response') === 'ok' + const raw = await response.clone().text() + let value = raw + try { + value = JSON.parse(raw) + } catch {} + if (command === 'plugin:dialog|save' && call.ok) call.path = value + if (command === 'plugin:fs|write_file') { + call.path = decodeURIComponent(new Headers(options.headers).get('path') || '') + } + if (!call.ok) call.error = String(value) + return response + } + window.fetch = observedFetch + if (window.fetch !== observedFetch) throw new Error('Cannot install native save observer') + }) + await page.getByText('数据备份', { exact: true }).last().click() + console.log(`Select this exact new path in the native Save dialog: ${unlockTarget}`) + await page.locator('input[type=file]').waitFor({ state: 'attached', timeout: 120000 }) + const unlockToast = page.locator('.message.success') + await unlockToast.waitFor() + const unlockText = await unlockToast.innerText() + const probe = await page.evaluate(() => window.__nativeSaveProbe) + assert.equal(probe.calls.length, 2) + assert.equal(probe.calls[0].ok, true, 'Real picker returned a selected path') + assert.equal(probe.calls[1].ok, true, 'Real write_file must unlock the gate') + assert.equal(resolve(probe.calls[0].path), unlockTarget) + assert.equal(resolve(probe.calls[1].path), unlockTarget) + assert.ok(existsSync(unlockTarget), 'Unlock must produce a real ZIP') + await unlockToast.waitFor({ state: 'detached' }) + assert.deepEqual(await snapshot(), before, 'Unlock export must not change disk, audio or stores') + const require = createRequire(import.meta.url) + const JSZip = require(resolve('public/libs/jszip.min.js')) + const fixture = JSON.parse(readFileSync(new URL('./fixtures/backup/backup-fixture.json', import.meta.url))) + for (const key of ['PracticeSaveWord', 'PracticeSaveArticle']) { + Object.assign(fixture.val[key].val.statStoreData, { stage: 0, inputWordNumber: 3 }) + } + const audio = readFileSync(new URL('./fixtures/backup/backup-tone.mp3', import.meta.url)) + const zip = async data => { + const archive = new JSZip().file('data.json', JSON.stringify(data)) + archive.file('mp3/backup-tone.mp3', audio) + return archive.generateAsync({ type: 'nodebuffer' }) + } + const clone = () => JSON.parse(JSON.stringify(fixture)) + const emptyWord = clone() + emptyWord.val.PracticeSaveWord.val.taskWordsStr.new = [''] + const negativeIndex = clone() + negativeIndex.val.PracticeSaveWord.val.practiceData.index = -1 + const shortcut = clone() + shortcut.val.setting.val.shortcutKeyMap = { ShowWord: 1 } + const emptyBooks = clone() + emptyBooks.val.dict.val.word.bookList = [] + const cases = [ + ['empty-cache-word', await zip(emptyWord), 'Invalid backup practice cache'], + ['negative-word-index', await zip(negativeIndex), 'Invalid backup practice cache'], + ['invalid-shortcut-map', await zip(shortcut), 'Invalid backup settings'], + ['empty-word-booklist', await zip(emptyBooks), 'Invalid backup dictionary'], + ] + const results = [] + for (const [name, buffer, message] of cases) { + await page.locator('input[type=file]').setInputFiles({ + name: `${name}.zip`, + mimeType: 'application/zip', + buffer, + }) + const toast = page.locator('.message.error') + await toast.waitFor() + const text = await toast.innerText() + assert.ok(text.includes(message), `${name}: expected ${message}, received ${text}`) + await pause(1600) + assert.deepEqual(await snapshot(), before, `${name}: disk, audio and actual store state unchanged`) + assert.equal(await page.locator('.message.success').count(), 0, `${name}: no false success`) + results.push({ name, text, unchanged: true }) + save('cache-errors-partial.json', results) + await toast.waitFor({ state: 'detached' }) + } + await page.keyboard.press('Escape') + await page.locator('input[type=file]').waitFor({ state: 'detached' }) + assert.deepEqual( + await snapshot(), + before, + 'Rejected cache/settings/dictionary ZIPs must leave isolated data intact' + ) + assert.deepEqual(external, [], 'No external request, including retained sync config') + assert.deepEqual(errors, []) + const tabs = await page.locator('.tab').allTextContents() + assert.equal(tabs.length, 9) + assert.ok(!tabs.some(text => /sync|同步/i.test(text))) + await page.screenshot({ path: resolve(evidence, 'cache-errors.png') }) + save('cache-errors.json', { + identity, + probe, + unlockText, + unlockBytes: readFileSync(unlockTarget).length, + results, + tabs, + note: before.base.noteData.fixture, + volume: before.setting.wordSoundVolume, + external, + errors, + }) + console.log('PASS: native cache/settings/dictionary reject UI on isolated 0.1.3; data unchanged.') + } else if (mode === 'source-errors') { + if (!page.url().includes('/setting')) { + await page.goto('http://tauri.localhost/setting?index=5') + await ready() + } + const before = await snapshot() + assert.ok(before.disk['typing-word-files']?.[0]?.bytes?.length === 2943, 'Isolated profile must keep custom audio') + assert.equal(await page.locator('input[type=file]').count(), 0, 'Start with the backup gate closed') + const unlockTarget = resolve(evidence, 'source-ui-unlock.zip') + assert.equal(existsSync(unlockTarget), false, 'Never overwrite an existing evidence file') + if ((await page.getByText('数据备份', { exact: true }).count()) < 2) { + await page + .getByText(/^(Import Data Restore|导入数据恢复)$/) + .first() + .click() + await page.getByText('数据备份', { exact: true }).last().waitFor() + } + await page.evaluate(() => { + const originalFetch = window.fetch + const urls = new Map( + ['plugin:dialog|save', 'plugin:fs|write_file'].map(command => [ + window.__TAURI_INTERNALS__.convertFileSrc(command, 'ipc'), + command, + ]) + ) + window.__nativeSaveProbe = { calls: [] } + window.__nativeSaveRestore = () => { + window.fetch = originalFetch + } + const observedFetch = async function (url, options) { + const command = urls.get(String(url)) + if (!command) return originalFetch.call(this, url, options) + const call = { command } + window.__nativeSaveProbe.calls.push(call) + const response = await originalFetch.call(this, url, options) + call.ok = response.headers.get('Tauri-Response') === 'ok' + const raw = await response.clone().text() + let value = raw + try { + value = JSON.parse(raw) + } catch {} + if (command === 'plugin:dialog|save' && call.ok) call.path = value + if (command === 'plugin:fs|write_file') { + call.path = decodeURIComponent(new Headers(options.headers).get('path') || '') + } + if (!call.ok) call.error = String(value) + return response + } + window.fetch = observedFetch + if (window.fetch !== observedFetch) throw new Error('Cannot install native save observer') + }) + await page.getByText('数据备份', { exact: true }).last().click() + console.log(`Select this exact new path in the native Save dialog: ${unlockTarget}`) + await page.locator('input[type=file]').waitFor({ state: 'attached', timeout: 120000 }) + const unlockToast = page.locator('.message.success') + await unlockToast.waitFor() + const unlockText = await unlockToast.innerText() + const probe = await page.evaluate(() => window.__nativeSaveProbe) + assert.equal(probe.calls.length, 2) + assert.equal(probe.calls[0].ok, true, 'Real picker returned a selected path') + assert.equal(probe.calls[1].ok, true, 'Real write_file must unlock the gate') + assert.equal(resolve(probe.calls[0].path), unlockTarget) + assert.equal(resolve(probe.calls[1].path), unlockTarget) + assert.ok(existsSync(unlockTarget), 'Unlock must produce a real ZIP') + await unlockToast.waitFor({ state: 'detached' }) + assert.deepEqual(await snapshot(), before, 'Unlock export must not change disk, audio or stores') + const require = createRequire(import.meta.url) + const JSZip = require(resolve('public/libs/jszip.min.js')) + const fixture = JSON.parse(readFileSync(new URL('./fixtures/backup/backup-fixture.json', import.meta.url))) + for (const key of ['PracticeSaveWord', 'PracticeSaveArticle']) { + Object.assign(fixture.val[key].val.statStoreData, { stage: 0, inputWordNumber: 3 }) + } + const audio = readFileSync(new URL('./fixtures/backup/backup-tone.mp3', import.meta.url)) + const zip = async data => { + const archive = new JSZip().file('data.json', JSON.stringify(data)) + archive.file('mp3/backup-tone.mp3', audio) + return archive.generateAsync({ type: 'nodebuffer' }) + } + const clone = () => JSON.parse(JSON.stringify(fixture)) + const emptyWord = clone() + const custom = emptyWord.val.dict.val.word.bookList.find(book => book.enName === 'backup-custom') + assert.ok(custom, 'Fixture must include the backup-custom word book') + custom.words = [{ word: '' }] + const inverted = clone() + inverted.val.setting.val.fsrsEasyLimit = 4 + inverted.val.setting.val.fsrsGoodLimit = 2 + const question = clone() + question.val.PracticeSaveWord.val.practiceData.question = { + candidates: [ + { word: { word: 'hello' }, similarity: 1 }, + { word: { word: 'world' }, similarity: 0.2 }, + ], + correctIndex: 1, + } + const loop = clone() + loop.val.PracticeSaveWord.val.sessionSnapshot.cursor.loop = { startIndex: 0, endIndex: 6, subStepIndex: 1 } + const cases = [ + ['empty-word-identifier', await zip(emptyWord), 'Invalid backup dictionary'], + ['inverted-fsrs-limits', await zip(inverted), 'Invalid backup settings'], + ['question-correct-mismatch', await zip(question), 'Invalid backup practice cache'], + ['cursor-loop-past-list', await zip(loop), 'Invalid backup practice cache'], + ] + const results = [] + for (const [name, buffer, message] of cases) { + await page.locator('input[type=file]').setInputFiles({ + name: `${name}.zip`, + mimeType: 'application/zip', + buffer, + }) + const toast = page.locator('.message.error') + await toast.waitFor() + const text = await toast.innerText() + assert.ok(text.includes(message), `${name}: expected ${message}, received ${text}`) + await pause(1600) + assert.deepEqual(await snapshot(), before, `${name}: disk, audio and actual store state unchanged`) + assert.equal(await page.locator('.message.success').count(), 0, `${name}: no false success`) + results.push({ name, text, unchanged: true }) + save('source-errors-partial.json', results) + await toast.waitFor({ state: 'detached' }) + } + await page.keyboard.press('Escape') + await page.locator('input[type=file]').waitFor({ state: 'detached' }) + assert.deepEqual(await snapshot(), before, 'Rejected source-boundary ZIPs must leave isolated data intact') + assert.deepEqual(external, [], 'No external request, including retained sync config') + assert.deepEqual(errors, []) + const tabs = await page.locator('.tab').allTextContents() + assert.equal(tabs.length, 9) + assert.ok(!tabs.some(text => /sync|同步/i.test(text))) + await page.screenshot({ path: resolve(evidence, 'source-errors.png') }) + save('source-errors.json', { + identity, + probe, + unlockText, + unlockBytes: readFileSync(unlockTarget).length, + results, + tabs, + note: before.base.noteData.fixture, + volume: before.setting.wordSoundVolume, + productVersion: launch.productVersion, + exeSha256: launch.sha256, + external, + errors, + }) + console.log('PASS: native source-boundary ZIP/settings/dictionary reject UI; data unchanged.') + } else { + await page.goto('http://tauri.localhost/setting?index=6') + await ready() + await pause(1600) + const expected = JSON.parse(readFileSync(resolve(evidence, 'expected-restart.json'))) + const actual = await snapshot() + assert.deepEqual(actual.disk, expected.disk, 'All five keys retained after normal native restart') + assert.equal(actual.setting.wordSoundVolume, 62) + assert.equal(actual.base.noteData.fixture, 'Native local recovery edited') + assert.equal(actual.config, expected.config) + const tabs = await page.locator('.tab').allTextContents() + assert.equal(tabs.length, 9) + assert.ok(!tabs.some(text => /sync|同步/i.test(text))) + await page.getByText(/^(Export Data Backup \(ZIP\)|导出数据备份\(ZIP\))$/).waitFor() + assert.deepEqual(external, []) + assert.deepEqual(errors, []) + save('restart-result.json', { identity, fiveKeysUnchanged: true, tabs, external, errors }) + await page.screenshot({ path: resolve(evidence, 'restart.png') }) + console.log('PASS: native restart, five persisted keys/audio, old sync link and retained config.') + } +} finally { + if (ownsReadonlyTarget) chmodSync(readonlyTarget, 0o666) + await page.evaluate(() => window.__nativeSaveRestore?.()).catch(() => {}) + await page.evaluate(() => window.__nativeAbortRestore?.()).catch(() => {}) + await context.unrouteAll({ behavior: 'wait' }) + await browser.close() +} diff --git a/tests/desktop/native-mailto.mjs b/tests/desktop/native-mailto.mjs new file mode 100644 index 000000000..bd647d10f --- /dev/null +++ b/tests/desktop/native-mailto.mjs @@ -0,0 +1,81 @@ +// Explicit release-WebView check for the product feedback mailbox. +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const evidence = resolve(process.argv[2] || '') +assert.ok(process.argv[2], 'Supply an evidence directory with an isolated launch.json') +const launch = JSON.parse(readFileSync(resolve(evidence, 'launch.json'), 'utf8').replace(/^\uFEFF/, '')) +assert.equal(resolve(launch.profile), resolve(evidence, 'isolated-profile')) +assert.equal(createHash('sha256').update(readFileSync(launch.exe)).digest('hex'), launch.sha256.toLowerCase()) +assert.ok(process.env.TYPEWORDS_PLAYWRIGHT_MODULE, 'Set TYPEWORDS_PLAYWRIGHT_MODULE') +const { chromium } = await import(pathToFileURL(process.env.TYPEWORDS_PLAYWRIGHT_MODULE).href) +const url = 'mailto:zyronon@163.com' +const browser = await chromium.connectOverCDP(`http://127.0.0.1:${launch.port}`) +const context = browser.contexts()[0] +const page = context.pages().find(item => item.url().startsWith('http://tauri.localhost')) +assert.ok(page, 'Expected a production Tauri page') +const createdPages = [] +const report = { sha256: launch.sha256, url, webviewRequests: [], errors: [] } +const onPage = opened => createdPages.push(opened.url()) +context.on('page', onPage) +try { + page.on('pageerror', error => report.errors.push(error.message)) + page.on('request', request => { + if (/163\.com|mailto:/i.test(request.url())) report.webviewRequests.push(request.url()) + }) + await page.goto('http://tauri.localhost/setting?index=9') + await page.waitForFunction(() => { + const pinia = document.querySelector('#__nuxt')?.__vue_app__?.config.globalProperties.$pinia + return pinia?._s.get('base')?.load && pinia?._s.get('setting')?.load + }) + const link = page.locator(`a[href="${url}"]`) + await link.waitFor({ state: 'visible' }) + await page.evaluate(() => { + const internals = window.__TAURI_INTERNALS__ + const originalFetch = window.fetch + const opener = internals.convertFileSrc('plugin:opener|open_url', 'ipc') + window.__externalOpenProbe = { fetches: [] } + window.fetch = async function (input, init) { + const href = String(input) + const response = await originalFetch.call(this, input, init) + if (href === opener || /plugin:opener\|open_url/.test(href)) { + const raw = await response.clone().text() + let body = raw + try { + body = JSON.parse(raw) + } catch {} + window.__externalOpenProbe.fetches.push({ + url: href, + ok: response.headers.get('Tauri-Response') === 'ok', + body, + }) + } + return response + } + }) + const original = page.url() + await link.click() + await page.waitForFunction( + () => window.__externalOpenProbe?.fetches?.some(item => item.ok), + null, + { timeout: 10000 } + ) + const probe = await page.evaluate(() => window.__externalOpenProbe) + report.probe = probe + report.identity = { href: page.url() } + assert.equal(page.url(), original) + assert.deepEqual(createdPages, []) + assert.deepEqual(report.webviewRequests, []) + assert.deepEqual(report.errors, []) + assert.equal(await page.locator('.message.error').count(), 0) + await page.screenshot({ path: resolve(evidence, 'native-mailto.png') }) + report.passed = true + console.log('PASS: About mailto opened via scoped opener; main document retained; no WebView mail page.') +} finally { + context.off('page', onPage) + writeFileSync(resolve(evidence, 'native-mailto.json'), JSON.stringify(report, null, 2)) + await browser.close() +} diff --git a/tests/desktop/native-save-dialog.ps1 b/tests/desktop/native-save-dialog.ps1 new file mode 100644 index 000000000..275668389 --- /dev/null +++ b/tests/desktop/native-save-dialog.ps1 @@ -0,0 +1,166 @@ +param( + [Parameter(Mandatory = $true)][string] $Path, + [int] $Seconds = 90, + [int] $PollMs = 200 +) +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName UIAutomationClient +Add-Type -AssemblyName UIAutomationTypes +Add-Type -AssemblyName System.Windows.Forms +Add-Type -TypeDefinition @" +using System; +using System.Text; +using System.Runtime.InteropServices; +public static class NativeSaveDialogWin { + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] + public static extern IntPtr SendMessage(IntPtr hWnd, uint msg, IntPtr wParam, string lParam); + [DllImport("user32.dll")] + public static extern IntPtr SendMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] + public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int maxCount); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] + public static extern int SendMessage(IntPtr hWnd, uint msg, IntPtr wParam, StringBuilder lParam); +} +"@ +$target = [System.IO.Path]::GetFullPath($Path) +if ([System.IO.File]::Exists($target)) { throw "Refusing to overwrite existing path: $target" } +$root = [System.Windows.Automation.AutomationElement]::RootElement +$windowType = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Window +) +$idCondition = { + param([string] $Id) + New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::AutomationIdProperty, + $Id + ) +} +$deadline = (Get-Date).AddSeconds($Seconds) + +function Test-SaveWindow([string] $Name) { + return $Name -match 'Save As|另存为|Save File' +} + +function Get-SaveWindow { + foreach ($proc in Get-Process typewords-desktop -ErrorAction SilentlyContinue) { + if ($proc.MainWindowHandle -eq [IntPtr]::Zero) { continue } + $hostWindow = [System.Windows.Automation.AutomationElement]::FromHandle($proc.MainWindowHandle) + if (-not $hostWindow) { continue } + foreach ($child in $hostWindow.FindAll([System.Windows.Automation.TreeScope]::Children, $windowType)) { + if (Test-SaveWindow $child.Current.Name) { return $child } + } + } + foreach ($window in $root.FindAll([System.Windows.Automation.TreeScope]::Children, $windowType)) { + if (Test-SaveWindow $window.Current.Name) { return $window } + } + return $null +} + +function Focus-Window($Window) { + $hwnd = [IntPtr]$Window.Current.NativeWindowHandle + if ($hwnd -eq [IntPtr]::Zero) { return } + [void][NativeSaveDialogWin]::ShowWindow($hwnd, 9) + [void][NativeSaveDialogWin]::SetForegroundWindow($hwnd) +} + +function Get-FileNameField($Window) { + $hostField = $Window.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + (& $idCondition 'FileNameControlHost') + ) + if (-not $hostField) { return $null } + $field = $hostField.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + (& $idCondition '1001') + ) + return $(if ($field) { $field } else { $hostField }) +} + +function Get-SaveButton($Window) { + foreach ($el in $Window.FindAll([System.Windows.Automation.TreeScope]::Descendants, (& $idCondition '1'))) { + if ($el.Current.ControlType.ProgrammaticName -match 'ListItem') { continue } + if ($el.Current.Name -match '保存|Save') { return $el } + } + return $null +} + +function Read-WindowText($Field) { + $hwnd = [IntPtr]$Field.Current.NativeWindowHandle + if ($hwnd -ne [IntPtr]::Zero) { + $text = New-Object System.Text.StringBuilder 2048 + [void][NativeSaveDialogWin]::SendMessage($hwnd, 0x000D, [IntPtr]2048, $text) + if ($text.Length -gt 0) { return $text.ToString() } + $fallback = New-Object System.Text.StringBuilder 2048 + [void][NativeSaveDialogWin]::GetWindowText($hwnd, $fallback, $fallback.Capacity) + if ($fallback.Length -gt 0) { return $fallback.ToString() } + } + if ($Field.GetSupportedPatterns().Contains([System.Windows.Automation.ValuePattern]::Pattern)) { + return $Field.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern).Current.Value + } + return $Field.Current.Name +} + +function Escape-SendKeys([string] $Value) { + return [regex]::Replace($Value, '[+^%~(){}]', { param($m) '{' + $m.Value + '}' }) +} + +function Set-FileName($Window, [string] $Value) { + $field = Get-FileNameField $Window + if (-not $field) { return $false } + $hwnd = [IntPtr]$field.Current.NativeWindowHandle + Focus-Window $Window + try { $field.SetFocus() } catch { } + Start-Sleep -Milliseconds 80 + if ($hwnd -ne [IntPtr]::Zero) { + [void][NativeSaveDialogWin]::SendMessage($hwnd, 0x000C, [IntPtr]::Zero, $Value) + } else { + [System.Windows.Forms.SendKeys]::SendWait('^a') + [System.Windows.Forms.SendKeys]::SendWait((Escape-SendKeys $Value)) + } + Start-Sleep -Milliseconds 80 + $current = Read-WindowText $field + if ($current -ne $Value) { + try { $field.SetFocus() } catch { } + [System.Windows.Forms.SendKeys]::SendWait('^a') + [System.Windows.Forms.SendKeys]::SendWait((Escape-SendKeys $Value)) + Start-Sleep -Milliseconds 80 + $current = Read-WindowText $field + } + if ($current -ne $Value) { + throw "Filename field is '$current', not the required full path" + } + return $true +} + +function Invoke-Save($Window) { + $save = Get-SaveButton $Window + if (-not $save) { return $null } + $hwnd = [IntPtr]$save.Current.NativeWindowHandle + if ($hwnd -ne [IntPtr]::Zero) { + [void][NativeSaveDialogWin]::SendMessage($hwnd, 0x00F5, [IntPtr]::Zero, [IntPtr]::Zero) + return $save.Current.Name + } + if ($save.GetSupportedPatterns().Contains([System.Windows.Automation.InvokePattern]::Pattern)) { + $save.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + return $save.Current.Name + } + return $null +} + +$used = $null +while ((Get-Date) -lt $deadline) { + $dialog = Get-SaveWindow + if ($dialog) { + if (-not (Set-FileName $dialog $target)) { throw 'Save dialog had no writable file-name field' } + $clicked = Invoke-Save $dialog + if (-not $clicked) { throw 'Save dialog had no Save button' } + $used = [pscustomobject]@{ path = $target; button = $clicked; window = $dialog.Current.Name } + break + } + Start-Sleep -Milliseconds $PollMs +} +if (-not $used) { throw "No Save As dialog appeared within $Seconds seconds" } +$used | ConvertTo-Json -Compress diff --git a/tests/desktop/native-save-with-dialog.mjs b/tests/desktop/native-save-with-dialog.mjs new file mode 100644 index 000000000..bbe94f85b --- /dev/null +++ b/tests/desktop/native-save-with-dialog.mjs @@ -0,0 +1,58 @@ +// Drive the real Save As dialog while an explicit native runner waits. +import { spawn } from 'node:child_process' +import { resolve } from 'node:path' + +const [evidencePath, mode] = process.argv.slice(2) +if (!evidencePath || !['save-failure', 'save-retry', 'cache-errors', 'source-errors'].includes(mode)) { + throw new Error('Usage: native-save-with-dialog.mjs save-failure|save-retry|cache-errors|source-errors') +} +const evidence = resolve(evidencePath) +const target = + mode === 'save-failure' + ? resolve(evidence, 'selected-readonly.zip') + : mode === 'cache-errors' + ? resolve(evidence, 'cache-ui-unlock.zip') + : mode === 'source-errors' + ? resolve(evidence, 'source-ui-unlock.zip') + : resolve(evidence, 'retry-backup.zip') +const dialog = spawn( + 'powershell.exe', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + resolve('tests/desktop/native-save-dialog.ps1'), + '-Path', + target, + '-Seconds', + '90', + ], + { windowsHide: true } +) +const dialogOut = [] +const dialogErr = [] +dialog.stdout.on('data', chunk => dialogOut.push(chunk)) +dialog.stderr.on('data', chunk => dialogErr.push(chunk)) +const runner = spawn(process.execPath, [resolve('tests/desktop/native-local-recovery.mjs'), mode, evidence], { + stdio: ['ignore', 'inherit', 'inherit'], + env: process.env, +}) +const [dialogCode, runnerCode] = await Promise.all([ + new Promise((resolveDone, reject) => { + dialog.on('error', reject) + dialog.on('exit', code => resolveDone(code ?? 1)) + }), + new Promise((resolveDone, reject) => { + runner.on('error', reject) + runner.on('exit', code => resolveDone(code ?? 1)) + }), +]) +if (runnerCode !== 0) process.exit(runnerCode) +if (dialogCode !== 0) { + console.log( + `Dialog helper exited ${dialogCode} after runner passed: ${Buffer.concat(dialogErr).toString('utf8') || Buffer.concat(dialogOut).toString('utf8')}` + ) +} else { + console.log(`Dialog selected ${target}: ${Buffer.concat(dialogOut).toString('utf8').trim()}`) +} diff --git a/tests/desktop/native-security.mjs b/tests/desktop/native-security.mjs new file mode 100644 index 000000000..a329e2e8b --- /dev/null +++ b/tests/desktop/native-security.mjs @@ -0,0 +1,174 @@ +// Explicit release-WebView integration test; not part of the Node unit suite. +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const evidence = resolve(process.argv[2] || '') +assert.ok(process.argv[2], 'Supply an evidence directory with an isolated launch.json') +const launch = JSON.parse(readFileSync(resolve(evidence, 'launch.json'), 'utf8').replace(/^\uFEFF/, '')) +assert.equal(resolve(launch.profile), resolve(evidence, 'isolated-profile')) +assert.equal(createHash('sha256').update(readFileSync(launch.exe)).digest('hex'), launch.sha256.toLowerCase()) +assert.ok(process.env.TYPEWORDS_PLAYWRIGHT_MODULE, 'Set TYPEWORDS_PLAYWRIGHT_MODULE') +const { chromium } = await import(pathToFileURL(process.env.TYPEWORDS_PLAYWRIGHT_MODULE).href) +const sentinel = resolve(evidence, 'unselected-sentinel.txt') +const directory = resolve(evidence, 'unselected-directory') +assert.equal(existsSync(directory), false) +const content = 'Synthetic native security sentinel. Must remain unchanged.' +if (existsSync(sentinel)) assert.equal(readFileSync(sentinel, 'utf8'), content) +else writeFileSync(sentinel, content, { flag: 'wx' }) +const report = { sha256: launch.sha256, ipc: [], navigation: [], popups: [], requests: [], errors: [] } +const server = createServer((request, response) => { + report.requests.push(request.url) + response.end('UNTRUSTED TEST DOCUMENT') +}) +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) +const browser = await chromium.connectOverCDP(`http://127.0.0.1:${launch.port}`) +const context = browser.contexts()[0] +const page = context.pages().find(page => page.url().startsWith('http://tauri.localhost')) +assert.ok(page, 'Expected a production Tauri page') +const client = await context.newCDPSession(page) +try { + page.on('pageerror', error => report.errors.push(error.message)) + await page.goto('http://tauri.localhost/setting?index=5') + await page.waitForFunction(() => { + const pinia = document.querySelector('#__nuxt')?.__vue_app__?.config.globalProperties.$pinia + return pinia?._s.get('base')?.load && pinia?._s.get('setting')?.load + }) + report.identity = await page.evaluate(() => ({ + origin: location.origin, + desktop: window.__NUXT__?.config?.public?.isDesktop, + ipc: typeof window.__TAURI_INTERNALS__?.invoke === 'function', + ua: navigator.userAgent, + })) + assert.equal(report.identity.desktop, true) + assert.equal(report.identity.ipc, true) + const cases = [ + ['read-unselected-file', 'plugin:fs|read_file', { path: sentinel }, /not allowed|denied/i], + ['create-unselected-directory', 'plugin:fs|mkdir', { path: directory }, /not allowed|denied/i], + ['open-unselected-path', 'plugin:opener|open_path', { path: sentinel }, /not allowed|denied/i], + ['ungranted-window-command', 'plugin:window|set_title', { title: 'Must not change' }, /not allowed|denied/i], + ...['http://github.com/', 'https://github.com.evil.invalid/', 'javascript:void(0)', 'file:///synthetic.txt'].map( + url => [`opener-${url}`, 'plugin:opener|open_url', { url }, /not allowed|denied|forbidden/i] + ), + ['write-unselected-file', 'plugin:fs|write_file', null, /forbidden path|not allowed|denied/i], + ] + for (const [name, command, args, expected] of cases) { + const result = await page.evaluate( + async ({ command, args, sentinel }) => { + try { + const operation = + command === 'plugin:fs|write_file' + ? window.__TAURI_INTERNALS__.invoke(command, new Uint8Array([80, 75]), { + headers: { path: encodeURIComponent(sentinel), options: '{}' }, + }) + : window.__TAURI_INTERNALS__.invoke(command, args) + let timer + try { + await Promise.race([ + operation, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('TEST IPC TIMEOUT')), 5000) + }), + ]) + } finally { + clearTimeout(timer) + } + return { rejected: false } + } catch (error) { + return { rejected: true, error: String(error) } + } + }, + { command, args, sentinel } + ) + report.ipc.push({ name, command, ...result }) + assert.equal(result.rejected, true, name) + assert.match(result.error, expected, `${name}: must be a permission/scope denial, not malformed IPC`) + assert.equal(readFileSync(sentinel, 'utf8'), content, `${name}: sentinel intact`) + assert.equal(existsSync(directory), false, `${name}: directory not created`) + } + const original = page.url() + const marker = 'native-security-document' + await page.evaluate(marker => (window.__securityDocumentMarker = marker), marker) + // CDP navigation bypasses the application's anchor/window.open interception. + for (const url of [ + `http://127.0.0.1:${server.address().port}/untrusted`, + 'data:text/html,UNTRUSTED TEST DOCUMENT', + pathToFileURL(sentinel).href, + ]) { + const result = await client.send('Page.navigate', { url }) + await new Promise(resolve => setTimeout(resolve, 500)) + assert.equal(page.url(), original, `Host must reject ${url}`) + assert.equal(await page.evaluate(() => window.__securityDocumentMarker), marker) + report.navigation.push({ url, result, originalDocumentRetained: true }) + } + // An isolated world has the browser's native window.open, not the app wrapper. + // A user gesture and windowOpen event distinguish host denial from an untested call. + await client.send('Page.enable') + const { frameTree } = await client.send('Page.getFrameTree') + const { executionContextId } = await client.send('Page.createIsolatedWorld', { + frameId: frameTree.frame.id, + worldName: 'typewords-native-popup-probe', + }) + const opened = [] + const createdPages = [] + const onWindowOpen = event => opened.push(event) + const onPage = page => createdPages.push(page.url()) + client.on('Page.windowOpen', onWindowOpen) + context.on('page', onPage) + try { + for (const url of [ + 'http://tauri.localhost/words', + `http://127.0.0.1:${server.address().port}/popup`, + 'data:text/html,UNTRUSTED POPUP', + ]) { + const before = opened.length + const result = await client.send('Runtime.callFunctionOn', { + executionContextId, + functionDeclaration: `function(url) { + if (!Function.prototype.toString.call(window.open).includes('[native code]')) { + throw new Error('Expected browser-native window.open'); + } + return window.open(url, '_blank') === null; + }`, + arguments: [{ value: url }], + returnByValue: true, + userGesture: true, + }) + assert.equal(result.exceptionDetails, undefined, JSON.stringify(result.exceptionDetails)) + await new Promise(resolve => setTimeout(resolve, 500)) + const events = opened.slice(before) + assert.equal(events.length, 1, `${url}: browser must observe one real popup attempt`) + assert.equal(events[0].url, url) + assert.equal(events[0].userGesture, true) + assert.equal(result.result.value, true, `${url}: no window handle returned`) + assert.deepEqual(createdPages, [], `${url}: no new WebView page`) + assert.equal(page.url(), original) + assert.equal(await page.evaluate(() => window.__securityDocumentMarker), marker) + report.popups.push({ url, event: events[0], noWindowHandle: true, originalDocumentRetained: true }) + } + } finally { + client.off('Page.windowOpen', onWindowOpen) + context.off('page', onPage) + } + // WebView2 can initiate a request before the host cancels document navigation. + // The security contract here is document isolation, not a network firewall. + assert.deepEqual(report.errors, []) + await page.reload() + await page.waitForFunction(() => window.__TAURI_INTERNALS__ && document.querySelector('.tab')) + assert.equal(new URL(page.url()).origin, 'http://tauri.localhost') + report.localReload = true + await page.screenshot({ path: resolve(evidence, 'native-security.png') }) + report.passed = true + console.log( + `PASS: ${report.ipc.length} real IPC denials, ${report.navigation.length} host navigation denials, ${report.popups.length} native popup denials, local reload.` + ) +} finally { + writeFileSync(resolve(evidence, 'native-security.json'), JSON.stringify(report, null, 2)) + await client.detach().catch(() => {}) + await browser.close() + server.closeAllConnections() + await new Promise(resolve => server.close(resolve)) +} diff --git a/tests/desktop/native-source-errors.test.mjs b/tests/desktop/native-source-errors.test.mjs new file mode 100644 index 000000000..a69cb6e20 --- /dev/null +++ b/tests/desktop/native-source-errors.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import test from 'node:test' + +const root = resolve(process.env.TYPEWORDS_TEST_ROOT || process.cwd()) + +test('native recovery runner and dialog wrapper list the source-errors mode', () => { + const runner = readFileSync(resolve(root, 'tests/desktop/native-local-recovery.mjs'), 'utf8') + const wrapper = readFileSync(resolve(root, 'tests/desktop/native-save-with-dialog.mjs'), 'utf8') + assert.match(runner, /source-errors/) + assert.match(wrapper, /source-errors/) + assert.match(wrapper, /source-ui-unlock\.zip/) + assert.match(runner, /cache-errors', 'source-errors'/) + assert.match(runner, /http:\/\/tauri\.localhost\/setting\?index=5/) + assert.ok( + runner.includes("'empty-word-identifier'") && + runner.includes("'inverted-fsrs-limits'") && + runner.includes("'question-correct-mismatch'") && + runner.includes("'cursor-loop-past-list'"), + 'source-errors must exercise the batch 60-62 ZIP/settings/dictionary boundaries' + ) + assert.match(runner, /word: ''/) + assert.match(runner, /fsrsEasyLimit = 4/) + assert.match(runner, /correctIndex: 1/) + assert.match(runner, /endIndex: 6/) + assert.match(runner, /Invalid backup dictionary/) + assert.match(runner, /Invalid backup settings/) + assert.match(runner, /Invalid backup practice cache/) +}) diff --git a/tests/desktop/native-youdao.mjs b/tests/desktop/native-youdao.mjs new file mode 100644 index 000000000..a1d095a8b --- /dev/null +++ b/tests/desktop/native-youdao.mjs @@ -0,0 +1,297 @@ +// Explicit installed-WebView check: practice lookup → 有道词典. +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const evidence = resolve(process.argv[2] || '') +assert.ok(process.argv[2], 'Supply an evidence directory with an isolated launch.json') +const launch = JSON.parse(readFileSync(resolve(evidence, 'launch.json'), 'utf8').replace(/^\uFEFF/, '')) +assert.equal(resolve(launch.profile), resolve(evidence, 'isolated-profile')) +assert.equal(createHash('sha256').update(readFileSync(launch.exe)).digest('hex'), launch.sha256.toLowerCase()) +assert.ok(process.env.TYPEWORDS_PLAYWRIGHT_MODULE, 'Set TYPEWORDS_PLAYWRIGHT_MODULE') +const { chromium } = await import(pathToFileURL(process.env.TYPEWORDS_PLAYWRIGHT_MODULE).href) +const observeScript = resolve('tests/desktop/native-external-observe.ps1') +const observeFile = resolve(evidence, 'native-youdao-observe.json') +const hostPattern = /youdao\.com/i +const extraMarkers = ['有道', 'youdao', '词典', 'Youdao', '无法访问'] +const asArray = value => (value == null ? [] : Array.isArray(value) ? value : [value]) +const tabText = tab => `${tab?.name || ''} ${tab?.window || ''}` +const matchingTabs = tabs => + asArray(tabs).filter(tab => { + const text = tabText(tab) + return hostPattern.test(text) || extraMarkers.some(marker => text.includes(marker)) + }) +const report = { sha256: launch.sha256, webviewRequests: [], errors: [] } + +function powershell(args) { + return new Promise((resolvePromise, reject) => { + const child = spawn('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ...args], { + windowsHide: true, + }) + const stdout = [] + const stderr = [] + child.stdout.on('data', chunk => stdout.push(chunk)) + child.stderr.on('data', chunk => stderr.push(chunk)) + child.on('error', reject) + child.on('exit', code => { + const result = { + code, + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + } + if (code === 0) resolvePromise(result) + else reject(new Error(`powershell ${args[0]} exited ${code}: ${result.stderr || result.stdout}`)) + }) + }) +} + +function startWatch(url) { + const child = spawn( + 'powershell.exe', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + observeScript, + '-Mode', + 'Watch', + '-Url', + url, + '-OutFile', + observeFile, + '-Seconds', + '20', + ], + { windowsHide: true } + ) + const stderr = [] + child.stderr.on('data', chunk => stderr.push(chunk)) + const done = new Promise((resolvePromise, reject) => { + child.on('error', reject) + child.on('exit', code => { + if (code === 0) resolvePromise() + else reject(new Error(`observe watch exited ${code}: ${Buffer.concat(stderr).toString('utf8')}`)) + }) + }) + return { child, done } +} + +async function connect() { + const deadline = Date.now() + 30000 + let last + while (Date.now() < deadline) { + try { + return await chromium.connectOverCDP(`http://127.0.0.1:${launch.port}`) + } catch (error) { + last = error + await new Promise(resolveWait => setTimeout(resolveWait, 250)) + } + } + throw last +} + +async function ready(page) { + await page.waitForFunction(() => { + const pinia = document.querySelector('#__nuxt')?.__vue_app__?.config.globalProperties.$pinia + return pinia?._s.get('base')?.load && pinia?._s.get('setting')?.load + }) +} + +function installProbe(page, marker) { + return page.evaluate(value => { + window.__externalDocumentMarker = value + const internals = window.__TAURI_INTERNALS__ + const originalInvoke = internals.invoke.bind(internals) + const originalFetch = window.fetch + const opener = internals.convertFileSrc('plugin:opener|open_url', 'ipc') + window.__externalOpenProbe = { invokes: [], fetches: [] } + internals.invoke = async function (command, args, options) { + try { + const result = await originalInvoke(command, args, options) + window.__externalOpenProbe.invokes.push({ command, args, ok: true }) + return result + } catch (error) { + window.__externalOpenProbe.invokes.push({ command, args, ok: false, error: String(error) }) + throw error + } + } + window.fetch = async function (input, init) { + const href = String(input) + const response = await originalFetch.call(this, input, init) + if (href === opener || /plugin:opener\|open_url/.test(href)) { + const raw = await response.clone().text() + let body = raw + try { + body = JSON.parse(raw) + } catch {} + window.__externalOpenProbe.fetches.push({ + url: href, + ok: response.headers.get('Tauri-Response') === 'ok', + body, + }) + } + return response + } + if (window.fetch === originalFetch) throw new Error('Cannot install opener observer') + }, marker) +} + +const browser = await connect() +const context = browser.contexts()[0] +const page = context.pages().find(item => item.url().startsWith('http://tauri.localhost')) +assert.ok(page, 'Expected a production Tauri page') +const createdPages = [] +const onPage = opened => createdPages.push(opened.url()) +context.on('page', onPage) +let watch +try { + page.on('pageerror', error => report.errors.push(error.message)) + page.on('request', request => { + if (/^https:\/\/www\.youdao\.com\b/i.test(request.url())) report.webviewRequests.push(request.url()) + }) + await page.goto('http://tauri.localhost/words') + await ready(page) + const start = page.getByText(/^(开始学习|继续学习|开始系统练习|开始自由练习|开始自定义练习|继续自定义练习)/) + await start.first().click() + await page.waitForURL(/\/practice-words\//, { timeout: 15000 }) + await ready(page) + await page.waitForSelector('.typing-word', { timeout: 15000 }) + await page.evaluate(() => { + const pinia = document.querySelector('#__nuxt').__vue_app__.config.globalProperties.$pinia + const setting = pinia._s.get('setting') + if (setting) { + setting.showPanel = true + setting.showToolbar = true + } + }) + await page.waitForTimeout(300) + report.identity = await page.evaluate(() => ({ + origin: location.origin, + href: location.href, + desktop: window.__NUXT__?.config?.public?.isDesktop, + ipc: typeof window.__TAURI_INTERNALS__?.invoke === 'function', + })) + assert.equal(report.identity.desktop, true) + assert.equal(report.identity.ipc, true) + let clickable = page.locator('.clickable-word').first() + if ((await page.locator('.clickable-word').count()) === 0) { + report.path = 'book-detail-lookup' + await page.goto('http://tauri.localhost/book-list') + await ready(page) + await page.locator('#dict-246').waitFor({ state: 'visible', timeout: 15000 }) + await page.locator('#dict-246').click() + await page.waitForURL(/\/book\/246\b/, { timeout: 15000 }) + await ready(page) + await page.getByText('Excuse me!').first().waitFor({ state: 'visible', timeout: 20000 }) + await page.getByText('Excuse me!').nth(0).click() + await page.waitForTimeout(300) + clickable = page.locator('.clickable-word').first() + } else { + report.path = 'practice-lookup' + } + await clickable.waitFor({ state: 'visible', timeout: 15000 }) + report.identity = await page.evaluate(() => ({ + origin: location.origin, + href: location.href, + desktop: window.__NUXT__?.config?.public?.isDesktop, + ipc: typeof window.__TAURI_INTERNALS__?.invoke === 'function', + })) + assert.equal(report.identity.desktop, true) + assert.equal(report.identity.ipc, true) + report.lookupWord = (await clickable.innerText()).trim() + await clickable.click() + const youdaoButton = page.locator('.word-lookup-popover').getByText('有道词典', { exact: true }) + await youdaoButton.waitFor({ state: 'visible', timeout: 10000 }) + report.popover = await page.evaluate(() => ({ + visible: !!document.querySelector('.word-lookup-popover'), + text: document.querySelector('.word-lookup-popover')?.textContent || '', + })) + assert.match(report.popover.text, /暂未收录该单词/) + const expectedUrl = `https://www.youdao.com/result?word=${encodeURIComponent(report.lookupWord)}&lang=en` + report.url = expectedUrl + const marker = 'native-youdao-document' + await installProbe(page, marker) + watch = startWatch(expectedUrl) + const readyUntil = Date.now() + 8000 + while (Date.now() < readyUntil) { + if (existsSync(observeFile)) { + try { + const current = JSON.parse(readFileSync(observeFile, 'utf8').replace(/^\uFEFF/, '')) + if (current.uiaReady) break + } catch {} + } + await new Promise(resolveWait => setTimeout(resolveWait, 150)) + } + const original = page.url() + await youdaoButton.click() + await page.waitForFunction( + () => { + const probe = window.__externalOpenProbe + return ( + probe?.invokes?.some(item => item.command === 'plugin:opener|open_url' && item.ok) || + probe?.fetches?.some(item => item.ok) + ) + }, + null, + { timeout: 10000 } + ) + await watch.done + const observe = existsSync(observeFile) ? JSON.parse(readFileSync(observeFile, 'utf8').replace(/^\uFEFF/, '')) : null + const probe = await page.evaluate(() => window.__externalOpenProbe) + report.probe = probe + report.observe = observe + const invoke = asArray(probe.invokes).find(item => item.command === 'plugin:opener|open_url') + const fetched = asArray(probe.fetches).find(item => item.ok) + assert.ok(invoke || fetched, '有道词典 must call the native opener') + if (invoke) { + assert.match(String(invoke.args?.url || ''), /https:\/\/www\.youdao\.com\/result\?word=/) + } + assert.equal(page.url(), original) + assert.equal(await page.evaluate(() => window.__externalDocumentMarker), marker) + assert.deepEqual(createdPages, [], 'Host must not open a WebView page for Youdao') + assert.deepEqual(report.webviewRequests, [], 'www.youdao.com must not load inside the privileged WebView') + assert.deepEqual(report.errors, []) + assert.equal(await page.locator('.message.error').count(), 0) + if (report.lookupWord && !extraMarkers.includes(report.lookupWord)) extraMarkers.push(report.lookupWord) + const processHits = asArray(observe?.processes) + const tabHits = matchingTabs(observe?.newTabs) + const retargetHits = matchingTabs(observe?.retargetedTabs) + const windowHits = asArray(observe?.newWindows) + const existingHits = matchingTabs(observe?.existingMatches) + assert.ok( + processHits.length > 0 || + tabHits.length > 0 || + retargetHits.length > 0 || + windowHits.length > 0 || + existingHits.length > 0, + 'System browser must receive the Youdao URL; opener IPC success is not enough' + ) + await page.screenshot({ path: resolve(evidence, 'native-youdao.png') }) + report.systemBrowser = { + httpsProgId: observe?.httpsProgId ?? null, + processes: processHits.length, + newTabs: tabHits.length, + retargetedTabs: retargetHits.length, + newWindows: windowHits.length, + existingMatches: existingHits.length, + } + try { + await powershell([observeScript, '-Mode', 'Close', '-Url', expectedUrl, '-OutFile', observeFile]) + report.observe = JSON.parse(readFileSync(observeFile, 'utf8').replace(/^\uFEFF/, '')) + } catch (error) { + report.closeError = String(error) + } + report.passed = true + console.log( + `PASS: practice lookup 有道词典 opened ${expectedUrl}; system browser evidence processes=${processHits.length} tabs=${tabHits.length} retargeted=${retargetHits.length} windows=${windowHits.length} existing=${existingHits.length}; main document retained.` + ) +} finally { + context.off('page', onPage) + if (watch?.child && watch.child.exitCode == null) watch.child.kill() + writeFileSync(resolve(evidence, 'native-youdao.json'), JSON.stringify(report, null, 2)) + await browser.close() +} diff --git a/tests/desktop/nce-sample.test.mjs b/tests/desktop/nce-sample.test.mjs new file mode 100644 index 000000000..65d82c74f --- /dev/null +++ b/tests/desktop/nce-sample.test.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import test from 'node:test' + +const root = resolve(process.env.TYPEWORDS_TEST_ROOT || '.') +const read = path => JSON.parse(readFileSync(resolve(root, path), 'utf8')) +for (const name of ['article', 'recommend_article']) { + test(`${name} advertises the existing NCE1 content as a five-article sample`, () => { + const book = read(`public/list/${name}.json`).find(book => book.id === 246) + assert.ok(book) + assert.equal(book.name, '新概念英语1(示例,5篇)') + assert.match(book.description, /仅含 5 篇示例文章,非完整教材/) + assert.equal(book.enName, 'nce1') + assert.equal(book.url, 'NCE_1.json') + assert.equal(book.version, 1) + const rows = read('public/dicts/en/article/NCE_1.json') + assert.equal(rows.length, 5) + assert.equal(book.length, rows.length) + }) +} diff --git a/tests/desktop/practice-reload.test.mjs b/tests/desktop/practice-reload.test.mjs new file mode 100644 index 000000000..924158665 --- /dev/null +++ b/tests/desktop/practice-reload.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { runInNewContext } from 'node:vm' +import test from 'node:test' +import ts from 'typescript' + +const root = process.env.TYPEWORDS_TEST_ROOT || process.cwd() +const page = readFileSync(resolve(root, 'app/pages/(words)/practice-words/[id].vue'), 'utf8') +const utils = readFileSync(resolve(root, 'app/core/utils/index.ts'), 'utf8') +// Execute the actual page loader and existing identity helpers, not a copied implementation. +function declaration(source, name) { + const file = ts.createSourceFile('fixture.ts', source, ts.ScriptTarget.Latest, true) + const node = file.statements.find(n => ts.isFunctionDeclaration(n) && n.name?.text === name) + assert.ok(node, `missing ${name}`) + return node.getText(file).replace(/^export /, '') +} +async function load({ id = '1', personal = [], catalog = [{ id: 1, words: [] }] } = {}) { + const events = [] + const store = { word: { bookList: personal }, changeDict: d => events.push(['dict', d.id]) } + const context = { + store, + route: { params: { id } }, + getDefaultDict: () => ({}), + fetch: async () => ({ json: async () => catalog }), + resourceWrap: x => x, + DICT_LIST: { WORD: { ALL: '/list/word.json' } }, + _getDictDataByUrl: async d => ({ ...d, words: [{ word: 'cancel' }] }), + initData: async () => events.push(['cache']), + loading: true, + router: { push: url => events.push(['redirect', url]) }, + Toast: { warning: x => events.push(['warning', x]) }, + } + const source = + ['normalizeDictId', 'getDictIdentityList', 'isDictIdMatch'].map(n => declaration(utils, n)).join('\n') + + '\n' + + declaration(page.match(/