Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/desktop.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
9 changes: 9 additions & 0 deletions app/assets/css/web-fonts.css
Original file line number Diff line number Diff line change
@@ -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');
}
12 changes: 0 additions & 12 deletions app/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand All @@ -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']
Expand Down Expand Up @@ -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']
Expand All @@ -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']
Expand All @@ -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']
Expand Down Expand Up @@ -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']
Expand Down
4 changes: 3 additions & 1 deletion app/components/article/EditArticle.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
}
Expand Down Expand Up @@ -801,7 +804,6 @@ function minusStartTime(val: Sentence) {
}
}


@media (max-width: 768px) {
.content {
flex-direction: column;
Expand Down
5 changes: 3 additions & 2 deletions app/components/channel-icons/ChannelIcons.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down Expand Up @@ -54,10 +55,10 @@ const qqQrSrc = withAppBaseURL('/imgs/channel/qq.jpg')
</a>

<a
href="mailto:zyronon@163.com"
:href="`mailto:${EMAIL}`"
target="_blank"
rel="noreferrer"
:aria-label="$t('send_email', { email: 'zyronon@163.com' })"
:aria-label="$t('send_email', { email: EMAIL })"
>
<BaseIcon :title="$t('email')">
<IconMaterialSymbolsMail class="color-blue" />
Expand Down
17 changes: 12 additions & 5 deletions app/components/dialog/BackupGateDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
</script>

Expand Down
107 changes: 107 additions & 0 deletions app/core/composables/backupImport.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> => 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<BackupData['val']> {
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<string>()
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<string>()
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 }
}
Loading