diff --git a/docs/en/references/data/boot-config-overview.md b/docs/en/references/data/boot-config-overview.md index 57d9d70683..b9e4e7b0d3 100644 --- a/docs/en/references/data/boot-config-overview.md +++ b/docs/en/references/data/boot-config-overview.md @@ -107,6 +107,8 @@ Keys follow the same naming convention as preferences: `namespace.key_name` └─────────────────────────────────────────────────────────────────┘ ``` +BootConfig also carries data migrated from v1's `~/.cherrystudio/config/config.json` file (see `BootConfigMigrator`'s file source). The `app.user_data_path` key holds the custom user data directory mapping that the v1 file stored under `appDataPath`. Long-term, BootConfig will fully replace the legacy `config/config.json` — the follow-up PR will rewire `initAppDataDir()` to read `app.user_data_path` from BootConfig instead of parsing the legacy file directly. + ## Access Convention | Context | API | Note | @@ -156,10 +158,15 @@ Utility functions in `packages/shared/data/preference/preferenceUtils.ts`: ```json { - "app.disable_hardware_acceleration": false + "app.disable_hardware_acceleration": false, + "app.user_data_path": { + "/Applications/Cherry Studio.app/Contents/MacOS/Cherry Studio": "/Volumes/External/CherryData" + } } ``` +`app.user_data_path` is a `Record` keyed by the executable path — same-machine multiple installations (stable / dev / portable) can each have their own user data directory, matching the semantic of v1's `appDataPath` array. + ## Related Source Code | File | Purpose | diff --git a/docs/en/references/data/boot-config-schema-guide.md b/docs/en/references/data/boot-config-schema-guide.md index 0011065fa0..7e12b4d915 100644 --- a/docs/en/references/data/boot-config-schema-guide.md +++ b/docs/en/references/data/boot-config-schema-guide.md @@ -121,7 +121,7 @@ The `v2-refactor-temp/tools/data-classify/` directory contains the code generati 2. The generator reads these classifications and produces: - `packages/shared/data/bootConfig/bootConfigSchemas.ts` — schema interface and defaults - `src/main/data/migration/v2/migrators/mappings/BootConfigMappings.ts` — legacy-to-new key mappings -3. At migration time, `BootConfigMigrator` reads values from legacy sources (Redux, ElectronStore, Dexie settings, localStorage) and writes them to `bootConfigService` +3. At migration time, `BootConfigMigrator` reads values from legacy sources (Redux, ElectronStore, Dexie settings, localStorage, and the legacy home config file) and writes them to `bootConfigService` ### Migration Sources @@ -131,6 +131,14 @@ The `v2-refactor-temp/tools/data-classify/` directory contains the code generati | ElectronStore | `ConfigManager.get(key)` | Direct key lookup | | Dexie settings | Key-value table | Direct key lookup | | localStorage | `localStorage.getItem(key)` | Direct key lookup | +| Legacy home config file | `LegacyHomeConfigReader` | `~/.cherrystudio/config/config.json` (`appDataPath` field only) | + +> **Config-file source mappings are manually maintained.** The `data-classify` toolchain's `classification.json` doesn't model config-file sources yet. In two places, a small hand-maintained list complements the classification-driven pipeline: +> +> - **Schema keys**: `MANUAL_BOOT_CONFIG_ITEMS` at the top of `v2-refactor-temp/tools/data-classify/scripts/generate-boot-config.js` — these items are merged with the classification-derived items and emitted into `bootConfigSchemas.ts` as part of the normal auto-generated output. The resulting schema file is fully auto-generated (no manual sections). +> - **Mappings**: inline `configFileMappings` inside `BootConfigMigrator.loadMigrationItems()` — a small `ReadonlyArray<{ originalKey: string; targetKey: BootConfigKey }>` whose `BootConfigKey` annotation is the regen safety net: if the schema loses `app.user_data_path`, this array fails to compile at its declaration site. +> +> To add a new config-file-sourced key in the future: add an entry to `MANUAL_BOOT_CONFIG_ITEMS` in the generator, add the matching entry to `BootConfigMigrator.loadMigrationItems()`'s `configFileMappings`, and run `npm run generate`. ### Adding a Migration Mapping @@ -164,6 +172,16 @@ cd v2-refactor-temp/tools/data-classify && npm run generate | Legacy Source | Legacy Key | Target Key | |---------------|-----------|------------| | Redux (`settings`) | `disableHardwareAcceleration` | `app.disable_hardware_acceleration` | +| Config file (`~/.cherrystudio/config/config.json`) | `appDataPath` | `app.user_data_path` | + +#### Known Limitation: AppImage / Windows Portable Executable Path + +The v1 `~/.cherrystudio/config/config.json` stores `appDataPath` as an array of `{ executablePath, dataPath }` entries keyed by executable path. On AppImage Linux builds and Windows portable builds, `src/main/utils/init.ts:51-60` writes a **special** `executablePath` that differs from `app.getPath('exe')`: + +- AppImage: `path.dirname(process.env.APPIMAGE) + '/cherry-studio.appimage'` +- Windows portable: `process.env.PORTABLE_EXECUTABLE_DIR + '/cherry-studio-portable.exe'` + +`LegacyHomeConfigReader` does NOT reproduce this normalization — array entries are migrated verbatim with their original `executablePath` key, and the legacy-string fallback uses the raw `app.getPath('exe')`. This is harmless in the current PR because nothing yet reads `app.user_data_path`. **But the follow-up PR that rewires `initAppDataDir()` to consume it MUST normalize the exe path using the same logic in `src/main/utils/init.ts:51-60`, otherwise migrated records under AppImage/portable will never match the lookup key.** ## File Structure diff --git a/docs/en/references/data/v2-migration-guide.md b/docs/en/references/data/v2-migration-guide.md index d448134bb2..b701a37a87 100644 --- a/docs/en/references/data/v2-migration-guide.md +++ b/docs/en/references/data/v2-migration-guide.md @@ -17,7 +17,7 @@ src/main/data/migration/v2/ - `core/MigrationEngine.ts` coordinates all migrators in order, surfaces progress to the UI, and marks status in `app_state.key = 'migration_v2_status'`. It will clear new-schema tables before running and abort on any validation failure. - `core/MigrationContext.ts` builds the shared context passed to every migrator: - - `sources`: `ConfigManager` (ElectronStore), `ReduxStateReader` (parsed Redux Persist data), `DexieFileReader` (JSON exports) + - `sources`: `ConfigManager` (ElectronStore), `ReduxStateReader` (parsed Redux Persist data), `DexieFileReader` (JSON exports), `LegacyHomeConfigReader` (v1 `~/.cherrystudio/config/config.json` for the config-file migration path used by `BootConfigMigrator`) - `db`: current SQLite connection - `sharedData`: `Map` for passing cross-cutting info between migrators - `logger`: `loggerService` scoped to migration @@ -34,6 +34,7 @@ src/main/data/migration/v2/ - Current migrators (see `migrators/README-.md` for detailed documentation): - `PreferencesMigrator` (implemented): maps ElectronStore + Redux settings to the `preference` table using `mappings/PreferencesMappings.ts`. - `ChatMigrator` (implemented): migrates topics and messages from Dexie to SQLite. See [`README-ChatMigrator.md`](../../../src/main/data/migration/v2/migrators/README-ChatMigrator.md). + - `BootConfigMigrator` (implemented, file-target): migrates early-boot settings into the file-based `bootConfigService` (`~/.cherrystudio/boot-config.json`) rather than a SQLite table. Reads from Redux (`disableHardwareAcceleration`) and from the v1 home config file (`~/.cherrystudio/config/config.json`'s `appDataPath` → `app.user_data_path`) via a `'configfile'` source kind. See [`README-BootConfigMigrator.md`](../../../src/main/data/migration/v2/migrators/README-BootConfigMigrator.md). - `AssistantMigrator`, `KnowledgeMigrator` (placeholders): scaffolding and TODO notes for future tables. - Conventions: - All logging goes through `loggerService` with a migrator-specific context. @@ -49,6 +50,7 @@ src/main/data/migration/v2/ - `utils/ReduxStateReader.ts`: safe accessor for categorized Redux Persist data with dot-path lookup. - `utils/DexieFileReader.ts`: reads exported Dexie JSON tables; can stream large tables. - `utils/JSONStreamReader.ts`: streaming reader with batching, counting, and sampling helpers for very large arrays. +- `utils/LegacyHomeConfigReader.ts`: synchronously reads the v1 `~/.cherrystudio/config/config.json` file and normalizes its `appDataPath` field (both the legacy string shape and the current `{ executablePath, dataPath }[]` shape) into a `Record | null`. Used exclusively by `BootConfigMigrator`'s `'configfile'` source. ## Window & IPC Integration diff --git a/packages/shared/data/bootConfig/bootConfigSchemas.ts b/packages/shared/data/bootConfig/bootConfigSchemas.ts index fe74817f5c..9e44c3e227 100644 --- a/packages/shared/data/bootConfig/bootConfigSchemas.ts +++ b/packages/shared/data/bootConfig/bootConfigSchemas.ts @@ -1,9 +1,13 @@ /** * Auto-generated boot config schema - * Generated at: 2026-03-25T09:39:29.081Z + * Generated at: 2026-04-07T16:01:25.234Z * - * This file is automatically generated from classification.json - * To update this file, modify classification.json and run: + * This file is automatically generated from classification.json (plus a + * small MANUAL_BOOT_CONFIG_ITEMS list in generate-boot-config.js for keys + * that don't fit classification.json's model yet, e.g. config-file sources). + * + * To update this file, either modify classification.json or the manual list + * in the generator, then run: * node v2-refactor-temp/tools/data-classify/scripts/generate-boot-config.js * * === AUTO-GENERATED CONTENT START === @@ -12,10 +16,28 @@ export interface BootConfigSchema { // redux/settings/disableHardwareAcceleration 'app.disable_hardware_acceleration': boolean + + /** + * Custom user data directory, keyed by executable path. + * + * Conceptually a single setting ("where user data lives"); stored as a + * Record so the same machine can host multiple installations (stable / dev / + * portable) with independent user data locations — matching the v1 behavior + * of ~/.cherrystudio/config/config.json's `appDataPath` array. + * + * Key: executable path (matches Electron's `app.getPath('exe')`). + * Value: absolute path to the chosen userData directory. + * + * Migrated from v1 ~/.cherrystudio/config/config.json on first v1→v2 run + * via the 'configfile' source in BootConfigMigrator. + */ + // configfile/legacy-home/appDataPath + 'app.user_data_path': Record } export const DefaultBootConfig: BootConfigSchema = { - 'app.disable_hardware_acceleration': false + 'app.disable_hardware_acceleration': false, + 'app.user_data_path': {} } // === AUTO-GENERATED CONTENT END === diff --git a/src/main/data/migration/v2/README.md b/src/main/data/migration/v2/README.md index cd121aeacb..93f44f235e 100644 --- a/src/main/data/migration/v2/README.md +++ b/src/main/data/migration/v2/README.md @@ -13,7 +13,7 @@ src/main/data/migration/v2/ ├── core/ # MigrationEngine, MigrationContext ├── migrators/ # Domain-specific migrators │ └── mappings/ # Mapping definitions -├── utils/ # ReduxStateReader, DexieFileReader, JSONStreamReader +├── utils/ # ReduxStateReader, DexieFileReader, JSONStreamReader, LegacyHomeConfigReader ├── window/ # IPC handlers, window manager └── index.ts # Public exports ``` diff --git a/src/main/data/migration/v2/core/MigrationContext.ts b/src/main/data/migration/v2/core/MigrationContext.ts index fd6899bb1c..49d1f811f3 100644 --- a/src/main/data/migration/v2/core/MigrationContext.ts +++ b/src/main/data/migration/v2/core/MigrationContext.ts @@ -10,6 +10,7 @@ import fs from 'fs/promises' import { DexieFileReader } from '../utils/DexieFileReader' import { DexieSettingsReader, type DexieSettingsRecord } from '../utils/DexieSettingsReader' +import { LegacyHomeConfigReader } from '../utils/LegacyHomeConfigReader' import { LocalStorageReader } from '../utils/LocalStorageReader' import { ReduxStateReader } from '../utils/ReduxStateReader' @@ -30,6 +31,7 @@ export interface MigrationContext { dexieExport: DexieFileReader dexieSettings: DexieSettingsReader localStorage: LocalStorageReader + legacyHomeConfig: LegacyHomeConfigReader } // Target database @@ -90,7 +92,8 @@ export async function createMigrationContext( reduxState: new ReduxStateReader(reduxData), dexieExport: dexieFileReader, dexieSettings: new DexieSettingsReader(dexieSettingsRecords), - localStorage: new LocalStorageReader(localStorageRecords) + localStorage: new LocalStorageReader(localStorageRecords), + legacyHomeConfig: new LegacyHomeConfigReader() }, db, sharedData: new Map(), diff --git a/src/main/data/migration/v2/migrators/BootConfigMigrator.ts b/src/main/data/migration/v2/migrators/BootConfigMigrator.ts index d68040b256..82944ae6f2 100644 --- a/src/main/data/migration/v2/migrators/BootConfigMigrator.ts +++ b/src/main/data/migration/v2/migrators/BootConfigMigrator.ts @@ -1,8 +1,9 @@ /** * Boot config migrator - migrates boot configuration from legacy storage to BootConfigService * - * Reads from ElectronStore, Redux, Dexie settings, and localStorage sources, - * then writes values to bootConfigService (boot-config.json). + * Reads from ElectronStore, Redux, Dexie settings, localStorage, and the legacy + * home config file (~/.cherrystudio/config/config.json) sources, then writes + * values to bootConfigService (~/.cherrystudio/boot-config.json). */ import { loggerService } from '@logger' @@ -22,18 +23,20 @@ import { const logger = loggerService.withContext('BootConfigMigrator') +type MigrationSource = 'electronStore' | 'redux' | 'dexie-settings' | 'localStorage' | 'configfile' + interface MigrationItem { originalKey: string - targetKey: string + targetKey: BootConfigKey defaultValue: unknown - source: 'electronStore' | 'redux' | 'dexie-settings' | 'localStorage' + source: MigrationSource sourceCategory?: string } interface PreparedData { - targetKey: string + targetKey: BootConfigKey value: unknown - source: 'electronStore' | 'redux' | 'dexie-settings' | 'localStorage' + source: MigrationSource originalKey: string } @@ -71,6 +74,11 @@ export class BootConfigMigrator extends BaseMigrator { originalValue = ctx.sources.dexieSettings.get(item.originalKey) } else if (item.source === 'localStorage') { originalValue = ctx.sources.localStorage.get(item.originalKey) + } else if (item.source === 'configfile') { + // Reader returns Record | null. `null` flows into + // the shared null-skip guard below, matching the other sources' + // "no data → skip" semantics without a special branch. + originalValue = ctx.sources.legacyHomeConfig.getUserDataPath() } // Determine value to migrate @@ -124,7 +132,7 @@ export class BootConfigMigrator extends BaseMigrator { let processedCount = 0 for (const item of this.preparedItems) { - bootConfigService.set(item.targetKey as BootConfigKey, item.value as never) + bootConfigService.set(item.targetKey, item.value as never) processedCount++ const progress = Math.round((processedCount / this.preparedItems.length) * 100) @@ -160,8 +168,7 @@ export class BootConfigMigrator extends BaseMigrator { let targetCount = 0 for (const item of this.preparedItems) { - const key = item.targetKey as BootConfigKey - const value = bootConfigService.get(key) + const value = bootConfigService.get(item.targetKey) if (value === undefined) { errors.push({ @@ -212,7 +219,7 @@ export class BootConfigMigrator extends BaseMigrator { // Process ElectronStore mappings for (const mapping of BOOT_CONFIG_ELECTRON_STORE_MAPPINGS) { - const defaultValue = DefaultBootConfig[mapping.targetKey as BootConfigKey] ?? null + const defaultValue = DefaultBootConfig[mapping.targetKey] ?? null items.push({ originalKey: mapping.originalKey, targetKey: mapping.targetKey, @@ -224,7 +231,7 @@ export class BootConfigMigrator extends BaseMigrator { // Process Redux mappings for (const [category, mappings] of Object.entries(BOOT_CONFIG_REDUX_MAPPINGS)) { for (const mapping of mappings) { - const defaultValue = DefaultBootConfig[mapping.targetKey as BootConfigKey] ?? null + const defaultValue = DefaultBootConfig[mapping.targetKey] ?? null items.push({ originalKey: mapping.originalKey, targetKey: mapping.targetKey, @@ -237,7 +244,7 @@ export class BootConfigMigrator extends BaseMigrator { // Process Dexie settings mappings for (const mapping of BOOT_CONFIG_DEXIE_SETTINGS_MAPPINGS) { - const defaultValue = DefaultBootConfig[mapping.targetKey as BootConfigKey] ?? null + const defaultValue = DefaultBootConfig[mapping.targetKey] ?? null items.push({ originalKey: mapping.originalKey, targetKey: mapping.targetKey, @@ -248,7 +255,7 @@ export class BootConfigMigrator extends BaseMigrator { // Process localStorage mappings for (const mapping of BOOT_CONFIG_LOCALSTORAGE_MAPPINGS) { - const defaultValue = DefaultBootConfig[mapping.targetKey as BootConfigKey] ?? null + const defaultValue = DefaultBootConfig[mapping.targetKey] ?? null items.push({ originalKey: mapping.originalKey, targetKey: mapping.targetKey, @@ -257,6 +264,35 @@ export class BootConfigMigrator extends BaseMigrator { }) } + // Config-file source mappings — manually maintained, not auto-generated. + // The `targetKey: BootConfigKey` type annotation is the regen safety net: + // if the schema loses 'app.user_data_path', this array literal fails to + // compile at its declaration site (loud failure, not silent drift). + // + // Config-file items intentionally use `defaultValue: null` rather than + // the schema default. The other sources fall back to DefaultBootConfig on + // a missing source value to ensure the key exists with a sane default — + // but for config-file data like `app.user_data_path`, "no v1 file" means + // "nothing to migrate", and writing the schema default `{}` would be a + // spurious migration. Null here flows into the shared null-skip guard in + // prepare(), matching the reader's `null` return semantics. + const configFileMappings: ReadonlyArray<{ originalKey: string; targetKey: BootConfigKey }> = [ + { + // `appDataPath` field at the top level of ~/.cherrystudio/config/config.json + // (legacy string or array of { executablePath, dataPath }) + originalKey: 'appDataPath', + targetKey: 'app.user_data_path' + } + ] + for (const mapping of configFileMappings) { + items.push({ + originalKey: mapping.originalKey, + targetKey: mapping.targetKey, + defaultValue: null, + source: 'configfile' + }) + } + return items } } diff --git a/src/main/data/migration/v2/migrators/README-BootConfigMigrator.md b/src/main/data/migration/v2/migrators/README-BootConfigMigrator.md new file mode 100644 index 0000000000..1c81e426db --- /dev/null +++ b/src/main/data/migration/v2/migrators/README-BootConfigMigrator.md @@ -0,0 +1,85 @@ +# BootConfigMigrator + +The `BootConfigMigrator` migrates early-boot configuration from legacy storage into `bootConfigService` — the synchronous, file-based config used by code that runs before the lifecycle system takes over (e.g. Chromium flags, custom userData directory). + +Unlike other migrators, it writes to a **file-based store** (`~/.cherrystudio/boot-config.json`) rather than a SQLite table. See [Boot Config Overview](../../../../../docs/en/references/data/boot-config-overview.md) for why this system exists. + +## Data Sources + +Boot config pulls from **five** source kinds. Four are classification-driven (via `data-classify` + `BOOT_CONFIG_*_MAPPINGS` in `mappings/BootConfigMappings.ts`); one is manually maintained inline for a data shape the toolchain doesn't yet model. + +| Source kind | Reader | Origin | Currently migrates | +|------|--------|-----------|-----| +| `redux` | `ReduxStateReader` | Redux Persist `reduxData` JSON | `settings.disableHardwareAcceleration` → `app.disable_hardware_acceleration` | +| `electronStore` | `ctx.sources.electronStore` (electron-store) | `{userData}/config.json` | (none currently — classification empty) | +| `dexie-settings` | `DexieSettingsReader` | Dexie `settings` table export | (none currently — classification empty) | +| `localStorage` | `LocalStorageReader` | localStorage export JSON | (none currently — classification empty) | +| `configfile` | `LegacyHomeConfigReader` | `~/.cherrystudio/config/config.json` (v1 home config file) | `appDataPath` → `app.user_data_path` | + +### The `configfile` source + +The `configfile` source exists because v1 stored the user-customized userData directory in `~/.cherrystudio/config/config.json` rather than in any of the four classification-driven stores. That file is outside the app's `userData` directory (intentionally — it needs to be readable before the userData path is decided), so none of the other readers can reach it. + +`LegacyHomeConfigReader` reads the v1 file and normalizes two historical data shapes: + +- Legacy string: `{ "appDataPath": "/path" }` → wrapped into a single-entry record keyed by `app.getPath('exe')` +- Array (current v1): `{ "appDataPath": [{ executablePath, dataPath }, ...] }` → converted to `Record`; entries missing either field are filtered out + +Returns `null` (not `{}`) when no data is present (missing file / parse error / empty array / all entries invalid). This `null` flows into the shared null-skip guard in `prepare()`, matching the other sources' "no data → skip" semantics. + +## Field Mappings + +### Redux → BootConfig + +| Source (category / key) | Target Key | Type | Default | +|---|---|---|---| +| `settings.disableHardwareAcceleration` | `app.disable_hardware_acceleration` | `boolean` | `false` | + +### Config file → BootConfig + +| Source (file / field) | Target Key | Type | Default | +|---|---|---|---| +| `~/.cherrystudio/config/config.json` → `appDataPath` | `app.user_data_path` | `Record` | *(null — see below)* | + +**Why `defaultValue: null` for config-file entries**: the other sources fall back to `DefaultBootConfig[targetKey]` when the source has no value, so missing keys get sensible defaults. For config-file data like `app.user_data_path`, "no v1 file" must mean "nothing to migrate" — writing the schema default `{}` would be a spurious migration. Setting `defaultValue: null` on these entries routes them through the shared null-skip guard in `prepare()`, skipping the item entirely when the reader returns `null`. + +## Data Quality Handling + +| Issue | Detection | Handling | +|---|---|---| +| v1 file missing | `!fs.existsSync(path)` in reader | Reader returns `null` → migrator skips `app.user_data_path` | +| v1 file JSON parse error | `JSON.parse` throws in reader | Reader returns `null` → migrator skips | +| v1 file I/O error | `fs.readFileSync` throws | Reader returns `null` → migrator skips | +| `appDataPath` field missing | Not in parsed object | Reader returns `null` → migrator skips | +| `appDataPath` wrong type (e.g. number) | `typeof !== 'string' && !Array.isArray` | Reader returns `null` → migrator skips | +| `appDataPath: []` or array with all invalid entries | Filtered record has 0 keys | Reader returns `null` → migrator skips (C1 correctness — must not write `{}`) | +| Redux source missing a key | `reduxData` path lookup returns undefined | Falls back to `DefaultBootConfig[targetKey]` (e.g. `app.disable_hardware_acceleration` → `false`) | + +## Writes and Validation + +- Writes via `bootConfigService.set(targetKey, value)` followed by `bootConfigService.flush()` to force an immediate durable write. +- `validate()` iterates `preparedItems` and checks `bootConfigService.get(targetKey) !== undefined`. +- **Known validate weakness**: for `Record` keys like `app.user_data_path`, `mergeDefaults()` fills a `{}` default on get, so `value !== undefined` is always true. The validation step cannot detect a silent write failure for Record-typed keys. Unit tests in `__tests__/BootConfigMigrator.test.ts` compensate by directly asserting `bootConfigService.get('app.user_data_path')` returns the expected structure rather than relying on validate(). + +## Implementation Files + +- `BootConfigMigrator.ts` — `prepare/execute/validate` phases; `loadMigrationItems()` merges classification-derived mappings (from `BootConfigMappings.ts`) with the inline `configFileMappings` local const. +- `../utils/LegacyHomeConfigReader.ts` — sync reader for v1 home config file; read-only; does not validate path accessibility of the returned `dataPath` values. +- `mappings/BootConfigMappings.ts` — auto-generated mappings for the 4 classification-driven sources. The `targetKey: BootConfigKey` type annotation (emitted by `generate-migration.js`) provides the regen safety net: if a key is removed from the schema, mapping references fail to compile. +- `../../../../../packages/shared/data/bootConfig/bootConfigSchemas.ts` — fully auto-generated schema (classification keys + `MANUAL_BOOT_CONFIG_ITEMS` from `generate-boot-config.js`). Single `BootConfigSchema` interface, single `DefaultBootConfig` const. + +## Known Limitation: AppImage / Windows Portable Executable Path + +On AppImage Linux and Windows portable builds, v1's `init.ts:51-60` writes a **special** `executablePath` into `config.json`: + +- AppImage: `path.dirname(APPIMAGE) + '/cherry-studio.appimage'` +- Windows portable: `PORTABLE_EXECUTABLE_DIR + '/cherry-studio-portable.exe'` + +These differ from `app.getPath('exe')`. `LegacyHomeConfigReader` does NOT reproduce this normalization — array entries are migrated verbatim with their original `executablePath` key, and the legacy-string fallback uses raw `app.getPath('exe')`. This is harmless for the current PR because nothing yet **reads** `app.user_data_path`. The follow-up PR that rewires `initAppDataDir()` to consume the migrated record **must** apply the same exe-path normalization on the read side, otherwise AppImage/portable users' migrated entries will never match the lookup key. + +## Code Quality + +All implementation code includes detailed comments: +- File-level comments: describe sources and write target +- Type-level comments: explain why `MigrationItem.targetKey` is `BootConfigKey` (regen safety net) and why `configFileMappings` is inline rather than in an auto-generated file +- Logic-level comments: explain the `defaultValue: null` semantic for config-file items (vs fallback-to-default for other sources) diff --git a/src/main/data/migration/v2/migrators/__tests__/BootConfigMigrator.test.ts b/src/main/data/migration/v2/migrators/__tests__/BootConfigMigrator.test.ts new file mode 100644 index 0000000000..8bb137a991 --- /dev/null +++ b/src/main/data/migration/v2/migrators/__tests__/BootConfigMigrator.test.ts @@ -0,0 +1,192 @@ +/** + * Tests for BootConfigMigrator. + * + * Focuses on the 'configfile' source branch added for migrating v1 + * ~/.cherrystudio/config/config.json → boot-config's `app.user_data_path`, + * plus a regression test covering the existing redux source. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { ReduxStateReader } from '../../utils/ReduxStateReader' + +// Mock bootConfigService — the migrator writes via .set() and .flush(), then +// validates via .get(). We spy on the mutations and stub the reads. +const bootConfigStore: Record = {} +const mockBootConfigSet = vi.fn((key: string, value: unknown) => { + bootConfigStore[key] = value +}) +const mockBootConfigGet = vi.fn((key: string) => bootConfigStore[key]) +const mockBootConfigFlush = vi.fn() + +vi.mock('@main/data/bootConfig', () => ({ + bootConfigService: { + set: mockBootConfigSet, + get: mockBootConfigGet, + flush: mockBootConfigFlush + } +})) + +/** + * Build a minimal MigrationContext mock carrying only the sources that + * BootConfigMigrator actually consumes. Individual tests override specific + * sources via the `overrides` parameter. + */ +function createMockContext(overrides?: { + redux?: Record + legacyHomeConfig?: Record | null +}) { + const reduxState = new ReduxStateReader(overrides?.redux ?? {}) + + const legacyHomeConfig = { + getUserDataPath: vi.fn(() => (overrides && 'legacyHomeConfig' in overrides ? overrides.legacyHomeConfig : null)) + } + + return { + sources: { + electronStore: { get: vi.fn() }, + reduxState, + dexieExport: { readTable: vi.fn(), createStreamReader: vi.fn(), tableExists: vi.fn() }, + dexieSettings: { keys: vi.fn().mockReturnValue([]), get: vi.fn() }, + localStorage: { get: vi.fn(), has: vi.fn(), keys: vi.fn(), size: 0 }, + legacyHomeConfig + }, + db: {} as any, + sharedData: new Map(), + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn() + } + } as any +} + +describe('BootConfigMigrator', () => { + beforeEach(async () => { + // Reset in-memory store and all mock calls between tests + for (const key of Object.keys(bootConfigStore)) delete bootConfigStore[key] + vi.clearAllMocks() + }) + + async function createMigrator() { + const { BootConfigMigrator } = await import('../BootConfigMigrator') + const migrator = new BootConfigMigrator() + migrator.setProgressCallback(vi.fn()) + return migrator + } + + describe('metadata', () => { + it('has stable id/name/order matching the registered migrator', async () => { + const migrator = await createMigrator() + expect(migrator.id).toBe('bootConfig') + expect(migrator.name).toBe('Boot Config') + expect(migrator.order).toBe(0.5) + }) + }) + + describe('configfile source — prepare/execute/validate', () => { + it('prepares and executes a legacy-string derived record', async () => { + const migrator = await createMigrator() + const ctx = createMockContext({ + legacyHomeConfig: { '/Applications/Cherry Studio.app/exe': '/Volumes/Ext/Data' } + }) + + const prepared = await migrator.prepare(ctx) + expect(prepared.success).toBe(true) + // Redux 'disableHardwareAcceleration' is missing → falls back to default (false). + // Config-file 'appDataPath' parsed → becomes a real item. + // Both count toward itemCount. + expect(prepared.itemCount).toBeGreaterThanOrEqual(1) + + const executed = await migrator.execute() + expect(executed.success).toBe(true) + + // C1-critical: directly assert the value structure — validate() alone + // can't be trusted for Record-typed keys (see §4.5 in the plan). + expect(mockBootConfigSet).toHaveBeenCalledWith('app.user_data_path', { + '/Applications/Cherry Studio.app/exe': '/Volumes/Ext/Data' + }) + expect(mockBootConfigFlush).toHaveBeenCalled() + }) + + it('skips the configfile source when reader returns null (no v1 config file)', async () => { + const migrator = await createMigrator() + const ctx = createMockContext({ legacyHomeConfig: null }) + + const prepared = await migrator.prepare(ctx) + expect(prepared.success).toBe(true) + + await migrator.execute() + + // The configfile key must NOT have been written. Other sources (if any + // run with defaults) may still call set, but not for app.user_data_path. + const configFileCalls = mockBootConfigSet.mock.calls.filter(([key]) => key === 'app.user_data_path') + expect(configFileCalls).toHaveLength(0) + }) + + it('skips the configfile source on edge case: reader returns null for empty array', async () => { + // When the v1 file has `appDataPath: []`, LegacyHomeConfigReader returns + // null (tested in LegacyHomeConfigReader.test.ts). The migrator then + // hits the shared null-skip guard and never writes app.user_data_path. + // This test locks in the migrator's side of that contract. + const migrator = await createMigrator() + const ctx = createMockContext({ legacyHomeConfig: null }) + + await migrator.prepare(ctx) + await migrator.execute() + + expect(mockBootConfigSet).not.toHaveBeenCalledWith('app.user_data_path', expect.anything()) + }) + + it('converts an array-derived record and writes it verbatim', async () => { + const migrator = await createMigrator() + const multiInstall = { + '/Applications/Cherry Studio.app/exe': '/Volumes/Ext1/Data', + '/Applications/Cherry Studio Dev.app/exe': '/Volumes/Ext2/DevData' + } + const ctx = createMockContext({ legacyHomeConfig: multiInstall }) + + await migrator.prepare(ctx) + await migrator.execute() + + expect(mockBootConfigSet).toHaveBeenCalledWith('app.user_data_path', multiInstall) + }) + }) + + describe('redux source regression', () => { + it('still migrates disableHardwareAcceleration from redux settings', async () => { + const migrator = await createMigrator() + const ctx = createMockContext({ + redux: { settings: { disableHardwareAcceleration: true } }, + legacyHomeConfig: null + }) + + await migrator.prepare(ctx) + await migrator.execute() + + expect(mockBootConfigSet).toHaveBeenCalledWith('app.disable_hardware_acceleration', true) + }) + }) + + describe('reset', () => { + it('clears prepared items and skipped count between runs', async () => { + const migrator = await createMigrator() + const ctx = createMockContext({ + legacyHomeConfig: { '/exe': '/data' } + }) + + await migrator.prepare(ctx) + migrator.reset() + + // After reset, an empty-context prepare should produce 0 configfile items + // (redux default still counts as 1 for disableHardwareAcceleration). + const prepared2 = await migrator.prepare(createMockContext({ legacyHomeConfig: null })) + expect(prepared2.success).toBe(true) + + await migrator.execute() + + const configFileCalls = mockBootConfigSet.mock.calls.filter(([key]) => key === 'app.user_data_path') + expect(configFileCalls).toHaveLength(0) + }) + }) +}) diff --git a/src/main/data/migration/v2/migrators/mappings/BootConfigMappings.ts b/src/main/data/migration/v2/migrators/mappings/BootConfigMappings.ts index 96e747bf74..0df1e6764e 100644 --- a/src/main/data/migration/v2/migrators/mappings/BootConfigMappings.ts +++ b/src/main/data/migration/v2/migrators/mappings/BootConfigMappings.ts @@ -1,6 +1,6 @@ /** * Auto-generated boot config mappings from classification.json - * Generated at: 2026-03-25T13:34:17.164Z + * Generated at: 2026-04-07T16:01:25.235Z * * This file contains pure mapping relationships without default values. * Default values are managed in packages/shared/data/bootConfig/bootConfigSchemas.ts @@ -8,12 +8,14 @@ * === AUTO-GENERATED CONTENT START === */ +import type { BootConfigKey } from '@shared/data/bootConfig/bootConfigTypes' + /** * ElectronStore映射关系 - 简单一层结构 * * ElectronStore没有嵌套,originalKey直接对应configManager.get(key) */ -export const BOOT_CONFIG_ELECTRON_STORE_MAPPINGS: ReadonlyArray<{ originalKey: string; targetKey: string }> = +export const BOOT_CONFIG_ELECTRON_STORE_MAPPINGS: ReadonlyArray<{ originalKey: string; targetKey: BootConfigKey }> = [] as const /** @@ -33,13 +35,14 @@ export const BOOT_CONFIG_REDUX_MAPPINGS = { /** * Dexie Settings映射关系 - 简单KV结构 */ -export const BOOT_CONFIG_DEXIE_SETTINGS_MAPPINGS: ReadonlyArray<{ originalKey: string; targetKey: string }> = +export const BOOT_CONFIG_DEXIE_SETTINGS_MAPPINGS: ReadonlyArray<{ originalKey: string; targetKey: BootConfigKey }> = [] as const /** * localStorage映射关系 - 简单KV结构 */ -export const BOOT_CONFIG_LOCALSTORAGE_MAPPINGS: ReadonlyArray<{ originalKey: string; targetKey: string }> = [] as const +export const BOOT_CONFIG_LOCALSTORAGE_MAPPINGS: ReadonlyArray<{ originalKey: string; targetKey: BootConfigKey }> = + [] as const // === AUTO-GENERATED CONTENT END === diff --git a/src/main/data/migration/v2/utils/LegacyHomeConfigReader.ts b/src/main/data/migration/v2/utils/LegacyHomeConfigReader.ts new file mode 100644 index 0000000000..dc5508cef4 --- /dev/null +++ b/src/main/data/migration/v2/utils/LegacyHomeConfigReader.ts @@ -0,0 +1,110 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { HOME_CHERRY_DIR } from '@shared/config/constant' +import { app } from 'electron' + +/** + * Reader for the legacy v1 home config file at ~/.cherrystudio/config/config.json. + * + * Responsibilities: + * - Synchronously read and parse the file at construction time. + * - Normalize the `appDataPath` field into a `Record`. + * Handles two historical shapes: + * 1. Legacy string: `{ "appDataPath": "/some/path" }` — wrapped into a + * single-entry record keyed by the current `app.getPath('exe')`. + * 2. Array: `{ "appDataPath": [{ executablePath, dataPath }, ...] }` — + * entries missing either field are skipped. + * + * Error handling: the migration pipeline must never crash on a malformed + * legacy file — all I/O and parse errors are swallowed and surfaced as a + * `null` return from `getUserDataPath()`. + * + * Read-only: this reader does NOT validate whether the `dataPath` on disk + * is still accessible or writable. That concern belongs to downstream + * consumers (e.g. the future `initAppDataDir()` rewire). + * + * Known limitation: AppImage / Windows portable builds write a special + * `executablePath` (cherry-studio.appimage / cherry-studio-portable.exe) + * that differs from `app.getPath('exe')`. This reader does not reproduce + * that normalization — the legacy-string fallback uses the raw exe path, + * and array entries are preserved verbatim. Consumers of `app.user_data_path` + * will need their own exe-path normalization (see `src/main/utils/init.ts:51-60`). + */ +export class LegacyHomeConfigReader { + private readonly userDataPath: Record | null + + constructor() { + this.userDataPath = this.loadSync() + } + + /** + * Return the parsed `appDataPath` as a Record, + * or `null` if: + * - the file does not exist + * - the file cannot be read (permission, etc.) + * - the file contents are not valid JSON + * - the `appDataPath` field is missing or of an unexpected type + * - the `appDataPath` array is empty or contains only invalid entries + */ + getUserDataPath(): Record | null { + return this.userDataPath + } + + private loadSync(): Record | null { + const filePath = path.join(os.homedir(), HOME_CHERRY_DIR, 'config', 'config.json') + + let raw: string + try { + if (!fs.existsSync(filePath)) { + return null + } + raw = fs.readFileSync(filePath, 'utf-8') + } catch { + return null + } + + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return null + } + + if (typeof parsed !== 'object' || parsed === null) { + return null + } + + const appDataPath = (parsed as Record).appDataPath + + // Legacy string format → single-entry record keyed by current exe. + if (typeof appDataPath === 'string') { + if (appDataPath.length === 0) { + return null + } + return { [app.getPath('exe')]: appDataPath } + } + + // Array format → filter invalid entries and build a record. + if (Array.isArray(appDataPath)) { + const result: Record = {} + for (const entry of appDataPath) { + if ( + typeof entry === 'object' && + entry !== null && + typeof (entry as { executablePath?: unknown }).executablePath === 'string' && + typeof (entry as { dataPath?: unknown }).dataPath === 'string' + ) { + const { executablePath, dataPath } = entry as { executablePath: string; dataPath: string } + if (executablePath.length > 0 && dataPath.length > 0) { + result[executablePath] = dataPath + } + } + } + return Object.keys(result).length > 0 ? result : null + } + + return null + } +} diff --git a/src/main/data/migration/v2/utils/__tests__/LegacyHomeConfigReader.test.ts b/src/main/data/migration/v2/utils/__tests__/LegacyHomeConfigReader.test.ts new file mode 100644 index 0000000000..5576d610e1 --- /dev/null +++ b/src/main/data/migration/v2/utils/__tests__/LegacyHomeConfigReader.test.ts @@ -0,0 +1,166 @@ +import fs from 'node:fs' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mockFs = vi.mocked(fs) + +const CONFIG_PATH = '/mock/home/.cherrystudio/config/config.json' +// tests/main.setup.ts mocks app.getPath() to only handle 'userData' / 'temp' / +// 'logs' explicitly; everything else (including 'exe') falls through to '/mock/unknown'. +const MOCK_EXE = '/mock/unknown' + +async function createReader() { + const { LegacyHomeConfigReader } = await import('../LegacyHomeConfigReader') + return new LegacyHomeConfigReader() +} + +describe('LegacyHomeConfigReader', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('null return cases', () => { + it('returns null when the file does not exist', async () => { + mockFs.existsSync.mockReturnValue(false) + + const reader = await createReader() + + expect(reader.getUserDataPath()).toBeNull() + expect(mockFs.existsSync).toHaveBeenCalledWith(CONFIG_PATH) + }) + + it('returns null when fs read throws (permission, etc.)', async () => { + mockFs.existsSync.mockReturnValue(true) + mockFs.readFileSync.mockImplementation(() => { + throw new Error('EACCES: permission denied') + }) + + const reader = await createReader() + + expect(reader.getUserDataPath()).toBeNull() + }) + + it('returns null when JSON parsing fails', async () => { + mockFs.existsSync.mockReturnValue(true) + mockFs.readFileSync.mockReturnValue('not-valid-json{{{') + + const reader = await createReader() + + expect(reader.getUserDataPath()).toBeNull() + }) + + it('returns null when the root JSON is not an object', async () => { + mockFs.existsSync.mockReturnValue(true) + mockFs.readFileSync.mockReturnValue(JSON.stringify([1, 2, 3])) + + const reader = await createReader() + + expect(reader.getUserDataPath()).toBeNull() + }) + + it('returns null when appDataPath field is missing', async () => { + mockFs.existsSync.mockReturnValue(true) + mockFs.readFileSync.mockReturnValue(JSON.stringify({ somethingElse: 'foo' })) + + const reader = await createReader() + + expect(reader.getUserDataPath()).toBeNull() + }) + + it('returns null when appDataPath is of an unexpected type (number)', async () => { + mockFs.existsSync.mockReturnValue(true) + mockFs.readFileSync.mockReturnValue(JSON.stringify({ appDataPath: 42 })) + + const reader = await createReader() + + expect(reader.getUserDataPath()).toBeNull() + }) + + it('returns null when appDataPath is an empty array (C1: must NOT return {})', async () => { + mockFs.existsSync.mockReturnValue(true) + mockFs.readFileSync.mockReturnValue(JSON.stringify({ appDataPath: [] })) + + const reader = await createReader() + + expect(reader.getUserDataPath()).toBeNull() + }) + + it('returns null when all array entries are invalid (missing fields)', async () => { + mockFs.existsSync.mockReturnValue(true) + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + appDataPath: [{}, { executablePath: '/foo' /* missing dataPath */ }, { dataPath: '/bar' /* missing exe */ }] + }) + ) + + const reader = await createReader() + + expect(reader.getUserDataPath()).toBeNull() + }) + + it('returns null when appDataPath is an empty legacy string', async () => { + mockFs.existsSync.mockReturnValue(true) + mockFs.readFileSync.mockReturnValue(JSON.stringify({ appDataPath: '' })) + + const reader = await createReader() + + expect(reader.getUserDataPath()).toBeNull() + }) + }) + + describe('record return cases', () => { + it('wraps a legacy string value under the current exe path', async () => { + mockFs.existsSync.mockReturnValue(true) + mockFs.readFileSync.mockReturnValue(JSON.stringify({ appDataPath: '/Volumes/External/Data' })) + + const reader = await createReader() + + expect(reader.getUserDataPath()).toEqual({ [MOCK_EXE]: '/Volumes/External/Data' }) + }) + + it('converts an array of valid entries into a record', async () => { + mockFs.existsSync.mockReturnValue(true) + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + appDataPath: [ + { executablePath: '/Applications/Cherry Studio.app/exe', dataPath: '/Volumes/Ext1/Data' }, + { executablePath: '/Applications/Cherry Studio Dev.app/exe', dataPath: '/Volumes/Ext2/DevData' } + ] + }) + ) + + const reader = await createReader() + + expect(reader.getUserDataPath()).toEqual({ + '/Applications/Cherry Studio.app/exe': '/Volumes/Ext1/Data', + '/Applications/Cherry Studio Dev.app/exe': '/Volumes/Ext2/DevData' + }) + }) + + it('filters out invalid entries from a mixed array', async () => { + mockFs.existsSync.mockReturnValue(true) + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + appDataPath: [ + { executablePath: '/valid/exe', dataPath: '/valid/data' }, + { executablePath: '/no-data/exe' }, // missing dataPath + { dataPath: '/no-exe/data' }, // missing executablePath + { executablePath: '', dataPath: '/empty-exe/data' }, // empty string + { executablePath: '/another/valid/exe', dataPath: '/another/valid/data' } + ] + }) + ) + + const reader = await createReader() + + expect(reader.getUserDataPath()).toEqual({ + '/valid/exe': '/valid/data', + '/another/valid/exe': '/another/valid/data' + }) + }) + }) +}) diff --git a/v2-refactor-temp/tools/data-classify/scripts/generate-boot-config.js b/v2-refactor-temp/tools/data-classify/scripts/generate-boot-config.js index e69b6cbd40..c8f6ad5781 100644 --- a/v2-refactor-temp/tools/data-classify/scripts/generate-boot-config.js +++ b/v2-refactor-temp/tools/data-classify/scripts/generate-boot-config.js @@ -3,6 +3,42 @@ const fs = require('fs') const path = require('path') +/** + * Manually maintained boot config items that aren't sourced from + * classification.json. These get merged into the generator's normal + * extraction pipeline so they flow through the same sort/emit code as + * classification-derived items — the output schema is a single, flat, + * fully auto-generated file. + * + * When classification.json learns to model new source kinds (e.g. a + * 'configfile' category), these entries should move into classification.json + * and this constant can shrink. + */ +const MANUAL_BOOT_CONFIG_ITEMS = [ + { + source: 'configfile', + sourceCategory: 'legacy-home', + originalKey: 'appDataPath', + targetKey: 'app.user_data_path', + type: 'Record', + defaultValue: 'VALUE: {}', + jsdoc: [ + 'Custom user data directory, keyed by executable path.', + '', + 'Conceptually a single setting ("where user data lives"); stored as a', + 'Record so the same machine can host multiple installations (stable / dev /', + "portable) with independent user data locations — matching the v1 behavior", + "of ~/.cherrystudio/config/config.json's `appDataPath` array.", + '', + "Key: executable path (matches Electron's `app.getPath('exe')`).", + 'Value: absolute path to the chosen userData directory.', + '', + 'Migrated from v1 ~/.cherrystudio/config/config.json on first v1→v2 run', + "via the 'configfile' source in BootConfigMigrator." + ] + } +] + class BootConfigGenerator { constructor() { this.dataDir = path.resolve(__dirname, '../data') @@ -94,7 +130,14 @@ class BootConfigGenerator { } }) - console.log(`After deduplication: ${deduplicatedData.length} bootConfig items`) + // Append manually maintained items (config-file source for v1 legacy + // home config). These bypass classification.json deliberately — see the + // MANUAL_BOOT_CONFIG_ITEMS comment at the top of this file. + for (const manual of MANUAL_BOOT_CONFIG_ITEMS) { + deduplicatedData.push({ ...manual, fullPath: `${manual.source}/${manual.sourceCategory}/${manual.originalKey}` }) + } + + console.log(`After deduplication + manual items: ${deduplicatedData.length} bootConfig items`) return deduplicatedData } @@ -146,16 +189,29 @@ class BootConfigGenerator { * Auto-generated boot config schema * Generated at: ${new Date().toISOString()} * - * This file is automatically generated from classification.json - * To update this file, modify classification.json and run: + * This file is automatically generated from classification.json (plus a + * small MANUAL_BOOT_CONFIG_ITEMS list in generate-boot-config.js for keys + * that don't fit classification.json's model yet, e.g. config-file sources). + * + * To update this file, either modify classification.json or the manual list + * in the generator, then run: * node v2-refactor-temp/tools/data-classify/scripts/generate-boot-config.js * * === AUTO-GENERATED CONTENT START === */` let interfaceCode = 'export interface BootConfigSchema {\n' - sortedData.forEach((item) => { + sortedData.forEach((item, index) => { const tsType = this.mapType(item.type, item.defaultValue) + // Optional JSDoc block (currently only emitted for manual items). + if (Array.isArray(item.jsdoc) && item.jsdoc.length > 0) { + if (index > 0) interfaceCode += '\n' + interfaceCode += ' /**\n' + for (const line of item.jsdoc) { + interfaceCode += line.length > 0 ? ` * ${line}\n` : ' *\n' + } + interfaceCode += ' */\n' + } interfaceCode += ` // ${item.source}/${item.sourceCategory}/${item.originalKey}\n` interfaceCode += ` '${item.targetKey}': ${tsType}\n` }) diff --git a/v2-refactor-temp/tools/data-classify/scripts/generate-migration.js b/v2-refactor-temp/tools/data-classify/scripts/generate-migration.js index f836470ed6..d54d03e074 100644 --- a/v2-refactor-temp/tools/data-classify/scripts/generate-migration.js +++ b/v2-refactor-temp/tools/data-classify/scripts/generate-migration.js @@ -287,12 +287,14 @@ export const LOCALSTORAGE_MAPPINGS: ReadonlyArray<{ originalKey: string; targetK * === AUTO-GENERATED CONTENT START === */ +import type { BootConfigKey } from '@shared/data/bootConfig/bootConfigTypes' + /** * ElectronStore映射关系 - 简单一层结构 * * ElectronStore没有嵌套,originalKey直接对应configManager.get(key) */ -export const BOOT_CONFIG_ELECTRON_STORE_MAPPINGS: ReadonlyArray<{ originalKey: string; targetKey: string }> = ${JSON.stringify(electronStoreMappings, null, 2)} as const +export const BOOT_CONFIG_ELECTRON_STORE_MAPPINGS: ReadonlyArray<{ originalKey: string; targetKey: BootConfigKey }> = ${JSON.stringify(electronStoreMappings, null, 2)} as const /** * Redux Store映射关系 - 按category分组,支持嵌套路径 @@ -304,12 +306,12 @@ export const BOOT_CONFIG_REDUX_MAPPINGS = ${JSON.stringify(reduxMappings, null, /** * Dexie Settings映射关系 - 简单KV结构 */ -export const BOOT_CONFIG_DEXIE_SETTINGS_MAPPINGS: ReadonlyArray<{ originalKey: string; targetKey: string }> = ${JSON.stringify(dexieSettingsMappings, null, 2)} as const +export const BOOT_CONFIG_DEXIE_SETTINGS_MAPPINGS: ReadonlyArray<{ originalKey: string; targetKey: BootConfigKey }> = ${JSON.stringify(dexieSettingsMappings, null, 2)} as const /** * localStorage映射关系 - 简单KV结构 */ -export const BOOT_CONFIG_LOCALSTORAGE_MAPPINGS: ReadonlyArray<{ originalKey: string; targetKey: string }> = ${JSON.stringify(localStorageMappings, null, 2)} as const +export const BOOT_CONFIG_LOCALSTORAGE_MAPPINGS: ReadonlyArray<{ originalKey: string; targetKey: BootConfigKey }> = ${JSON.stringify(localStorageMappings, null, 2)} as const // === AUTO-GENERATED CONTENT END ===