From 624fad67e6c0931c10f50860300fbbcaba85b6d5 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Thu, 13 Aug 2026 22:49:50 +0200
Subject: [PATCH 01/12] fix(storage): unify desktop key access and use atomic
writes
---
components/ApiKeySection.tsx | 13 ++++-----
services/fs/assetFsStore.ts | 8 +++---
services/fs/codexFsStore.ts | 12 ++++++--
services/fs/fsCore.ts | 37 +++++++++++++++++++++++++
services/fs/projectFsStore.ts | 12 ++++++--
services/fs/settingsFsStore.ts | 6 ++--
services/fs/snapshotFsStore.ts | 10 +++++--
src-tauri/capabilities/default.json | 10 ++++++-
tests/unit/ApiKeySection.test.tsx | 25 +++++++----------
tests/unit/fileSystemService.test.ts | 1 +
tests/unit/services/fs/fsStores.test.ts | 15 +++++++++-
11 files changed, 109 insertions(+), 40 deletions(-)
diff --git a/components/ApiKeySection.tsx b/components/ApiKeySection.tsx
index 704c2de68..6f2f649cb 100644
--- a/components/ApiKeySection.tsx
+++ b/components/ApiKeySection.tsx
@@ -1,9 +1,9 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from '../hooks/useTranslation';
-import { dbService } from '../services/dbService';
import { generateText, invalidateAiClientCache } from '../services/geminiService';
import { logger } from '../services/logger';
+import { storageService } from '../services/storageService';
import { Button } from './ui/Button';
import { Input } from './ui/Input';
import { Spinner } from './ui/Spinner';
@@ -34,14 +34,11 @@ export const ApiKeySection: FC = () => {
const checkKeyStatus = useCallback(async () => {
setIsLoading(true);
try {
- const exists = await dbService.hasGeminiApiKey();
+ const exists = Boolean(await storageService.getGeminiApiKey());
setHasKey(exists);
// Check if key exists but decryption failed (device change, cleared site data)
if (!exists) {
- const raw = await dbService.getGeminiApiKey();
- if (raw === 'DECRYPT_FAILED') {
- setDecryptFailed(true);
- }
+ setDecryptFailed(false);
}
} catch (error) {
logger.error('Failed to check API key status:', error);
@@ -68,7 +65,7 @@ export const ApiKeySection: FC = () => {
setMessage(null);
try {
// QNBS-v3: this only checks syntax and persists — handleTestConnection (auto-triggered below) is what actually confirms the key authenticates; "Active" here means "saved", not "verified working."
- await dbService.saveGeminiApiKey(normalizedKey);
+ await storageService.saveGeminiApiKey(normalizedKey);
invalidateAiClientCache();
setApiKey('');
setHasKey(true);
@@ -95,7 +92,7 @@ export const ApiKeySection: FC = () => {
setMessage(null);
setTestResult(null);
try {
- await dbService.clearGeminiApiKey();
+ await storageService.clearGeminiApiKey();
invalidateAiClientCache();
setHasKey(false);
setMessage({ type: 'success', text: t('settings.apiKey.removed') });
diff --git a/services/fs/assetFsStore.ts b/services/fs/assetFsStore.ts
index 32ce52ca7..55e5833a3 100644
--- a/services/fs/assetFsStore.ts
+++ b/services/fs/assetFsStore.ts
@@ -6,7 +6,7 @@
import { logger } from '../logger';
import type { BinderAssetMeta, BinderAssetPayload } from '../storageBackend';
-import { retryFs, sanitizePathSegment } from './fsCore';
+import { retryFs, sanitizePathSegment, writeFileAtomic, writeTextFileAtomic } from './fsCore';
import { FsSnapshotStore } from './snapshotFsStore';
export class FsAssetStore extends FsSnapshotStore {
@@ -23,7 +23,7 @@ export class FsAssetStore extends FsSnapshotStore {
const imageFile = await apis.join(imagesPath, `${sanitizePathSegment(id, 'image')}.png`);
const cleanBase64 = base64Data.replace(/^data:image\/png;base64,/, '');
- await retryFs(() => apis.writeTextFile(imageFile, cleanBase64));
+ await writeTextFileAtomic(apis, imageFile, cleanBase64);
}
async getImage(id: string): Promise {
@@ -88,8 +88,8 @@ export class FsAssetStore extends FsSnapshotStore {
const { dir, binFile, metaFile } = await this.binderAssetPaths(projectId, assetId);
if (!(await apis.exists(dir))) await apis.mkdir(dir, { recursive: true });
const metaOut: BinderAssetMeta = { ...meta, byteSize: data.byteLength };
- await retryFs(() => apis.writeFile(binFile, new Uint8Array(data)));
- await retryFs(() => apis.writeTextFile(metaFile, JSON.stringify(metaOut)));
+ await writeFileAtomic(apis, binFile, new Uint8Array(data));
+ await writeTextFileAtomic(apis, metaFile, JSON.stringify(metaOut));
}
async getBinderAsset(projectId: string, assetId: string): Promise {
diff --git a/services/fs/codexFsStore.ts b/services/fs/codexFsStore.ts
index 2176506a1..b1e6d3736 100644
--- a/services/fs/codexFsStore.ts
+++ b/services/fs/codexFsStore.ts
@@ -6,7 +6,13 @@
import type { StoryCodex } from '../../types';
import { logger } from '../logger';
-import { compressData, decompressData, retryFs, sanitizePathSegment } from './fsCore';
+import {
+ compressData,
+ decompressData,
+ retryFs,
+ sanitizePathSegment,
+ writeTextFileAtomic,
+} from './fsCore';
import { FsSettingsStore } from './settingsFsStore';
export class FsCodexStore extends FsSettingsStore {
@@ -19,7 +25,7 @@ export class FsCodexStore extends FsSettingsStore {
const codexDir = await apis.join(appDataPath, 'projects', safeId, 'codex');
if (!(await apis.exists(codexDir))) await apis.mkdir(codexDir, { recursive: true });
const codexFile = await apis.join(codexDir, 'codex.snap');
- await retryFs(() => apis.writeTextFile(codexFile, compressData(codex)));
+ await writeTextFileAtomic(apis, codexFile, compressData(codex));
}
async getStoryCodex(projectId: string): Promise {
@@ -58,7 +64,7 @@ export class FsCodexStore extends FsSettingsStore {
const codexDir = await apis.join(appDataPath, 'projects', safeId, 'codex');
if (!(await apis.exists(codexDir))) await apis.mkdir(codexDir, { recursive: true });
const vectorsFile = await apis.join(codexDir, 'vectors.snap');
- await retryFs(() => apis.writeTextFile(vectorsFile, compressData(vectors)));
+ await writeTextFileAtomic(apis, vectorsFile, compressData(vectors));
}
async getRagVectors(projectId: string): Promise {
diff --git a/services/fs/fsCore.ts b/services/fs/fsCore.ts
index 9f09f0128..48ef5f5d0 100644
--- a/services/fs/fsCore.ts
+++ b/services/fs/fsCore.ts
@@ -16,6 +16,7 @@ export type TauriApis = {
exists: (path: string) => Promise;
readDir: (path: string) => Promise<{ name?: string; isDirectory?: boolean }[]>;
remove: (path: string, opts?: { recursive?: boolean }) => Promise;
+ rename: (oldPath: string, newPath: string) => Promise;
open: (opts?: Record) => Promise;
save: (opts?: Record) => Promise;
appDataDir: () => Promise;
@@ -44,6 +45,7 @@ export async function loadTauriApis(): Promise {
exists: fsModule.exists,
readDir: fsModule.readDir as TauriApis['readDir'],
remove: fsModule.remove,
+ rename: fsModule.rename,
open: dialogModule.open as TauriApis['open'],
save: dialogModule.save as TauriApis['save'],
appDataDir: pathModule.appDataDir,
@@ -78,6 +80,41 @@ export async function retryFs(fn: () => Promise, retries = 2, delayMs = 50
throw lastError;
}
+function temporaryPath(path: string): string {
+ const suffix =
+ typeof crypto.randomUUID === 'function'
+ ? crypto.randomUUID()
+ : Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) =>
+ byte.toString(16).padStart(2, '0'),
+ ).join('');
+ return `${path}.tmp-${suffix}`;
+}
+
+async function writeAndReplace(
+ apis: TauriApis,
+ path: string,
+ write: (temporary: string) => Promise,
+): Promise {
+ const temporary = temporaryPath(path);
+ try {
+ await retryFs(() => write(temporary));
+ await retryFs(() => apis.rename(temporary, path));
+ } catch (error) {
+ await apis.remove(temporary).catch(() => undefined);
+ throw error;
+ }
+}
+
+// QNBS-v3: replace authoritative files only after a complete sibling write, preserving the last valid file on interruption.
+export function writeTextFileAtomic(apis: TauriApis, path: string, content: string): Promise {
+ return writeAndReplace(apis, path, (temporary) => apis.writeTextFile(temporary, content));
+}
+
+// QNBS-v3: binary assets use the same same-directory replace so readers never observe a partial file.
+export function writeFileAtomic(apis: TauriApis, path: string, data: Uint8Array): Promise {
+ return writeAndReplace(apis, path, (temporary) => apis.writeFile(temporary, data));
+}
+
// --- LZ-String compression (mirrors dbService threshold and prefix) ---
const COMPRESS_THRESHOLD = 10_240;
diff --git a/services/fs/projectFsStore.ts b/services/fs/projectFsStore.ts
index cfcb5a086..0f59d5380 100644
--- a/services/fs/projectFsStore.ts
+++ b/services/fs/projectFsStore.ts
@@ -10,7 +10,13 @@ import { logger } from '../logger';
import { parseImportedProjectJson } from '../projectImportSchema';
import { normalizeSaveProjectInputToStoryProject, type SaveProjectInput } from '../storageBackend';
import { FsAssetStore } from './assetFsStore';
-import { compressData, decompressData, retryFs, sanitizePathSegment } from './fsCore';
+import {
+ compressData,
+ decompressData,
+ retryFs,
+ sanitizePathSegment,
+ writeTextFileAtomic,
+} from './fsCore';
export class FsProjectStore extends FsAssetStore {
async saveProject(project: SaveProjectInput): Promise {
@@ -36,7 +42,7 @@ export class FsProjectStore extends FsAssetStore {
}
const projectFile = await apis.join(projectPath, 'project.json');
- await retryFs(() => apis.writeTextFile(projectFile, compressData(flat)));
+ await writeTextFileAtomic(apis, projectFile, compressData(flat));
// QNBS-v3 (#332): documented best-effort abort — the project data above already saved; a failed marker write only degrades the next cold-boot's project selection, not worth failing this save over.
await this.setActiveProjectId(projectId).catch((error) => {
logger.warn('Failed to persist active-project marker (project save itself succeeded)', {
@@ -54,7 +60,7 @@ export class FsProjectStore extends FsAssetStore {
await apis.mkdir(configPath, { recursive: true });
}
const markerFile = await apis.join(configPath, 'active-project-id.txt');
- await retryFs(() => apis.writeTextFile(markerFile, projectId));
+ await writeTextFileAtomic(apis, markerFile, projectId);
}
/**
diff --git a/services/fs/settingsFsStore.ts b/services/fs/settingsFsStore.ts
index 09be76e44..4a610d508 100644
--- a/services/fs/settingsFsStore.ts
+++ b/services/fs/settingsFsStore.ts
@@ -10,7 +10,7 @@ import type { Settings } from '../../types';
import { logger } from '../logger';
import { normalizePersistedSettings } from '../storage/idbProjectStore';
import type { TauriApis } from './fsCore';
-import { decryptText, encryptText, FsCore, retryFs } from './fsCore';
+import { decryptText, encryptText, FsCore, retryFs, writeTextFileAtomic } from './fsCore';
export class FsSettingsStore extends FsCore {
async saveSettings(settings: Settings): Promise {
@@ -23,7 +23,7 @@ export class FsSettingsStore extends FsCore {
}
const settingsFile = await apis.join(configPath, 'settings.json');
- await retryFs(() => apis.writeTextFile(settingsFile, JSON.stringify(settings, null, 2)));
+ await writeTextFileAtomic(apis, settingsFile, JSON.stringify(settings, null, 2));
}
async loadSettings(): Promise {
@@ -77,7 +77,7 @@ export class FsSettingsStore extends FsCore {
`${appDataPath}|${provider}|WorldScriptStudio|v1`,
);
const filePath = await apis.join(configPath, `${provider}_key.enc.json`);
- await retryFs(() => apis.writeTextFile(filePath, JSON.stringify(encrypted)));
+ await writeTextFileAtomic(apis, filePath, JSON.stringify(encrypted));
}
async getApiKey(provider: string): Promise {
diff --git a/services/fs/snapshotFsStore.ts b/services/fs/snapshotFsStore.ts
index 9574fcac6..c689fc5cb 100644
--- a/services/fs/snapshotFsStore.ts
+++ b/services/fs/snapshotFsStore.ts
@@ -6,7 +6,13 @@
import type { ProjectSnapshot } from '../../types';
import { logger } from '../logger';
import { FsCodexStore } from './codexFsStore';
-import { compressData, countProjectWords, decompressData, retryFs } from './fsCore';
+import {
+ compressData,
+ countProjectWords,
+ decompressData,
+ retryFs,
+ writeTextFileAtomic,
+} from './fsCore';
// Envelope stored in each snapshot file — outer shell is plain JSON, `data` field is compressed.
interface SnapshotEnvelope {
@@ -36,7 +42,7 @@ export class FsSnapshotStore extends FsCodexStore {
data: compressData(data),
};
const snapshotFile = await apis.join(snapshotsPath, `${id}.json`);
- await retryFs(() => apis.writeTextFile(snapshotFile, JSON.stringify(envelope)));
+ await writeTextFileAtomic(apis, snapshotFile, JSON.stringify(envelope));
return id;
}
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index 0f7032d4b..2aae7f50a 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -55,6 +55,14 @@
}
]
},
+ {
+ "identifier": "fs:allow-rename",
+ "allow": [
+ {
+ "path": "$APPDATA/**"
+ }
+ ]
+ },
"dialog:default",
"dialog:allow-open",
"dialog:allow-save",
@@ -84,4 +92,4 @@
"core:window:allow-close",
"core:window:allow-destroy"
]
-}
\ No newline at end of file
+}
diff --git a/tests/unit/ApiKeySection.test.tsx b/tests/unit/ApiKeySection.test.tsx
index 74b346137..934f2f102 100644
--- a/tests/unit/ApiKeySection.test.tsx
+++ b/tests/unit/ApiKeySection.test.tsx
@@ -7,14 +7,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
// ---------------------------------------------------------------------------
const {
- mockHasGeminiApiKey,
mockGetGeminiApiKey,
mockSaveGeminiApiKey,
mockClearGeminiApiKey,
mockGenerateText,
mockInvalidateAiClientCache,
} = vi.hoisted(() => ({
- mockHasGeminiApiKey: vi.fn(),
mockGetGeminiApiKey: vi.fn(),
mockSaveGeminiApiKey: vi.fn(),
mockClearGeminiApiKey: vi.fn(),
@@ -22,9 +20,8 @@ const {
mockInvalidateAiClientCache: vi.fn(),
}));
-vi.mock('../../services/dbService', () => ({
- dbService: {
- hasGeminiApiKey: mockHasGeminiApiKey,
+vi.mock('../../services/storageService', () => ({
+ storageService: {
getGeminiApiKey: mockGetGeminiApiKey,
saveGeminiApiKey: mockSaveGeminiApiKey,
clearGeminiApiKey: mockClearGeminiApiKey,
@@ -65,7 +62,6 @@ import ApiKeySection, { isSyntacticallySafeGeminiApiKey } from '../../components
describe('ApiKeySection', () => {
beforeEach(() => {
vi.clearAllMocks();
- mockHasGeminiApiKey.mockResolvedValue(false);
mockGetGeminiApiKey.mockResolvedValue(null);
mockSaveGeminiApiKey.mockResolvedValue(undefined);
mockClearGeminiApiKey.mockResolvedValue(undefined);
@@ -83,19 +79,18 @@ describe('ApiKeySection', () => {
});
it('shows configured status when key exists', async () => {
- mockHasGeminiApiKey.mockResolvedValue(true);
+ mockGetGeminiApiKey.mockResolvedValue('configured-key');
render();
await waitFor(() => {
expect(screen.getByText('settings.apiKey.statusActive')).toBeTruthy();
});
});
- it('shows decryptFailed warning when getGeminiApiKey returns DECRYPT_FAILED', async () => {
- mockHasGeminiApiKey.mockResolvedValue(false);
- mockGetGeminiApiKey.mockResolvedValue('DECRYPT_FAILED');
+ it('keeps the key inactive when the shared storage path cannot read a key', async () => {
+ mockGetGeminiApiKey.mockResolvedValue(null);
render();
await waitFor(() => {
- expect(screen.getByText('apiKey.decryptFailed')).toBeTruthy();
+ expect(screen.getByText('settings.apiKey.statusInactive')).toBeTruthy();
});
});
@@ -159,7 +154,7 @@ describe('ApiKeySection', () => {
});
});
- it('shows save error when dbService throws', async () => {
+ it('shows save error when shared storage throws', async () => {
mockSaveGeminiApiKey.mockRejectedValue(new Error('DB error'));
render();
await waitFor(() => screen.getByText('settings.apiKey.statusInactive'));
@@ -173,7 +168,7 @@ describe('ApiKeySection', () => {
});
it('removes key and shows removed message', async () => {
- mockHasGeminiApiKey.mockResolvedValue(true);
+ mockGetGeminiApiKey.mockResolvedValue('configured-key');
render();
await waitFor(() => screen.getByText('settings.apiKey.statusActive'));
@@ -189,7 +184,7 @@ describe('ApiKeySection', () => {
});
it('shows test connection result on success', async () => {
- mockHasGeminiApiKey.mockResolvedValue(true);
+ mockGetGeminiApiKey.mockResolvedValue('configured-key');
mockGenerateText.mockResolvedValue('OK');
render();
await waitFor(() => screen.getByText('apiKey.test'));
@@ -201,7 +196,7 @@ describe('ApiKeySection', () => {
});
it('shows invalid key message when test returns INVALID_API_KEY error', async () => {
- mockHasGeminiApiKey.mockResolvedValue(true);
+ mockGetGeminiApiKey.mockResolvedValue('configured-key');
mockGenerateText.mockRejectedValue(new Error('INVALID_API_KEY error'));
render();
await waitFor(() => screen.getByText('apiKey.test'));
diff --git a/tests/unit/fileSystemService.test.ts b/tests/unit/fileSystemService.test.ts
index e38c6b96e..d5a3f089e 100644
--- a/tests/unit/fileSystemService.test.ts
+++ b/tests/unit/fileSystemService.test.ts
@@ -17,6 +17,7 @@ vi.mock('@tauri-apps/plugin-fs', () => ({
exists: vi.fn().mockRejectedValue(new Error('Tauri not available')),
readDir: vi.fn().mockRejectedValue(new Error('Tauri not available')),
remove: vi.fn().mockRejectedValue(new Error('Tauri not available')),
+ rename: vi.fn().mockRejectedValue(new Error('Tauri not available')),
}));
vi.mock('@tauri-apps/plugin-dialog', () => ({
diff --git a/tests/unit/services/fs/fsStores.test.ts b/tests/unit/services/fs/fsStores.test.ts
index 596511f15..a33481ea6 100644
--- a/tests/unit/services/fs/fsStores.test.ts
+++ b/tests/unit/services/fs/fsStores.test.ts
@@ -27,6 +27,7 @@ vi.mock('@tauri-apps/plugin-fs', () => ({
exists: (p: string) => fsHolder.current.exists(p),
readDir: (p: string) => fsHolder.current.readDir(p),
remove: (p: string, opts?: { recursive?: boolean }) => fsHolder.current.remove(p, opts),
+ rename: (from: string, to: string) => fsHolder.current.rename(from, to),
}));
vi.mock('@tauri-apps/plugin-dialog', () => ({
open: (opts?: Record) => fsHolder.current.open(opts),
@@ -95,6 +96,18 @@ function makeFakeFs(): FakeFs {
for (const k of [...bin.keys()]) if (k.startsWith(`${p}/`)) bin.delete(k);
return Promise.resolve();
},
+ rename: (from: string, to: string) => {
+ if (!text.has(from) && !bin.has(from)) return Promise.reject(new Error(`ENOENT ${from}`));
+ text.delete(to);
+ bin.delete(to);
+ const textValue = text.get(from);
+ const binaryValue = bin.get(from);
+ if (textValue !== undefined) text.set(to, textValue);
+ if (binaryValue !== undefined) bin.set(to, binaryValue);
+ text.delete(from);
+ bin.delete(from);
+ return Promise.resolve();
+ },
readDir: (p: string) => Promise.resolve(under(p).map((name) => ({ name, isDirectory: false }))),
open: () => Promise.resolve(null),
save: () => Promise.resolve(null),
@@ -159,7 +172,7 @@ describe('FsProjectStore — projects', () => {
it('still resolves saveProject and logs a warning when the active-project marker write rejects', async () => {
const originalWriteTextFile = fake.apis.writeTextFile;
fake.apis.writeTextFile = (p: string, c: string) => {
- if (p.endsWith('active-project-id.txt')) return Promise.reject(new Error('disk full'));
+ if (p.includes('active-project-id.txt')) return Promise.reject(new Error('disk full'));
return originalWriteTextFile(p, c);
};
From 509278660dbe8e5c9b7d54499ea64b7fc5eb11bf Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Thu, 13 Aug 2026 22:56:41 +0200
Subject: [PATCH 02/12] fix(security): fail closed desktop key storage and
recovery reset
---
README.md | 6 +--
.../settings/EncryptionRecoveryModal.tsx | 39 +++++++++++++++++--
components/settings/IdbUnlockModal.tsx | 20 ++++++++++
docs/SECURITY-THREAT-MODEL.md | 11 +++---
services/factoryResetService.ts | 17 ++++++++
services/fs/settingsFsStore.ts | 31 +++++----------
services/storageService.ts | 19 ++++-----
tests/unit/factoryResetService.test.ts | 1 +
tests/unit/services/fs/fsStores.test.ts | 37 +++++++-----------
.../settings/EncryptionRecoveryModal.test.tsx | 4 +-
10 files changed, 114 insertions(+), 71 deletions(-)
diff --git a/README.md b/README.md
index 66401b016..937868341 100644
--- a/README.md
+++ b/README.md
@@ -312,7 +312,7 @@ The current primary project, settings, snapshot, image, Codex, RAG, and binder-a
- **AES-256-GCM** with a PBKDF2-derived key (600 000 iterations, SHA-256, 32-byte random salt).
- Gated behind `featureFlags.enableIdbAtRestEncryption`. When a library is configured but locked, protected reads and writes fail closed rather than falling back to plaintext.
- Disable and passphrase rotation are temporarily unavailable until a journaled, cross-store migration protocol can prove recovery after interruption.
-- **Web/PWA build only.** The unlock screen (`IdbUnlockModal`) and session-scoped in-memory key protect the IndexedDB-backed storage path used by the browser/PWA build. On the **Tauri desktop build**, primary project, settings, snapshot, image, Codex, RAG, and binder-asset data are written by the filesystem-backed store (`services/fs/*`), which is plaintext (LZ-string compressed, not encrypted) regardless of this setting — enabling it on desktop still shows the same unlock screen (the passphrase sentinel lives in the WebView's IndexedDB) but does not encrypt the actual manuscript files on disk. No `tauri-plugin-stronghold` or equivalent OS-keychain integration ships today — see the API-key encryption note below for the desktop-specific mechanism that does exist.
+- **Web/PWA and desktop API-key paths are separate from project-file protection.** The unlock screen (`IdbUnlockModal`) and session-scoped in-memory key protect the IndexedDB-backed storage path. On the **Tauri desktop build**, primary project, settings, snapshot, image, Codex, RAG, and binder-asset data are written by the filesystem-backed store (`services/fs/*`), which is plaintext (LZ-string compressed, not encrypted) regardless of this setting. API keys are kept in the WebView's IndexedDB random-key store; the desktop filesystem never receives API-key ciphertext or derived key material.
- At-rest protection reduces disclosure from an extracted browser profile while the library is locked; it does not protect an unlocked renderer, a compromised device, or every persistence surface.
### 🔐 Encrypted Library Backup
@@ -333,7 +333,7 @@ different data, with different key material:
|------|-----------|-------|
| **Browser BYOK API key** | Random, non-extractable AES-256-GCM key generated via `crypto.subtle.generateKey()` — no passphrase, nothing to derive | `services/storage/idbKeyStore.ts` |
| **Browser IDB-at-rest data** _(opt-in, B-1)_ | User passphrase → PBKDF2 (600 000 iterations, SHA-256, random 32-byte salt) → AES-256-GCM, non-extractable key | `services/storage/storageEncryptionService.ts` |
-| **Desktop (Tauri) BYOK API key** | Install-scoped secret material → PBKDF2 (600 000 iterations, SHA-256, random 32-byte salt) → AES-256-GCM, non-extractable key | `services/fs/fsCore.ts`, `services/fs/settingsFsStore.ts` |
+| **Desktop (Tauri) BYOK API key** | Same random, non-extractable AES-256-GCM key store as the browser; no filesystem-derived secret material | `services/storage/idbKeyStore.ts`, `services/storageService.ts` |
| **Library backup vault** | User passphrase → PBKDF2 (600 000 iterations, SHA-256) → AES-256-GCM | `services/libraryBackupService.ts` |
See [`docs/SECURITY-THREAT-MODEL.md`](docs/SECURITY-THREAT-MODEL.md) for the full threat-model mapping.
@@ -573,7 +573,7 @@ WorldScript Studio supports local-only AI (no API key) as well as BYOK cloud pro
1. **Get your key** — e.g. at [Google AI Studio](https://aistudio.google.com/app/apikey) (free tier available)
2. **Open Settings** → AI Provider → select your provider
-3. **Enter your API key** — encrypted with AES-256-GCM (web build: stored in your browser's IndexedDB; desktop build: stored on disk, see the encryption breakdown below); never transmitted except to the provider you select
+3. **Enter your API key** — encrypted with AES-256-GCM in the local random-key store (browser and desktop WebView); never transmitted except to the provider you select
**Security best practices:**
- ✅ Your key never leaves your device in plaintext
diff --git a/components/settings/EncryptionRecoveryModal.tsx b/components/settings/EncryptionRecoveryModal.tsx
index 5b9d94c73..25fd086cf 100644
--- a/components/settings/EncryptionRecoveryModal.tsx
+++ b/components/settings/EncryptionRecoveryModal.tsx
@@ -1,6 +1,7 @@
import type { FC } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from '../../hooks/useTranslation';
+import { wipeAllAppData } from '../../services/factoryResetService';
import { logger } from '../../services/logger';
import type { EncryptionMigrationJournal } from '../../services/storage/encryptionMigrationJournal';
import type { ProtectedStoreMigrationProgress } from '../../services/storage/protectedStoreMigration';
@@ -87,6 +88,12 @@ export const EncryptionRecoveryModal: FC = ({ journal, onRecovered }) =>
const canSubmit =
!busy && sourcePassphrase.length > 0 && (!needsTargetPassphrase || targetPassphrase.length > 0);
+ const handleFactoryReset = useCallback(async () => {
+ if (!window.confirm(t('settings.data.dangerZone.factoryReset.modalWarning'))) return;
+ setBusy(true);
+ await wipeAllAppData();
+ }, [t]);
+
return (
= ({ journal, onRecovered }) =>
{stuck ? (
-
- {t('settings.privacy.encryptionRecoveryStuck')}
-
+
+
+ {t('settings.privacy.encryptionRecoveryStuck')}
+
+
+ {t('settings.data.dangerZone.factoryReset.modalDescription')}
+
+
+
) : (
<>
@@ -196,6 +216,19 @@ export const EncryptionRecoveryModal: FC
= ({ journal, onRecovered }) =>
{t('settings.privacy.encryptionRecoveryResumeButton')}
+
+
+ {t('settings.data.dangerZone.factoryReset.modalDescription')}
+
+
+
>
)}
diff --git a/components/settings/IdbUnlockModal.tsx b/components/settings/IdbUnlockModal.tsx
index 1397fce62..a1baad375 100644
--- a/components/settings/IdbUnlockModal.tsx
+++ b/components/settings/IdbUnlockModal.tsx
@@ -1,6 +1,7 @@
import type { FC } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../hooks/useTranslation';
+import { wipeAllAppData } from '../../services/factoryResetService';
import { verifyAndInitIdbEncryption } from '../../services/storage/storageEncryptionService';
import { Button } from '../ui/Button';
import { Modal } from '../ui/Modal';
@@ -163,6 +164,12 @@ export const IdbUnlockModal: FC = ({ onUnlocked }) => {
[handleUnlock],
);
+ const handleFactoryReset = useCallback(async () => {
+ if (!window.confirm(t('settings.data.dangerZone.factoryReset.modalWarning'))) return;
+ setBusy(true);
+ await wipeAllAppData();
+ }, [t]);
+
const errorId = 'idb-unlock-error';
const hasError = error.length > 0;
@@ -223,6 +230,19 @@ export const IdbUnlockModal: FC = ({ onUnlocked }) => {
: t('settings.privacy.encryptionUnlockButton')}
+
+
+ {t('settings.data.dangerZone.factoryReset.modalDescription')}
+
+
+
);
diff --git a/docs/SECURITY-THREAT-MODEL.md b/docs/SECURITY-THREAT-MODEL.md
index 96e8b8292..db918bb08 100644
--- a/docs/SECURITY-THREAT-MODEL.md
+++ b/docs/SECURITY-THREAT-MODEL.md
@@ -39,7 +39,7 @@ This document provides a formal STRIDE threat analysis for WorldScript Studio, m
| Threat | Mitigation | Code Location |
|--------|------------|-------------|
| API key leakage via logs | StructuredLogger sanitization; never log keys | `services/logger.ts:sanitizeLogContext()` |
-| Desktop API key exposure via local file-read access | AES-256-GCM with a PBKDF2-derived key (600 000 iterations, SHA-256, random 32-byte salt per encryption) — fixed 2026-07-29; the prior scheme derived the key from a single unsalted SHA-256 digest of publicly-derivable material (own file's parent path + provider name from the filename + a hardcoded literal), so anyone with read access to `config/_key.enc.json` could reconstruct the key in one hash operation (F-05/F-06). No migration path for pre-fix files by design — a legacy (unsalted) payload is discarded and the user is prompted to re-enter the key. | `services/fs/fsCore.ts:deriveFileSystemCryptoKey()`, `services/fs/settingsFsStore.ts:getApiKey()` |
+| Desktop API key exposure via local filesystem read | API keys are not written to the Tauri AppData filesystem. `storageService` uses the IndexedDB key store with a random non-extractable AES-GCM key; legacy filesystem key files are discarded and re-entry is required. | `services/storage/idbKeyStore.ts`, `services/storageService.ts`, `services/fs/settingsFsStore.ts` |
| Manuscript data in IndexedDB | AES-256-GCM at-rest encryption | `services/storage/storageEncryptionService.ts` |
| Voice audio to cloud | Web Speech API consent gate | `components/voice/VoicePrivacyConsentModal.tsx` |
| DuckDB analytics unencrypted (SEC-6) | **Bounded by design, with one prose column now encrypted:** most persisted fields are local metadata only (titles, loglines, character names, word counts, embeddings) and **nothing leaves the device**. The one column that genuinely holds literal manuscript prose, `codex_mentions.excerpt`, is now cell-level encrypted (AES-256-GCM via `services/duckdb/duckdbEncryption.ts`, reusing the IDB at-rest encryption key) whenever `enableIdbAtRestEncryption` is active: `duckdbCodexWrite()` writes ciphertext into `excerpt_enc BLOB` and nulls the plaintext `excerpt` column; `services/duckdb/codexExcerptEncryptionMigration.ts` backfills any pre-existing plaintext rows once encryption is unlocked. Gated by `enableDuckDbAnalytics` **and** the Settings → Privacy "Analytics" opt-out (`isAnalyticsPersistenceAllowed` in `app/listenerMiddleware.ts`); turning the toggle off stops all DuckDB writes + inference telemetry. Full OPFS file-level encryption remains **infeasible** — DuckDB-WASM owns the OPFS file handle directly, so there is no app-level interception point; the other metadata columns stay intentionally plaintext (bounded-exposure design). | `app/listenerMiddleware.ts:isAnalyticsPersistenceAllowed`, `services/duckdb/duckdbAnalytics.ts:duckdbCodexWrite()`, `services/duckdb/duckdbEncryption.ts`, `services/duckdb/codexExcerptEncryptionMigration.ts` |
@@ -123,10 +123,9 @@ Goal: Intercept/decrypt collaboration traffic
```
Goal: Recover a user's cloud-provider API key from the Tauri desktop install
-├─ OR: Read config/_key.enc.json directly (local process / malware with user-level FS access)
-│ └─ Mitigation: AES-256-GCM with PBKDF2-derived key (600k iter, random 32-byte salt per file) —
-│ reading the ciphertext no longer reveals the key material; the pre-2026-07-29 scheme derived
-│ the key from data an attacker with file-read access already had (F-05/F-06, fixed)
+├─ OR: Read the Tauri AppData filesystem directly (local process / malware with user-level FS access)
+│ └─ Mitigation: API keys are not stored there; the filesystem adapter rejects key writes and
+│ deletes legacy derived-key files. Runtime key access uses the WebView IDB key store.
├─ OR: Read the IDB-at-rest passphrase sentinel (enableIdbAtRestEncryption)
│ └─ Mitigation: same PBKDF2 + non-extractable-key pattern; session-scoped in-memory key, never
│ persisted to disk (`services/storage/storageEncryptionService.ts`)
@@ -259,4 +258,4 @@ platform dashboard, not in application code.
- NIST SP 800-63B Digital Identity Guidelines
- CWE-200: Exposure of Sensitive Information
- CWE-79: Cross-site Scripting (XSS)
-- CWE-89: SQL Injection (N/A - no SQL backend)
\ No newline at end of file
+- CWE-89: SQL Injection (N/A - no SQL backend)
diff --git a/services/factoryResetService.ts b/services/factoryResetService.ts
index 992dc8586..62811774e 100644
--- a/services/factoryResetService.ts
+++ b/services/factoryResetService.ts
@@ -9,6 +9,7 @@
*/
import { logger } from './logger';
+import { isTauriRuntime } from './tauriRuntime';
/** All IDB databases the app may have created. */
const KNOWN_DB_NAMES = [
@@ -56,6 +57,21 @@ async function clearServiceWorkerCaches(): Promise {
}
}
+async function clearTauriAppData(): Promise {
+ if (!isTauriRuntime()) return;
+ try {
+ const { loadTauriApis } = await import('./fs/fsCore');
+ const apis = await loadTauriApis();
+ const appDataPath = await apis.appDataDir();
+ if (await apis.exists(appDataPath)) {
+ await apis.remove(appDataPath, { recursive: true });
+ }
+ } catch (error) {
+ logger.error('Failed to clear Tauri app data during factory reset:', error);
+ throw new Error('Factory reset could not clear desktop data');
+ }
+}
+
/**
* Wipe all app data and reload.
* Clears: IDB, localStorage, sessionStorage, SW caches.
@@ -65,6 +81,7 @@ export async function wipeAllAppData(): Promise {
logger.warn('[factoryReset] Wiping all app data…');
await deleteAllIndexedDBDatabases();
await clearServiceWorkerCaches();
+ await clearTauriAppData();
try {
localStorage.clear();
sessionStorage.clear();
diff --git a/services/fs/settingsFsStore.ts b/services/fs/settingsFsStore.ts
index 4a610d508..0405b82f5 100644
--- a/services/fs/settingsFsStore.ts
+++ b/services/fs/settingsFsStore.ts
@@ -1,6 +1,6 @@
/**
- * FsSettingsStore — Settings persistence and AES-256-GCM API key encryption on the filesystem.
- * ENCRYPTION: AES-256-GCM — API keys stored as `_key.enc.json` in config/.
+ * FsSettingsStore — Settings persistence on the filesystem.
+ * API-key persistence is deliberately disabled here; storageService routes keys to the random-key IDB store.
* QNBS-v3: Extracted from fileSystemService.ts.
*/
@@ -10,7 +10,7 @@ import type { Settings } from '../../types';
import { logger } from '../logger';
import { normalizePersistedSettings } from '../storage/idbProjectStore';
import type { TauriApis } from './fsCore';
-import { decryptText, encryptText, FsCore, retryFs, writeTextFileAtomic } from './fsCore';
+import { FsCore, retryFs, writeTextFileAtomic } from './fsCore';
export class FsSettingsStore extends FsCore {
async saveSettings(settings: Settings): Promise {
@@ -61,23 +61,11 @@ export class FsSettingsStore extends FsCore {
return this.clearApiKey('gemini');
}
- // Generic provider API key — stored encrypted in app data dir
- async saveApiKey(provider: string, apiKey: string): Promise {
- if (!apiKey?.trim()) {
- throw new Error('API key cannot be empty');
- }
-
- const apis = await this.getApis();
- const appDataPath = await this.ensureAppDataPath();
- const configPath = await apis.join(appDataPath, 'config');
- if (!(await apis.exists(configPath))) await apis.mkdir(configPath, { recursive: true });
-
- const encrypted = await encryptText(
- apiKey.trim(),
- `${appDataPath}|${provider}|WorldScriptStudio|v1`,
+ // QNBS-v3: filesystem key persistence is disabled because app-path-derived material is recoverable.
+ async saveApiKey(provider: string, _apiKey: string): Promise {
+ throw new Error(
+ `Desktop filesystem API-key storage is disabled for ${provider}; use storageService instead`,
);
- const filePath = await apis.join(configPath, `${provider}_key.enc.json`);
- await writeTextFileAtomic(apis, filePath, JSON.stringify(encrypted));
}
async getApiKey(provider: string): Promise {
@@ -93,9 +81,8 @@ export class FsSettingsStore extends FsCore {
const keyFile = await apis.join(appDataPath, 'config', `${provider}_key.enc.json`);
keyFileForCleanup = keyFile;
if (!(await apis.exists(keyFile))) return null;
- const content = await retryFs(() => apis.readTextFile(keyFile));
- const payload = JSON.parse(content) as { iv: string; salt?: string; data: string };
- return await decryptText(payload, `${appDataPath}|${provider}|WorldScriptStudio|v1`);
+ await retryFs(() => apis.remove(keyFile));
+ return null;
} catch (error) {
// QNBS-v3: pre-2026-07-29 key files used an unsalted single-SHA-256 derivation (F-05/F-06,
// fixed in fsCore.ts) and are not migrated (locked decision — discard, not migrate). Remove
diff --git a/services/storageService.ts b/services/storageService.ts
index 3f16b297c..59f89dedc 100644
--- a/services/storageService.ts
+++ b/services/storageService.ts
@@ -114,33 +114,28 @@ class StorageManager {
}
async saveGeminiApiKey(apiKey: string): Promise {
- const backend = await this.getBackend();
- return backend.saveGeminiApiKey(apiKey);
+ // QNBS-v3: API keys stay in the random-key IndexedDB store; filesystem-derived material is not a secret.
+ return dbService.saveGeminiApiKey(apiKey);
}
async getGeminiApiKey(): Promise {
- const backend = await this.getBackend();
- return backend.getGeminiApiKey();
+ return dbService.getGeminiApiKey();
}
async clearGeminiApiKey(): Promise {
- const backend = await this.getBackend();
- return backend.clearGeminiApiKey();
+ return dbService.clearGeminiApiKey();
}
async saveApiKey(provider: string, apiKey: string): Promise {
- const backend = await this.getBackend();
- return backend.saveApiKey(provider, apiKey);
+ return dbService.saveApiKey(provider, apiKey);
}
async getApiKey(provider: string): Promise {
- const backend = await this.getBackend();
- return backend.getApiKey(provider);
+ return dbService.getApiKey(provider);
}
async clearApiKey(provider: string): Promise {
- const backend = await this.getBackend();
- return backend.clearApiKey(provider);
+ return dbService.clearApiKey(provider);
}
async saveSnapshot(name: string, data: unknown): Promise {
diff --git a/tests/unit/factoryResetService.test.ts b/tests/unit/factoryResetService.test.ts
index 38d1a9ff0..237f93327 100644
--- a/tests/unit/factoryResetService.test.ts
+++ b/tests/unit/factoryResetService.test.ts
@@ -10,6 +10,7 @@ import { logger } from '../../services/logger';
vi.mock('../../services/logger', () => ({
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
}));
+vi.mock('../../services/tauriRuntime', () => ({ isTauriRuntime: vi.fn(() => false) }));
function createDb(name: string): Promise {
return new Promise((resolve, reject) => {
diff --git a/tests/unit/services/fs/fsStores.test.ts b/tests/unit/services/fs/fsStores.test.ts
index a33481ea6..0bf53c7cd 100644
--- a/tests/unit/services/fs/fsStores.test.ts
+++ b/tests/unit/services/fs/fsStores.test.ts
@@ -197,31 +197,27 @@ describe('FsSettingsStore — settings + encrypted API keys', () => {
expect(await store.loadSettings()).toBeNull();
});
- it('encrypts and decrypts an API key round-trip', async () => {
- await store.saveApiKey('openai', 'sk-secret-123');
- expect(await store.getApiKey('openai')).toBe('sk-secret-123');
- await store.clearApiKey('openai');
- expect(await store.getApiKey('openai')).toBeNull();
+ it('rejects filesystem API-key persistence', async () => {
+ await expect(store.saveApiKey('openai', 'sk-secret-123')).rejects.toThrow(/disabled/);
});
- it('delegates the Gemini key helpers to provider storage', async () => {
- await store.saveGeminiApiKey('gem-key');
- expect(await store.getGeminiApiKey()).toBe('gem-key');
+ it('does not read legacy filesystem API-key files', async () => {
+ const keyFile = '/app/config/openai_key.enc.json';
+ fake.text.set(keyFile, JSON.stringify({ iv: 'legacy', data: 'legacy' }));
+ expect(await store.getApiKey('openai')).toBeNull();
+ expect(fake.text.has(keyFile)).toBe(false);
});
- it('rejects an empty API key', async () => {
- await expect(store.saveApiKey('openai', ' ')).rejects.toThrow(/empty/);
+ it('rejects filesystem API-key persistence even for an empty key', async () => {
+ await expect(store.saveApiKey('openai', ' ')).rejects.toThrow(/disabled/);
});
- it('returns null when decrypting a missing key', async () => {
+ it('returns null when no filesystem key exists', async () => {
expect(await store.getApiKey('anthropic')).toBeNull();
});
- // QNBS-v3 (F-05/F-06 fix, 2026-07-29): a pre-2026-07-29 key file (unsalted single-SHA-256
- // scheme) is discarded, not migrated (locked decision) — this asserts the discard path returns
- // null without throwing, removes the stale file, and surfaces a one-time notification rather
- // than failing silently.
- it('discards a legacy unsalted key file, removes it, and notifies instead of throwing', async () => {
+ // QNBS-v3: legacy filesystem key files are discarded because their derivation was recoverable.
+ it('discards a legacy unsalted key file without notifying or throwing', async () => {
const dispatch = vi.fn();
appStoreRef.current = { getState: vi.fn(), dispatch } as never;
try {
@@ -235,14 +231,7 @@ describe('FsSettingsStore — settings + encrypted API keys', () => {
expect(result).toBeNull();
expect(fake.text.has(legacyFile)).toBe(false);
- expect(dispatch).toHaveBeenCalledWith(
- expect.objectContaining({
- payload: expect.objectContaining({
- type: 'info',
- title: expect.stringContaining('API Key Reset'),
- }),
- }),
- );
+ expect(dispatch).not.toHaveBeenCalled();
} finally {
appStoreRef.current = null;
}
diff --git a/tests/unit/settings/EncryptionRecoveryModal.test.tsx b/tests/unit/settings/EncryptionRecoveryModal.test.tsx
index 3ee62e965..bc4121a89 100644
--- a/tests/unit/settings/EncryptionRecoveryModal.test.tsx
+++ b/tests/unit/settings/EncryptionRecoveryModal.test.tsx
@@ -171,7 +171,9 @@ describe('EncryptionRecoveryModal', () => {
expect(
screen.queryByLabelText('settings.privacy.encryptionPassphrase'),
).not.toBeInTheDocument();
- expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'settings.data.dangerZone.factoryReset.button' }),
+ ).toBeInTheDocument();
});
it('disables the resume button until the required fields are filled', async () => {
From fa9bf3aaae30798071b7f16a6834d20152e5353c Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Thu, 13 Aug 2026 22:58:23 +0200
Subject: [PATCH 03/12] ci: gate merges on Rust, E2E, and visual checks
---
.github/workflows/ci.yml | 36 ++++++++++++++++++++++++++++++++++--
docs/CI.md | 10 +++++++---
2 files changed, 41 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8275b21d1..6d6983496 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -179,6 +179,32 @@ jobs:
# flaky, non-gating check. Mutation now runs ONLY via the manual `.github/workflows/
# mutation.yml` (workflow_dispatch). To be re-integrated in a later iteration.
# ----------------------------------------------------------
+ rust-tauri:
+ name: 🦀 Tauri Rust Gate
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ needs: [security]
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
+ with:
+ persist-credentials: false
+ - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+ with:
+ toolchain: stable
+ components: rustfmt, clippy
+ - name: Rust format check
+ working-directory: src-tauri
+ run: cargo fmt --check
+ - name: Rust compile check
+ working-directory: src-tauri
+ run: cargo check --locked
+ - name: Rust clippy
+ working-directory: src-tauri
+ run: cargo clippy --locked --all-targets -- -D warnings
+ - name: Rust tests
+ working-directory: src-tauri
+ run: cargo test --locked
+
build:
name: 🏗️ Build
runs-on: ubuntu-latest
@@ -263,18 +289,24 @@ jobs:
name: ✅ CI Success
runs-on: ubuntu-latest
timeout-minutes: 5
- needs: [security, quality, build]
+ needs: [security, quality, rust-tauri, build, e2e, vrt]
if: always()
steps:
- name: Verify all required jobs succeeded
run: |
if [ "${{ needs.security.result }}" != "success" ] || \
[ "${{ needs.quality.result }}" != "success" ] || \
- [ "${{ needs.build.result }}" != "success" ]; then
+ [ "${{ needs.rust-tauri.result }}" != "success" ] || \
+ [ "${{ needs.build.result }}" != "success" ] || \
+ [ "${{ needs.e2e.result }}" != "success" ] || \
+ [ "${{ needs.vrt.result }}" != "success" ]; then
echo "One or more required jobs did not succeed:"
echo " security: ${{ needs.security.result }}"
echo " quality: ${{ needs.quality.result }}"
+ echo " rust-tauri: ${{ needs.rust-tauri.result }}"
echo " build: ${{ needs.build.result }}"
+ echo " e2e: ${{ needs.e2e.result }}"
+ echo " vrt: ${{ needs.vrt.result }}"
exit 1
fi
echo "All required jobs succeeded."
diff --git a/docs/CI.md b/docs/CI.md
index 37e39de18..3296232e4 100644
--- a/docs/CI.md
+++ b/docs/CI.md
@@ -77,7 +77,10 @@ security ──► quality ──┬──► build ──┬──► lighthous
security ─┬
quality ──┼──► ci-success (required-status aggregator)
-build ────┘
+rust ────┤
+build ────┤
+e2e ──────┤
+vrt ──────┘
build (main, non-PR) ──► upload-pages-artifact
deploy (main, non-PR) needs: build + e2e ──► GitHub Pages
@@ -89,12 +92,13 @@ Mutation testing (Stryker) is **not** in this graph — it runs only via manual
|-----|--------|---------|
| `security` | — | `pnpm audit --audit-level=high`; **OSV scanner** (`google/osv-scanner-action`) for npm + Rust lockfiles; `gitleaks` secrets scan; on PRs: `dependency-review-action` |
| `quality` | `security` | Matrix **Node 22** and **24** → Biome lint, **`pnpm run i18n:check`**, **`pnpm run docs:check`**, **`pnpm run parity:check`**, `pnpm run typecheck`, Vitest + coverage (+ non-blocking coverage-ratchet suggestion), Codecov (optional token), coverage artifact |
+| `rust-tauri` | `security` | Rust `cargo fmt --check`, `cargo check --locked`, `cargo clippy --locked --all-targets -- -D warnings`, and `cargo test --locked`; compile/lint signal for Tauri changes without building installers on every PR |
| `build` | `quality` | Production `pnpm run build`, **`bundle:budget`**, **`analyze`** (upload `bundle-analysis.html`), **`pnpm run smoke:prod`** (headless-Chromium prod-build + CSP-runtime gate — see below), `dist` artifact; on `main` (non-PR): Pages artifact + **SLSA build provenance attestation**. No `if:` on the job itself — `smoke:prod` runs on every PR, not just `main` pushes. |
| `e2e` | `quality` | Playwright **Chromium** + **Mobile Chrome** (Pixel 5) — `CI=true`, 2× retries, 50 min timeout; browser cache via `actions/cache@v5`. Firefox optional locally. `PLAYWRIGHT_SKIP_VRT=true` (VRT is its own job). |
| `lighthouse` | `build` | LHCI (mobile): **accessibility error gate** `minScore: 0.95`; **CLS error** ≤ 0.1; performance/SEO warn. Desktop run: `continue-on-error: true` until baselines stabilise. Timeout 25 min. |
| `storybook` | `quality` | Cloud-first — Storybook build + test-runner only run in CI (not locally); Playwright browser cache `v5`; `--maxWorkers=2 --junit` (non-blocking, `continue-on-error: true` — see [exit criteria](#non-blocking-gates--exit-criteria-f-13)); artifacts uploaded always. Debug: manual `storybook-debug.yml` workflow. |
| `vrt` | `build` | Visual regression against production `dist`; `toHaveScreenshot()` with committed PNG baselines (4 views × Chromium); artifacts uploaded always |
-| `ci-success` | `security`, `quality`, `build` | Required-status **aggregator** — `if: always()`, fails if any of its three `needs` didn't resolve to `success` (a matrix job like `quality` only reports `success` once every Node 22/24 leg passes). Exists so branch protection can require **one** context instead of enumerating `security`/`quality (Node 22)`/`quality (Node 24)`/`build` by name; a future required job just joins this job's `needs` list, with no branch-protection settings edit needed. |
+| `ci-success` | `security`, `quality`, `rust-tauri`, `build`, `e2e`, `vrt` | Required-status **aggregator** — `if: always()`, fails if any required release-safety job does not resolve to `success`; Storybook, Lighthouse and deep-E2E remain informational until their stability criteria are met. |
| `deploy` | `build`, `e2e` | **Only** `main` push (not PR): `deploy-pages` |
> **Desktop:** On-demand / tag-driven Tauri bundles live in [`tauri-build.yml`](../.github/workflows/tauri-build.yml); **`v*` tags** additionally publish installers on a **GitHub Release**. See [`docs/TAURI-CI.md`](TAURI-CI.md). Desktop CI does not block the web deploy graph above.
@@ -195,7 +199,7 @@ once a manual run demonstrates the flakiness is resolved — concretely, three c
| **Dependabot** | Weekly (Monday) | PRs for npm deps (dev-tooling grouped) + GitHub Actions SHA bumps (max 5 open PRs) |
| **`dependency-review-action`** | PRs only (security job) | Blocks PRs that introduce new high/critical vulnerabilities |
| **pnpm v11 build-script policy** | Dependency installation | `pnpm-workspace.yaml` uses the sole supported, default-deny `allowBuilds` map; legacy build-script lists are intentionally absent |
-| **Branch protection** | Always | `main` requires 1 approved review, required status checks (security, quality ×2, build), no force-push |
+| **Branch protection** | Always | `main` requires 1 approved review, required status checks including the `CI Success` aggregator, no force-push |
Only the two reviewed native packages marked `true` in `allowBuilds` (`@swc/core` and `esbuild`)
may run dependency lifecycle scripts. `@google/genai`, `core-js`, `onnxruntime-node`, `protobufjs`,
From f7c068e2cbc1765ccc66795950434e8ac6f741d7 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Thu, 13 Aug 2026 23:12:20 +0200
Subject: [PATCH 04/12] ci(tauri): install native dependencies and satisfy
rustfmt gate
---
.github/workflows/ci.yml | 4 +
src-tauri/build.rs | 2 +-
src-tauri/src/commands/task_supervisor.rs | 4 +-
src-tauri/src/lib.rs | 288 +++++++++++-----------
src-tauri/src/main.rs | 2 +-
5 files changed, 157 insertions(+), 143 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6d6983496..961d61651 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -192,6 +192,10 @@ jobs:
with:
toolchain: stable
components: rustfmt, clippy
+ - name: Install Linux Tauri build dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev libappindicator3-dev librsvg2-dev patchelf
- name: Rust format check
working-directory: src-tauri
run: cargo fmt --check
diff --git a/src-tauri/build.rs b/src-tauri/build.rs
index 795b9b7c8..d860e1e6a 100644
--- a/src-tauri/build.rs
+++ b/src-tauri/build.rs
@@ -1,3 +1,3 @@
fn main() {
- tauri_build::build()
+ tauri_build::build()
}
diff --git a/src-tauri/src/commands/task_supervisor.rs b/src-tauri/src/commands/task_supervisor.rs
index 9dfb2b797..467e5196e 100644
--- a/src-tauri/src/commands/task_supervisor.rs
+++ b/src-tauri/src/commands/task_supervisor.rs
@@ -81,7 +81,9 @@ pub fn worldscript_task_supervisor_ping() -> Result {
/// tasks; reserves `Err` for transport-level problems (none currently). This keeps
/// the router's fallback logic driven by `result.success`, not by a thrown error.
#[tauri::command]
-pub fn worldscript_task_supervisor_submit(request: RustTaskRequest) -> Result {
+pub fn worldscript_task_supervisor_submit(
+ request: RustTaskRequest,
+) -> Result {
let started = Instant::now();
let task_id = request.task_id.clone();
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index c4683af34..df57bb26b 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -6,20 +6,20 @@ use tauri::Emitter;
#[cfg(desktop)]
fn build_file_menu(
- handle: &tauri::AppHandle,
+ handle: &tauri::AppHandle,
) -> tauri::Result> {
- use tauri::menu::{MenuItem, PredefinedMenuItem, Submenu};
- Submenu::with_items(
- handle,
- "File",
- true,
- &[
- &MenuItem::with_id(handle, "menu-export", "Export Project", true, None::<&str>)?,
- &MenuItem::with_id(handle, "menu-settings", "Settings", true, None::<&str>)?,
- &PredefinedMenuItem::separator(handle)?,
- &PredefinedMenuItem::quit(handle, None)?,
- ],
- )
+ use tauri::menu::{MenuItem, PredefinedMenuItem, Submenu};
+ Submenu::with_items(
+ handle,
+ "File",
+ true,
+ &[
+ &MenuItem::with_id(handle, "menu-export", "Export Project", true, None::<&str>)?,
+ &MenuItem::with_id(handle, "menu-settings", "Settings", true, None::<&str>)?,
+ &PredefinedMenuItem::separator(handle)?,
+ &PredefinedMenuItem::quit(handle, None)?,
+ ],
+ )
}
// QNBS-v3 (D4): standard Edit menu via predefined items — the OS routes these to the focused
@@ -27,165 +27,173 @@ fn build_file_menu(
// event emit is needed. Fills the desktop-affordance gap flagged as C-7 in DESKTOP-UI-AUDIT.md.
#[cfg(desktop)]
fn build_edit_menu(
- handle: &tauri::AppHandle,
+ handle: &tauri::AppHandle,
) -> tauri::Result> {
- use tauri::menu::{PredefinedMenuItem, Submenu};
- Submenu::with_items(
- handle,
- "Edit",
- true,
- &[
- &PredefinedMenuItem::undo(handle, None)?,
- &PredefinedMenuItem::redo(handle, None)?,
- &PredefinedMenuItem::separator(handle)?,
- &PredefinedMenuItem::cut(handle, None)?,
- &PredefinedMenuItem::copy(handle, None)?,
- &PredefinedMenuItem::paste(handle, None)?,
- &PredefinedMenuItem::select_all(handle, None)?,
- ],
- )
+ use tauri::menu::{PredefinedMenuItem, Submenu};
+ Submenu::with_items(
+ handle,
+ "Edit",
+ true,
+ &[
+ &PredefinedMenuItem::undo(handle, None)?,
+ &PredefinedMenuItem::redo(handle, None)?,
+ &PredefinedMenuItem::separator(handle)?,
+ &PredefinedMenuItem::cut(handle, None)?,
+ &PredefinedMenuItem::copy(handle, None)?,
+ &PredefinedMenuItem::paste(handle, None)?,
+ &PredefinedMenuItem::select_all(handle, None)?,
+ ],
+ )
}
// QNBS-v3 (D4): View menu — custom "Command Palette" emits "menu-action" → the frontend maps it
// to the `global-open-command-palette` command (services/tauriMenuService.ts + App.tsx).
#[cfg(desktop)]
fn build_view_menu(
- handle: &tauri::AppHandle,
+ handle: &tauri::AppHandle,
) -> tauri::Result> {
- use tauri::menu::{MenuItem, Submenu};
- Submenu::with_items(
- handle,
- "View",
- true,
- // QNBS-v3: no native accelerator on this item. CmdOrCtrl+K is already a *toggle* in the web
- // shortcut layer; binding it here to the open-only menu command would shadow that toggle on
- // desktop (the key could open but never close the palette). The menu item still opens it on click.
- &[&MenuItem::with_id(
- handle,
- "menu-command-palette",
- "Command Palette",
- true,
- None::<&str>,
- )?],
- )
+ use tauri::menu::{MenuItem, Submenu};
+ Submenu::with_items(
+ handle,
+ "View",
+ true,
+ // QNBS-v3: no native accelerator on this item. CmdOrCtrl+K is already a *toggle* in the web
+ // shortcut layer; binding it here to the open-only menu command would shadow that toggle on
+ // desktop (the key could open but never close the palette). The menu item still opens it on click.
+ &[&MenuItem::with_id(
+ handle,
+ "menu-command-palette",
+ "Command Palette",
+ true,
+ None::<&str>,
+ )?],
+ )
}
// QNBS-v3 (D4): Window menu via predefined items — OS-native window controls, no wiring needed.
#[cfg(desktop)]
fn build_window_menu(
- handle: &tauri::AppHandle,
+ handle: &tauri::AppHandle,
) -> tauri::Result> {
- use tauri::menu::{PredefinedMenuItem, Submenu};
- Submenu::with_items(
- handle,
- "Window",
- true,
- &[
- &PredefinedMenuItem::minimize(handle, None)?,
- &PredefinedMenuItem::maximize(handle, None)?,
- &PredefinedMenuItem::separator(handle)?,
- &PredefinedMenuItem::fullscreen(handle, None)?,
- &PredefinedMenuItem::close_window(handle, None)?,
- ],
- )
+ use tauri::menu::{PredefinedMenuItem, Submenu};
+ Submenu::with_items(
+ handle,
+ "Window",
+ true,
+ &[
+ &PredefinedMenuItem::minimize(handle, None)?,
+ &PredefinedMenuItem::maximize(handle, None)?,
+ &PredefinedMenuItem::separator(handle)?,
+ &PredefinedMenuItem::fullscreen(handle, None)?,
+ &PredefinedMenuItem::close_window(handle, None)?,
+ ],
+ )
}
#[cfg(desktop)]
fn build_help_menu(
- handle: &tauri::AppHandle,
+ handle: &tauri::AppHandle,
) -> tauri::Result> {
- use tauri::menu::{MenuItem, Submenu};
- Submenu::with_items(
- handle,
- "Help",
- true,
- &[&MenuItem::with_id(handle, "menu-help", "Help Center", true, None::<&str>)?],
- )
+ use tauri::menu::{MenuItem, Submenu};
+ Submenu::with_items(
+ handle,
+ "Help",
+ true,
+ &[&MenuItem::with_id(
+ handle,
+ "menu-help",
+ "Help Center",
+ true,
+ None::<&str>,
+ )?],
+ )
}
// QNBS-v3: split into per-submenu builders (was one large function) to keep cyclomatic
// complexity low — DeepSource RS-R1000 flagged the monolithic version at 26 ("very-high" risk).
#[cfg(desktop)]
fn install_app_menu(app: &tauri::App) -> tauri::Result<()> {
- use tauri::menu::Menu;
+ use tauri::menu::Menu;
- let handle = app.handle();
- let file_menu = build_file_menu(handle)?;
- let edit_menu = build_edit_menu(handle)?;
- let view_menu = build_view_menu(handle)?;
- let window_menu = build_window_menu(handle)?;
- let help_menu = build_help_menu(handle)?;
- let menu =
- Menu::with_items(handle, &[&file_menu, &edit_menu, &view_menu, &window_menu, &help_menu])?;
- app.set_menu(menu)?;
- Ok(())
+ let handle = app.handle();
+ let file_menu = build_file_menu(handle)?;
+ let edit_menu = build_edit_menu(handle)?;
+ let view_menu = build_view_menu(handle)?;
+ let window_menu = build_window_menu(handle)?;
+ let help_menu = build_help_menu(handle)?;
+ let menu = Menu::with_items(
+ handle,
+ &[&file_menu, &edit_menu, &view_menu, &window_menu, &help_menu],
+ )?;
+ app.set_menu(menu)?;
+ Ok(())
}
#[cfg(not(desktop))]
fn install_app_menu(_app: &tauri::App) -> tauri::Result<()> {
- Ok(())
+ Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
- tauri::Builder::default()
- // QNBS-v3: Single-instance plugin with deep-link feature handles file associations
- // The plugin automatically handles CLI args and emits "deep-link://new-url" event
- .plugin(tauri_plugin_single_instance::Builder::new().build())
- .plugin(tauri_plugin_deep_link::init())
- .plugin(tauri_plugin_fs::init())
- .plugin(tauri_plugin_http::init())
- .plugin(tauri_plugin_dialog::init())
- .plugin(tauri_plugin_shell::init())
- .plugin(tauri_plugin_updater::Builder::new().build())
- // QNBS-v3 (T3): native OS notifications — permission request/gating lives entirely in JS
- // (services/desktop/desktopNotifications.ts); the Rust side only registers the plugin.
- .plugin(tauri_plugin_notification::init())
- // QNBS-v3 (#332/D3): exposes exit() to JS so tray/menu Quit can flush state before terminating.
- .plugin(tauri_plugin_process::init())
- .plugin(
- tauri_plugin_window_state::Builder::new()
- .with_state_flags(tauri_plugin_window_state::StateFlags::all())
- .build(),
- )
- .invoke_handler(tauri::generate_handler![
- pandoc::pandoc_markdown_to_epub,
- lora::train_lora,
- lora::merge_lora,
- lora::abort_lora_training,
- lora::generate_ollama_modelfile,
- lora::check_lora_environment,
- lora::set_lora_python_path,
- commands::task_supervisor::worldscript_task_supervisor_ping,
- commands::task_supervisor::worldscript_task_supervisor_submit,
- ])
- .setup(|app| {
- if cfg!(debug_assertions) {
- app.handle().plugin(
- tauri_plugin_log::Builder::default()
- .level(log::LevelFilter::Info)
- .build(),
- )?;
- }
- install_app_menu(app)?;
- Ok(())
- })
- .on_menu_event(|app, event| {
- let id = event.id().0.clone();
- // QNBS-v3 (#187): only forward the custom ids the frontend actually handles. The predefined
- // Edit/Window items (undo/redo/cut/copy/paste/select-all/minimize/…) are handled natively by the
- // OS; emitting them too would flood the JS bridge with high-frequency events during editing that
- // the frontend just ignores.
- if matches!(
- id.as_str(),
- "menu-export" | "menu-settings" | "menu-help" | "menu-command-palette"
- ) {
- let _ = app.emit("menu-action", id);
- }
- })
- // QNBS-v3: RunEvent handlers removed because tauri_plugin_single_instance
- // handles SecondInstance/Opened via "deep-link://new-url" events and
- // tauri::Builder no longer exposes .on_event() in Tauri v2.
- .run(tauri::generate_context!())
- .expect("error while running tauri application");
+ tauri::Builder::default()
+ // QNBS-v3: Single-instance plugin with deep-link feature handles file associations
+ // The plugin automatically handles CLI args and emits "deep-link://new-url" event
+ .plugin(tauri_plugin_single_instance::Builder::new().build())
+ .plugin(tauri_plugin_deep_link::init())
+ .plugin(tauri_plugin_fs::init())
+ .plugin(tauri_plugin_http::init())
+ .plugin(tauri_plugin_dialog::init())
+ .plugin(tauri_plugin_shell::init())
+ .plugin(tauri_plugin_updater::Builder::new().build())
+ // QNBS-v3 (T3): native OS notifications — permission request/gating lives entirely in JS
+ // (services/desktop/desktopNotifications.ts); the Rust side only registers the plugin.
+ .plugin(tauri_plugin_notification::init())
+ // QNBS-v3 (#332/D3): exposes exit() to JS so tray/menu Quit can flush state before terminating.
+ .plugin(tauri_plugin_process::init())
+ .plugin(
+ tauri_plugin_window_state::Builder::new()
+ .with_state_flags(tauri_plugin_window_state::StateFlags::all())
+ .build(),
+ )
+ .invoke_handler(tauri::generate_handler![
+ pandoc::pandoc_markdown_to_epub,
+ lora::train_lora,
+ lora::merge_lora,
+ lora::abort_lora_training,
+ lora::generate_ollama_modelfile,
+ lora::check_lora_environment,
+ lora::set_lora_python_path,
+ commands::task_supervisor::worldscript_task_supervisor_ping,
+ commands::task_supervisor::worldscript_task_supervisor_submit,
+ ])
+ .setup(|app| {
+ if cfg!(debug_assertions) {
+ app.handle().plugin(
+ tauri_plugin_log::Builder::default()
+ .level(log::LevelFilter::Info)
+ .build(),
+ )?;
+ }
+ install_app_menu(app)?;
+ Ok(())
+ })
+ .on_menu_event(|app, event| {
+ let id = event.id().0.clone();
+ // QNBS-v3 (#187): only forward the custom ids the frontend actually handles. The predefined
+ // Edit/Window items (undo/redo/cut/copy/paste/select-all/minimize/…) are handled natively by the
+ // OS; emitting them too would flood the JS bridge with high-frequency events during editing that
+ // the frontend just ignores.
+ if matches!(
+ id.as_str(),
+ "menu-export" | "menu-settings" | "menu-help" | "menu-command-palette"
+ ) {
+ let _ = app.emit("menu-action", id);
+ }
+ })
+ // QNBS-v3: RunEvent handlers removed because tauri_plugin_single_instance
+ // handles SecondInstance/Opened via "deep-link://new-url" events and
+ // tauri::Builder no longer exposes .on_event() in Tauri v2.
+ .run(tauri::generate_context!())
+ .expect("error while running tauri application");
}
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index ad5fe8399..69c3a72ec 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -2,5 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
- app_lib::run();
+ app_lib::run();
}
From 22ddda24fccfbdadcce34e870a60b1e6bf0ac326 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Thu, 13 Aug 2026 23:14:44 +0200
Subject: [PATCH 05/12] test(storage): cover atomic write failure recovery
---
tests/unit/services/fs/fsStores.test.ts | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/tests/unit/services/fs/fsStores.test.ts b/tests/unit/services/fs/fsStores.test.ts
index 0bf53c7cd..c25bfe47b 100644
--- a/tests/unit/services/fs/fsStores.test.ts
+++ b/tests/unit/services/fs/fsStores.test.ts
@@ -183,6 +183,21 @@ describe('FsProjectStore — projects', () => {
expect.objectContaining({ error: 'disk full' }),
);
});
+
+ it('keeps the previous project when the replacement write fails', async () => {
+ await store.saveProject(project as never);
+ const originalWriteTextFile = fake.apis.writeTextFile;
+ fake.apis.writeTextFile = (path: string, content: string) => {
+ if (path.includes('/project.json.tmp-')) return Promise.reject(new Error('disk full'));
+ return originalWriteTextFile(path, content);
+ };
+
+ await expect(
+ store.saveProject({ ...project, title: 'Should not replace' } as never),
+ ).rejects.toThrow('disk full');
+ expect((await store.loadProject('p1'))?.title).toBe('My Novel');
+ expect([...fake.text.keys()].some((path) => path.includes('.tmp-'))).toBe(false);
+ });
});
describe('FsSettingsStore — settings + encrypted API keys', () => {
From 9e8550eca613910fba5ed7f2a24458a9872c0037 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Fri, 14 Aug 2026 00:28:33 +0200
Subject: [PATCH 06/12] ci(tauri): align Linux appindicator dependency
---
.github/workflows/ci.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 961d61651..be301623c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -195,7 +195,7 @@ jobs:
- name: Install Linux Tauri build dependencies
run: |
sudo apt-get update
- sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev libappindicator3-dev librsvg2-dev patchelf
+ sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Rust format check
working-directory: src-tauri
run: cargo fmt --check
From b416694c5c519050e7a563662583fe08a69e99fc Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Fri, 14 Aug 2026 03:03:33 +0200
Subject: [PATCH 07/12] test(storage): lock desktop API key routing
---
tests/unit/storageService.test.ts | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/tests/unit/storageService.test.ts b/tests/unit/storageService.test.ts
index 5300b49c3..8b275cb7c 100644
--- a/tests/unit/storageService.test.ts
+++ b/tests/unit/storageService.test.ts
@@ -119,6 +119,22 @@ describe('storageService (IndexedDB backend in browser)', () => {
expect(mockDb.clearApiKey).toHaveBeenCalledWith('openai');
});
+ it('keeps API keys on IndexedDB when the desktop filesystem backend is active', async () => {
+ (window as { __TAURI__?: unknown }).__TAURI__ = {};
+ vi.resetModules();
+ // QNBS-v3: Desktop project storage must not redirect API keys to filesystem persistence.
+ const desktopStorage = (await import('../../services/storageService')).storageService;
+
+ await desktopStorage.saveApiKey('openai', 'desktop-key');
+ await desktopStorage.getApiKey('openai');
+ await desktopStorage.clearApiKey('openai');
+
+ expect(mockDb.saveApiKey).toHaveBeenCalledWith('openai', 'desktop-key');
+ expect(mockDb.getApiKey).toHaveBeenCalledWith('openai');
+ expect(mockDb.clearApiKey).toHaveBeenCalledWith('openai');
+ delete (window as { __TAURI__?: unknown }).__TAURI__;
+ });
+
it('delegates snapshot operations to dbService', async () => {
mockDb.saveSnapshot.mockResolvedValueOnce(42);
const id = await storageService.saveSnapshot('label', { data: 1 });
From 292106400979d87bffe574c64afbc5c14706bc15 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Fri, 14 Aug 2026 03:26:59 +0200
Subject: [PATCH 08/12] fix(recovery): close review-loop persistence risks
---
components/ApiKeySection.tsx | 35 -------------------
.../settings/EncryptionRecoveryModal.tsx | 12 ++++++-
components/settings/IdbUnlockModal.tsx | 13 ++++++-
services/factoryResetService.ts | 15 ++++++--
services/fs/settingsFsStore.ts | 16 +++++++++
services/storageService.ts | 1 +
tests/unit/factoryResetService.test.ts | 2 +-
tests/unit/services/fs/fsStores.test.ts | 17 +++++++--
tests/unit/storageService.test.ts | 1 +
9 files changed, 70 insertions(+), 42 deletions(-)
diff --git a/components/ApiKeySection.tsx b/components/ApiKeySection.tsx
index 6f2f649cb..9464eed2c 100644
--- a/components/ApiKeySection.tsx
+++ b/components/ApiKeySection.tsx
@@ -29,17 +29,11 @@ export const ApiKeySection: FC = () => {
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [showKey, setShowKey] = useState(false);
- const [decryptFailed, setDecryptFailed] = useState(false);
-
const checkKeyStatus = useCallback(async () => {
setIsLoading(true);
try {
const exists = Boolean(await storageService.getGeminiApiKey());
setHasKey(exists);
- // Check if key exists but decryption failed (device change, cleared site data)
- if (!exists) {
- setDecryptFailed(false);
- }
} catch (error) {
logger.error('Failed to check API key status:', error);
} finally {
@@ -69,7 +63,6 @@ export const ApiKeySection: FC = () => {
invalidateAiClientCache();
setApiKey('');
setHasKey(true);
- setDecryptFailed(false);
setMessage({ type: 'success', text: t('settings.apiKey.saved') });
setTestResult(null);
// QNBS-v3: surface an invalid/unauthenticated key immediately instead of deferring discovery
@@ -196,34 +189,6 @@ export const ApiKeySection: FC = () => {
- {/* Decrypt Failed Warning */}
- {decryptFailed && (
-
-
- {/* QNBS-v3: Decorative icon - hidden from assistive tech */}
-
-
-
{t('apiKey.decryptFailed')}
-
{t('apiKey.decryptFailedDetail')}
-
-
-
- )}
-
{/* Key Status / Input */}
{hasKey ? (
diff --git a/components/settings/EncryptionRecoveryModal.tsx b/components/settings/EncryptionRecoveryModal.tsx
index 25fd086cf..60cefff30 100644
--- a/components/settings/EncryptionRecoveryModal.tsx
+++ b/components/settings/EncryptionRecoveryModal.tsx
@@ -91,7 +91,17 @@ export const EncryptionRecoveryModal: FC
= ({ journal, onRecovered }) =>
const handleFactoryReset = useCallback(async () => {
if (!window.confirm(t('settings.data.dangerZone.factoryReset.modalWarning'))) return;
setBusy(true);
- await wipeAllAppData();
+ setError('');
+ try {
+ await wipeAllAppData();
+ } catch (err) {
+ setError(t('settings.privacy.encryptionRecoveryFailed'));
+ logger.error('Factory reset failed', {
+ error: err instanceof Error ? err.message : String(err),
+ });
+ } finally {
+ setBusy(false);
+ }
}, [t]);
return (
diff --git a/components/settings/IdbUnlockModal.tsx b/components/settings/IdbUnlockModal.tsx
index a1baad375..3b4c7cb1c 100644
--- a/components/settings/IdbUnlockModal.tsx
+++ b/components/settings/IdbUnlockModal.tsx
@@ -2,6 +2,7 @@ import type { FC } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../hooks/useTranslation';
import { wipeAllAppData } from '../../services/factoryResetService';
+import { logger } from '../../services/logger';
import { verifyAndInitIdbEncryption } from '../../services/storage/storageEncryptionService';
import { Button } from '../ui/Button';
import { Modal } from '../ui/Modal';
@@ -167,7 +168,17 @@ export const IdbUnlockModal: FC = ({ onUnlocked }) => {
const handleFactoryReset = useCallback(async () => {
if (!window.confirm(t('settings.data.dangerZone.factoryReset.modalWarning'))) return;
setBusy(true);
- await wipeAllAppData();
+ setError('');
+ try {
+ await wipeAllAppData();
+ } catch (err) {
+ setError(t('settings.privacy.encryptionRecoveryFailed'));
+ logger.error('Factory reset failed', {
+ error: err instanceof Error ? err.message : String(err),
+ });
+ } finally {
+ setBusy(false);
+ }
}, [t]);
const errorId = 'idb-unlock-error';
diff --git a/services/factoryResetService.ts b/services/factoryResetService.ts
index 62811774e..3a18f6db0 100644
--- a/services/factoryResetService.ts
+++ b/services/factoryResetService.ts
@@ -64,7 +64,17 @@ async function clearTauriAppData(): Promise {
const apis = await loadTauriApis();
const appDataPath = await apis.appDataDir();
if (await apis.exists(appDataPath)) {
- await apis.remove(appDataPath, { recursive: true });
+ // QNBS-v3: keep the capability-scoped AppData root and remove only its contents.
+ const entries = await apis.readDir(appDataPath);
+ await Promise.all(
+ entries
+ .map((entry) => entry.name)
+ .filter((name): name is string => Boolean(name))
+ .map(async (name) => {
+ const childPath = await apis.join(appDataPath, name);
+ await apis.remove(childPath, { recursive: true });
+ }),
+ );
}
} catch (error) {
logger.error('Failed to clear Tauri app data during factory reset:', error);
@@ -79,9 +89,10 @@ async function clearTauriAppData(): Promise {
*/
export async function wipeAllAppData(): Promise {
logger.warn('[factoryReset] Wiping all app data…');
+ // QNBS-v3: clear fallible desktop data first so a failed desktop reset never leaves a mixed wipe.
+ await clearTauriAppData();
await deleteAllIndexedDBDatabases();
await clearServiceWorkerCaches();
- await clearTauriAppData();
try {
localStorage.clear();
sessionStorage.clear();
diff --git a/services/fs/settingsFsStore.ts b/services/fs/settingsFsStore.ts
index 0405b82f5..1f4507d9d 100644
--- a/services/fs/settingsFsStore.ts
+++ b/services/fs/settingsFsStore.ts
@@ -13,6 +13,22 @@ import type { TauriApis } from './fsCore';
import { FsCore, retryFs, writeTextFileAtomic } from './fsCore';
export class FsSettingsStore extends FsCore {
+ async removeLegacyApiKeyFiles(): Promise {
+ const apis = await this.getApis();
+ const appDataPath = await this.ensureAppDataPath();
+ const configPath = await apis.join(appDataPath, 'config');
+ const providers = ['gemini', 'openai', 'anthropic', 'grok', 'openrouter'];
+
+ for (const provider of providers) {
+ const keyFile = await apis.join(configPath, `${provider}_key.enc.json`);
+ try {
+ if (await apis.exists(keyFile)) await retryFs(() => apis.remove(keyFile));
+ } catch (error) {
+ logger.warn(`Failed to remove legacy API key file for provider "${provider}":`, error);
+ }
+ }
+ }
+
async saveSettings(settings: Settings): Promise {
const apis = await this.getApis();
const appDataPath = await this.ensureAppDataPath();
diff --git a/services/storageService.ts b/services/storageService.ts
index 59f89dedc..a1e4a7432 100644
--- a/services/storageService.ts
+++ b/services/storageService.ts
@@ -49,6 +49,7 @@ class StorageManager {
if (isTauriRuntime()) {
try {
await fileSystemService.initialize();
+ await fileSystemService.removeLegacyApiKeyFiles();
this.backend = fileSystemService;
logger.debug('Using file system storage backend');
} catch (error) {
diff --git a/tests/unit/factoryResetService.test.ts b/tests/unit/factoryResetService.test.ts
index 237f93327..27e59f1c9 100644
--- a/tests/unit/factoryResetService.test.ts
+++ b/tests/unit/factoryResetService.test.ts
@@ -1,6 +1,6 @@
/**
* Tests for services/factoryResetService.ts
- * QNBS-v3: wipeAllAppData — clears IDB + web storage + SW caches, then reloads. Covers the
+ * QNBS-v3: [data safety / verify complete reset sequencing / preserves deterministic recovery]. Covers the
* native indexedDB.databases() path, the known-list fallback, and the Cache API branch.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
diff --git a/tests/unit/services/fs/fsStores.test.ts b/tests/unit/services/fs/fsStores.test.ts
index c25bfe47b..c3bc11c07 100644
--- a/tests/unit/services/fs/fsStores.test.ts
+++ b/tests/unit/services/fs/fsStores.test.ts
@@ -213,7 +213,7 @@ describe('FsSettingsStore — settings + encrypted API keys', () => {
});
it('rejects filesystem API-key persistence', async () => {
- await expect(store.saveApiKey('openai', 'sk-secret-123')).rejects.toThrow(/disabled/);
+ await expect(store.saveApiKey('openai', 'test-provider-key')).rejects.toThrow(/disabled/);
});
it('does not read legacy filesystem API-key files', async () => {
@@ -231,7 +231,20 @@ describe('FsSettingsStore — settings + encrypted API keys', () => {
expect(await store.getApiKey('anthropic')).toBeNull();
});
- // QNBS-v3: legacy filesystem key files are discarded because their derivation was recoverable.
+ it('removes known legacy API-key files during desktop startup cleanup', async () => {
+ const providers = ['gemini', 'openai', 'anthropic', 'grok', 'openrouter'];
+ for (const provider of providers) {
+ fake.text.set(`/app/config/${provider}_key.enc.json`, 'legacy-ciphertext');
+ }
+
+ await store.removeLegacyApiKeyFiles();
+
+ expect(
+ providers.every((provider) => !fake.text.has(`/app/config/${provider}_key.enc.json`)),
+ ).toBe(true);
+ });
+
+ // QNBS-v3: [security / discard recoverable legacy ciphertext / prevents unsafe migration].
it('discards a legacy unsalted key file without notifying or throwing', async () => {
const dispatch = vi.fn();
appStoreRef.current = { getState: vi.fn(), dispatch } as never;
diff --git a/tests/unit/storageService.test.ts b/tests/unit/storageService.test.ts
index 8b275cb7c..f0b4b4f80 100644
--- a/tests/unit/storageService.test.ts
+++ b/tests/unit/storageService.test.ts
@@ -18,6 +18,7 @@ const mockDb = {
saveApiKey: vi.fn().mockResolvedValue(undefined),
getApiKey: vi.fn().mockResolvedValue(null),
clearApiKey: vi.fn().mockResolvedValue(undefined),
+ removeLegacyApiKeyFiles: vi.fn().mockResolvedValue(undefined),
saveSnapshot: vi.fn().mockResolvedValue(1),
getSnapshotData: vi.fn().mockResolvedValue(null),
listSnapshots: vi.fn().mockResolvedValue([]),
From b8017a9325668b11a8d927171a8c3093e4121422 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Fri, 14 Aug 2026 03:57:36 +0200
Subject: [PATCH 09/12] fix(storage): serialize atomic writes and harden reset
cleanup
---
services/factoryResetService.ts | 21 ++++++++++++---------
services/fs/fsCore.ts | 25 +++++++++++++++++++------
2 files changed, 31 insertions(+), 15 deletions(-)
diff --git a/services/factoryResetService.ts b/services/factoryResetService.ts
index 3a18f6db0..33a70f54d 100644
--- a/services/factoryResetService.ts
+++ b/services/factoryResetService.ts
@@ -66,15 +66,18 @@ async function clearTauriAppData(): Promise {
if (await apis.exists(appDataPath)) {
// QNBS-v3: keep the capability-scoped AppData root and remove only its contents.
const entries = await apis.readDir(appDataPath);
- await Promise.all(
- entries
- .map((entry) => entry.name)
- .filter((name): name is string => Boolean(name))
- .map(async (name) => {
- const childPath = await apis.join(appDataPath, name);
- await apis.remove(childPath, { recursive: true });
- }),
- );
+ let firstError: unknown;
+ for (const name of entries
+ .map((entry) => entry.name)
+ .filter((entryName): entryName is string => Boolean(entryName))) {
+ try {
+ const childPath = await apis.join(appDataPath, name);
+ await apis.remove(childPath, { recursive: true });
+ } catch (error) {
+ firstError ??= error;
+ }
+ }
+ if (firstError) throw firstError;
}
} catch (error) {
logger.error('Failed to clear Tauri app data during factory reset:', error);
diff --git a/services/fs/fsCore.ts b/services/fs/fsCore.ts
index 48ef5f5d0..8163a1846 100644
--- a/services/fs/fsCore.ts
+++ b/services/fs/fsCore.ts
@@ -90,18 +90,31 @@ function temporaryPath(path: string): string {
return `${path}.tmp-${suffix}`;
}
+const atomicWriteTails = new Map>();
+
async function writeAndReplace(
apis: TauriApis,
path: string,
write: (temporary: string) => Promise,
): Promise {
- const temporary = temporaryPath(path);
+ const previous = atomicWriteTails.get(path);
+ const current = (previous?.catch(() => undefined) ?? Promise.resolve()).then(async () => {
+ const temporary = temporaryPath(path);
+ try {
+ await retryFs(() => write(temporary));
+ await retryFs(() => apis.rename(temporary, path));
+ } catch (error) {
+ await apis.remove(temporary).catch(() => undefined);
+ throw error;
+ }
+ });
+ atomicWriteTails.set(path, current);
try {
- await retryFs(() => write(temporary));
- await retryFs(() => apis.rename(temporary, path));
- } catch (error) {
- await apis.remove(temporary).catch(() => undefined);
- throw error;
+ await current;
+ } finally {
+ if (atomicWriteTails.get(path) === current) {
+ atomicWriteTails.delete(path);
+ }
}
}
From b3395804e52b1133a0fd8d56519139194dc14860 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Fri, 14 Aug 2026 04:37:00 +0200
Subject: [PATCH 10/12] test(storage): cover Tauri factory-reset and
danger-zone patch gaps
Adds patch coverage for the new clearTauriAppData branch and the
factory-reset danger-zone UI in IdbUnlockModal/EncryptionRecoveryModal
(codecov/patch was at 52.75%). Extracts the confirm/wipe/error/busy
logic into a shared useFactoryReset hook and FactoryResetDangerZone
component, replacing three near-identical copies (CodeAnt duplicate-
code finding). Also fixes a bug: the stuck (recovery-required) branch
had no error paragraph, so a failed reset there set error state with
nothing to render it.
---
.../settings/EncryptionRecoveryModal.tsx | 58 ++++------
.../settings/FactoryResetDangerZone.tsx | 39 +++++++
components/settings/IdbUnlockModal.tsx | 34 +-----
hooks/useFactoryReset.ts | 31 ++++++
tests/unit/factoryResetService.test.ts | 103 +++++++++++++++++-
.../settings/EncryptionRecoveryModal.test.tsx | 79 +++++++++++++-
tests/unit/settings/IdbUnlockModal.test.tsx | 53 +++++++++
7 files changed, 325 insertions(+), 72 deletions(-)
create mode 100644 components/settings/FactoryResetDangerZone.tsx
create mode 100644 hooks/useFactoryReset.ts
diff --git a/components/settings/EncryptionRecoveryModal.tsx b/components/settings/EncryptionRecoveryModal.tsx
index 60cefff30..c10501901 100644
--- a/components/settings/EncryptionRecoveryModal.tsx
+++ b/components/settings/EncryptionRecoveryModal.tsx
@@ -1,7 +1,7 @@
import type { FC } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useFactoryReset } from '../../hooks/useFactoryReset';
import { useTranslation } from '../../hooks/useTranslation';
-import { wipeAllAppData } from '../../services/factoryResetService';
import { logger } from '../../services/logger';
import type { EncryptionMigrationJournal } from '../../services/storage/encryptionMigrationJournal';
import type { ProtectedStoreMigrationProgress } from '../../services/storage/protectedStoreMigration';
@@ -11,6 +11,7 @@ import {
} from '../../services/storage/storageEncryptionService';
import { Button } from '../ui/Button';
import { Modal } from '../ui/Modal';
+import { FactoryResetDangerZone } from './FactoryResetDangerZone';
interface Props {
journal: EncryptionMigrationJournal;
@@ -88,21 +89,7 @@ export const EncryptionRecoveryModal: FC = ({ journal, onRecovered }) =>
const canSubmit =
!busy && sourcePassphrase.length > 0 && (!needsTargetPassphrase || targetPassphrase.length > 0);
- const handleFactoryReset = useCallback(async () => {
- if (!window.confirm(t('settings.data.dangerZone.factoryReset.modalWarning'))) return;
- setBusy(true);
- setError('');
- try {
- await wipeAllAppData();
- } catch (err) {
- setError(t('settings.privacy.encryptionRecoveryFailed'));
- logger.error('Factory reset failed', {
- error: err instanceof Error ? err.message : String(err),
- });
- } finally {
- setBusy(false);
- }
- }, [t]);
+ const handleFactoryReset = useFactoryReset({ t, setBusy, setError });
return (
= ({ journal, onRecovered }) =>
{t('settings.privacy.encryptionRecoveryStuck')}
-
- {t('settings.data.dangerZone.factoryReset.modalDescription')}
-
-
+ {error}
+
+ void handleFactoryReset()}
+ bordered={false}
+ descriptionClassName="text-xs text-[var(--sc-text-secondary)]"
+ />
) : (
<>
@@ -226,19 +218,7 @@ export const EncryptionRecoveryModal: FC = ({ journal, onRecovered }) =>
{t('settings.privacy.encryptionRecoveryResumeButton')}
-
-
- {t('settings.data.dangerZone.factoryReset.modalDescription')}
-
-
-
+ void handleFactoryReset()} />
>
)}
diff --git a/components/settings/FactoryResetDangerZone.tsx b/components/settings/FactoryResetDangerZone.tsx
new file mode 100644
index 000000000..264e58f64
--- /dev/null
+++ b/components/settings/FactoryResetDangerZone.tsx
@@ -0,0 +1,39 @@
+import type { FC } from 'react';
+import { Button } from '../ui/Button';
+
+interface Props {
+ t: (key: string) => string;
+ busy: boolean;
+ onReset: () => void;
+ bordered?: boolean;
+ descriptionClassName?: string;
+}
+
+/**
+ * Shared "wipe all app data" danger-zone block for encryption dead-end recovery flows —
+ * one copy instead of the near-identical block previously repeated across the unlock modal
+ * and both branches of the recovery modal.
+ */
+export const FactoryResetDangerZone: FC = ({
+ t,
+ busy,
+ onReset,
+ bordered = true,
+ descriptionClassName = 'text-xs text-[var(--sc-danger-fg)] mb-2',
+}) => {
+ const content = (
+ <>
+
+ {t('settings.data.dangerZone.factoryReset.modalDescription')}
+
+
+ >
+ );
+ return bordered ? (
+ {content}
+ ) : (
+ content
+ );
+};
diff --git a/components/settings/IdbUnlockModal.tsx b/components/settings/IdbUnlockModal.tsx
index 3b4c7cb1c..6ae5d78d7 100644
--- a/components/settings/IdbUnlockModal.tsx
+++ b/components/settings/IdbUnlockModal.tsx
@@ -1,11 +1,11 @@
import type { FC } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
+import { useFactoryReset } from '../../hooks/useFactoryReset';
import { useTranslation } from '../../hooks/useTranslation';
-import { wipeAllAppData } from '../../services/factoryResetService';
-import { logger } from '../../services/logger';
import { verifyAndInitIdbEncryption } from '../../services/storage/storageEncryptionService';
import { Button } from '../ui/Button';
import { Modal } from '../ui/Modal';
+import { FactoryResetDangerZone } from './FactoryResetDangerZone';
interface Props {
onUnlocked: () => void;
@@ -165,21 +165,7 @@ export const IdbUnlockModal: FC = ({ onUnlocked }) => {
[handleUnlock],
);
- const handleFactoryReset = useCallback(async () => {
- if (!window.confirm(t('settings.data.dangerZone.factoryReset.modalWarning'))) return;
- setBusy(true);
- setError('');
- try {
- await wipeAllAppData();
- } catch (err) {
- setError(t('settings.privacy.encryptionRecoveryFailed'));
- logger.error('Factory reset failed', {
- error: err instanceof Error ? err.message : String(err),
- });
- } finally {
- setBusy(false);
- }
- }, [t]);
+ const handleFactoryReset = useFactoryReset({ t, setBusy, setError });
const errorId = 'idb-unlock-error';
const hasError = error.length > 0;
@@ -241,19 +227,7 @@ export const IdbUnlockModal: FC = ({ onUnlocked }) => {
: t('settings.privacy.encryptionUnlockButton')}
-
-
- {t('settings.data.dangerZone.factoryReset.modalDescription')}
-
-
-
+ void handleFactoryReset()} />
);
diff --git a/hooks/useFactoryReset.ts b/hooks/useFactoryReset.ts
new file mode 100644
index 000000000..7307c5754
--- /dev/null
+++ b/hooks/useFactoryReset.ts
@@ -0,0 +1,31 @@
+import { useCallback } from 'react';
+import { wipeAllAppData } from '../services/factoryResetService';
+import { logger } from '../services/logger';
+
+interface Options {
+ t: (key: string) => string;
+ setBusy: (busy: boolean) => void;
+ setError: (error: string) => void;
+}
+
+/**
+ * Shared confirm → wipeAllAppData → busy/error handling for the danger-zone factory-reset
+ * action, used by both the passphrase-unlock modal and the encryption-recovery modal.
+ */
+export function useFactoryReset({ t, setBusy, setError }: Options): () => Promise {
+ return useCallback(async () => {
+ if (!window.confirm(t('settings.data.dangerZone.factoryReset.modalWarning'))) return;
+ setBusy(true);
+ setError('');
+ try {
+ await wipeAllAppData();
+ } catch (err) {
+ setError(t('settings.privacy.encryptionRecoveryFailed'));
+ logger.error('Factory reset failed', {
+ error: err instanceof Error ? err.message : String(err),
+ });
+ } finally {
+ setBusy(false);
+ }
+ }, [t, setBusy, setError]);
+}
diff --git a/tests/unit/factoryResetService.test.ts b/tests/unit/factoryResetService.test.ts
index 27e59f1c9..af653d29e 100644
--- a/tests/unit/factoryResetService.test.ts
+++ b/tests/unit/factoryResetService.test.ts
@@ -1,16 +1,25 @@
/**
* Tests for services/factoryResetService.ts
* QNBS-v3: [data safety / verify complete reset sequencing / preserves deterministic recovery]. Covers the
- * native indexedDB.databases() path, the known-list fallback, and the Cache API branch.
+ * native indexedDB.databases() path, the known-list fallback, the Cache API branch, and the
+ * Tauri AppData clear branch (exists/missing/partial-failure).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { wipeAllAppData } from '../../services/factoryResetService';
import { logger } from '../../services/logger';
+const mockIsTauriRuntime = vi.fn(() => false);
+const mockLoadTauriApis = vi.fn();
+
vi.mock('../../services/logger', () => ({
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
}));
-vi.mock('../../services/tauriRuntime', () => ({ isTauriRuntime: vi.fn(() => false) }));
+vi.mock('../../services/tauriRuntime', () => ({
+ isTauriRuntime: () => mockIsTauriRuntime(),
+}));
+vi.mock('../../services/fs/fsCore', () => ({
+ loadTauriApis: (...args: unknown[]) => mockLoadTauriApis(...args),
+}));
function createDb(name: string): Promise {
return new Promise((resolve, reject) => {
@@ -43,6 +52,7 @@ let originalLocation: Location;
beforeEach(() => {
vi.clearAllMocks();
+ mockIsTauriRuntime.mockReturnValue(false);
reloadMock = vi.fn();
originalLocation = window.location;
Object.defineProperty(window, 'location', {
@@ -101,4 +111,93 @@ describe('wipeAllAppData', () => {
expect(del).toHaveBeenCalledWith('dynamic-v1');
expect(reloadMock).toHaveBeenCalledTimes(1);
});
+
+ describe('desktop (Tauri) AppData clearing', () => {
+ beforeEach(() => {
+ mockIsTauriRuntime.mockReturnValue(true);
+ });
+
+ it('skips readDir/remove when the AppData path does not exist, and still completes the wipe', async () => {
+ const removeMock = vi.fn().mockResolvedValue(undefined);
+ mockLoadTauriApis.mockResolvedValue({
+ appDataDir: vi.fn().mockResolvedValue('/app/data'),
+ exists: vi.fn().mockResolvedValue(false),
+ readDir: vi.fn(),
+ join: vi.fn(),
+ remove: removeMock,
+ });
+
+ await runWipe();
+
+ expect(removeMock).not.toHaveBeenCalled();
+ expect(reloadMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('removes every AppData entry (skipping unnamed entries) and completes the wipe', async () => {
+ const removeMock = vi.fn().mockResolvedValue(undefined);
+ const joinMock = vi.fn((base: string, name: string) => Promise.resolve(`${base}/${name}`));
+ mockLoadTauriApis.mockResolvedValue({
+ appDataDir: vi.fn().mockResolvedValue('/app/data'),
+ exists: vi.fn().mockResolvedValue(true),
+ readDir: vi
+ .fn()
+ .mockResolvedValue([{ name: 'projects' }, { name: undefined }, { name: 'keys.bin' }]),
+ join: joinMock,
+ remove: removeMock,
+ });
+
+ await runWipe();
+
+ expect(removeMock).toHaveBeenCalledWith('/app/data/projects', { recursive: true });
+ expect(removeMock).toHaveBeenCalledWith('/app/data/keys.bin', { recursive: true });
+ expect(removeMock).toHaveBeenCalledTimes(2);
+ expect(reloadMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('rejects and never reloads when every AppData entry fails to remove', async () => {
+ mockLoadTauriApis.mockResolvedValue({
+ appDataDir: vi.fn().mockResolvedValue('/app/data'),
+ exists: vi.fn().mockResolvedValue(true),
+ readDir: vi.fn().mockResolvedValue([{ name: 'locked-file' }]),
+ join: vi.fn((base: string, name: string) => Promise.resolve(`${base}/${name}`)),
+ remove: vi.fn().mockRejectedValue(new Error('EBUSY')),
+ });
+
+ vi.useFakeTimers();
+ try {
+ await expect(wipeAllAppData()).rejects.toThrow(
+ 'Factory reset could not clear desktop data',
+ );
+ } finally {
+ vi.useRealTimers();
+ }
+
+ expect(reloadMock).not.toHaveBeenCalled();
+ expect(logger.error).toHaveBeenCalledWith(
+ 'Failed to clear Tauri app data during factory reset:',
+ expect.any(Error),
+ );
+ });
+
+ it('rejects and never reloads when readDir itself throws', async () => {
+ mockLoadTauriApis.mockResolvedValue({
+ appDataDir: vi.fn().mockResolvedValue('/app/data'),
+ exists: vi.fn().mockResolvedValue(true),
+ readDir: vi.fn().mockRejectedValue(new Error('permission denied')),
+ join: vi.fn(),
+ remove: vi.fn(),
+ });
+
+ vi.useFakeTimers();
+ try {
+ await expect(wipeAllAppData()).rejects.toThrow(
+ 'Factory reset could not clear desktop data',
+ );
+ } finally {
+ vi.useRealTimers();
+ }
+
+ expect(reloadMock).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/tests/unit/settings/EncryptionRecoveryModal.test.tsx b/tests/unit/settings/EncryptionRecoveryModal.test.tsx
index bc4121a89..f7d135e0c 100644
--- a/tests/unit/settings/EncryptionRecoveryModal.test.tsx
+++ b/tests/unit/settings/EncryptionRecoveryModal.test.tsx
@@ -19,13 +19,22 @@ type OnRecovered = () => void;
const mockResumeEncryptionMigration = vi.fn();
const mockLoggerWarn = vi.fn();
+const mockLoggerError = vi.fn();
+const mockWipeAllAppData = vi.fn();
vi.mock('../../../hooks/useTranslation', () => ({
useTranslation: () => ({ t: (k: string) => k, language: 'en' }),
}));
vi.mock('../../../services/logger', () => ({
- logger: { warn: (...args: unknown[]) => mockLoggerWarn(...args) },
+ logger: {
+ warn: (...args: unknown[]) => mockLoggerWarn(...args),
+ error: (...args: unknown[]) => mockLoggerError(...args),
+ },
+}));
+
+vi.mock('../../../services/factoryResetService', () => ({
+ wipeAllAppData: (...args: unknown[]) => mockWipeAllAppData(...args),
}));
// QNBS-v3: IdbWrongPassphraseError must be a real class (not a plain mock) so the component's
@@ -99,6 +108,8 @@ describe('EncryptionRecoveryModal', () => {
beforeEach(() => {
vi.clearAllMocks();
onRecovered = vi.fn();
+ mockWipeAllAppData.mockResolvedValue(undefined);
+ vi.spyOn(window, 'confirm').mockReturnValue(true);
});
it('renders the recovery title', () => {
@@ -317,4 +328,70 @@ describe('EncryptionRecoveryModal', () => {
resolveResume?.();
await waitFor(() => expect(onRecovered).toHaveBeenCalledTimes(1));
});
+
+ describe('danger-zone factory reset', () => {
+ it('asks for confirmation and wipes app data from the stuck (recovery-required) state', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ await user.click(
+ screen.getByRole('button', { name: 'settings.data.dangerZone.factoryReset.button' }),
+ );
+ expect(window.confirm).toHaveBeenCalledWith(
+ 'settings.data.dangerZone.factoryReset.modalWarning',
+ );
+ await waitFor(() => expect(mockWipeAllAppData).toHaveBeenCalledTimes(1));
+ });
+
+ it('asks for confirmation and wipes app data from the normal resume state', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ await user.click(
+ screen.getByRole('button', { name: 'settings.data.dangerZone.factoryReset.button' }),
+ );
+ await waitFor(() => expect(mockWipeAllAppData).toHaveBeenCalledTimes(1));
+ });
+
+ it('does not wipe app data when the confirmation is dismissed', async () => {
+ vi.spyOn(window, 'confirm').mockReturnValue(false);
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ await user.click(
+ screen.getByRole('button', { name: 'settings.data.dangerZone.factoryReset.button' }),
+ );
+ expect(mockWipeAllAppData).not.toHaveBeenCalled();
+ });
+
+ it('shows a recovery-failed error and logs it when wipeAllAppData rejects', async () => {
+ mockWipeAllAppData.mockRejectedValue(new Error('disk full'));
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ await user.click(
+ screen.getByRole('button', { name: 'settings.data.dangerZone.factoryReset.button' }),
+ );
+ await waitFor(() =>
+ expect(screen.getByText('settings.privacy.encryptionRecoveryFailed')).toBeInTheDocument(),
+ );
+ expect(mockLoggerError).toHaveBeenCalledWith('Factory reset failed', { error: 'disk full' });
+ });
+ });
});
diff --git a/tests/unit/settings/IdbUnlockModal.test.tsx b/tests/unit/settings/IdbUnlockModal.test.tsx
index e289b6d2a..e719c8234 100644
--- a/tests/unit/settings/IdbUnlockModal.test.tsx
+++ b/tests/unit/settings/IdbUnlockModal.test.tsx
@@ -13,6 +13,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
// ---------------------------------------------------------------------------
const mockVerifyAndInit = vi.fn();
+const mockWipeAllAppData = vi.fn();
+const mockLoggerError = vi.fn();
const localStorageMock = (() => {
let store: Record = {};
@@ -39,6 +41,14 @@ vi.mock('../../../services/storage/storageEncryptionService', () => ({
verifyAndInitIdbEncryption: (...args: unknown[]) => mockVerifyAndInit(...args),
}));
+vi.mock('../../../services/factoryResetService', () => ({
+ wipeAllAppData: (...args: unknown[]) => mockWipeAllAppData(...args),
+}));
+
+vi.mock('../../../services/logger', () => ({
+ logger: { error: (...args: unknown[]) => mockLoggerError(...args) },
+}));
+
vi.mock('../../../components/ui/Button', () => ({
Button: (props: React.ButtonHTMLAttributes) => ,
}));
@@ -79,6 +89,8 @@ describe('IdbUnlockModal', () => {
vi.clearAllMocks();
localStorageMock.clear();
mockVerifyAndInit.mockResolvedValue(undefined);
+ mockWipeAllAppData.mockResolvedValue(undefined);
+ vi.spyOn(window, 'confirm').mockReturnValue(true);
});
it('renders the unlock modal dialog', () => {
@@ -246,4 +258,45 @@ describe('IdbUnlockModal', () => {
screen.queryByText('settings.privacy.encryptionForgotPassphrase'),
).not.toBeInTheDocument();
});
+
+ describe('danger-zone factory reset', () => {
+ it('renders the factory reset description and button', () => {
+ render();
+ expect(
+ screen.getByText('settings.data.dangerZone.factoryReset.modalDescription'),
+ ).toBeInTheDocument();
+ expect(screen.getByText('settings.data.dangerZone.factoryReset.button')).toBeInTheDocument();
+ });
+
+ it('asks for confirmation and wipes app data when confirmed', async () => {
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getAllByRole('button')[1] as HTMLElement);
+ expect(window.confirm).toHaveBeenCalledWith(
+ 'settings.data.dangerZone.factoryReset.modalWarning',
+ );
+ await waitFor(() => expect(mockWipeAllAppData).toHaveBeenCalledTimes(1));
+ });
+
+ it('does not wipe app data when the confirmation is dismissed', async () => {
+ vi.spyOn(window, 'confirm').mockReturnValue(false);
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getAllByRole('button')[1] as HTMLElement);
+ expect(mockWipeAllAppData).not.toHaveBeenCalled();
+ });
+
+ it('shows a recovery-failed error and logs it when wipeAllAppData rejects', async () => {
+ mockWipeAllAppData.mockRejectedValue(new Error('disk full'));
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getAllByRole('button')[1] as HTMLElement);
+ await waitFor(() => {
+ expect(screen.getByText('settings.privacy.encryptionRecoveryFailed')).toBeInTheDocument();
+ });
+ expect(mockLoggerError).toHaveBeenCalledWith('Factory reset failed', {
+ error: 'disk full',
+ });
+ });
+ });
});
From b7de360bd85455fa957f8ff22babb8c9ec1c4ef6 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Fri, 14 Aug 2026 05:04:50 +0200
Subject: [PATCH 11/12] fix(storage): close CodeAnt/CodeRabbit/Codex review
findings on PR 363
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Grant exact $APPDATA scope (not just $APPDATA/**) for fs:allow-exists
and fs:allow-read-dir — the glob alone rejected exists()/readDir() on
the AppData root itself, breaking factory reset on packaged builds (P1).
- Use retryFs for clearTauriAppData's readDir/remove calls, matching
the retry pattern used everywhere else in the fs store layer.
- Retry (not silently drop) orphaned temp-file cleanup after a failed
atomic write, log a warning if cleanup itself fails, and always
rethrow the original write/rename error.
- Detect a binary/metadata byteSize mismatch in getBinderAsset and
treat it as corrupt rather than pairing two different save
generations (the two files are independent atomic writes, not one
transaction).
- Scan the config directory by the `*_key.enc.json` naming convention
in removeLegacyApiKeyFiles instead of a fixed 5-provider list, so an
unlisted/future provider's legacy key file is no longer left behind.
- Guard Builder::on_menu_event with #[cfg(desktop)] — the tauri crate
itself gates that method to desktop, so the prior unconditional call
would fail to compile for the mobile targets this file already
anticipates (#[cfg_attr(mobile, ...)]).
- Re-wire the dead Rust menu-action event bridge: registerTauriMenuHandler
is back as a pre-paint-window fallback and now unregisters itself once
installDesktopMenu's JS menu takes over, so the Rust fallback menu's
items are never silently inert (and never double-dispatch once JS owns
the menu).
- Correct the desktop threat-model doc: key routing to IndexedDB is
unconditional (not a filesystem-adapter rejection), and legacy-file
cleanup is best-effort, not guaranteed.
- Investigated and did not change: apis.rename on Windows already
requests MOVEFILE_REPLACE_EXISTING via std::fs::rename (verified
against the vendored tauri-plugin-fs 2.5.1 source) — the "Windows
rename fails when destination exists" finding does not apply here.
Co-Authored-By: Claude Sonnet 5
---
App.tsx | 26 ++++++--
docs/SECURITY-THREAT-MODEL.md | 8 ++-
services/factoryResetService.ts | 6 +-
services/fs/assetFsStore.ts | 10 ++++
services/fs/fsCore.ts | 10 +++-
services/fs/settingsFsStore.ts | 23 +++++--
src-tauri/capabilities/default.json | 6 ++
src-tauri/src/lib.rs | 43 ++++++++------
tests/unit/factoryResetService.test.ts | 6 +-
tests/unit/services/fs/fsCore.test.ts | 79 ++++++++++++++++++++++++-
tests/unit/services/fs/fsStores.test.ts | 30 ++++++++++
11 files changed, 208 insertions(+), 39 deletions(-)
diff --git a/App.tsx b/App.tsx
index c5b8b9922..2696f5933 100644
--- a/App.tsx
+++ b/App.tsx
@@ -65,6 +65,7 @@ import { useTranslation } from './hooks/useTranslation';
import { runCommandById } from './services/commands/commandBuilder';
import { getEffectiveTheme } from './services/commands/effectiveTheme';
import { approximateManuscriptWordCount } from './services/commands/wordCountApprox';
+import { DESKTOP_COMMANDS } from './services/desktop/desktopEvents';
import { installDesktopMenu } from './services/desktop/desktopMenu';
import { installCloseToTray, installDesktopTray } from './services/desktop/desktopTray';
import { logger } from './services/logger';
@@ -80,6 +81,11 @@ import {
isIdbEncryptionReady,
} from './services/storage/storageEncryptionService';
import { initTauriDeepLink } from './services/tauriDeepLink';
+import {
+ registerTauriMenuHandler,
+ type TauriMenuAction,
+ unregisterTauriMenuHandler,
+} from './services/tauriMenuService';
import { applyDesktopRuntimeFlags } from './services/tauriRuntime';
import { viewNavigationLabelKey } from './services/viewNavigationLabels';
import type { View } from './types';
@@ -582,10 +588,7 @@ const App: FC = ({ isNewUser }) => {
return () => window.removeEventListener('voice-command', handler);
}, [executeCommand]);
- // QNBS-v3 (T1/#189): the localized JS menu (installDesktopMenu) is the single source of truth for
- // native menu actions — its item callbacks dispatch via executeCommand directly. The old
- // registerTauriMenuHandler (Rust `menu-action` event bridge) reused the SAME item ids, so on desktop
- // every menu click dispatched twice (JS callback + Rust event path). It is removed; the JS menu owns it.
+ // QNBS-v3 (T1/#189): installDesktopMenu is the source of truth for native menu actions once installed; registerTauriMenuHandler below is scoped to the pre-paint window (or a permanent fallback if install fails) and unregisters itself once the JS menu takes over, so the two paths never double-dispatch the same click.
//
// executeCommand is held in a ref so the menu only rebuilds when the language (t) changes — not on
// every executeCommand identity change (it depends on characters/worlds/settings/… and recreates often).
@@ -610,11 +613,24 @@ const App: FC = ({ isNewUser }) => {
executeCommandRef.current = executeCommand;
}, [executeCommand]);
useEffect(() => {
+ // QNBS-v3: fallback bridge for the Rust pre-paint menu (see comment above) — unregisters itself once installDesktopMenu confirms the JS menu has taken over, or stays registered if it failed.
+ const RUST_MENU_ACTION_TO_COMMAND: Record = {
+ 'menu-export': DESKTOP_COMMANDS.export,
+ 'menu-settings': DESKTOP_COMMANDS.settings,
+ 'menu-help': DESKTOP_COMMANDS.help,
+ 'menu-command-palette': DESKTOP_COMMANDS.commandPalette,
+ };
+ void registerTauriMenuHandler((action) =>
+ executeCommandRef.current(RUST_MENU_ACTION_TO_COMMAND[action]),
+ );
void installDesktopMenu(
(key) => t(key),
(id) => executeCommandRef.current(id),
quitApp,
- );
+ ).then((installed) => {
+ if (installed) unregisterTauriMenuHandler();
+ });
+ return unregisterTauriMenuHandler;
}, [t, quitApp]);
// QNBS-v3 (T2): system tray (created once; guard makes re-calls a no-op). No-op on the web.
diff --git a/docs/SECURITY-THREAT-MODEL.md b/docs/SECURITY-THREAT-MODEL.md
index db918bb08..5f25039d9 100644
--- a/docs/SECURITY-THREAT-MODEL.md
+++ b/docs/SECURITY-THREAT-MODEL.md
@@ -39,7 +39,7 @@ This document provides a formal STRIDE threat analysis for WorldScript Studio, m
| Threat | Mitigation | Code Location |
|--------|------------|-------------|
| API key leakage via logs | StructuredLogger sanitization; never log keys | `services/logger.ts:sanitizeLogContext()` |
-| Desktop API key exposure via local filesystem read | API keys are not written to the Tauri AppData filesystem. `storageService` uses the IndexedDB key store with a random non-extractable AES-GCM key; legacy filesystem key files are discarded and re-entry is required. | `services/storage/idbKeyStore.ts`, `services/storageService.ts`, `services/fs/settingsFsStore.ts` |
+| Desktop API key exposure via local filesystem read | Filesystem API-key persistence is disabled: `storageService`'s key methods route directly to the IndexedDB key store (random non-extractable AES-GCM key) on every platform, desktop included — the filesystem adapter's own `saveApiKey` is a defense-in-depth backstop that throws if ever called directly. Legacy filesystem key files are removed on a best-effort basis (each failure is logged, not retried indefinitely) and re-entry is required if cleanup or decryption fails. | `services/storage/idbKeyStore.ts`, `services/storageService.ts`, `services/fs/settingsFsStore.ts` |
| Manuscript data in IndexedDB | AES-256-GCM at-rest encryption | `services/storage/storageEncryptionService.ts` |
| Voice audio to cloud | Web Speech API consent gate | `components/voice/VoicePrivacyConsentModal.tsx` |
| DuckDB analytics unencrypted (SEC-6) | **Bounded by design, with one prose column now encrypted:** most persisted fields are local metadata only (titles, loglines, character names, word counts, embeddings) and **nothing leaves the device**. The one column that genuinely holds literal manuscript prose, `codex_mentions.excerpt`, is now cell-level encrypted (AES-256-GCM via `services/duckdb/duckdbEncryption.ts`, reusing the IDB at-rest encryption key) whenever `enableIdbAtRestEncryption` is active: `duckdbCodexWrite()` writes ciphertext into `excerpt_enc BLOB` and nulls the plaintext `excerpt` column; `services/duckdb/codexExcerptEncryptionMigration.ts` backfills any pre-existing plaintext rows once encryption is unlocked. Gated by `enableDuckDbAnalytics` **and** the Settings → Privacy "Analytics" opt-out (`isAnalyticsPersistenceAllowed` in `app/listenerMiddleware.ts`); turning the toggle off stops all DuckDB writes + inference telemetry. Full OPFS file-level encryption remains **infeasible** — DuckDB-WASM owns the OPFS file handle directly, so there is no app-level interception point; the other metadata columns stay intentionally plaintext (bounded-exposure design). | `app/listenerMiddleware.ts:isAnalyticsPersistenceAllowed`, `services/duckdb/duckdbAnalytics.ts:duckdbCodexWrite()`, `services/duckdb/duckdbEncryption.ts`, `services/duckdb/codexExcerptEncryptionMigration.ts` |
@@ -124,8 +124,10 @@ Goal: Intercept/decrypt collaboration traffic
```
Goal: Recover a user's cloud-provider API key from the Tauri desktop install
├─ OR: Read the Tauri AppData filesystem directly (local process / malware with user-level FS access)
-│ └─ Mitigation: API keys are not stored there; the filesystem adapter rejects key writes and
-│ deletes legacy derived-key files. Runtime key access uses the WebView IDB key store.
+│ └─ Mitigation: API keys are not stored there — `storageService` routes every key operation to the
+│ WebView's IndexedDB key store regardless of platform; the filesystem adapter's own key-write
+│ path is a defense-in-depth backstop that throws rather than persisting. Legacy derived-key
+│ files are removed best-effort (logged, not guaranteed) during startup cleanup.
├─ OR: Read the IDB-at-rest passphrase sentinel (enableIdbAtRestEncryption)
│ └─ Mitigation: same PBKDF2 + non-extractable-key pattern; session-scoped in-memory key, never
│ persisted to disk (`services/storage/storageEncryptionService.ts`)
diff --git a/services/factoryResetService.ts b/services/factoryResetService.ts
index 33a70f54d..da7a8e92d 100644
--- a/services/factoryResetService.ts
+++ b/services/factoryResetService.ts
@@ -60,19 +60,19 @@ async function clearServiceWorkerCaches(): Promise {
async function clearTauriAppData(): Promise {
if (!isTauriRuntime()) return;
try {
- const { loadTauriApis } = await import('./fs/fsCore');
+ const { loadTauriApis, retryFs } = await import('./fs/fsCore');
const apis = await loadTauriApis();
const appDataPath = await apis.appDataDir();
if (await apis.exists(appDataPath)) {
// QNBS-v3: keep the capability-scoped AppData root and remove only its contents.
- const entries = await apis.readDir(appDataPath);
+ const entries = await retryFs(() => apis.readDir(appDataPath));
let firstError: unknown;
for (const name of entries
.map((entry) => entry.name)
.filter((entryName): entryName is string => Boolean(entryName))) {
try {
const childPath = await apis.join(appDataPath, name);
- await apis.remove(childPath, { recursive: true });
+ await retryFs(() => apis.remove(childPath, { recursive: true }));
} catch (error) {
firstError ??= error;
}
diff --git a/services/fs/assetFsStore.ts b/services/fs/assetFsStore.ts
index 55e5833a3..ac0e2d288 100644
--- a/services/fs/assetFsStore.ts
+++ b/services/fs/assetFsStore.ts
@@ -102,6 +102,16 @@ export class FsAssetStore extends FsSnapshotStore {
retryFs(() => apis.readTextFile(metaFile)),
]);
const meta = JSON.parse(metaRaw) as BinderAssetMeta;
+ // QNBS-v3: binary + metadata are two independent atomic writes, not one transaction — a byteSize mismatch is the cheapest reliable signal that a partial failure paired a new generation with a stale one.
+ if (meta.byteSize !== bytes.byteLength) {
+ logger.warn('getBinderAsset: byteSize/binary mismatch — treating pair as corrupt', {
+ projectId,
+ assetId,
+ expected: meta.byteSize,
+ actual: bytes.byteLength,
+ });
+ return null;
+ }
const copy = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
return { data: copy, meta };
} catch (error) {
diff --git a/services/fs/fsCore.ts b/services/fs/fsCore.ts
index 8163a1846..a5abc0d76 100644
--- a/services/fs/fsCore.ts
+++ b/services/fs/fsCore.ts
@@ -104,7 +104,15 @@ async function writeAndReplace(
await retryFs(() => write(temporary));
await retryFs(() => apis.rename(temporary, path));
} catch (error) {
- await apis.remove(temporary).catch(() => undefined);
+ // QNBS-v3: retry cleanup, then log (not throw) — the caller must always see the original write/rename error, with an orphaned-temp-file warning surfaced for diagnostics.
+ try {
+ await retryFs(() => apis.remove(temporary));
+ } catch (cleanupError) {
+ logger.warn('Failed to remove temp file after a failed atomic write', {
+ path: temporary,
+ error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
+ });
+ }
throw error;
}
});
diff --git a/services/fs/settingsFsStore.ts b/services/fs/settingsFsStore.ts
index 1f4507d9d..712ebd003 100644
--- a/services/fs/settingsFsStore.ts
+++ b/services/fs/settingsFsStore.ts
@@ -13,18 +13,31 @@ import type { TauriApis } from './fsCore';
import { FsCore, retryFs, writeTextFileAtomic } from './fsCore';
export class FsSettingsStore extends FsCore {
+ // QNBS-v3 (CodeRabbit #363): scan by the `*_key.enc.json` naming convention, not a fixed provider list — saveApiKey/getApiKey accept any provider string, so a hardcoded list left unlisted providers' legacy files uncleaned.
async removeLegacyApiKeyFiles(): Promise {
const apis = await this.getApis();
const appDataPath = await this.ensureAppDataPath();
const configPath = await apis.join(appDataPath, 'config');
- const providers = ['gemini', 'openai', 'anthropic', 'grok', 'openrouter'];
+ if (!(await apis.exists(configPath))) return;
- for (const provider of providers) {
- const keyFile = await apis.join(configPath, `${provider}_key.enc.json`);
+ let entries: Awaited>;
+ try {
+ entries = await retryFs(() => apis.readDir(configPath));
+ } catch (error) {
+ logger.warn('Failed to enumerate config directory for legacy API key cleanup:', error);
+ return;
+ }
+
+ const keyFileNames = entries
+ .map((entry) => entry.name)
+ .filter((name): name is string => typeof name === 'string' && /_key\.enc\.json$/i.test(name));
+
+ for (const name of keyFileNames) {
+ const keyFile = await apis.join(configPath, name);
try {
- if (await apis.exists(keyFile)) await retryFs(() => apis.remove(keyFile));
+ await retryFs(() => apis.remove(keyFile));
} catch (error) {
- logger.warn(`Failed to remove legacy API key file for provider "${provider}":`, error);
+ logger.warn(`Failed to remove legacy API key file "${name}":`, error);
}
}
}
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index 2aae7f50a..7af79cc34 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -34,6 +34,9 @@
{
"identifier": "fs:allow-exists",
"allow": [
+ {
+ "path": "$APPDATA"
+ },
{
"path": "$APPDATA/**"
}
@@ -42,6 +45,9 @@
{
"identifier": "fs:allow-read-dir",
"allow": [
+ {
+ "path": "$APPDATA"
+ },
{
"path": "$APPDATA/**"
}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index df57bb26b..1263cbe34 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -136,7 +136,7 @@ fn install_app_menu(_app: &tauri::App) -> tauri::Result<()> {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
- tauri::Builder::default()
+ let builder = tauri::Builder::default()
// QNBS-v3: Single-instance plugin with deep-link feature handles file associations
// The plugin automatically handles CLI args and emits "deep-link://new-url" event
.plugin(tauri_plugin_single_instance::Builder::new().build())
@@ -177,23 +177,30 @@ pub fn run() {
}
install_app_menu(app)?;
Ok(())
- })
- .on_menu_event(|app, event| {
- let id = event.id().0.clone();
- // QNBS-v3 (#187): only forward the custom ids the frontend actually handles. The predefined
- // Edit/Window items (undo/redo/cut/copy/paste/select-all/minimize/…) are handled natively by the
- // OS; emitting them too would flood the JS bridge with high-frequency events during editing that
- // the frontend just ignores.
- if matches!(
- id.as_str(),
- "menu-export" | "menu-settings" | "menu-help" | "menu-command-palette"
- ) {
- let _ = app.emit("menu-action", id);
- }
- })
- // QNBS-v3: RunEvent handlers removed because tauri_plugin_single_instance
- // handles SecondInstance/Opened via "deep-link://new-url" events and
- // tauri::Builder no longer exposes .on_event() in Tauri v2.
+ });
+
+ // QNBS-v3: Builder::on_menu_event is itself #[cfg(desktop)]-gated inside the tauri crate — an
+ // unconditional call here would fail to compile for the mobile targets #[cfg_attr(mobile, ...)]
+ // above anticipates. Rebinding `builder` keeps the rest of the chain shared across targets.
+ #[cfg(desktop)]
+ let builder = builder.on_menu_event(|app, event| {
+ let id = event.id().0.clone();
+ // QNBS-v3 (#187): only forward the custom ids the frontend actually handles. The predefined
+ // Edit/Window items (undo/redo/cut/copy/paste/select-all/minimize/…) are handled natively by the
+ // OS; emitting them too would flood the JS bridge with high-frequency events during editing that
+ // the frontend just ignores.
+ if matches!(
+ id.as_str(),
+ "menu-export" | "menu-settings" | "menu-help" | "menu-command-palette"
+ ) {
+ let _ = app.emit("menu-action", id);
+ }
+ });
+
+ // QNBS-v3: RunEvent handlers removed because tauri_plugin_single_instance
+ // handles SecondInstance/Opened via "deep-link://new-url" events and
+ // tauri::Builder no longer exposes .on_event() in Tauri v2.
+ builder
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
diff --git a/tests/unit/factoryResetService.test.ts b/tests/unit/factoryResetService.test.ts
index af653d29e..829d009ff 100644
--- a/tests/unit/factoryResetService.test.ts
+++ b/tests/unit/factoryResetService.test.ts
@@ -1,8 +1,6 @@
/**
* Tests for services/factoryResetService.ts
- * QNBS-v3: [data safety / verify complete reset sequencing / preserves deterministic recovery]. Covers the
- * native indexedDB.databases() path, the known-list fallback, the Cache API branch, and the
- * Tauri AppData clear branch (exists/missing/partial-failure).
+ * QNBS-v3: covers the native indexedDB.databases() path, the known-list fallback, the Cache API branch, and the Tauri AppData clear branch (exists/missing/partial-failure).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { wipeAllAppData } from '../../services/factoryResetService';
@@ -19,6 +17,8 @@ vi.mock('../../services/tauriRuntime', () => ({
}));
vi.mock('../../services/fs/fsCore', () => ({
loadTauriApis: (...args: unknown[]) => mockLoadTauriApis(...args),
+ // QNBS-v3: pass-through — retry/backoff behavior is covered by fsCore.test.ts directly.
+ retryFs: (fn: () => Promise) => fn(),
}));
function createDb(name: string): Promise {
diff --git a/tests/unit/services/fs/fsCore.test.ts b/tests/unit/services/fs/fsCore.test.ts
index 284d2194d..025e1ff08 100644
--- a/tests/unit/services/fs/fsCore.test.ts
+++ b/tests/unit/services/fs/fsCore.test.ts
@@ -4,7 +4,8 @@
* sanitization, and word counting — no Tauri APIs required.
*/
-import { describe, expect, it, vi } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { TauriApis } from '../../../../services/fs/fsCore';
import {
compressData,
countProjectWords,
@@ -13,8 +14,14 @@ import {
encryptText,
retryFs,
sanitizePathSegment,
+ writeTextFileAtomic,
} from '../../../../services/fs/fsCore';
+const mockLoggerWarn = vi.fn();
+vi.mock('../../../../services/logger', () => ({
+ logger: { warn: (...args: unknown[]) => mockLoggerWarn(...args) },
+}));
+
describe('retryFs', () => {
it('returns on first success without retrying', async () => {
const fn = vi.fn().mockResolvedValue('ok');
@@ -44,6 +51,76 @@ describe('retryFs', () => {
});
});
+describe('writeTextFileAtomic', () => {
+ function makeApis(overrides: Partial = {}): TauriApis {
+ return {
+ readTextFile: vi.fn(),
+ writeTextFile: vi.fn().mockResolvedValue(undefined),
+ readFile: vi.fn(),
+ writeFile: vi.fn(),
+ mkdir: vi.fn(),
+ exists: vi.fn(),
+ readDir: vi.fn(),
+ remove: vi.fn().mockResolvedValue(undefined),
+ rename: vi.fn().mockResolvedValue(undefined),
+ open: vi.fn(),
+ save: vi.fn(),
+ appDataDir: vi.fn(),
+ join: vi.fn(),
+ invoke: vi.fn(),
+ ...overrides,
+ };
+ }
+
+ beforeEach(() => {
+ mockLoggerWarn.mockClear();
+ });
+
+ it('writes to a temp sibling then renames it into place', async () => {
+ const apis = makeApis();
+ await writeTextFileAtomic(apis, '/data/project.json', '{"a":1}');
+
+ expect(apis.writeTextFile).toHaveBeenCalledTimes(1);
+ const [temporary, content] = (apis.writeTextFile as ReturnType).mock.calls[0] as [
+ string,
+ string,
+ ];
+ expect(temporary).toMatch(/^\/data\/project\.json\.tmp-/);
+ expect(content).toBe('{"a":1}');
+ expect(apis.rename).toHaveBeenCalledWith(temporary, '/data/project.json');
+ expect(apis.remove).not.toHaveBeenCalled();
+ });
+
+ it('removes the orphaned temp file and rethrows the original error when the write fails', async () => {
+ const apis = makeApis({ writeTextFile: vi.fn().mockRejectedValue(new Error('disk full')) });
+
+ await expect(writeTextFileAtomic(apis, '/data/project.json', 'x')).rejects.toThrow('disk full');
+ expect(apis.remove).toHaveBeenCalledTimes(1);
+ expect(mockLoggerWarn).not.toHaveBeenCalled();
+ });
+
+ it('removes the temp file and rethrows the original error when rename fails', async () => {
+ const apis = makeApis({ rename: vi.fn().mockRejectedValue(new Error('EPERM')) });
+
+ await expect(writeTextFileAtomic(apis, '/data/project.json', 'x')).rejects.toThrow('EPERM');
+ expect(apis.remove).toHaveBeenCalledTimes(1);
+ });
+
+ it('logs a warning but still rethrows the original error when temp-file cleanup itself fails', async () => {
+ // QNBS-v3: a non-transient message keeps the cleanup retry a single immediate attempt — a transient-looking one (e.g. "busy") would trigger real 500ms retry delays here.
+ const apis = makeApis({
+ rename: vi.fn().mockRejectedValue(new Error('EPERM')),
+ remove: vi.fn().mockRejectedValue(new Error('access denied')),
+ });
+
+ await expect(writeTextFileAtomic(apis, '/data/project.json', 'x')).rejects.toThrow('EPERM');
+ expect(mockLoggerWarn).toHaveBeenCalledWith(
+ 'Failed to remove temp file after a failed atomic write',
+ expect.objectContaining({ error: 'access denied' }),
+ );
+ });
+});
+
describe('compressData / decompressData', () => {
it('round-trips small data uncompressed (plain JSON)', () => {
const data = { a: 1, b: ['x', 'y'], c: 'hello' };
diff --git a/tests/unit/services/fs/fsStores.test.ts b/tests/unit/services/fs/fsStores.test.ts
index c3bc11c07..979fc4a11 100644
--- a/tests/unit/services/fs/fsStores.test.ts
+++ b/tests/unit/services/fs/fsStores.test.ts
@@ -244,6 +244,17 @@ describe('FsSettingsStore — settings + encrypted API keys', () => {
).toBe(true);
});
+ // QNBS-v3 (CodeRabbit #363): regression guard — a hardcoded provider list would leave an unlisted/future provider's legacy file uncleaned forever, since cleanup now scans by filename pattern instead.
+ it('removes a legacy key file for a provider not in any hardcoded list, and leaves other files alone', async () => {
+ fake.text.set('/app/config/some-future-provider_key.enc.json', 'legacy-ciphertext');
+ fake.text.set('/app/config/settings.json', '{"kept":true}');
+
+ await store.removeLegacyApiKeyFiles();
+
+ expect(fake.text.has('/app/config/some-future-provider_key.enc.json')).toBe(false);
+ expect(fake.text.has('/app/config/settings.json')).toBe(true);
+ });
+
// QNBS-v3: [security / discard recoverable legacy ciphertext / prevents unsafe migration].
it('discards a legacy unsalted key file without notifying or throwing', async () => {
const dispatch = vi.fn();
@@ -344,6 +355,25 @@ describe('FsAssetStore — images + binder assets', () => {
expect(await store.getBinderAsset('p1', 'missing')).toBeNull();
expect(await store.listBinderAssetIds('p1')).toEqual([]);
});
+
+ // QNBS-v3 (CodeAnt #363): simulates a torn write (a partially-applied metadata update from a different generation) and asserts the read side refuses to pair mismatched binary/metadata.
+ it('treats a binary/metadata byteSize mismatch as corrupt rather than returning a mixed pair', async () => {
+ const data = new Uint8Array([1, 2, 3, 4]).buffer;
+ await store.saveBinderAsset('p1', 'a1', data, {
+ name: 'doc.pdf',
+ mime: 'application/pdf',
+ } as never);
+
+ const metaFile = '/app/projects/p1/binder/a1.meta.json';
+ const staleMeta = JSON.parse(fake.text.get(metaFile) as string);
+ fake.text.set(metaFile, JSON.stringify({ ...staleMeta, byteSize: 999 }));
+
+ expect(await store.getBinderAsset('p1', 'a1')).toBeNull();
+ expect(logger.warn).toHaveBeenCalledWith(
+ 'getBinderAsset: byteSize/binary mismatch — treating pair as corrupt',
+ expect.objectContaining({ expected: 999, actual: 4 }),
+ );
+ });
});
describe('FsProjectStore — export / import', () => {
From f3fb622fc2c06173aaa9d7d66561641c8208f781 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Fri, 14 Aug 2026 05:20:00 +0200
Subject: [PATCH 12/12] fix(desktop): close review-wave-2 findings on PR 363
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Generation-guard registerTauriMenuHandler (mirrors desktopMenu.ts's
menuInstallToken) — its async dynamic-import + listen() call could
otherwise let a stale completion install a listener after unregister,
or leave two overlapping registrations both active and double-dispatch
a single menu click. Confirmed real via CodeRabbit's own behavioral
probe; regression test added.
- vi.hoisted() for fsCore.test.ts's mockLoggerWarn, removing the TDZ
risk the plain const carried even though it worked in practice.
- Select the factory-reset button by accessible name in
IdbUnlockModal.test.tsx instead of an index-dependent getAllByRole.
Investigated and did not change: the same Windows-rename claim
recurred on services/fs/codexFsStore.ts — already refuted against the
vendored tauri-plugin-fs source in the prior commit; same reasoning
applies here (std::fs::rename requests MOVEFILE_REPLACE_EXISTING).
Co-Authored-By: Claude Sonnet 5
---
services/tauriMenuService.ts | 14 +++++++
tests/unit/services/fs/fsCore.test.ts | 4 +-
tests/unit/settings/IdbUnlockModal.test.tsx | 12 ++++--
tests/unit/tauriServices.test.ts | 41 ++++++++++++++++++---
4 files changed, 61 insertions(+), 10 deletions(-)
diff --git a/services/tauriMenuService.ts b/services/tauriMenuService.ts
index d5a973a84..349a038da 100644
--- a/services/tauriMenuService.ts
+++ b/services/tauriMenuService.ts
@@ -13,12 +13,19 @@ type MenuHandler = (action: TauriMenuAction) => void;
let handler: MenuHandler | null = null;
let unlisten: (() => void) | null = null;
+// QNBS-v3 (CodeAnt/CodeRabbit #363): generation guard, mirroring desktopMenu.ts's menuInstallToken —
+// registerTauriMenuHandler is async (dynamic import + listen()), so an unregister (or a second,
+// overlapping call) can land while a prior call is still pending. Without this, a stale completion
+// could install a listener after cleanup, or two overlapping calls could both end up registered,
+// double-dispatching a single menu click.
+let registrationToken = 0;
/** Registers a handler for native menu events emitted from the Tauri shell. */
export async function registerTauriMenuHandler(onAction: MenuHandler): Promise {
if (!isTauriRuntime()) return;
handler = onAction;
if (unlisten) return;
+ const myToken = ++registrationToken;
try {
const { listen } = await import('@tauri-apps/api/event');
const stop = await listen('menu-action', (event) => {
@@ -35,6 +42,12 @@ export async function registerTauriMenuHandler(onAction: MenuHandler): Promise ({ mockLoggerWarn: vi.fn() }));
vi.mock('../../../../services/logger', () => ({
logger: { warn: (...args: unknown[]) => mockLoggerWarn(...args) },
}));
diff --git a/tests/unit/settings/IdbUnlockModal.test.tsx b/tests/unit/settings/IdbUnlockModal.test.tsx
index e719c8234..551981a65 100644
--- a/tests/unit/settings/IdbUnlockModal.test.tsx
+++ b/tests/unit/settings/IdbUnlockModal.test.tsx
@@ -271,7 +271,9 @@ describe('IdbUnlockModal', () => {
it('asks for confirmation and wipes app data when confirmed', async () => {
const user = userEvent.setup();
render();
- await user.click(screen.getAllByRole('button')[1] as HTMLElement);
+ await user.click(
+ screen.getByRole('button', { name: 'settings.data.dangerZone.factoryReset.button' }),
+ );
expect(window.confirm).toHaveBeenCalledWith(
'settings.data.dangerZone.factoryReset.modalWarning',
);
@@ -282,7 +284,9 @@ describe('IdbUnlockModal', () => {
vi.spyOn(window, 'confirm').mockReturnValue(false);
const user = userEvent.setup();
render();
- await user.click(screen.getAllByRole('button')[1] as HTMLElement);
+ await user.click(
+ screen.getByRole('button', { name: 'settings.data.dangerZone.factoryReset.button' }),
+ );
expect(mockWipeAllAppData).not.toHaveBeenCalled();
});
@@ -290,7 +294,9 @@ describe('IdbUnlockModal', () => {
mockWipeAllAppData.mockRejectedValue(new Error('disk full'));
const user = userEvent.setup();
render();
- await user.click(screen.getAllByRole('button')[1] as HTMLElement);
+ await user.click(
+ screen.getByRole('button', { name: 'settings.data.dangerZone.factoryReset.button' }),
+ );
await waitFor(() => {
expect(screen.getByText('settings.privacy.encryptionRecoveryFailed')).toBeInTheDocument();
});
diff --git a/tests/unit/tauriServices.test.ts b/tests/unit/tauriServices.test.ts
index 3fba134b3..f71562b05 100644
--- a/tests/unit/tauriServices.test.ts
+++ b/tests/unit/tauriServices.test.ts
@@ -3,6 +3,7 @@
* QNBS-v3: Mocks isTauriRuntime — non-Tauri paths do nothing and don't import Tauri modules.
*/
+import { waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// ---------------------------------------------------------------------------
@@ -16,14 +17,17 @@ vi.mock('../../services/tauriRuntime', () => ({
}));
// QNBS-v3: capture the registered `listen` callback so tests can simulate native menu events.
+// mockListen is a controllable reference (not the default auto-resolving body inline) so the race-
+// condition regression test below can defer its resolution to simulate a stale completion.
let capturedMenuListener: ((event: { payload: string }) => void) | null = null;
+const mockListen = vi.fn(async (_name: string, cb: (event: { payload: string }) => void) => {
+ capturedMenuListener = cb;
+ return () => {
+ capturedMenuListener = null;
+ };
+});
vi.mock('@tauri-apps/api/event', () => ({
- listen: vi.fn(async (_name: string, cb: (event: { payload: string }) => void) => {
- capturedMenuListener = cb;
- return () => {
- capturedMenuListener = null;
- };
- }),
+ listen: (...args: Parameters) => mockListen(...args),
}));
// ---------------------------------------------------------------------------
@@ -77,6 +81,31 @@ describe('registerTauriMenuHandler', () => {
capturedMenuListener?.({ payload: 'undo' });
expect(handler).not.toHaveBeenCalled();
});
+
+ // QNBS-v3 (CodeAnt/CodeRabbit #363): registration is async (dynamic import + listen()) — an
+ // unregister landing while it's still pending must tear down the stale listener instead of
+ // letting its late completion install one anyway (which would survive past "unregistered").
+ it('tears down a stale listener whose registration resolves after an unregister', async () => {
+ mockIsTauri = true;
+ let resolveListen: ((stop: () => void) => void) | undefined;
+ const staleStop = vi.fn();
+ mockListen.mockImplementationOnce(
+ () =>
+ new Promise<() => void>((resolve) => {
+ resolveListen = resolve;
+ }),
+ );
+
+ const registerPromise = registerTauriMenuHandler(vi.fn());
+ // Wait for the pending dynamic import + listen() call to actually land before unregistering —
+ // otherwise unregister would run before registrationToken has even been captured.
+ await waitFor(() => expect(resolveListen).toBeDefined());
+ unregisterTauriMenuHandler(); // lands before listen() above resolves
+ resolveListen?.(staleStop);
+ await registerPromise;
+
+ expect(staleStop).toHaveBeenCalledTimes(1);
+ });
});
describe('unregisterTauriMenuHandler', () => {