mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-06 05:55:28 +08:00
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:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 ===
|
||||
|
||||
|
||||
110
src/main/data/migration/v2/utils/LegacyHomeConfigReader.ts
Normal file
110
src/main/data/migration/v2/utils/LegacyHomeConfigReader.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
})
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
|
||||
Reference in New Issue
Block a user