Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,36 @@ 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: 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 libayatana-appindicator3-dev librsvg2-dev patchelf
- 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
Expand Down Expand Up @@ -263,18 +293,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."
Expand Down
26 changes: 21 additions & 5 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -582,10 +588,7 @@ const App: FC<AppProps> = ({ 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.
Comment thread
qnbs marked this conversation as resolved.
//
// 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).
Expand All @@ -610,11 +613,24 @@ const App: FC<AppProps> = ({ 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<TauriMenuAction, string> = {
'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();
});
Comment thread
qnbs marked this conversation as resolved.
return unregisterTauriMenuHandler;
}, [t, quitApp]);

// QNBS-v3 (T2): system tray (created once; guard makes re-calls a no-op). No-op on the web.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
46 changes: 4 additions & 42 deletions components/ApiKeySection.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -29,20 +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 = 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);
}
}
} catch (error) {
logger.error('Failed to check API key status:', error);
} finally {
Expand All @@ -68,11 +59,10 @@ 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);
setDecryptFailed(false);
setMessage({ type: 'success', text: t('settings.apiKey.saved') });
setTestResult(null);
// QNBS-v3: surface an invalid/unauthenticated key immediately instead of deferring discovery
Expand All @@ -95,7 +85,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') });
Expand Down Expand Up @@ -199,34 +189,6 @@ export const ApiKeySection: FC = () => {
</div>
</div>

{/* Decrypt Failed Warning */}
{decryptFailed && (
<div className="p-4 rounded-lg bg-[var(--sc-danger-bg)] border border-[var(--sc-danger-fg)]/30">
<div className="flex items-start gap-3">
{/* QNBS-v3: Decorative icon - hidden from assistive tech */}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-5 h-5 text-[var(--sc-danger-fg)] flex-shrink-0 mt-0.5"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
<div className="text-sm text-[var(--sc-danger-fg)]">
<p className="font-medium mb-1">{t('apiKey.decryptFailed')}</p>
<p className="text-[var(--sc-danger-fg)]/80">{t('apiKey.decryptFailedDetail')}</p>
</div>
</div>
</div>
)}

{/* Key Status / Input */}
{hasKey ? (
<div className="flex items-center justify-between p-4 rounded-lg bg-[var(--sc-surface-overlay)] border border-[var(--sc-border-subtle)]">
Expand Down
29 changes: 26 additions & 3 deletions components/settings/EncryptionRecoveryModal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { FC } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFactoryReset } from '../../hooks/useFactoryReset';
import { useTranslation } from '../../hooks/useTranslation';
import { logger } from '../../services/logger';
import type { EncryptionMigrationJournal } from '../../services/storage/encryptionMigrationJournal';
Expand All @@ -10,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;
Expand Down Expand Up @@ -87,6 +89,8 @@ export const EncryptionRecoveryModal: FC<Props> = ({ journal, onRecovered }) =>
const canSubmit =
!busy && sourcePassphrase.length > 0 && (!needsTargetPassphrase || targetPassphrase.length > 0);

const handleFactoryReset = useFactoryReset({ t, setBusy, setError });

return (
<Modal
isOpen={true}
Expand All @@ -102,9 +106,27 @@ export const EncryptionRecoveryModal: FC<Props> = ({ journal, onRecovered }) =>
</p>

{stuck ? (
<p className="text-sm text-[var(--sc-danger-fg)] bg-[var(--sc-danger-border)]/10 rounded-md px-3 py-2">
{t('settings.privacy.encryptionRecoveryStuck')}
</p>
<div className="space-y-3">
<p className="text-sm text-[var(--sc-danger-fg)] bg-[var(--sc-danger-border)]/10 rounded-md px-3 py-2">
{t('settings.privacy.encryptionRecoveryStuck')}
</p>
{/* QNBS-v3: mirrors the resume-flow error paragraph — without it a failed reset here left setError with nowhere to render */}
<p
id={ERROR_ID}
role="alert"
className="text-sm text-[var(--sc-danger-fg)]"
style={{ minHeight: '1.25rem' }}
>
{error}
</p>
<FactoryResetDangerZone
t={t}
busy={busy}
onReset={() => void handleFactoryReset()}
bordered={false}
descriptionClassName="text-xs text-[var(--sc-text-secondary)]"
/>
</div>
) : (
<>
<div className="space-y-1">
Expand Down Expand Up @@ -196,6 +218,7 @@ export const EncryptionRecoveryModal: FC<Props> = ({ journal, onRecovered }) =>
{t('settings.privacy.encryptionRecoveryResumeButton')}
</Button>
</div>
<FactoryResetDangerZone t={t} busy={busy} onReset={() => void handleFactoryReset()} />
</>
)}
</div>
Expand Down
39 changes: 39 additions & 0 deletions components/settings/FactoryResetDangerZone.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({
t,
busy,
onReset,
bordered = true,
descriptionClassName = 'text-xs text-[var(--sc-danger-fg)] mb-2',
}) => {
const content = (
<>
<p className={descriptionClassName}>
{t('settings.data.dangerZone.factoryReset.modalDescription')}
</p>
<Button variant="danger" onClick={onReset} disabled={busy} aria-busy={busy}>
{t('settings.data.dangerZone.factoryReset.button')}
</Button>
</>
);
return bordered ? (
<div className="border-t border-[var(--sc-border-subtle)] pt-3">{content}</div>
) : (
content
);
};
5 changes: 5 additions & 0 deletions components/settings/IdbUnlockModal.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +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 { verifyAndInitIdbEncryption } from '../../services/storage/storageEncryptionService';
import { Button } from '../ui/Button';
import { Modal } from '../ui/Modal';
import { FactoryResetDangerZone } from './FactoryResetDangerZone';

interface Props {
onUnlocked: () => void;
Expand Down Expand Up @@ -163,6 +165,8 @@ export const IdbUnlockModal: FC<Props> = ({ onUnlocked }) => {
[handleUnlock],
);

const handleFactoryReset = useFactoryReset({ t, setBusy, setError });

const errorId = 'idb-unlock-error';
const hasError = error.length > 0;

Expand Down Expand Up @@ -223,6 +227,7 @@ export const IdbUnlockModal: FC<Props> = ({ onUnlocked }) => {
: t('settings.privacy.encryptionUnlockButton')}
</Button>
</div>
<FactoryResetDangerZone t={t} busy={busy} onReset={() => void handleFactoryReset()} />
</div>
</Modal>
);
Expand Down
Loading
Loading