feat(migration): migrate v1 ~/.cherrystudio/config/config.json into BootConfig

Add a 'configfile' source kind to BootConfigMigrator so the v1 home config
file's `appDataPath` is migrated into a new BootConfig key
`app.user_data_path: Record<string, string>`, keyed by executable path.

v1's `~/.cherrystudio/config/config.json` is conceptually "early-boot
configuration that must be readable before userData is decided" — the same
category as BootConfig. v1 `config/config.json` will eventually be replaced
entirely by BootConfig; this PR establishes the data migration path, and a
follow-up will rewire `initAppDataDir()` to read from BootConfig instead.

Key pieces:

- New `LegacyHomeConfigReader` — sync fs read, normalizes both the legacy
  string and the array-of-{executablePath,dataPath} v1 formats into a
  `Record<string, string>`. Returns `null` (not `{}`) for "no data" so the
  migrator's null-skip guard distinguishes "missing" from "empty record"
  and doesn't write a spurious empty entry.

- BootConfigMigrator: new 'configfile' branch in `prepare()`; inline
  `configFileMappings` local const with `targetKey: BootConfigKey` type
  annotation (regen safety net — schema loses `app.user_data_path` →
  compile error at the declaration site). `defaultValue: null` on
  config-file items so "no v1 file" skips the item rather than falling
  back to the schema default `{}`.

- Type tightening: `MigrationItem.targetKey` and `PreparedData.targetKey`
  narrowed from `string` to `BootConfigKey`; all 6 `as BootConfigKey`
  assertions deleted. This removes a silent "regen drops the key" failure
  mode where the assertions previously suppressed compile errors.

- Generator updates: `generate-boot-config.js` gains a
  `MANUAL_BOOT_CONFIG_ITEMS` list that flows through the same sort/emit
  pipeline as classification-derived items, keeping `bootConfigSchemas.ts`
  as a fully auto-generated single-interface file (no manual sections, no
  declaration merging, no preservation logic). `generate-migration.js`
  tightens the 3 BootConfig mapping arrays to `targetKey: BootConfigKey`.

- Tests: new `LegacyHomeConfigReader.test.ts` covers 9 scenarios (7 null
  returns + 2 populated records); new `BootConfigMigrator.test.ts` covers
  the configfile source end-to-end plus a redux-source regression.

- Docs: per-migrator `README-BootConfigMigrator.md` created per the
  convention in `v2-migration-guide.md`. `v2-migration-guide.md`,
  `boot-config-overview.md`, `boot-config-schema-guide.md`, and
  `src/main/data/migration/v2/README.md` updated to mention
  BootConfigMigrator, `LegacyHomeConfigReader`, and the 'configfile'
  source kind. The AppImage/Windows-portable `executablePath`
  normalization gap is documented as a known limitation that must be
  addressed in the follow-up read-side PR.

Explicitly out of scope:

- Rewiring `initAppDataDir()` / `updateAppDataConfig()` to read/write
  BootConfig — the v1 home config file keeps working unchanged.
- Adding a 'configfile' category to classification.json — the
  data-classify toolchain isn't modelled for file sources yet.
- Deleting the v1 home config file post-migration — left intact.

Verified end-to-end on a real v1 config.json: the migrator produced
`"app.user_data_path": { [executablePath]: dataPath }` in
`~/.cherrystudio/boot-config.json`, verbatim-preserving the v1 data.

Signed-off-by: fullex <0xfullex@gmail.com>
This commit is contained in:
fullex
2026-04-07 09:20:23 -07:00
parent b336731521
commit c17dd033fc
14 changed files with 735 additions and 33 deletions

View File

@@ -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<executablePath, dataPath>` 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 |

View File

@@ -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

View File

@@ -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-<name>.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<executablePath, dataPath> | null`. Used exclusively by `BootConfigMigrator`'s `'configfile'` source.
## Window & IPC Integration

View File

@@ -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<string, string>
}
export const DefaultBootConfig: BootConfigSchema = {
'app.disable_hardware_acceleration': false
'app.disable_hardware_acceleration': false,
'app.user_data_path': {}
}
// === AUTO-GENERATED CONTENT END ===

View File

@@ -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
```

View File

@@ -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(),

View File

@@ -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<string, string> | 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
}
}

View File

@@ -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<executablePath, dataPath>`; 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<string, string>` | *(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<string, string>` 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)

View File

@@ -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<string, unknown> = {}
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<string, unknown>
legacyHomeConfig?: Record<string, string> | 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)
})
})
})

View File

@@ -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 ===

View File

@@ -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<executablePath, dataPath>`.
* 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<string, string> | null
constructor() {
this.userDataPath = this.loadSync()
}
/**
* Return the parsed `appDataPath` as a Record<executablePath, dataPath>,
* 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<string, string> | null {
return this.userDataPath
}
private loadSync(): Record<string, string> | 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<string, unknown>).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<string, string> = {}
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
}
}

View File

@@ -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'
})
})
})
})

View File

@@ -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<string, string>',
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`
})

View File

@@ -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 ===