Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 6 additions & 0 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
hasPassphraseSentinel,
isIdbEncryptionReady,
} from './services/storage/storageEncryptionService';
import { initTauriDeepLink } from './services/tauriDeepLink';
import { registerTauriMenuHandler, unregisterTauriMenuHandler } from './services/tauriMenuService';
import { viewNavigationLabelKey } from './services/viewNavigationLabels';
import type { View } from './types';
Expand Down Expand Up @@ -490,6 +491,11 @@ const App: FC<AppProps> = ({ isNewUser }) => {
return () => unregisterTauriMenuHandler();
}, [executeCommand]);

// QNBS-v3: Tauri deep link handler for native file associations (.storycraft, .scst)
useEffect(() => {
void initTauriDeepLink(dispatch);
}, [dispatch]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The deep-link initialization effect ignores the async cleanup function returned by initTauriDeepLink, so listener teardown never runs on unmount/re-mount. In React StrictMode this can register duplicate listeners and trigger repeated imports/notifications; capture the returned cleanup and invoke it in the effect cleanup. [missing cleanup]

Severity Level: Major ⚠️
- ⚠️ Deep-link events trigger duplicate project import attempts.
- ⚠️ Users may see duplicate error notifications for one file.
Steps of Reproduction ✅
1. The application entrypoint `index.tsx` at line 246 renders `<App isNewUser={isNewUser}
/>` inside `<React.StrictMode>` (see `/workspace/StoryCraft-Studio/index.tsx:246-250`),
enabling double-invocation of React effects in development.

2. On initial mount of `App` in `App.tsx`, the deep-link effect at lines 495–497
(`useEffect(() => { void initTauriDeepLink(dispatch); }, [dispatch]);`) runs and calls
`initTauriDeepLink(dispatch)` once.

3. `initTauriDeepLink` in `services/tauriDeepLink.ts` at lines 25–36 registers a listener
via `listen('deep-link://new-url', async (event) => { ... })`, storing an `unlisten`
function but returning a cleanup function that is never wired back into the React effect
(no `return` from the `useEffect`).

4. Under React StrictMode, `App` is unmounted and remounted; because the effect has no
cleanup, the previously registered listener remains active while a new listener is added
on re-mount, so when a `.storycraft` file is opened and the deep-link plugin emits
`deep-link://new-url`, multiple handlers fire and dispatch `importProjectThunk` and
`statusActions.addNotification` multiple times.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** App.tsx
**Line:** 495:497
**Comment:**
	*Missing Cleanup: The deep-link initialization effect ignores the async cleanup function returned by `initTauriDeepLink`, so listener teardown never runs on unmount/re-mount. In React StrictMode this can register duplicate listeners and trigger repeated imports/notifications; capture the returned cleanup and invoke it in the effect cleanup.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎


const renderView = () => {
switch (currentView) {
case 'dashboard':
Expand Down
1 change: 1 addition & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Status: 🔄 in progress | ⬜ open | ✅ done
- ✅ **P0-2** — Coverage C-7: 96 neue Tests geschrieben (Ziel 90 übertroffen).
- LoRA: datasetBuilder (19) + evaluationService (16)
- Voice: intentEngine (17) + feedbackService (23) + audioNavigator (21)
- ✅ **P0-4** — Native File Associations + Single-Instance: `.storycraft`/`.scst` extensions registered, deep link handler in `services/tauriDeepLink.ts`, Rust `RunEvent::Opened`/`RunEvent::SecondInstance` handlers in `lib.rs`.
- 🔄 **P0-3** — Quality Gates stabil: lint ✅ · typecheck ✅ · i18n:check ✅ (2026-06-07). parity:check + bundle:budget + smoke:prod benötigen Build → CI-only auf Low-End. Warte auf nächsten CI-Run für Bestätigung.

### P1 — AI Resilience & Core Reliability
Expand Down
30 changes: 30 additions & 0 deletions docs/TAURI-CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,36 @@ Complete these steps once before pushing the first signed release tag:
| Updater | `docs/TAURI-UPDATER.md` when publishing signed tags |
| Local CI | Heavy builds optional — `infra/low-end-ci/` act + native `pnpm run ci:quick` |
| Version UI | Settings shows app version; matches release tag after install |
| File Associations | `.storycraft` and `.scst` extensions registered; double-click opens project |
| Single-Instance | Second instance focuses main window + opens file via `RunEvent::SecondInstance` |

## Native File Associations (v1.20)

StoryCraft Studio registers `.storycraft` and `.scst` file extensions for native project opening.

### Configuration

- `tauri.conf.json` → `bundle.fileAssociations` registers both extensions with `role: "Editor"`
- `tauri.conf.json` → `app.deepLink.protocols` enables `storycraft://` protocol
- `src-tauri/src/lib.rs` → `RunEvent::Opened` and `RunEvent::SecondInstance` handlers
- `services/tauriDeepLink.ts` → Frontend event listener for `open-project-file`

### Behavior

1. **Double-click** a `.storycraft` or `.scst` file → App opens (or activates existing instance) and loads the project
2. **Drag-drop** file onto app icon → Same behavior as double-click
3. **Second instance** → Main window focused, file opened in existing instance
4. **Error handling** → Toast notification with error details if file cannot be loaded

### Icon Preparation

Create icons in `icons/` directory:
- `icons/storycraft-16x16.png` - File icon for Windows/Linux
- `icons/storycraft-32x32.png` - File icon for Windows/Linux
- `icons/storycraft-48x48.png` - File icon for Windows/Linux
- `icons/storycraft-256x256.png` - File icon for Windows/Linux

macOS uses the existing `icons/icon.icns` for file associations.

**Post-release (updater):**

Expand Down
130 changes: 130 additions & 0 deletions services/tauriDeepLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* Tauri Deep Link Service — handles native file associations and single-instance behavior.
* QNBS-v3: Listens for `deep-link://new-url` events emitted from the deep-link plugin
* when a .storycraft or .scst file is double-clicked or dragged onto the app icon.
* Integrates with the existing importProjectThunk flow for seamless project loading.
*/

import type { AppDispatch } from '../app/store';
import { importProjectThunk } from '../features/project/thunks/projectManagementThunks';
import { statusActions } from '../features/status/statusSlice';
import { createLogger } from './logger';
import { isTauriRuntime } from './tauriRuntime';

const log = createLogger('tauriDeepLink');

let unlisten: (() => void) | null = null;

/**
* Initialize the deep link handler for Tauri file associations.
* Call this once during app startup (e.g., in App.tsx useEffect).
*
* @param dispatch - Redux dispatch function for importing projects
* @returns Cleanup function to remove the event listener
*/
export async function initTauriDeepLink(dispatch: AppDispatch): Promise<() => void> {
if (!isTauriRuntime()) {
return () => {};
}

try {
const { listen } = await import('@tauri-apps/api/event');
// Listen for deep-link://new-url event (emitted by tauri-plugin-deep-link)
unlisten = await listen<string[] | string>('deep-link://new-url', async (event) => {
// Handle both array and string payloads
const urls = Array.isArray(event.payload) ? event.payload : [event.payload];
const url = urls[0];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The event payload supports multiple URLs, but only the first entry is processed and all others are silently dropped. This loses user-selected files when the plugin emits more than one path; iterate through all payload URLs or explicitly handle batching. [incomplete implementation]

Severity Level: Major ⚠️
- ⚠️ Multi-file opens import only the first project.
- ⚠️ Users receive no feedback about ignored files.
Steps of Reproduction ✅
1. A user selects multiple `.storycraft` or `.scst` files in the OS file explorer and
opens them together via the registered StoryCraft file association, causing the Tauri
deep-link plugin to emit a `deep-link://new-url` event with an array payload like
`['storycraft:///C:/p1.storycraft', 'storycraft:///C:/p2.storycraft']`.

2. The deep-link listener registered in `initTauriDeepLink` at
`/workspace/StoryCraft-Studio/services/tauriDeepLink.ts:33-37` executes, and the handler
computes `const urls = Array.isArray(event.payload) ? event.payload : [event.payload];`
and then `const url = urls[0];` at lines 35–36.

3. Only `urls[0]` is normalized to a file path and read via `readTextFile(filePath)` at
lines 52–68, then passed into the existing import flow via
`dispatch(importProjectThunk(file))` at lines 76–78; the remaining `urls[1..]` entries are
ignored and never processed.

4. As a result, StoryCraft opens just the first selected project while silently dropping
additional files from the same OS open operation, without any notification that some
requested projects were ignored.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** services/tauriDeepLink.ts
**Line:** 35:36
**Comment:**
	*Incomplete Implementation: The event payload supports multiple URLs, but only the first entry is processed and all others are silently dropped. This loses user-selected files when the plugin emits more than one path; iterate through all payload URLs or explicitly handle batching.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎


if (!url) {
log.warn('Received empty URL in deep-link event');
return;
}

log.info('Received deep-link event', { url });

// Parse the URL to get the file path (storycraft://path/to/file.storycraft)
// The deep-link plugin handles custom schemes, but for file associations we need
// to handle the case where the file path is passed as a CLI argument
try {
// For file associations on Windows/Linux, the deep-link plugin parses CLI args
// and emits the URL. We need to convert storycraft:// URLs back to file paths
// or handle direct file paths if passed.
let filePath = url;

// Check if it's a storycraft:// URL and extract the path
if (url.startsWith('storycraft://') || url.startsWith('storycraft:')) {
// On Windows, the URL might be storycraft:///C:/path/to/file.storycraft
// On Linux, it might be storycraft:///home/user/file.storycraft
filePath = url.replace(/^storycraft:\/\/?/, '').replace(/^\/+/, '/');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The URL-to-path conversion keeps a leading slash for Windows drive-letter paths (for example storycraft:///C:/... becomes /C:/...), which makes exists/readTextFile fail with file-not-found on Windows. Normalize Windows paths so drive-letter paths are not prefixed with /. [logic error]

Severity Level: Critical 🚨
- ❌ Windows file associations fail to open StoryCraft projects.
- ⚠️ Users see error notifications instead of project loading.
Steps of Reproduction ✅
1. On a Windows installation with file associations configured, the user double-clicks
`C:\projects\my-novel.storycraft`, which Tauri's deep-link plugin emits as a
`deep-link://new-url` event with payload like
`storycraft:///C:/projects/my-novel.storycraft` (commented at
`/workspace/StoryCraft-Studio/services/tauriDeepLink.ts:56`).

2. The handler in `initTauriDeepLink` at
`/workspace/StoryCraft-Studio/services/tauriDeepLink.ts:33-37` receives the event, sets
`let filePath = url;`, and enters the `if (url.startsWith('storycraft://') ||
url.startsWith('storycraft:')) { ... }` block at lines 55–58.

3. The normalization `filePath = url.replace(/^storycraft:\/\/?/, '').replace(/^\/+/,
'/');` removes only `storycraft://` from `storycraft:///C:/...`, leaving
`/C:/projects/my-novel.storycraft`; the second replace collapses leading slashes to a
single `/` instead of stripping them, so `filePath` becomes
`/C:/projects/my-novel.storycraft` rather than `C:/projects/my-novel.storycraft`.

4. The subsequent `exists(filePath)` and `readTextFile(filePath)` calls at lines 62–68 are
issued against `/C:/projects/my-novel.storycraft`, which does not exist on Windows,
causing the code to throw "File not found" and execute the error path that dispatches
`statusActions.addNotification({ title: 'Failed to open project file', ... })` without
ever importing the project.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** services/tauriDeepLink.ts
**Line:** 55:58
**Comment:**
	*Logic Error: The URL-to-path conversion keeps a leading slash for Windows drive-letter paths (for example `storycraft:///C:/...` becomes `/C:/...`), which makes `exists`/`readTextFile` fail with file-not-found on Windows. Normalize Windows paths so drive-letter paths are not prefixed with `/`.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

}

// Read file via Tauri FS plugin
const { readTextFile, exists } = await import('@tauri-apps/plugin-fs');

if (!(await exists(filePath))) {
throw new Error('File not found');
}

const content = await readTextFile(filePath);
const fileName = filePath.split(/[/\\]/).pop() || 'project.json';

// Create a File object for the existing import flow
const file = new File([content], fileName, {
type: 'application/json',
});

// Dispatch import thunk
const resultAction = await dispatch(importProjectThunk(file));

if (importProjectThunk.fulfilled.match(resultAction)) {
// Navigate to manuscript view after successful import
window.location.hash = '#/manuscript';
} else {
// Show error notification
dispatch(
statusActions.addNotification({
type: 'error',
title: 'Failed to open project file',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Replace this hardcoded notification title with an i18n translation key and add that key across all supported locale bundles. [custom_rule]

Severity Level: Minor ⚠️

Why it matters? 🤔

The code emits a user-facing notification with a hardcoded English title instead of using the app's translation system. This is a real localization violation because the repository uses i18n keys elsewhere for UI text.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** services/tauriDeepLink.ts
**Line:** 87:87
**Comment:**
	*Custom Rule: Replace this hardcoded notification title with an i18n translation key and add that key across all supported locale bundles.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

description: resultAction.error?.message ?? 'Unknown error',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Replace the hardcoded fallback error text with a translated string key so the default message is localized. [custom_rule]

Severity Level: Minor ⚠️

Why it matters? 🤔

The fallback message is hardcoded English shown to users when no error message is present. That makes it a genuine i18n violation under the stated localization rule.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** services/tauriDeepLink.ts
**Line:** 88:88
**Comment:**
	*Custom Rule: Replace the hardcoded fallback error text with a translated string key so the default message is localized.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

}),
);
}
} catch (error) {
log.error('Failed to open project file via deep link', { error, url });
dispatch(
statusActions.addNotification({
type: 'error',
title: 'Failed to open project file',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Localize this second hardcoded notification title through the translation-key flow to keep error messaging consistent with UI i18n rules. [custom_rule]

Severity Level: Minor ⚠️

Why it matters? 🤔

This is the same hardcoded user-facing title in the error-handling path. It is still untranslated and therefore violates the localization rule for UI strings.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** services/tauriDeepLink.ts
**Line:** 97:97
**Comment:**
	*Custom Rule: Localize this second hardcoded notification title through the translation-key flow to keep error messaging consistent with UI i18n rules.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

description: error instanceof Error ? error.message : String(error),
}),
);
}
});

log.info('Tauri deep link handler initialized');
} catch (error) {
log.warn('Failed to initialize Tauri deep link handler', { error });
}

return () => {
unlisten?.();
unlisten = null;
};
}

/**
* Check if a file path is a StoryCraft project file.
*/
export function isStoryCraftProjectFile(filePath: string): boolean {
const ext = filePath.split('.').pop()?.toLowerCase();
return ext === 'storycraft' || ext === 'scst' || filePath.endsWith('.json');
}

/**
* Get the project ID from a file path (for logging/debugging).
*/
export function getProjectIdFromPath(filePath: string): string {
const lastSegment = filePath.split(/[/\\]/).pop();
if (!lastSegment) return 'unknown';
return lastSegment.replace(/\.(storycraft|scst|json)$/, '') || 'unknown';
}
2 changes: 2 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ tauri-plugin-dialog = "2"
tauri-plugin-shell = "2"
tauri-plugin-updater = "2"
tauri-plugin-window-state = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
tokio = { version = "1", features = ["sync", "time", "process", "rt-multi-thread"] }
candle-core = { version = "0.8", optional = true }
candle-nn = { version = "0.8", optional = true }
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ fn install_app_menu(_app: &tauri::App) -> tauri::Result<()> {
#[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())
Expand Down
24 changes: 23 additions & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"minHeight": 600,
"center": true,
"resizable": true,
"fullscreen": false
"fullscreen": false,
"dragDropEnabled": true
}
],
"security": {
Expand All @@ -42,6 +43,22 @@
"icons/icon.icns",
"icons/icon.ico"
],
"fileAssociations": [
{
"ext": ["storycraft"],
"name": "StoryCraft Project",
"description": "StoryCraft Studio creative writing project",
"mimeType": "application/x-storycraft-project",
"role": "Editor"
},
{
"ext": ["scst"],
"name": "StoryCraft Project",
"description": "StoryCraft Studio creative writing project (short extension)",
"mimeType": "application/x-storycraft-project",
"role": "Editor"
}
],
"macOS": {
"minimumSystemVersion": "11.0",
"entitlements": "Entitlements.plist"
Expand All @@ -64,6 +81,11 @@
"windows": {
"installMode": "passive"
}
},
"deep-link": {
"desktop": {
"schemes": ["storycraft"]
}
}
}
}
55 changes: 55 additions & 0 deletions tests/unit/services/tauriDeepLink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Tests for services/tauriDeepLink.ts
* QNBS-v3: Tauri deep link service for native file associations.
*/

import { describe, expect, it } from 'vitest';
import { getProjectIdFromPath, isStoryCraftProjectFile } from '../../../services/tauriDeepLink';

describe('tauriDeepLink', () => {
describe('isStoryCraftProjectFile', () => {
it('returns true for .storycraft extension', () => {
expect(isStoryCraftProjectFile('/path/to/project.storycraft')).toBe(true);
});

it('returns true for .scst extension', () => {
expect(isStoryCraftProjectFile('/path/to/project.scst')).toBe(true);
});

it('returns true for .json extension', () => {
expect(isStoryCraftProjectFile('/path/to/project.json')).toBe(true);
});

it('returns false for other extensions', () => {
expect(isStoryCraftProjectFile('/path/to/document.txt')).toBe(false);
expect(isStoryCraftProjectFile('/path/to/image.png')).toBe(false);
});

it('handles mixed case extensions', () => {
expect(isStoryCraftProjectFile('/path/to/project.STORYCRAFT')).toBe(true);
expect(isStoryCraftProjectFile('/path/to/project.Scst')).toBe(true);
});
});

describe('getProjectIdFromPath', () => {
it('extracts project ID from path', () => {
expect(getProjectIdFromPath('/path/to/my-novel.storycraft')).toBe('my-novel');
});

it('extracts project ID from .scst extension', () => {
expect(getProjectIdFromPath('/path/to/my-novel.scst')).toBe('my-novel');
});

it('extracts project ID from .json extension', () => {
expect(getProjectIdFromPath('/path/to/my-novel.json')).toBe('my-novel');
});

it('returns unknown for empty path', () => {
expect(getProjectIdFromPath('')).toBe('unknown');
});

it('handles path without extension', () => {
expect(getProjectIdFromPath('/path/to/project')).toBe('project');
});
});
});
Loading