mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
PR #428 added a General Settings zoom slider after the desktop shortcut work already introduced a native-first app zoom controller. Keeping both paths would create double scaling and stale UI state, so the slider now routes through the existing controller and store state while the app shell no longer applies a second CSS zoom. Constraint: UI zoom is device-local display state and should not be written into shared user settings. Rejected: Keep cc-haha-ui-zoom plus AppShell style.zoom | it conflicts with shortcut zoom and multiplies visual scale. Rejected: Persist zoom through /api/settings/user | it would sync display-specific state across machines. Confidence: high Scope-risk: moderate Directive: Keep app zoom behind desktop/src/lib/appZoom.ts; do not add another storage key or DOM zoom application point without migration and shortcut sync tests. Tested: cd desktop && bun run test -- --run src/lib/appZoom.test.ts src/hooks/useKeyboardShortcuts.test.tsx src/stores/settingsStore.test.ts src/__tests__/generalSettings.test.tsx src/lib/persistenceMigrations.test.ts src/lib/doctorRepair.test.ts Tested: bun run check:desktop Not-tested: Real Windows/Linux desktop runtime smoke.
179 lines
5.8 KiB
TypeScript
179 lines
5.8 KiB
TypeScript
import { THEME_MODES } from '../types/settings'
|
|
import {
|
|
APP_ZOOM_STORAGE_KEY,
|
|
LEGACY_UI_ZOOM_STORAGE_KEY,
|
|
isValidStoredAppZoomLevel,
|
|
normalizeAppZoomLevel,
|
|
} from './appZoom'
|
|
|
|
export const CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION = 1
|
|
export const DESKTOP_PERSISTENCE_VERSION_KEY = 'cc-haha.persistence.schemaVersion'
|
|
|
|
type DesktopMigrationReport = {
|
|
migratedKeys: string[]
|
|
}
|
|
|
|
type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
|
|
|
|
const TAB_STORAGE_KEY = 'cc-haha-open-tabs'
|
|
const SESSION_RUNTIME_STORAGE_KEY = 'cc-haha-session-runtime'
|
|
const THEME_STORAGE_KEY = 'cc-haha-theme'
|
|
const LOCALE_STORAGE_KEY = 'cc-haha-locale'
|
|
|
|
function readJson(storage: StorageLike, key: string): unknown {
|
|
const raw = storage.getItem(key)
|
|
if (!raw) return null
|
|
return JSON.parse(raw)
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
}
|
|
|
|
function writeJson(storage: StorageLike, key: string, value: unknown): void {
|
|
storage.setItem(key, JSON.stringify(value))
|
|
}
|
|
|
|
function migrateTabs(storage: StorageLike, report: DesktopMigrationReport): void {
|
|
const raw = storage.getItem(TAB_STORAGE_KEY)
|
|
if (!raw) return
|
|
|
|
try {
|
|
const parsed = readJson(storage, TAB_STORAGE_KEY)
|
|
const rawTabs = Array.isArray(parsed)
|
|
? parsed
|
|
: isRecord(parsed) && Array.isArray(parsed.openTabs)
|
|
? parsed.openTabs
|
|
: []
|
|
const openTabs = rawTabs
|
|
.filter((tab): tab is Record<string, unknown> => isRecord(tab))
|
|
.filter((tab) => typeof tab.sessionId === 'string' && typeof tab.title === 'string')
|
|
.filter((tab) => tab.type !== 'terminal' && !String(tab.sessionId).startsWith('__terminal__'))
|
|
.map((tab) => ({
|
|
sessionId: tab.sessionId as string,
|
|
title: tab.title as string,
|
|
type: tab.type === 'settings' || tab.type === 'scheduled' ? tab.type : 'session',
|
|
}))
|
|
const activeTabId =
|
|
isRecord(parsed) &&
|
|
typeof parsed.activeTabId === 'string' &&
|
|
openTabs.some((tab) => tab.sessionId === parsed.activeTabId)
|
|
? parsed.activeTabId
|
|
: (openTabs[0]?.sessionId ?? null)
|
|
|
|
if (openTabs.length === 0) {
|
|
storage.removeItem(TAB_STORAGE_KEY)
|
|
} else {
|
|
writeJson(storage, TAB_STORAGE_KEY, { openTabs, activeTabId })
|
|
}
|
|
} catch {
|
|
storage.removeItem(TAB_STORAGE_KEY)
|
|
}
|
|
report.migratedKeys.push(TAB_STORAGE_KEY)
|
|
}
|
|
|
|
function migrateSessionRuntime(storage: StorageLike, report: DesktopMigrationReport): void {
|
|
const raw = storage.getItem(SESSION_RUNTIME_STORAGE_KEY)
|
|
if (!raw) return
|
|
|
|
try {
|
|
const parsed = readJson(storage, SESSION_RUNTIME_STORAGE_KEY)
|
|
if (!isRecord(parsed)) {
|
|
storage.removeItem(SESSION_RUNTIME_STORAGE_KEY)
|
|
report.migratedKeys.push(SESSION_RUNTIME_STORAGE_KEY)
|
|
return
|
|
}
|
|
|
|
const next = Object.fromEntries(
|
|
Object.entries(parsed).filter(([, selection]) => (
|
|
isRecord(selection) &&
|
|
typeof selection.modelId === 'string' &&
|
|
(selection.providerId === null || typeof selection.providerId === 'string')
|
|
)),
|
|
)
|
|
|
|
if (Object.keys(next).length === 0) {
|
|
storage.removeItem(SESSION_RUNTIME_STORAGE_KEY)
|
|
} else {
|
|
writeJson(storage, SESSION_RUNTIME_STORAGE_KEY, next)
|
|
}
|
|
|
|
if (JSON.stringify(next) !== JSON.stringify(parsed)) {
|
|
report.migratedKeys.push(SESSION_RUNTIME_STORAGE_KEY)
|
|
}
|
|
} catch {
|
|
storage.removeItem(SESSION_RUNTIME_STORAGE_KEY)
|
|
report.migratedKeys.push(SESSION_RUNTIME_STORAGE_KEY)
|
|
}
|
|
}
|
|
|
|
function normalizeEnumKey(
|
|
storage: StorageLike,
|
|
key: string,
|
|
allowedValues: string[],
|
|
report: DesktopMigrationReport,
|
|
): void {
|
|
const value = storage.getItem(key)
|
|
if (value !== null && !allowedValues.includes(value)) {
|
|
storage.removeItem(key)
|
|
report.migratedKeys.push(key)
|
|
}
|
|
}
|
|
|
|
function normalizeAppZoomKey(storage: StorageLike, report: DesktopMigrationReport): void {
|
|
const value = storage.getItem(APP_ZOOM_STORAGE_KEY)
|
|
if (!isValidStoredAppZoomLevel(value)) {
|
|
storage.removeItem(APP_ZOOM_STORAGE_KEY)
|
|
report.migratedKeys.push(APP_ZOOM_STORAGE_KEY)
|
|
}
|
|
|
|
const currentValue = storage.getItem(APP_ZOOM_STORAGE_KEY)
|
|
const legacyValue = storage.getItem(LEGACY_UI_ZOOM_STORAGE_KEY)
|
|
if (currentValue === null && legacyValue !== null && isValidStoredAppZoomLevel(legacyValue)) {
|
|
storage.setItem(APP_ZOOM_STORAGE_KEY, String(normalizeAppZoomLevel(legacyValue)))
|
|
report.migratedKeys.push(APP_ZOOM_STORAGE_KEY)
|
|
}
|
|
if (legacyValue !== null) {
|
|
storage.removeItem(LEGACY_UI_ZOOM_STORAGE_KEY)
|
|
report.migratedKeys.push(LEGACY_UI_ZOOM_STORAGE_KEY)
|
|
}
|
|
}
|
|
|
|
function runMigrationStep(
|
|
report: DesktopMigrationReport,
|
|
fallbackKey: string,
|
|
step: () => void,
|
|
): void {
|
|
try {
|
|
step()
|
|
} catch {
|
|
report.migratedKeys.push(fallbackKey)
|
|
}
|
|
}
|
|
|
|
function getDefaultStorage(): StorageLike | null {
|
|
try {
|
|
return globalThis.localStorage ?? null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function runDesktopPersistenceMigrations(storage: StorageLike | null = getDefaultStorage()): DesktopMigrationReport {
|
|
const report: DesktopMigrationReport = { migratedKeys: [] }
|
|
if (!storage) return report
|
|
|
|
runMigrationStep(report, TAB_STORAGE_KEY, () => migrateTabs(storage, report))
|
|
runMigrationStep(report, SESSION_RUNTIME_STORAGE_KEY, () => migrateSessionRuntime(storage, report))
|
|
runMigrationStep(report, THEME_STORAGE_KEY, () => normalizeEnumKey(storage, THEME_STORAGE_KEY, [...THEME_MODES], report))
|
|
runMigrationStep(report, LOCALE_STORAGE_KEY, () => normalizeEnumKey(storage, LOCALE_STORAGE_KEY, ['zh', 'en'], report))
|
|
runMigrationStep(report, APP_ZOOM_STORAGE_KEY, () => normalizeAppZoomKey(storage, report))
|
|
try {
|
|
storage.setItem(DESKTOP_PERSISTENCE_VERSION_KEY, String(CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION))
|
|
} catch {
|
|
report.migratedKeys.push(DESKTOP_PERSISTENCE_VERSION_KEY)
|
|
}
|
|
|
|
return report
|
|
}
|