Repository navigation
feat: Native File Associations (.storycraft/.scst) and Single-Instance behavior #87
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
f3ee563
bc23c2f
4db3239
8d08fb2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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]; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||
|
|
||
| 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(/^\/+/, '/'); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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', | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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', | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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', | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'; | ||
| } | ||
| 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'); | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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⚠️
Steps of Reproduction ✅
Fix in Cursor | Fix in VSCode Claude
(Use Cmd/Ctrl + Click for best experience)
Prompt for AI Agent 🤖