feat: strengthen backup and mobile workflows
ci / gitleaks (push) Successful in 1m19s
ci / docker (push) Successful in 5m48s

This commit is contained in:
2026-09-16 21:12:52 +08:00
parent 6f38190c92
commit 6c234d7d82
64 changed files with 5059 additions and 635 deletions
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { backupFileSnapshot, isCurrentBackupSnapshot, shouldCommitBackupPreflight } from './backup-preflight-state'
describe('backup preflight identity', () => {
it('rejects a preflight result after the selected file changes even when the old request finishes last', () => {
const oldFile = new File(['old'], 'old.zip', { lastModified: 10 })
const newFile = new File(['new'], 'new.zip', { lastModified: 20 })
const started = backupFileSnapshot(oldFile, 'merge')
expect(isCurrentBackupSnapshot(started, newFile, 'merge')).toBe(false)
})
it('does not let a token for one filename authorize another file with matching metadata', () => {
const first = new File(['same'], 'first.zip', { lastModified: 10 })
const second = new File(['same'], 'second.zip', { lastModified: 10 })
expect(isCurrentBackupSnapshot(backupFileSnapshot(first, 'merge'), second, 'merge')).toBe(false)
})
it('invalidates a preflight when restore mode changes', () => {
const file = new File(['zip'], 'backup.zip', { lastModified: 10 })
expect(isCurrentBackupSnapshot(backupFileSnapshot(file, 'merge'), file, 'replace')).toBe(false)
})
it('rejects an older generation even when its file snapshot still matches', () => {
const file = new File(['zip'], 'backup.zip', { lastModified: 10 })
expect(shouldCommitBackupPreflight(1, 2, backupFileSnapshot(file, 'merge'), file, 'merge')).toBe(false)
expect(shouldCommitBackupPreflight(2, 2, backupFileSnapshot(file, 'merge'), file, 'merge')).toBe(true)
})
it('binds identity to the exact File object as well as name size and modified time', () => {
const first = new File(['same'], 'backup.zip', { lastModified: 10 })
const replacement = new File(['same'], 'backup.zip', { lastModified: 10 })
expect(isCurrentBackupSnapshot(backupFileSnapshot(first, 'merge'), replacement, 'merge')).toBe(false)
expect(isCurrentBackupSnapshot(backupFileSnapshot(first, 'merge'), first, 'merge')).toBe(true)
})
})
@@ -0,0 +1,36 @@
import type { BackupMode } from '../api'
export type BackupFileSnapshot = {
file: File
name: string
size: number
lastModified: number
mode: BackupMode
}
export function backupFileSnapshot(file: File, mode: BackupMode): BackupFileSnapshot {
return { file, name: file.name, size: file.size, lastModified: file.lastModified, mode }
}
export function isCurrentBackupSnapshot(snapshot: BackupFileSnapshot, file: File | null, mode: BackupMode) {
return Boolean(file)
&& snapshot.file === file
&& snapshot.name === file!.name
&& snapshot.size === file!.size
&& snapshot.lastModified === file!.lastModified
&& snapshot.mode === mode
}
export function shouldCommitBackupPreflight(
generation: number,
currentGeneration: number,
snapshot: BackupFileSnapshot,
file: File | null,
mode: BackupMode,
) {
return generation === currentGeneration && isCurrentBackupSnapshot(snapshot, file, mode)
}
export function isLegacyBackup(file: File) {
return !file.name.toLowerCase().endsWith('.zip')
}
-15
View File
@@ -1,15 +0,0 @@
import { describe, expect, it } from 'vitest'
import { nextDialogFocusIndex } from './list-purge'
describe('archived list purge dialog behavior', () => {
it('wraps Tab focus between the cancel and destructive actions', () => {
expect(nextDialogFocusIndex(0, 2, true)).toBe(1)
expect(nextDialogFocusIndex(1, 2, false)).toBe(0)
})
it('leaves focus alone while moving between interior controls', () => {
expect(nextDialogFocusIndex(1, 3, true)).toBeNull()
expect(nextDialogFocusIndex(1, 3, false)).toBeNull()
expect(nextDialogFocusIndex(0, 0, false)).toBeNull()
})
})
-6
View File
@@ -1,6 +0,0 @@
export function nextDialogFocusIndex(currentIndex: number, controlCount: number, shiftKey: boolean) {
if (controlCount < 2) return null
if (shiftKey && currentIndex === 0) return controlCount - 1
if (!shiftKey && currentIndex === controlCount - 1) return 0
return null
}