From 79c4bcea5aa8a5f526a437705c750d8165bdebbd Mon Sep 17 00:00:00 2001 From: Dorence Deng Date: Fri, 17 Jul 2026 21:44:48 +0800 Subject: [PATCH] fix(app): honor DEEPCHAT_E2E_USER_DATA_DIR before startup --- .../user-data-profile-path/spec.md | 107 ++++++++++++++++++ src/main/provider/providerDbLoader.ts | 2 +- src/shared/logger.ts | 2 +- test/e2e/fixtures/electronApp.ts | 5 +- test/main/provider/providerDbLoader.test.ts | 15 +++ 5 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 docs/architecture/user-data-profile-path/spec.md diff --git a/docs/architecture/user-data-profile-path/spec.md b/docs/architecture/user-data-profile-path/spec.md new file mode 100644 index 0000000000..d79a054aa1 --- /dev/null +++ b/docs/architecture/user-data-profile-path/spec.md @@ -0,0 +1,107 @@ +# User Data Profile Path + +Status: implemented as a runtime profile path contract and maintained as an architecture spec. + +## Summary + +DeepChat stores profile-scoped application files under one Electron `userData` profile path, used for settings, +SQLite databases, provider catalog cache, plugin data, OAuth credentials, generated assets, temporary files, and logs. + +By default DeepChat uses Electron's standard `userData` path and does not override it. Development, preview, E2E, and +build validation may launch DeepChat with an explicit profile path through `process.env.DEEPCHAT_E2E_USER_DATA_DIR`. + +## Path Contract + +### Definitions + +- **Profile path**: the effective directory returned by `app.getPath('userData')` after startup path selection has run. +- **Default profile path**: Electron's standard `userData` path, `path.join(app.getPath('appData'), 'DeepChat')`. The + `appData` base directory follows Electron's default and is platform-dependent. App name must stay `DeepChat`, as + configured in `package.json` and `electron-builder.yml`. +- **Explicit profile path**: a profile path set through `process.env.DEEPCHAT_E2E_USER_DATA_DIR`. When it holds a + trimmed non-whitespace value, it overrides the default profile path for the current process. + +### Explicit Profile Source + +The explicit profile path is process-local and does not migrate, copy, or delete data from any other profile. +`DEEPCHAT_E2E_USER_DATA_DIR` reaches the application from two sources: + +1. **User-defined**: a user sets it manually for local development, preview, or packaged-build validation, + e.g. `export DEEPCHAT_E2E_USER_DATA_DIR=/tmp/deepchat-profile`. The Playwright fixture reuses this profile and + preserves directory after the run. +2. **E2E-defined**: when the variable is unset, the Playwright fixture creates a temporary profile under + `os.tmpdir()/deepchat-e2e-user-data-*` and deletes it after the run. + +## Required Behavior + +- Modules covered by this contract must resolve profile-scoped files under the effective profile path. +- Startup must apply the explicit profile path before presenters, windows, databases, caches, or profile-backed + services are created. +- Modules that resolve profile paths before normal startup must follow the same rule. They may either delay path + resolution until after startup applies `app.setPath('userData', ...)`, or read `DEEPCHAT_E2E_USER_DATA_DIR` directly + and fall back to Electron's default path. +- Main-process logs must be written under `/logs/main.log`. +- Provider database cache files must be written under `/provider-db/`. +- While an explicit profile is in effect, the process must confine all profile-scoped reads and writes to that + profile and must not touch the default profile path, so a default-profile build and an explicit-profile build can + run at the same time. +- Selecting a profile path must not bypass privacy mode, credential handling, database encryption, or normal app + settings semantics. +- During E2E test runs, the fixture must pass its selected profile to the launched Electron process through + `DEEPCHAT_E2E_USER_DATA_DIR`. + +## Current Implementation + +`src/main/appMain.ts` applies the explicit profile path at the start of `startApp()`: + +```typescript +const e2eUserDataDir = process.env.DEEPCHAT_E2E_USER_DATA_DIR?.trim() +if (e2eUserDataDir) { + app.setPath('userData', e2eUserDataDir) +} +``` + +Once the Electron app has started and is ready, normal profile data resolves through `app.getPath('userData')`: +application settings (`app-settings.json`), SQLite databases (`app_db/`), OAuth credentials (`*-auth/`), plugins +(`plugins/`), workspaces (`workspaces/`), and generated images (`images/`). + +**Early path resolvers** run before startup applies `app.setPath('userData', ...)`, so they should read +`DEEPCHAT_E2E_USER_DATA_DIR` first and fall back to the default profile path. + +| Resolver | File | Provides | Rationale (Why not `app.getPath`) | +| --- | --- | --- | --- | +| `log.transports.file.resolvePathFn` | `src/shared/logger.ts` | log `logs/main.log` | `appMain.ts` → `logger.ts` | +| `ProviderDbLoader.userDataDir` | `src/main/provider/providerDbLoader.ts` | model provider `provider-db/` | `appMain.ts` → `app/mainProcess.ts` → `app/composition.ts` → `providerDbLoader.ts` | +| `getDefaultUserDataDir()` | `test/e2e/fixtures/electronApp.ts` | profile root | No Electron `app` | + +## Acceptance Criteria + +- With `DEEPCHAT_E2E_USER_DATA_DIR` unset or whitespace-only, startup does not call `app.setPath('userData', ...)` and + DeepChat runs on Electron's default profile path. +- With `DEEPCHAT_E2E_USER_DATA_DIR` set to a non-empty absolute path, startup applies that path before profile-backed + runtime initialization. +- In application startup and early path resolvers, whitespace-only `DEEPCHAT_E2E_USER_DATA_DIR` is treated as unset. +- Main-process logs are created under `/logs/main.log`. +- Provider database cache files are created under `/provider-db/`. +- With `DEEPCHAT_E2E_USER_DATA_DIR` already set, the E2E fixture uses that directory and must not delete it. +- With `DEEPCHAT_E2E_USER_DATA_DIR` unset, the E2E fixture creates a temporary profile and deletes only that + fixture-owned directory after the run. +- No code path uses `DEEPCHAT_E2E_USER_DATA_DIR` as a signal for smoke tests, CI, mocks, disabled network access, or + alternate provider behavior. + +## Non-Goals + +- Profile-path override mechanism other than `DEEPCHAT_E2E_USER_DATA_DIR`. + +## Validation + +- Launch with `DEEPCHAT_E2E_USER_DATA_DIR` set to a profile directory and verify profile-backed files appear under + that directory. +- Verify `logs/main.log` is created under the selected profile path. +- Verify provider DB cache files are created under the selected profile path. +- Run a Playwright spec with `DEEPCHAT_E2E_USER_DATA_DIR` unset and verify the fixture-owned temporary directory is + deleted. + +## Open Questions + +None. diff --git a/src/main/provider/providerDbLoader.ts b/src/main/provider/providerDbLoader.ts index c835cad047..197e2c8afb 100644 --- a/src/main/provider/providerDbLoader.ts +++ b/src/main/provider/providerDbLoader.ts @@ -51,7 +51,7 @@ export class ProviderDbLoader { private readonly catalogListeners = new Set() constructor() { - this.userDataDir = app.getPath('userData') + this.userDataDir = process.env.DEEPCHAT_E2E_USER_DATA_DIR?.trim() || app.getPath('userData') this.cacheDir = path.join(this.userDataDir, 'provider-db') this.cacheFilePath = path.join(this.cacheDir, 'providers.json') this.metaFilePath = path.join(this.cacheDir, 'meta.json') diff --git a/src/shared/logger.ts b/src/shared/logger.ts index 0540f2707c..648f8ba089 100644 --- a/src/shared/logger.ts +++ b/src/shared/logger.ts @@ -5,7 +5,7 @@ import { is } from '@electron-toolkit/utils' // Configure log file path // Use logger for recording instead of console -const userData = app?.getPath('userData') || '' +const userData = process.env.DEEPCHAT_E2E_USER_DATA_DIR?.trim() || app?.getPath('userData') || '' if (userData) { log.transports.file.resolvePathFn = () => path.join(userData, 'logs/main.log') } diff --git a/test/e2e/fixtures/electronApp.ts b/test/e2e/fixtures/electronApp.ts index 2079d57649..d75205757c 100644 --- a/test/e2e/fixtures/electronApp.ts +++ b/test/e2e/fixtures/electronApp.ts @@ -129,8 +129,9 @@ const attachDiagnostics = async ( } const getDefaultUserDataDir = (): string => { - if (process.env.DEEPCHAT_E2E_USER_DATA_DIR) { - return resolve(process.env.DEEPCHAT_E2E_USER_DATA_DIR) + const e2eUserDataDir = process.env.DEEPCHAT_E2E_USER_DATA_DIR?.trim() + if (e2eUserDataDir) { + return resolve(e2eUserDataDir) } if (process.platform === 'win32') { diff --git a/test/main/provider/providerDbLoader.test.ts b/test/main/provider/providerDbLoader.test.ts index f9a3a7d829..dc9de71bc8 100644 --- a/test/main/provider/providerDbLoader.test.ts +++ b/test/main/provider/providerDbLoader.test.ts @@ -106,6 +106,21 @@ describe('ProviderDbLoader', () => { fs.rmSync(tempRoot, { recursive: true, force: true }) }) + it('resolves DEEPCHAT_E2E_USER_DATA_DIR as provider-db base directory', async () => { + const oldDir = process.env.DEEPCHAT_E2E_USER_DATA_DIR + const e2eUserDataRoot = path.join(tempRoot, 'e2e-user-data') + process.env.DEEPCHAT_E2E_USER_DATA_DIR = e2eUserDataRoot + const ProviderDbLoader = await importLoader() + new ProviderDbLoader() + expect(fs.existsSync(path.join(e2eUserDataRoot, 'provider-db'))).toBe(true) + expect(fs.existsSync(getCacheDir())).toBe(false) + if (oldDir === undefined) { + delete process.env.DEEPCHAT_E2E_USER_DATA_DIR + } else { + process.env.DEEPCHAT_E2E_USER_DATA_DIR = oldDir + } + }) + it('initializes from cache and still triggers a startup refresh when cache is fresh', async () => { writeBuiltInDb(createAggregate(['builtin'])) writeCachedDb(createAggregate(['openai']))