From c67aacfba7428e5357c0d960730cb619f1942465 Mon Sep 17 00:00:00 2001 From: Sportinger Date: Fri, 1 May 2026 15:58:35 +0200 Subject: [PATCH 1/2] Fix native project restore and Firefox imports --- docs/Features/Native-Helper.md | 3 + docs/Features/Project-Persistence.md | 6 +- src/components/common/NativeHelperStatus.tsx | 43 +++- src/components/common/RelinkDialog.tsx | 36 ++- src/components/common/WelcomeOverlay.tsx | 69 ++--- .../common/settings/NativeHelperSettings.tsx | 36 ++- src/components/panels/MediaPanel.tsx | 27 +- .../timeline/hooks/useExternalDrop.ts | 146 +++++++++-- src/editorBoot.ts | 30 +++ .../native3d/assets/ModelRuntimeCache.ts | 77 +++++- src/engine/render/RenderDispatcher.ts | 18 ++ .../scene/runtime/SharedSplatRuntimeCache.ts | 14 + src/services/aiTools/bridge.ts | 26 ++ .../layerBuilder/LayerBuilderService.ts | 14 +- .../nativeHelper/NativeHelperClient.ts | 243 +++++++++++++++--- src/services/nativeHelper/protocol.ts | 7 + src/services/nativeHelper/releases.ts | 2 +- src/services/project/ProjectFileService.ts | 181 +++++++++++-- .../project/core/NativeProjectCoreService.ts | 19 +- .../project/core/ProjectCoreService.ts | 18 +- src/services/project/core/autosaveRecovery.ts | 53 ++++ src/services/project/projectLoad.ts | 152 +++++++---- src/services/project/projectSave.ts | 16 +- src/services/project/relinkMedia.ts | 198 ++++++++++++++ .../timeline/clip/addGaussianSplatClip.ts | 65 +++-- src/stores/timeline/clip/addModelClip.ts | 17 +- src/stores/timeline/clipSlice.ts | 14 + src/stores/timeline/serializationUtils.ts | 122 +++++++-- tests/unit/autosaveRecovery.test.ts | 86 +++++++ .../projectFileServiceNativeBackend.test.ts | 136 ++++++++++ tools/native-helper/src/protocol/commands.rs | 6 + tools/native-helper/src/server.rs | 11 +- tools/native-helper/src/session.rs | 64 ++++- tools/native-helper/src/utils.rs | 96 ++++--- 34 files changed, 1733 insertions(+), 318 deletions(-) create mode 100644 src/services/project/core/autosaveRecovery.ts create mode 100644 tests/unit/autosaveRecovery.test.ts create mode 100644 tests/unit/projectFileServiceNativeBackend.test.ts diff --git a/docs/Features/Native-Helper.md b/docs/Features/Native-Helper.md index 402e8f876..69c81cc88 100644 --- a/docs/Features/Native-Helper.md +++ b/docs/Features/Native-Helper.md @@ -23,6 +23,8 @@ The browser client can discover the auth token automatically from `GET /startup- - **Format selection** -- List available formats and choose quality/codec before downloading - **File system operations** -- Write files, create directories, list/delete/rename, check existence - **Folder picker** -- Native OS folder picker dialog (for Firefox project folder selection) +- **Picked-folder grants** -- Folder picker and restore paths are registered as allowed roots so projects outside the default Documents folder can be opened and served through the helper +- **Manual path fallback** -- If the helper cannot show a native folder picker on the current platform, the web app prompts for the project folder path instead - **Firefox persistence** -- Enables full project save/load on Firefox via file system commands - **External AI control** -- Local `POST /api/ai-tools` bridge for Claude Code, curl, and other local agents - **System tray** -- On Windows, runs as a system tray app with auto-start and self-update support @@ -242,6 +244,7 @@ cargo build --release 1. Check firewall allows localhost:9876 2. Ensure only one instance running 3. On Windows, try `--console` flag to see log output +4. If Firefox reports the helper as disconnected after refresh, press Check connection; the web client now refreshes the helper startup token on every reconnect, times out stalled reconnects, and retries every few seconds after a previously connected session --- diff --git a/docs/Features/Project-Persistence.md b/docs/Features/Project-Persistence.md index 5cad56109..f1159d7ba 100644 --- a/docs/Features/Project-Persistence.md +++ b/docs/Features/Project-Persistence.md @@ -39,6 +39,7 @@ On first launch or when no project is open, the Welcome Overlay appears: For Firefox users: - The overlay checks if the Native Helper is running and connected - If available, activates the native backend and shows "New Project" / "Open Existing" buttons (using the OS folder picker via Native Helper) +- If the helper cannot show an OS folder picker on the current platform, MasterSelects falls back to a manual path prompt seeded with the helper's project root - If unavailable or outdated, persistence is unavailable until the helper is installed and connected ### Select Project Folder @@ -69,12 +70,15 @@ The project system supports two backends, selected automatically based on browse ### Native Helper Backend (Firefox) - Uses a local Rust helper (`tools/native-helper`) communicating via WebSocket (port 9876) and HTTP (port 9877) - OS folder picker via `NativeHelperClient.pickFolder()` +- Manual project path fallback via `ProjectFileService` when the helper reports that no native picker is available +- User-picked project paths are granted to the helper at runtime, so external drives and non-default project folders remain readable through both WebSocket and HTTP file routes - File I/O via `NativeHelperClient.writeFile()` / `readFileText()` / `writeFileBinary()` plus `createDir()`, `deleteFile()`, `rename()`, `exists()`, `listDir()`, and `pickFolder()` - Project files are written through the helper's path-based storage layer; the browser never needs a `FileSystemDirectoryHandle` - Last project path stored in `localStorage` key `ms-native-last-project-path` - No permission prompts needed -- the Native Helper has full filesystem access - Project listing: `NativeProjectCoreService.listProjects()` scans the project root for directories containing `project.json` - The default project root comes from the helper (`Documents/MasterSelects` when available, otherwise `Home/MasterSelects`, or `MASTERSELECTS_PROJECT_ROOT` when set to an absolute path) +- On Firefox refresh, `ProjectFileService.restoreLastProject()` now activates the Native backend before attempting restore, so it no longer depends on the Welcome Overlay running first ### Backend Switching The `ProjectFileService` facade routes all calls to the active backend: @@ -401,7 +405,7 @@ Temporary camera `NO KF` live offsets are intentionally not saved. They only aff ### Restore Last Project On app load, attempts to restore the previously opened project: - **FSA**: Retrieves `lastProject` handle from IndexedDB, checks permission -- **Native**: Reads path from `localStorage` key `ms-native-last-project-path` +- **Native**: Activates the helper backend, reconnects to the helper with a bounded timeout, grants the stored project path to the helper, then reads path from `localStorage` key `ms-native-last-project-path` - If permission is needed, shows a "Grant Access" prompt - If the project folder no longer exists, the saved path is cleared and the user must choose/open another project diff --git a/src/components/common/NativeHelperStatus.tsx b/src/components/common/NativeHelperStatus.tsx index 5548dbb7f..74ae33f0b 100644 --- a/src/components/common/NativeHelperStatus.tsx +++ b/src/components/common/NativeHelperStatus.tsx @@ -8,6 +8,7 @@ import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react import { NativeHelperClient, isNativeHelperAvailable } from '../../services/nativeHelper'; import type { SystemInfo, ConnectionStatus } from '../../services/nativeHelper'; import { + compareNativeHelperVersions, fetchLatestPublishedNativeHelperRelease, NATIVE_HELPER_RELEASES_URL, NATIVE_HELPER_TARGET_VERSION, @@ -146,6 +147,7 @@ export function NativeHelperStatus({ variant = 'toolbar' }: NativeHelperStatusPr const { turboModeEnabled, + nativeHelperPort, nativeDecodeEnabled, setNativeDecodeEnabled, setNativeHelperConnected, @@ -161,6 +163,7 @@ export function NativeHelperStatus({ variant = 'toolbar' }: NativeHelperStatusPr } try { + NativeHelperClient.configure({ port: nativeHelperPort }); const available = await isNativeHelperAvailable(); setStatus(available ? 'connected' : 'disconnected'); setNativeHelperConnected(available); @@ -168,7 +171,7 @@ export function NativeHelperStatus({ variant = 'toolbar' }: NativeHelperStatusPr setStatus('disconnected'); setNativeHelperConnected(false); } - }, [helperEnabled, setNativeHelperConnected]); + }, [helperEnabled, nativeHelperPort, setNativeHelperConnected]); useEffect(() => { if (nativeDecodeEnabled) { @@ -270,7 +273,17 @@ function NativeHelperDialog({ useEffect(() => { if (status === 'connected') { - NativeHelperClient.getInfo().then(setInfo).catch(() => setInfo(null)); + let cancelled = false; + NativeHelperClient.getInfo(3000) + .then((nextInfo) => { + if (!cancelled) setInfo(nextInfo); + }) + .catch(() => { + if (!cancelled) setInfo(null); + }); + return () => { + cancelled = true; + }; } }, [status]); @@ -329,13 +342,17 @@ function NativeHelperDialog({ const downloadLink = publishedRelease?.url || ((platform !== 'unknown' && DOWNLOAD_LINKS[platform]) || NATIVE_HELPER_RELEASES); const connectedVersion = helperInfo?.version ?? null; const publishedVersion = publishedRelease?.version ?? null; - const expectedVersionInstalled = connectedVersion === NATIVE_HELPER_VERSION; + const versionKnown = connectedVersion !== null; + const helperVersionCompare = compareNativeHelperVersions(connectedVersion, NATIVE_HELPER_VERSION); + const expectedVersionInstalled = versionKnown && helperVersionCompare >= 0; + const helperNeedsUpdate = isConnected && versionKnown && helperVersionCompare < 0; + const publishedMatchesTarget = compareNativeHelperVersions(publishedVersion, NATIVE_HELPER_VERSION) >= 0; const statusTone = isConnected ? 'connected' : helperEnabled ? 'offline' : 'disabled'; const statusLabel = isConnected ? 'Connected' : helperEnabled ? 'Not running' : 'Disabled'; const capabilityPills: Array<{ label: string; tone: PillTone }> = isConnected && helperInfo ? [ - { label: connectedVersion ? `Installed v${connectedVersion}` : 'Connected', tone: expectedVersionInstalled ? 'good' : 'warn' }, + { label: connectedVersion ? `Installed v${connectedVersion}` : 'Connected', tone: helperNeedsUpdate ? 'warn' : 'good' }, { label: helperInfo.ytdlp_available ? 'Downloads ready' : 'yt-dlp missing', tone: helperInfo.ytdlp_available ? 'good' : 'warn' }, { label: helperInfo.fs_commands ? 'Projects ready' : 'Projects unavailable', tone: helperInfo.fs_commands ? 'good' : 'warn' }, { label: helperInfo.ai_bridge ? 'AI bridge ready' : 'AI bridge unavailable', tone: helperInfo.ai_bridge ? 'good' : 'warn' }, @@ -408,27 +425,29 @@ function NativeHelperDialog({ {isConnected ? 'Connected session' : 'Published release'}
- {isConnected && connectedVersion - ? `Helper v${connectedVersion}` + {isConnected + ? (connectedVersion ? `Helper v${connectedVersion}` : 'Helper connected') : publishedVersion ? `GitHub release v${publishedVersion}` : 'Native Helper releases'}
- - {isConnected ? (expectedVersionInstalled ? 'Up to date' : 'Update available') : (helperEnabled ? 'Waiting for helper' : 'Helper disabled')} + + {isConnected + ? (versionKnown ? (expectedVersionInstalled ? 'Up to date' : 'Update available') : 'Connected') + : (helperEnabled ? 'Waiting for helper' : 'Helper disabled')}

{isConnected ? ( - publishedVersion && publishedVersion !== NATIVE_HELPER_VERSION + publishedVersion && !publishedMatchesTarget ? `The helper is reachable from MasterSelects. GitHub still only publishes v${publishedVersion}, while this app build already targets v${NATIVE_HELPER_VERSION}.` : 'The helper is reachable from MasterSelects on this machine.' ) : ( - publishedVersion && publishedVersion !== NATIVE_HELPER_VERSION + publishedVersion && !publishedMatchesTarget ? `GitHub currently only has v${publishedVersion} published. MasterSelects already targets helper v${NATIVE_HELPER_VERSION}, but that release is not public yet.` : 'Install the current helper build and keep it running in the background.' )} @@ -441,11 +460,11 @@ function NativeHelperDialog({ ))} {publishedVersion && ( - + Public GitHub: v{publishedVersion} )} - {publishedVersion && publishedVersion !== NATIVE_HELPER_VERSION && ( + {publishedVersion && !publishedMatchesTarget && ( App target: v{NATIVE_HELPER_VERSION} diff --git a/src/components/common/RelinkDialog.tsx b/src/components/common/RelinkDialog.tsx index 8de9337c3..b67b03dab 100644 --- a/src/components/common/RelinkDialog.tsx +++ b/src/components/common/RelinkDialog.tsx @@ -11,6 +11,7 @@ import { applyRelinkMatch, createRelinkCandidateMapFromHandles, findRelinkMatch, + mediaNeedsRelink, type RelinkCandidate, type RelinkCandidateMap, type RelinkMatch, @@ -43,7 +44,7 @@ function isAbortError(error: unknown): boolean { } function getMissingFiles(files: MediaFile[]): MediaFile[] { - return files.filter(f => !f.file); + return files.filter(mediaNeedsRelink); } function matchStatuses( @@ -82,6 +83,8 @@ export function RelinkDialog({ onClose }: RelinkDialogProps) { // Initialize file statuses and auto-scan Raw folder useEffect(() => { + let cancelled = false; + const initializeStatuses = async () => { const missingFiles = getMissingFiles(files); const initialStatuses: FileStatus[] = missingFiles.map(f => ({ @@ -90,6 +93,7 @@ export function RelinkDialog({ onClose }: RelinkDialogProps) { filePath: f.filePath, status: 'missing' as const, })); + if (cancelled) return; setFileStatuses(initialStatuses); // Auto-scan the project folder for missing files if project is open. @@ -121,6 +125,7 @@ export function RelinkDialog({ onClose }: RelinkDialogProps) { } } + if (cancelled) return; setFileStatuses(updatedStatuses); if (searched.length > 0) { setSearchedFolders(searched); @@ -129,6 +134,9 @@ export function RelinkDialog({ onClose }: RelinkDialogProps) { }; initializeStatuses(); + return () => { + cancelled = true; + }; }, [files]); // Scan a folder for missing files @@ -165,7 +173,31 @@ export function RelinkDialog({ onClose }: RelinkDialogProps) { // Handle browse button const handleBrowse = useCallback(async () => { + if (projectFileService.activeBackend === 'native') { + setIsSearching(true); + try { + const result = await projectFileService.pickAndScanFolder('Search folder for missing media'); + if (!result) { + return; + } + + const candidates = await createRelinkCandidateMapFromHandles(result.files.values()); + setFileStatuses(prev => matchStatuses(prev, files, candidates)); + setSearchedFolders(prev => [...prev, result.name]); + } catch (e) { + log.error('Native browse error', e); + } finally { + setIsSearching(false); + } + return; + } + try { + if (typeof (window as RelinkPickerWindow).showDirectoryPicker !== 'function') { + log.warn('Directory picker is not available in this browser'); + return; + } + const dirHandle = await (window as RelinkPickerWindow).showDirectoryPicker({ mode: 'read', startIn: 'videos', @@ -179,7 +211,7 @@ export function RelinkDialog({ onClose }: RelinkDialogProps) { log.error('Browse error', e); } } - }, [scanFolder]); + }, [files, scanFolder]); // Handle picking individual file - allows multiple selection to relink several at once const handlePickFile = useCallback(async (fileStatus: FileStatus) => { diff --git a/src/components/common/WelcomeOverlay.tsx b/src/components/common/WelcomeOverlay.tsx index 9ed9addfe..bd8b47ea0 100644 --- a/src/components/common/WelcomeOverlay.tsx +++ b/src/components/common/WelcomeOverlay.tsx @@ -12,6 +12,7 @@ import { openExistingProject } from '../../services/projectSync'; import { NativeHelperClient } from '../../services/nativeHelper/NativeHelperClient'; import { loadProjectToStores } from '../../services/project/projectLoad'; import { syncStoresToProject } from '../../services/project/projectSave'; +import { useSettingsStore } from '../../stores/settingsStore'; type NativeStatus = 'checking' | 'available' | 'outdated' | 'unavailable'; type DirectoryPickerWindow = Window & typeof globalThis & { @@ -88,7 +89,8 @@ export function WelcomeOverlay({ onComplete, noFadeOnClose = false }: WelcomeOve // Native Helper state (for Firefox project persistence) const [nativeStatus, setNativeStatus] = useState('checking'); - const [nativeProjectRoot, setNativeProjectRoot] = useState(''); + const nativeHelperPort = useSettingsStore((state) => state.nativeHelperPort); + const setNativeHelperConnected = useSettingsStore((state) => state.setNativeHelperConnected); const isSupported = isFileSystemAccessSupported(); const browser = useMemo(() => detectBrowser(), []); @@ -105,13 +107,19 @@ export function WelcomeOverlay({ onComplete, noFadeOnClose = false }: WelcomeOve async function checkNativeHelper() { try { + NativeHelperClient.configure({ port: nativeHelperPort }); + // Try to connect if not already if (!NativeHelperClient.isConnected()) { - await NativeHelperClient.connect(); + const connected = await NativeHelperClient.connect(); + setNativeHelperConnected(connected); } if (!NativeHelperClient.isConnected()) { - if (!cancelled) setNativeStatus('unavailable'); + if (!cancelled) { + setNativeStatus('unavailable'); + setNativeHelperConnected(false); + } return; } @@ -129,20 +137,18 @@ export function WelcomeOverlay({ onComplete, noFadeOnClose = false }: WelcomeOve // Activate native backend projectFileService.activateNativeBackend(); - // Get project root path (used as default for folder picker) - const root = await NativeHelperClient.getProjectRoot(); - if (!cancelled && root) { - setNativeProjectRoot(root.replace(/\\/g, '/')); - } } catch (e) { log.warn('Native helper check failed', e); - if (!cancelled) setNativeStatus('unavailable'); + if (!cancelled) { + setNativeStatus('unavailable'); + setNativeHelperConnected(false); + } } } checkNativeHelper(); return () => { cancelled = true; }; - }, [needsNativeHelper]); + }, [needsNativeHelper, nativeHelperPort, setNativeHelperConnected]); // Typewriter effect useEffect(() => { @@ -317,33 +323,14 @@ export function WelcomeOverlay({ onComplete, noFadeOnClose = false }: WelcomeOve } }, [isSelecting, isClosing, onComplete, noFadeOnClose]); - // Native Helper: Create new project (opens OS folder picker, like Chrome) + // Native Helper: Create new project through the ProjectFileService native backend. const handleNativeNewProject = useCallback(async () => { if (isSelecting || isClosing) return; setIsSelecting(true); setError(null); try { - // Open OS folder picker — same UX as Chrome's showDirectoryPicker() - const folderPath = await NativeHelperClient.pickFolder( - 'Choose where to save your project', - nativeProjectRoot || undefined, - ); - - if (!folderPath) { - // User cancelled - return; - } - - const nativeCore = projectFileService.getNativeCoreService(); - if (!nativeCore) { - setError('Native backend not active.'); - return; - } - - // Create "Untitled" project in the selected folder (matches Chrome behavior) - const normalizedPath = folderPath.replace(/\\/g, '/'); - const success = await nativeCore.createProjectAtPath(normalizedPath, 'Untitled'); + const success = await projectFileService.createProject('Untitled'); if (success) { setSelectedFolder('Untitled'); @@ -361,28 +348,16 @@ export function WelcomeOverlay({ onComplete, noFadeOnClose = false }: WelcomeOve } finally { setIsSelecting(false); } - }, [isSelecting, isClosing, nativeProjectRoot, onComplete, noFadeOnClose]); + }, [isSelecting, isClosing, onComplete, noFadeOnClose]); - // Native Helper: Open existing project (opens OS folder picker, like Chrome) + // Native Helper: Open existing project through the ProjectFileService native backend. const handleNativeOpenProject = useCallback(async () => { if (isSelecting || isClosing) return; setIsSelecting(true); setError(null); try { - // Open OS folder picker — user selects existing project folder - const folderPath = await NativeHelperClient.pickFolder( - 'Select an existing project folder', - nativeProjectRoot || undefined, - ); - - if (!folderPath) { - // User cancelled - return; - } - - const normalizedPath = folderPath.replace(/\\/g, '/'); - const success = await projectFileService.loadProject(normalizedPath); + const success = await projectFileService.openProject(); if (success) { const projectData = projectFileService.getProjectData(); @@ -400,7 +375,7 @@ export function WelcomeOverlay({ onComplete, noFadeOnClose = false }: WelcomeOve } finally { setIsSelecting(false); } - }, [isSelecting, isClosing, nativeProjectRoot, onComplete, noFadeOnClose]); + }, [isSelecting, isClosing, onComplete, noFadeOnClose]); const handleContinue = useCallback(() => { if (isClosing) return; diff --git a/src/components/common/settings/NativeHelperSettings.tsx b/src/components/common/settings/NativeHelperSettings.tsx index 6646a210b..eb4b7de02 100644 --- a/src/components/common/settings/NativeHelperSettings.tsx +++ b/src/components/common/settings/NativeHelperSettings.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react import { NativeHelperClient, isNativeHelperAvailable } from '../../../services/nativeHelper'; import type { SystemInfo, ConnectionStatus } from '../../../services/nativeHelper'; import { + compareNativeHelperVersions, fetchLatestPublishedNativeHelperRelease, NATIVE_HELPER_RELEASES_URL, NATIVE_HELPER_TARGET_VERSION, @@ -128,6 +129,7 @@ export function NativeHelperSettings() { } setChecking(true); try { + NativeHelperClient.configure({ port: nativeHelperPort }); const available = await isNativeHelperAvailable(); setStatus(available ? 'connected' : 'disconnected'); setNativeHelperConnected(available); @@ -136,7 +138,7 @@ export function NativeHelperSettings() { setNativeHelperConnected(false); } setChecking(false); - }, [helperEnabled, setNativeHelperConnected]); + }, [helperEnabled, nativeHelperPort, setNativeHelperConnected]); useEffect(() => { queueMicrotask(() => void checkConnection()); @@ -149,7 +151,17 @@ export function NativeHelperSettings() { useEffect(() => { if (status === 'connected') { - NativeHelperClient.getInfo().then(setInfo).catch(() => setInfo(null)); + let cancelled = false; + NativeHelperClient.getInfo(3000) + .then((nextInfo) => { + if (!cancelled) setInfo(nextInfo); + }) + .catch(() => { + if (!cancelled) setInfo(null); + }); + return () => { + cancelled = true; + }; } }, [status]); @@ -164,7 +176,11 @@ export function NativeHelperSettings() { const isConnected = status === 'connected'; const connectedVersion = helperInfo?.version ?? null; const publishedVersion = publishedRelease?.version ?? null; - const expectedVersionInstalled = connectedVersion === NATIVE_HELPER_VERSION; + const versionKnown = connectedVersion !== null; + const helperVersionCompare = compareNativeHelperVersions(connectedVersion, NATIVE_HELPER_VERSION); + const expectedVersionInstalled = versionKnown && helperVersionCompare >= 0; + const helperNeedsUpdate = isConnected && versionKnown && helperVersionCompare < 0; + const publishedMatchesTarget = compareNativeHelperVersions(publishedVersion, NATIVE_HELPER_VERSION) >= 0; const downloadLink = publishedRelease?.url || NATIVE_HELPER_RELEASES_URL; const capabilities: Array<{ label: string; tone: CapTone }> = isConnected && helperInfo @@ -235,20 +251,20 @@ export function NativeHelperSettings() {

- {isConnected && connectedVersion - ? `Helper v${connectedVersion}` + {isConnected + ? (connectedVersion ? `Helper v${connectedVersion}` : 'Helper connected') : publishedVersion ? `GitHub release v${publishedVersion}` : 'Native Helper'} - + {isConnected - ? (expectedVersionInstalled ? 'Up to date' : 'Update available') + ? (versionKnown ? (expectedVersionInstalled ? 'Up to date' : 'Update available') : 'Connected') : (helperEnabled ? 'Waiting...' : 'Disabled')}
- {isConnected && publishedVersion && publishedVersion !== NATIVE_HELPER_VERSION && ( + {isConnected && publishedVersion && !publishedMatchesTarget && (

GitHub publishes v{publishedVersion}, app targets v{NATIVE_HELPER_VERSION}.

@@ -265,10 +281,10 @@ export function NativeHelperSettings() { {/* Version pills */} {publishedVersion && (
- + Public GitHub: v{publishedVersion} - {publishedVersion !== NATIVE_HELPER_VERSION && ( + {!publishedMatchesTarget && ( App target: v{NATIVE_HELPER_VERSION} )}
diff --git a/src/components/panels/MediaPanel.tsx b/src/components/panels/MediaPanel.tsx index ee4305f21..2ce99bb4f 100644 --- a/src/components/panels/MediaPanel.tsx +++ b/src/components/panels/MediaPanel.tsx @@ -18,6 +18,7 @@ import { useTimelineStore } from '../../stores/timeline'; import { useDockStore } from '../../stores/dockStore'; import { useContextMenuPosition } from '../../hooks/useContextMenuPosition'; import { RelinkDialog } from '../common/RelinkDialog'; +import { mediaNeedsRelink } from '../../services/project/relinkMedia'; import { clearExternalDragPayload, dispatchExternalDragBridgeEvent, @@ -1199,7 +1200,7 @@ export function MediaPanel() { } else if ((item.type === 'video' || item.type === 'image') && 'file' in item && (item as MediaFile).file) { // Open in source monitor useMediaStore.getState().setSourceMonitorFile(item.id); - } else if ('file' in item && !item.file) { + } else if ('file' in item && mediaNeedsRelink(item as MediaFile)) { // Media file needs reload - request permission const success = await reloadFile(item.id); if (success) { @@ -1567,8 +1568,8 @@ export function MediaPanel() { // Handle media file drag const mediaFile = item as MediaFile; - if (!mediaFile.file || mediaFile.isImporting) { - // File not available or still importing - only allow internal move + if (mediaFile.isImporting || mediaNeedsRelink(mediaFile)) { + // File still importing or truly unresolved - only allow internal move e.dataTransfer.effectAllowed = 'move'; if (e.currentTarget instanceof HTMLElement) { e.dataTransfer.setDragImage(e.currentTarget, 10, 10); @@ -1577,9 +1578,11 @@ export function MediaPanel() { } // Set the media file ID so Timeline can look it up + const fileName = mediaFile.file?.name ?? mediaFile.name; const isAudioOnly = - mediaFile.file.type.startsWith('audio/') || - /\.(mp3|wav|ogg|aac|m4a|flac|wma|aiff|alac|opus)$/i.test(mediaFile.file.name); + mediaFile.type === 'audio' || + mediaFile.file?.type.startsWith('audio/') || + /\.(mp3|wav|ogg|aac|m4a|flac|wma|aiff|alac|opus)$/i.test(fileName); setExternalDragPayload({ kind: 'media-file', id: mediaFile.id, @@ -1970,7 +1973,7 @@ export function MediaPanel() { const isRenaming = renamingId === item.id; const isExpanded = isFolder && expandedFolderIds.includes(item.id); const isMediaFile = isImportedMediaFileItem(item); - const hasFile = isMediaFile && !!item.file; + const needsRelink = isMediaFile && mediaNeedsRelink(item); const isImporting = isMediaFile && !!item.isImporting; const isDragTarget = isFolder && dragOverFolderId === item.id; const isBeingDragged = internalDragId === item.id; @@ -1980,7 +1983,7 @@ export function MediaPanel() {
handleDragStart(e, item)} onDragEnd={handleDragEnd} @@ -3557,7 +3560,7 @@ export function MediaPanel() { data-item-id={item.id} data-board-group-key={getMediaBoardOrderKey(placement.groupId)} data-media-panel-anim-id={item.id} - className={`media-board-node ${isSelected ? 'selected' : ''} ${isMediaFile && !mediaFile?.file ? 'no-file' : ''} ${importProgress !== null ? 'importing' : ''} ${isTextItem ? 'text' : ''} ${placement.isDraggingPreview ? 'drag-source-preview' : ''}`} + className={`media-board-node ${isSelected ? 'selected' : ''} ${mediaFile && mediaNeedsRelink(mediaFile) ? 'no-file' : ''} ${importProgress !== null ? 'importing' : ''} ${isTextItem ? 'text' : ''} ${placement.isDraggingPreview ? 'drag-source-preview' : ''}`} style={{ left: layout.x, top: layout.y, @@ -3790,9 +3793,11 @@ export function MediaPanel() { gridBreadcrumb.push(...path); } - // Check if any files need reload (lost permission after refresh) - const filesNeedReload = files.some(f => !f.file); - const filesNeedReloadCount = files.filter(f => !f.file).length; + // Check if any files need relinking (lost permission after refresh). + // Native-helper projects can be linked by project/absolute paths without + // eagerly materializing browser File objects for every media item. + const filesNeedReload = files.some(mediaNeedsRelink); + const filesNeedReloadCount = files.filter(mediaNeedsRelink).length; // Relink dialog state const [showRelinkDialog, setShowRelinkDialog] = useState(false); diff --git a/src/components/timeline/hooks/useExternalDrop.ts b/src/components/timeline/hooks/useExternalDrop.ts index 5bc277ba3..ad5dbba8f 100644 --- a/src/components/timeline/hooks/useExternalDrop.ts +++ b/src/components/timeline/hooks/useExternalDrop.ts @@ -23,7 +23,8 @@ import { } from '../utils/externalDragSession'; import type { ExternalDragState } from '../types'; import type { TimelineTrack, TimelineClip } from '../../../types'; -import type { Composition } from '../../../stores/mediaStore'; +import type { Composition, MediaFile } from '../../../stores/mediaStore'; +import { NativeHelperClient } from '../../../services/nativeHelper/NativeHelperClient'; import { Logger } from '../../../services/logger'; const log = Logger.create('useExternalDrop'); @@ -39,6 +40,109 @@ function setDroppedFilePath(file: File, filePath?: string): void { } } +const CLIP_TYPED_MEDIA_TYPES = new Set(['gaussian-splat', 'lottie', 'rive', 'model']); + +function getTimelineMediaTypeOverride(mediaFile: MediaFile): string | undefined { + return CLIP_TYPED_MEDIA_TYPES.has(mediaFile.type) ? mediaFile.type : undefined; +} + +function getPlaceholderMimeType(mediaFile: MediaFile): string { + const name = mediaFile.name.toLowerCase(); + + if (mediaFile.type === 'model') { + if (name.endsWith('.glb')) return 'model/gltf-binary'; + if (name.endsWith('.gltf')) return 'model/gltf+json'; + if (name.endsWith('.obj')) return 'model/obj'; + } + + if (mediaFile.type === 'gaussian-splat') { + if (name.endsWith('.ply')) return 'application/octet-stream'; + if (name.endsWith('.spz')) return 'application/octet-stream'; + } + + return ''; +} + +function createPlaceholderFileForMedia(mediaFile: MediaFile): File { + const file = new File([], mediaFile.name, { type: getPlaceholderMimeType(mediaFile) }); + setDroppedFilePath(file, mediaFile.absolutePath ?? mediaFile.filePath); + return file; +} + +function mediaFileHasLazy3DSource(mediaFile: MediaFile): boolean { + if (mediaFile.file || mediaFile.url || mediaFile.absolutePath || mediaFile.projectPath) { + return true; + } + + if (mediaFile.modelSequence?.frames.some((frame) => + Boolean(frame.file || frame.modelUrl || frame.absolutePath || frame.projectPath || frame.sourcePath) + )) { + return true; + } + + return Boolean(mediaFile.gaussianSplatSequence?.frames.some((frame) => + Boolean(frame.file || frame.splatUrl || frame.absolutePath || frame.projectPath || frame.sourcePath) + )); +} + +function isAudioOnlyMediaFile(mediaFile: MediaFile, file?: File): boolean { + return mediaFile.type === 'audio' || Boolean(file && isAudioFile(file)); +} + +async function resolveMediaFileForTimeline(mediaFile: MediaFile): Promise { + if (mediaFile.file) { + return mediaFile.file; + } + + if (mediaFile.type === 'model' || mediaFile.type === 'gaussian-splat') { + return mediaFileHasLazy3DSource(mediaFile) ? createPlaceholderFileForMedia(mediaFile) : null; + } + + const nativeReferenceUrl = NativeHelperClient.parseFileReferenceUrl(mediaFile.url) + ? mediaFile.url + : mediaFile.absolutePath + ? NativeHelperClient.getFileReferenceUrl(mediaFile.absolutePath) + : null; + + if (!nativeReferenceUrl) { + return null; + } + + try { + const file = await NativeHelperClient.getReferencedFile(nativeReferenceUrl, mediaFile.name); + if (!file) { + return null; + } + + const referencedPath = NativeHelperClient.parseFileReferenceUrl(nativeReferenceUrl) ?? mediaFile.absolutePath; + setDroppedFilePath(file, referencedPath ?? undefined); + const url = URL.createObjectURL(file); + + useMediaStore.setState((state) => ({ + files: state.files.map((currentFile) => + currentFile.id === mediaFile.id + ? { + ...currentFile, + file, + url, + hasFileHandle: true, + absolutePath: currentFile.absolutePath ?? referencedPath ?? undefined, + } + : currentFile + ), + })); + + return file; + } catch (error) { + log.warn('Could not resolve restored media file for timeline drop', { + mediaFileId: mediaFile.id, + name: mediaFile.name, + error, + }); + return null; + } +} + interface UseExternalDropProps { timelineRef: React.RefObject; scrollX: number; @@ -1111,8 +1215,8 @@ export function useExternalDrop({ if (mediaFileId) { const mediaStore = useMediaStore.getState(); const mediaFile = mediaStore.files.find((f) => f.id === mediaFileId); - if (mediaFile?.file) { - const fileIsAudio = isAudioFile(mediaFile.file); + if (mediaFile) { + const fileIsAudio = isAudioOnlyMediaFile(mediaFile, mediaFile.file); if (fileIsAudio && trackType === 'video') { log.debug('Audio files can only be dropped on audio tracks'); return; @@ -1217,12 +1321,17 @@ export function useExternalDrop({ if (mediaFileId) { const mediaStore = useMediaStore.getState(); const mediaFile = mediaStore.files.find((f) => f.id === mediaFileId); - if (mediaFile?.file) { - // Pass mediaType override for formats that are intentionally clip-typed. - const typeOverride = mediaFile.type === 'gaussian-splat' || mediaFile.type === 'lottie' || mediaFile.type === 'rive' || mediaFile.type === 'model' - ? mediaFile.type - : undefined; - addClip(newTrackId, mediaFile.file, startTime, mediaFile.duration, mediaFileId, typeOverride); + if (mediaFile) { + const file = await resolveMediaFileForTimeline(mediaFile); + if (!file) { + log.warn('Could not add media panel item to new track because the file is not resolved', { + mediaFileId, + name: mediaFile.name, + }); + return; + } + + addClip(newTrackId, file, startTime, mediaFile.duration, mediaFileId, getTimelineMediaTypeOverride(mediaFile)); return; } } @@ -1366,8 +1475,8 @@ export function useExternalDrop({ if (mediaFileId) { const mediaStore = useMediaStore.getState(); const mediaFile = mediaStore.files.find((f) => f.id === mediaFileId); - if (mediaFile?.file) { - const fileIsAudio = isAudioFile(mediaFile.file); + if (mediaFile) { + const fileIsAudio = isAudioOnlyMediaFile(mediaFile, mediaFile.file); // Audio-only files can only go on audio tracks if (fileIsAudio && isVideoTrack) { log.debug('Audio files can only be dropped on audio tracks'); @@ -1375,11 +1484,16 @@ export function useExternalDrop({ } // Video+audio files are allowed on both track types - // Pass mediaType override for formats that are intentionally clip-typed. - const typeOverride = mediaFile.type === 'gaussian-splat' || mediaFile.type === 'lottie' || mediaFile.type === 'rive' || mediaFile.type === 'model' - ? mediaFile.type - : undefined; - addClip(trackId, mediaFile.file, resolveDropStartTime(mediaFile.duration), mediaFile.duration, mediaFileId, typeOverride); + const file = await resolveMediaFileForTimeline(mediaFile); + if (!file) { + log.warn('Could not add media panel item to timeline because the file is not resolved', { + mediaFileId, + name: mediaFile.name, + }); + return; + } + + addClip(trackId, file, resolveDropStartTime(mediaFile.duration), mediaFile.duration, mediaFileId, getTimelineMediaTypeOverride(mediaFile)); return; } } diff --git a/src/editorBoot.ts b/src/editorBoot.ts index fc77d1835..b08841580 100644 --- a/src/editorBoot.ts +++ b/src/editorBoot.ts @@ -1,5 +1,35 @@ import { useTimelineStore } from './stores/timeline'; import { AI_TOOLS, executeAITool, getQuickTimelineSummary } from './services/aiTools'; +import { isFileSystemAccessSupported } from './services/fileSystemService'; +import { NativeHelperClient } from './services/nativeHelper/NativeHelperClient'; +import { useSettingsStore } from './stores/settingsStore'; + +function warmNativeHelperForProjectBackend(): void { + if (typeof window === 'undefined' || isFileSystemAccessSupported()) { + return; + } + + const { + turboModeEnabled, + nativeHelperPort, + setNativeHelperConnected, + } = useSettingsStore.getState(); + + if (!turboModeEnabled) { + return; + } + + NativeHelperClient.configure({ port: nativeHelperPort }); + NativeHelperClient.onStatusChange((status) => { + setNativeHelperConnected(status === 'connected'); + }); + + void NativeHelperClient.connect() + .then((connected) => setNativeHelperConnected(connected)) + .catch(() => setNativeHelperConnected(false)); +} + +warmNativeHelperForProjectBackend(); // Expose AI tools API for browser console, Claude skills, and external agents // Only available in development mode to prevent production exposure diff --git a/src/engine/native3d/assets/ModelRuntimeCache.ts b/src/engine/native3d/assets/ModelRuntimeCache.ts index 7b28cfd78..a517e2a15 100644 --- a/src/engine/native3d/assets/ModelRuntimeCache.ts +++ b/src/engine/native3d/assets/ModelRuntimeCache.ts @@ -1,4 +1,5 @@ import { Logger } from '../../../services/logger'; +import { NativeHelperClient } from '../../../services/nativeHelper/NativeHelperClient'; const log = Logger.create('ModelRuntimeCache'); @@ -9,6 +10,54 @@ const GLB_BIN_CHUNK = 0x004e4942; type ModelColor = readonly [number, number, number, number]; +type NativeFileReferenceClient = { + parseFileReferenceUrl?: (url: string | undefined) => string | null; + getDownloadedFile?: (path: string) => Promise; +}; + +function parseNativeFileReferenceUrl(url: string): string | null { + const client = NativeHelperClient as NativeFileReferenceClient; + return typeof client.parseFileReferenceUrl === 'function' + ? client.parseFileReferenceUrl(url) + : null; +} + +async function getNativeFileBytes(path: string): Promise { + const client = NativeHelperClient as NativeFileReferenceClient; + return typeof client.getDownloadedFile === 'function' + ? client.getDownloadedFile(path) + : null; +} + +async function fetchModelBytes(url: string): Promise<{ bytes: ArrayBuffer; contentType?: string } | null> { + const nativePath = parseNativeFileReferenceUrl(url); + if (nativePath) { + const bytes = await getNativeFileBytes(nativePath); + return bytes ? { bytes } : null; + } + + const response = await fetch(url); + if (!response.ok) { + return null; + } + + return { + bytes: await response.arrayBuffer(), + contentType: response.headers?.get('content-type') ?? undefined, + }; +} + +async function fetchModelText(url: string): Promise { + const nativePath = parseNativeFileReferenceUrl(url); + if (nativePath) { + const bytes = await getNativeFileBytes(nativePath); + return bytes ? new TextDecoder().decode(bytes) : null; + } + + const response = await fetch(url); + return response.ok ? response.text() : null; +} + export interface ModelRuntimeTexture { image: ImageBitmap; width: number; @@ -724,11 +773,11 @@ async function resolveGltfBuffers(gltf: GltfAsset, sourceUrl: string, embeddedGl } catch { return null; } - const response = await fetch(resolvedUrl); - if (!response.ok) { + const fetched = await fetchModelBytes(resolvedUrl); + if (!fetched) { return null; } - resolved.push(await response.arrayBuffer()); + resolved.push(fetched.bytes); } return resolved; @@ -754,9 +803,9 @@ async function resolveGltfTextures( if (image.uri) { try { const imageUrl = new URL(image.uri, sourceUrl).toString(); - const response = await fetch(imageUrl); - imageTextures[index] = response.ok - ? await createTextureFromBytes(await response.arrayBuffer(), image.mimeType ?? response.headers.get('content-type') ?? undefined) + const fetched = await fetchModelBytes(imageUrl); + imageTextures[index] = fetched + ? await createTextureFromBytes(fetched.bytes, image.mimeType ?? fetched.contentType) : null; } catch (error) { log.warn('Failed to fetch model texture', { uri: image.uri, error }); @@ -1177,13 +1226,13 @@ export class ModelRuntimeCache { ): Promise { const resolvedFileName = fileName ?? this.requests.get(url)?.fileName ?? url; const extension = resolvedFileName.split('.').pop()?.toLowerCase() ?? ''; - const response = await fetch(url); - if (!response.ok) { - return null; - } if (extension === 'obj') { - const parsedPrimitives = parseObj(await response.text()); + const text = await fetchModelText(url); + if (!text) { + return null; + } + const parsedPrimitives = parseObj(text); const sourceBounds = computeModelBounds(parsedPrimitives) ?? undefined; const primitives = normalizeModelPrimitives(parsedPrimitives, normalizationBounds ?? sourceBounds ?? null); return primitives.length > 0 @@ -1198,7 +1247,11 @@ export class ModelRuntimeCache { : null; } - const buffer = await response.arrayBuffer(); + const fetched = await fetchModelBytes(url); + if (!fetched) { + return null; + } + const buffer = fetched.bytes; let gltf: GltfAsset | null = null; let binaryChunk: ArrayBuffer | undefined; let format: 'gltf' | 'glb' = extension === 'gltf' ? 'gltf' : 'glb'; diff --git a/src/engine/render/RenderDispatcher.ts b/src/engine/render/RenderDispatcher.ts index 4a9af5845..5b355f06d 100644 --- a/src/engine/render/RenderDispatcher.ts +++ b/src/engine/render/RenderDispatcher.ts @@ -21,6 +21,7 @@ import { useSliceStore } from '../../stores/sliceStore'; import { useTimelineStore } from '../../stores/timeline'; import { reportRenderTime } from '../../services/performanceMonitor'; import { Logger } from '../../services/logger'; +import { NativeHelperClient } from '../../services/nativeHelper/NativeHelperClient'; import { scrubSettleState } from '../../services/scrubSettleState'; import { vfPipelineMonitor } from '../../services/vfPipelineMonitor'; import { getCopiedHtmlVideoPreviewFrame } from './htmlVideoPreviewFallback'; @@ -1694,6 +1695,23 @@ export class RenderDispatcher { message: 'Fetching splat file', }); + const nativePath = NativeHelperClient.parseFileReferenceUrl(request.url); + if (nativePath) { + const arrayBuffer = await NativeHelperClient.getDownloadedFile(nativePath); + if (!arrayBuffer) { + throw new Error(`Failed to fetch native gaussian splat: ${nativePath}`); + } + + this.setGaussianSplatLoadProgress(request, { + phase: 'fetching', + percent: 0.35, + loadedBytes: arrayBuffer.byteLength, + totalBytes: arrayBuffer.byteLength, + message: 'Fetched splat file', + }); + return new File([arrayBuffer], request.fileName || nativePath.split(/[\\/]/).pop() || 'splat.ply'); + } + const response = await fetch(request.url); if (!response.ok) { throw new Error(`Failed to fetch gaussian splat: ${response.status} ${response.statusText}`); diff --git a/src/engine/scene/runtime/SharedSplatRuntimeCache.ts b/src/engine/scene/runtime/SharedSplatRuntimeCache.ts index a6a3c9f07..ae3375c27 100644 --- a/src/engine/scene/runtime/SharedSplatRuntimeCache.ts +++ b/src/engine/scene/runtime/SharedSplatRuntimeCache.ts @@ -2,6 +2,7 @@ import { loadGaussianSplatAsset } from '../../gaussian/loaders'; import type { GaussianSplatAsset, GaussianSplatFormat } from '../../gaussian/loaders'; import { Logger } from '../../../services/logger'; import { projectFileService } from '../../../services/projectFileService'; +import { NativeHelperClient } from '../../../services/nativeHelper/NativeHelperClient'; import type { GaussianSplatBounds, GaussianSplatSequenceData } from '../../../types'; import { cloneGaussianSplatBounds, @@ -190,6 +191,19 @@ async function loadAsset(options: RuntimeSourceOptions): Promise = {} mediaState.selectSlotComposition(null); return { success: true, data: { action } }; } + case 'load-project-path': { + const projectPath = typeof args.path === 'string' ? args.path : ''; + if (!projectPath) { + return { success: false, error: 'Missing path' }; + } + + const loaded = await projectFileService.loadProject(projectPath); + if (!loaded) { + return { success: false, error: `Failed to load project: ${projectPath}` }; + } + + await loadProjectToStores(); + return { + success: true, + data: { + action, + projectPath, + projectName: projectFileService.getProjectData()?.name ?? null, + }, + }; + } + case 'reload-page': { + window.location.reload(); + return { success: true, data: { action } }; + } default: return { success: false, error: `Unknown debug action: ${action}` }; } diff --git a/src/services/layerBuilder/LayerBuilderService.ts b/src/services/layerBuilder/LayerBuilderService.ts index d1fdac329..4a485a5df 100644 --- a/src/services/layerBuilder/LayerBuilderService.ts +++ b/src/services/layerBuilder/LayerBuilderService.ts @@ -73,6 +73,10 @@ export class LayerBuilderService { : undefined; } + private getRenderableFile(file: File | undefined): File | undefined { + return file && (typeof file.size !== 'number' || file.size > 0) ? file : undefined; + } + private getLayerSourceMetadata( clip: TimelineClip, mediaFile?: { id?: string; width?: number; height?: number }, @@ -132,10 +136,10 @@ export class LayerBuilderService { mediaFile?: { file?: File }, ): File | undefined { return ( - this.resolveClipGaussianSplatFrame(clip, sourceTime)?.file ?? - mediaFile?.file ?? - clip.source?.file ?? - clip.file + this.getRenderableFile(this.resolveClipGaussianSplatFrame(clip, sourceTime)?.file) ?? + this.getRenderableFile(mediaFile?.file) ?? + this.getRenderableFile(clip.source?.file) ?? + this.getRenderableFile(clip.file) ); } @@ -926,7 +930,7 @@ export class LayerBuilderService { modelUrl, modelFileName: modelFrame?.name ?? clip.source?.modelFileName ?? clip.file?.name ?? clip.name, ...(modelSequence ? { modelSequence } : {}), - file: clip.file, + file: this.getRenderableFile(clip.file), threeDEffectorsEnabled: resolveSceneEffectorsEnabled(clip.source?.threeDEffectorsEnabled), meshType, text3DProperties, diff --git a/src/services/nativeHelper/NativeHelperClient.ts b/src/services/nativeHelper/NativeHelperClient.ts index 4fca52faf..7ab6b9317 100644 --- a/src/services/nativeHelper/NativeHelperClient.ts +++ b/src/services/nativeHelper/NativeHelperClient.ts @@ -30,11 +30,13 @@ import { // In production, use a proper LZ4 library like 'lz4js' const log = Logger.create('NativeHelper'); +const NATIVE_FILE_REFERENCE_PREFIX = 'native-helper-file://'; export interface NativeHelperConfig { port?: number; autoReconnect?: boolean; reconnectInterval?: number; + connectTimeoutMs?: number; token?: string; /** Only reconnect if we were previously connected */ onlyReconnectIfWasConnected?: boolean; @@ -51,6 +53,11 @@ export interface DecodedFrame { } export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; +export interface NativeFolderPickResult { + path: string | null; + cancelled: boolean; + error?: string; +} type ResponseCallback = (response: Response) => void; type FrameCallback = (frame: DecodedFrame) => void; @@ -101,7 +108,8 @@ class NativeHelperClientImpl { this.config = { port: 9876, autoReconnect: true, - reconnectInterval: 10000, // 10 seconds between reconnect attempts + reconnectInterval: 2500, + connectTimeoutMs: 5000, token: '', onlyReconnectIfWasConnected: true, // Don't spam reconnects if never connected }; @@ -150,11 +158,16 @@ class NativeHelperClientImpl { const connectPromise = new Promise((resolve) => { let settled = false; + let connectTimeout: ReturnType | null = null; const finish = (result: boolean) => { if (settled) { return; } settled = true; + if (connectTimeout) { + clearTimeout(connectTimeout); + connectTimeout = null; + } resolve(result); }; @@ -163,41 +176,75 @@ class NativeHelperClientImpl { this.ws = ws; ws.binaryType = 'arraybuffer'; // Ensure binary data comes as ArrayBuffer, not Blob + connectTimeout = setTimeout(() => { + if (settled) { + return; + } + if (this.ws === ws) { + this.ws = null; + } + try { + ws.close(); + } catch { + // Ignore close errors while unwinding a timed-out connection attempt. + } + this.setStatus('disconnected'); + log.warn(`Native helper connection timed out after ${this.config.connectTimeoutMs}ms`); + finish(false); + }, this.config.connectTimeoutMs); + ws.onopen = async () => { log.info('Connected to native helper'); this.wasEverConnected = true; - // If no token configured, try to discover it from the startup endpoint - if (!this.config.token) { - try { - const httpPort = this.config.port + 1; - const resp = await fetch(`http://127.0.0.1:${httpPort}/startup-token`); - if (resp.ok) { - const data = await resp.json(); - if (data.token) { - this.config.token = data.token; - log.info('Auth token discovered from startup endpoint'); - } + // Refresh the token on every new socket. Helper restarts generate a + // new startup token, so a cached token from the previous process is stale. + try { + const httpPort = this.config.port + 1; + const resp = await this.fetchWithTimeout( + `http://127.0.0.1:${httpPort}/startup-token`, + undefined, + Math.min(this.config.connectTimeoutMs, 1500), + ); + if (resp.ok) { + const data = await resp.json(); + if (typeof data.token === 'string' && data.token.length > 0) { + this.config.token = data.token; + log.info('Auth token discovered from startup endpoint'); } - } catch { - log.debug('Could not discover auth token from startup endpoint'); } + } catch { + log.debug('Could not discover auth token from startup endpoint'); } // Authenticate with token if (this.config.token) { + let authenticated = false; try { const authResp = await this.send({ cmd: 'auth', id: this.nextId(), token: this.config.token }); - if (okField(authResp, 'authenticated') !== true) { - log.warn('Auth response did not confirm authentication'); - } + authenticated = okField(authResp, 'authenticated') === true; } catch { log.warn('Auth failed'); } + + if (!authenticated) { + log.warn('Auth response did not confirm authentication'); + if (this.ws === ws) { + this.ws = null; + } + try { + ws.close(); + } catch { + // Ignore close errors while unwinding a failed auth attempt. + } + this.setStatus('disconnected'); + finish(false); + return; + } } try { - await this.send({ + const registerResponse = await this.send({ cmd: 'register_client', id: this.nextId(), role: 'editor', @@ -205,8 +252,22 @@ class NativeHelperClientImpl { session_name: 'masterselects-editor', app_version: APP_VERSION, }); + if (registerResponse.ok !== true) { + throw new Error(getErrorMessage(registerResponse, 'Registration failed')); + } } catch (error) { log.warn('Editor registration with native helper failed', error); + if (this.ws === ws) { + this.ws = null; + } + try { + ws.close(); + } catch { + // Ignore close errors while unwinding a failed registration attempt. + } + this.setStatus('disconnected'); + finish(false); + return; } this.setStatus('connected'); @@ -227,6 +288,9 @@ class NativeHelperClientImpl { ws.onerror = () => { // Don't log errors when helper isn't running - it's optional + if (this.ws === ws) { + this.ws = null; + } this.setStatus('disconnected'); finish(false); }; @@ -429,9 +493,9 @@ class NativeHelperClientImpl { /** * Get system info */ - async getInfo(): Promise { + async getInfo(timeoutMs = 30000): Promise { const id = this.nextId(); - const response = await this.send({ cmd: 'info', id }); + const response = await this.send({ cmd: 'info', id }, timeoutMs); if (!response.ok) { throw new Error(getErrorMessage(response, 'Failed to get info')); @@ -443,10 +507,10 @@ class NativeHelperClientImpl { /** * Ping the server */ - async ping(): Promise { + async ping(timeoutMs = 3000): Promise { try { const id = this.nextId(); - const response = await this.send({ cmd: 'ping', id }); + const response = await this.send({ cmd: 'ping', id }, timeoutMs); return response.ok === true; } catch { return false; @@ -663,9 +727,9 @@ class NativeHelperClientImpl { /** * Get the default project root path from the native helper */ - async getProjectRoot(): Promise { + async getProjectRoot(timeoutMs = 1500): Promise { try { - const response = await this.fetchWithAuth(`${this.getHttpBaseUrl()}/project-root`); + const response = await this.fetchWithTimeout(`${this.getHttpBaseUrl()}/project-root`, undefined, timeoutMs); if (response.ok) { const data = await response.json(); return data.path || null; @@ -673,7 +737,7 @@ class NativeHelperClientImpl { } catch { // Fallback to info command try { - const info = await this.getInfo(); + const info = await this.getInfo(timeoutMs); return info.project_root || null; } catch { return null; @@ -682,12 +746,21 @@ class NativeHelperClientImpl { return null; } - /** + /** * Check if the native helper supports file system commands */ - async hasFsCommands(): Promise { + async hasFsCommands(timeoutMs = 1500): Promise { + try { + const response = await this.fetchWithTimeout(`${this.getHttpBaseUrl()}/project-root`, undefined, timeoutMs); + if (response.ok) { + return true; + } + } catch { + // Fall back to the older info-based check below. + } + try { - const info = await this.getInfo(); + const info = await this.getInfo(timeoutMs); return info.fs_commands === true; } catch { return false; @@ -768,6 +841,12 @@ class NativeHelperClientImpl { const id = this.nextId(); try { const response = await this.send({ cmd: 'create_dir', id, path, recursive }); + if (response.ok !== true) { + log.warn('createDir rejected', { + path, + error: getErrorMessage(response, 'Create directory failed'), + }); + } return response.ok === true; } catch (e) { log.error('createDir failed', e); @@ -840,26 +919,61 @@ class NativeHelperClientImpl { /** * Open a native OS folder picker dialog via the Native Helper. - * Returns the selected folder path, or null if the user cancelled. + * Returns detailed picker state so callers can distinguish cancellation from + * platforms where the helper cannot show a native picker. */ - async pickFolder(title?: string, defaultPath?: string): Promise { + async pickFolderDetailed(title?: string, defaultPath?: string): Promise { const id = this.nextId(); try { const cmd = { cmd: 'pick_folder', id, title, default_path: defaultPath } satisfies JsonObject; if (title) cmd.title = title; if (defaultPath) cmd.default_path = defaultPath; - const response = await this.send(cmd as unknown as Command); + const response = await this.send(cmd as unknown as Command, 5 * 60 * 1000); const path = okField(response, 'path'); if (response.ok && path) { - return path; + return { path, cancelled: false }; } - return null; // cancelled + if (response.ok) { + return { path: null, cancelled: okField(response, 'cancelled') !== false }; + } + return { + path: null, + cancelled: false, + error: getErrorMessage(response, 'Folder picker failed'), + }; } catch (e) { log.error('pickFolder failed', e); - return null; + return { + path: null, + cancelled: false, + error: e instanceof Error ? e.message : String(e), + }; + } + } + + /** + * Grant helper file access to a user-approved project path. + * Used after restoring paths persisted by the browser between sessions. + */ + async grantPath(path: string): Promise { + const id = this.nextId(); + try { + const response = await this.send({ cmd: 'grant_path', id, path }); + return response.ok === true; + } catch (e) { + log.error('grantPath failed', e); + return false; } } + /** + * Open a native OS folder picker dialog via the Native Helper. + * Returns the selected folder path, or null if cancelled or unavailable. + */ + async pickFolder(title?: string, defaultPath?: string): Promise { + return (await this.pickFolderDetailed(title, defaultPath)).path; + } + /** * Build a URL that serves a file via the native helper HTTP server. * Use this for media src attributes (video, audio, img) in Firefox. @@ -868,6 +982,40 @@ class NativeHelperClientImpl { return `${this.getHttpBaseUrl()}/file?path=${encodeURIComponent(absolutePath)}`; } + /** + * Build an app-internal URL for files that must be fetched through the + * authenticated Native Helper client rather than by a DOM element. + */ + getFileReferenceUrl(absolutePath: string): string { + return `${NATIVE_FILE_REFERENCE_PREFIX}${encodeURIComponent(absolutePath)}`; + } + + parseFileReferenceUrl(url: string | undefined): string | null { + if (!url?.startsWith(NATIVE_FILE_REFERENCE_PREFIX)) { + return null; + } + + try { + return decodeURIComponent(url.slice(NATIVE_FILE_REFERENCE_PREFIX.length)); + } catch { + return null; + } + } + + async getReferencedFile(url: string, fileName: string): Promise { + const path = this.parseFileReferenceUrl(url); + if (!path) { + return null; + } + + const fileBuffer = await this.getDownloadedFile(path); + if (!fileBuffer) { + return null; + } + + return new File([fileBuffer], fileName || path.split(/[\\/]/).pop() || 'file'); + } + /** * Get a downloaded file from the Native Helper via HTTP (fast) or WebSocket fallback */ @@ -1163,6 +1311,26 @@ class NativeHelperClientImpl { return fetch(url, { ...init, headers }); } + private fetchWithTimeout( + url: string, + init?: RequestInit, + timeoutMs = 3000, + ): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + if (init?.signal) { + if (init.signal.aborted) { + controller.abort(); + } else { + init.signal.addEventListener('abort', () => controller.abort(), { once: true }); + } + } + + return fetch(url, { ...init, signal: controller.signal }) + .finally(() => clearTimeout(timeout)); + } + private nextId(): string { return `req_${++this.requestId}`; } @@ -1193,8 +1361,9 @@ class NativeHelperClientImpl { this.status !== 'connecting' && (!this.config.onlyReconnectIfWasConnected || this.wasEverConnected); - if (shouldReconnect) { + if (shouldReconnect && !this.reconnectTimer) { this.reconnectTimer = window.setTimeout(() => { + this.reconnectTimer = null; log.debug('Attempting reconnect...'); this.connect(); }, this.config.reconnectInterval); @@ -1323,7 +1492,7 @@ class NativeHelperClientImpl { } } - private async send(cmd: Command): Promise { + private async send(cmd: Command, timeoutMs = 30000): Promise { if (!this.isConnected()) { throw new Error('Not connected'); } @@ -1335,7 +1504,7 @@ class NativeHelperClientImpl { const timeout = setTimeout(() => { this.pendingRequests.delete(id); reject(new Error('Request timeout')); - }, 30000); + }, timeoutMs); // Register callback this.pendingRequests.set(id, (response) => { diff --git a/src/services/nativeHelper/protocol.ts b/src/services/nativeHelper/protocol.ts index 111b89a04..1193e520a 100644 --- a/src/services/nativeHelper/protocol.ts +++ b/src/services/nativeHelper/protocol.ts @@ -230,6 +230,12 @@ export interface RenameCommand { new_path: string; } +export interface GrantPathCommand { + cmd: 'grant_path'; + id: string; + path: string; +} + export interface DirEntry { name: string; kind: 'file' | 'directory'; @@ -312,6 +318,7 @@ export type Command = | DeleteCommand | ExistsCommand | RenameCommand + | GrantPathCommand | MatAnyoneStatusCommand | MatAnyoneSetupCommand | MatAnyoneDownloadModelCommand diff --git a/src/services/nativeHelper/releases.ts b/src/services/nativeHelper/releases.ts index 1be4e3d52..615e38482 100644 --- a/src/services/nativeHelper/releases.ts +++ b/src/services/nativeHelper/releases.ts @@ -1,4 +1,4 @@ -export const NATIVE_HELPER_TARGET_VERSION = '0.3.12'; +export const NATIVE_HELPER_TARGET_VERSION = '0.3.13'; export const NATIVE_HELPER_RELEASES_URL = 'https://github.com/Sportinger/MasterSelects/releases'; const GITHUB_RELEASES_API_URL = 'https://api.github.com/repos/Sportinger/MasterSelects/releases?per_page=20'; diff --git a/src/services/project/ProjectFileService.ts b/src/services/project/ProjectFileService.ts index b7a9b8638..ee274328a 100644 --- a/src/services/project/ProjectFileService.ts +++ b/src/services/project/ProjectFileService.ts @@ -61,6 +61,74 @@ class ProjectFileService { .join('/'); } + private normalizeNativePath(path: string): string { + return path.trim().replace(/\\/g, '/').replace(/\/+$/, ''); + } + + private ensureNativeBackend(): NativeProjectCoreService { + if (!this.nativeCoreService) { + this.nativeCoreService = new NativeProjectCoreService(); + this.nativeFileStorage = nativeFileStorageService; + } + this._activeBackend = 'native'; + return this.nativeCoreService; + } + + private async ensureNativeBackendReady(): Promise { + const nativeCore = this.ensureNativeBackend(); + + if (!NativeHelperClient.isConnected()) { + const connected = await NativeHelperClient.connect(); + if (!connected) { + log.warn('Native Helper backend requested but helper is not connected'); + return null; + } + } + + const hasFsCommands = await NativeHelperClient.hasFsCommands(); + if (!hasFsCommands) { + log.error('Native Helper does not support project file-system commands'); + return null; + } + + return nativeCore; + } + + private async pickNativeFolder(title: string, defaultPath?: string | null): Promise { + const fallbackPath = defaultPath ? this.normalizeNativePath(defaultPath) : ''; + const result = await NativeHelperClient.pickFolderDetailed(title, fallbackPath || undefined); + + if (result.path) { + const selectedPath = this.normalizeNativePath(result.path); + await NativeHelperClient.grantPath(selectedPath); + return selectedPath; + } + + if (result.cancelled) { + return null; + } + + log.warn('Native folder picker unavailable, falling back to manual path entry', { + title, + error: result.error, + }); + + const detectedRoot = fallbackPath || (await NativeHelperClient.getProjectRoot()); + const promptDefault = detectedRoot || ''; + const enteredPath = window.prompt( + `${title}\n\nNative folder picker is unavailable here. Enter the folder path manually:`, + promptDefault, + ); + + if (!enteredPath?.trim()) { + return null; + } + + const selectedPath = this.normalizeNativePath(enteredPath); + await NativeHelperClient.grantPath(selectedPath); + return selectedPath; + } + private getMimeTypeFromFileName(fileName: string): string { const extension = fileName.split('.').pop()?.toLowerCase() ?? ''; @@ -197,8 +265,9 @@ class ProjectFileService { isSameEntry: async (other: FileSystemHandle) => other === handle, queryPermission: async () => 'granted' as PermissionState, requestPermission: async () => 'granted' as PermissionState, - } as FileSystemFileHandle; + } as FileSystemFileHandle & { __nativePath?: string }; + handle.__nativePath = fullPath; return handle; } @@ -265,16 +334,14 @@ class ProjectFileService { /** Check if FSA (File System Access API) is available */ get isFsaAvailable(): boolean { - return 'showDirectoryPicker' in window && 'showSaveFilePicker' in window; + return typeof window !== 'undefined' + && 'showDirectoryPicker' in window + && 'showSaveFilePicker' in window; } /** Switch to native helper backend (for Firefox) */ activateNativeBackend(): void { - if (!this.nativeCoreService) { - this.nativeCoreService = new NativeProjectCoreService(); - this.nativeFileStorage = nativeFileStorageService; - } - this._activeBackend = 'native'; + this.ensureNativeBackend(); log.info('Switched to Native Helper backend'); } @@ -307,7 +374,7 @@ class ProjectFileService { } isSupported(): boolean { - if (this._activeBackend === 'native') { + if (this._activeBackend === 'native' || !this.isFsaAvailable) { return this.nativeCoreService?.isSupported() ?? false; } return this.coreService.isSupported(); @@ -315,7 +382,7 @@ class ProjectFileService { getProjectHandle(): FileSystemDirectoryHandle | null { // Only FSA backend has a handle - if (this._activeBackend === 'fsa') { + if (this._activeBackend === 'fsa' && this.isFsaAvailable) { return this.coreService.getProjectHandle(); } return null; @@ -358,6 +425,20 @@ class ProjectFileService { } async createProject(name: string): Promise { + if (this._activeBackend === 'native' || !this.isFsaAvailable) { + const nativeCore = await this.ensureNativeBackendReady(); + if (!nativeCore) return false; + + const projectRoot = await NativeHelperClient.getProjectRoot(); + const parentPath = await this.pickNativeFolder( + 'Choose where to save your project', + projectRoot, + ); + + if (!parentPath) return false; + return nativeCore.createProjectAtPath(parentPath, name); + } + return this.core.createProject(name); } @@ -367,21 +448,28 @@ class ProjectFileService { } async openProject(): Promise { - if (this._activeBackend === 'fsa') { + if (this._activeBackend === 'fsa' && this.isFsaAvailable) { return this.coreService.openProject(); } - // Native backend doesn't have a directory picker — use loadProject with a path - log.warn('openProject() called on native backend — use loadProject(path) instead'); - return false; + const nativeCore = await this.ensureNativeBackendReady(); + if (!nativeCore) return false; + + const projectRoot = await NativeHelperClient.getProjectRoot(); + const projectPath = await this.pickNativeFolder( + 'Select an existing project folder', + projectRoot, + ); + + if (!projectPath) return false; + return nativeCore.loadProject(projectPath); } async loadProject(handleOrPath: FileSystemDirectoryHandle | string): Promise { if (typeof handleOrPath === 'string') { - // Native path - if (this.nativeCoreService) { - return this.nativeCoreService.loadProject(handleOrPath); - } - return false; + const nativeCore = await this.ensureNativeBackendReady(); + return nativeCore + ? nativeCore.loadProject(this.normalizeNativePath(handleOrPath)) + : false; } // FSA handle return this.coreService.loadProject(handleOrPath); @@ -404,6 +492,11 @@ class ProjectFileService { } async restoreLastProject(): Promise { + if (this._activeBackend === 'native' || !this.isFsaAvailable) { + const nativeCore = await this.ensureNativeBackendReady(); + return nativeCore ? nativeCore.restoreLastProject() : false; + } + return this.core.restoreLastProject(); } @@ -544,6 +637,25 @@ class ProjectFileService { return this.rawMediaService.getFileFromRaw(handle, relativePath); } + resolveRawFilePath(relativePath: string | undefined): string | null { + if (this._activeBackend !== 'native' || !this.nativeCoreService || !relativePath) { + return null; + } + + const projectPath = this.nativeCoreService.getProjectPath(); + const target = parseRawRelativePath(relativePath); + if (!projectPath || !target) { + return null; + } + + return this.joinPath(projectPath, target.relativePath); + } + + resolveRawFileUrl(relativePath: string | undefined): string | null { + const fullPath = this.resolveRawFilePath(relativePath); + return fullPath ? NativeHelperClient.getFileReferenceUrl(fullPath) : null; + } + async hasFileInRaw(fileName: string): Promise { const handle = this.coreService.getProjectHandle(); if (!handle) return false; @@ -574,8 +686,37 @@ class ProjectFileService { return this.scanDirectoryHandle(handle); } + async pickAndScanFolder(title = 'Search folder for media'): Promise<{ + name: string; + path?: string; + files: Map; + } | null> { + if (this._activeBackend !== 'native') { + return null; + } + + const nativeCore = await this.ensureNativeBackendReady(); + if (!nativeCore) { + return null; + } + + const defaultPath = nativeCore.getProjectPath() ?? await NativeHelperClient.getProjectRoot(); + const folderPath = await this.pickNativeFolder(title, defaultPath); + if (!folderPath) { + return null; + } + + const normalizedPath = this.normalizeNativePath(folderPath); + const name = normalizedPath.split('/').filter(Boolean).pop() ?? normalizedPath; + return { + name, + path: normalizedPath, + files: await this.scanNativeFolder(normalizedPath), + }; + } + async importMediaFile(file: File, fileHandle?: FileSystemFileHandle): Promise { - const projectData = this.coreService.getProjectData(); + const projectData = this.core.getProjectData(); if (!projectData) return null; const mediaFile = await this.rawMediaService.importMediaFile(file, fileHandle); @@ -583,7 +724,7 @@ class ProjectFileService { // Add to project projectData.media.push(mediaFile); - this.coreService.markDirty(); + this.core.markDirty(); return mediaFile; } diff --git a/src/services/project/core/NativeProjectCoreService.ts b/src/services/project/core/NativeProjectCoreService.ts index 0631d67a0..e9123a85e 100644 --- a/src/services/project/core/NativeProjectCoreService.ts +++ b/src/services/project/core/NativeProjectCoreService.ts @@ -6,6 +6,7 @@ import { Logger } from '../../logger'; import { apiKeyManager } from '../../apiKeyManager'; import { NativeHelperClient } from '../../nativeHelper/NativeHelperClient'; import { PROJECT_FOLDER_PATHS, MAX_BACKUPS } from './constants'; +import { shouldPreferAutosave, shouldSkipEmptyProjectSave } from './autosaveRecovery'; import type { ProjectFile, ProjectMediaFile, ProjectComposition, ProjectFolder } from '../types'; const log = Logger.create('NativeProjectCore'); @@ -114,6 +115,7 @@ export class NativeProjectCoreService { } try { + await this.client.grantPath(basePath); const projectPath = this.joinPath(basePath, name); return await this.initializeProject(projectPath, name); } catch (e) { @@ -198,6 +200,7 @@ export class NativeProjectCoreService { async loadProject(projectPath: string): Promise { try { + await this.client.grantPath(projectPath); const projectData = await this.readLatestProjectData(projectPath); if (!projectData) { @@ -258,6 +261,15 @@ export class NativeProjectCoreService { try { const savedRevision = this.dirtyRevision; + const autosaveData = await this.readProjectFile(this.projectPath, PROJECT_AUTOSAVE_FILE_NAME); + if (shouldSkipEmptyProjectSave(this.projectData, autosaveData)) { + log.warn('Skipped empty project save because project.autosave.json contains recoverable project data'); + if (this.dirtyRevision === savedRevision) { + this.isDirty = false; + } + return true; + } + this.projectData.updatedAt = new Date().toISOString(); const jsonPath = this.joinPath(this.projectPath, PROJECT_FILE_NAME); @@ -432,6 +444,7 @@ export class NativeProjectCoreService { } try { + await this.client.grantPath(lastPath); const { exists, kind } = await this.client.exists(lastPath); if (!exists || kind !== 'directory') { log.info('Last project folder no longer exists'); @@ -562,11 +575,9 @@ export class NativeProjectCoreService { if (!projectData) return null; const autosaveData = await this.readProjectFile(projectPath, PROJECT_AUTOSAVE_FILE_NAME); - const projectUpdatedAt = Date.parse(projectData.updatedAt); - const autosaveUpdatedAt = autosaveData ? Date.parse(autosaveData.updatedAt) : NaN; - if (autosaveData && autosaveUpdatedAt > projectUpdatedAt) { - log.warn('Loaded newer project.autosave.json because project.json was older'); + if (shouldPreferAutosave(projectData, autosaveData)) { + log.warn('Loaded project.autosave.json because it is newer or project.json appears empty'); return autosaveData; } diff --git a/src/services/project/core/ProjectCoreService.ts b/src/services/project/core/ProjectCoreService.ts index 02f7df122..987fce661 100644 --- a/src/services/project/core/ProjectCoreService.ts +++ b/src/services/project/core/ProjectCoreService.ts @@ -4,6 +4,7 @@ import { Logger } from '../../logger'; import { projectDB } from '../../projectDB'; import { apiKeyManager } from '../../apiKeyManager'; +import { shouldPreferAutosave, shouldSkipEmptyProjectSave } from './autosaveRecovery'; const log = Logger.create('ProjectCore'); import { FileStorageService } from './FileStorageService'; @@ -295,6 +296,15 @@ export class ProjectCoreService { try { const savedRevision = this.dirtyRevision; + const autosaveData = await this.readProjectFile(this.projectHandle, PROJECT_AUTOSAVE_FILE_NAME); + if (shouldSkipEmptyProjectSave(this.projectData, autosaveData)) { + log.warn('Skipped empty project save because project.autosave.json contains recoverable project data'); + if (this.dirtyRevision === savedRevision) { + this.isDirty = false; + } + return true; + } + this.projectData.updatedAt = new Date().toISOString(); await this.writeProjectJsonWithAutosaveFallback(this.projectHandle, this.projectData); @@ -713,12 +723,10 @@ export class ProjectCoreService { } const autosaveData = await this.readProjectFile(handle, PROJECT_AUTOSAVE_FILE_NAME); - const projectUpdatedAt = Date.parse(projectData.updatedAt); - const autosaveUpdatedAt = autosaveData ? Date.parse(autosaveData.updatedAt) : NaN; - if (autosaveData && autosaveUpdatedAt > projectUpdatedAt) { - log.warn('Loaded newer project.autosave.json because project.json was older'); - return autosaveData; + if (shouldPreferAutosave(projectData, autosaveData)) { + log.warn('Loaded project.autosave.json because it is newer or project.json appears empty'); + return autosaveData ?? projectData; } return projectData; diff --git a/src/services/project/core/autosaveRecovery.ts b/src/services/project/core/autosaveRecovery.ts new file mode 100644 index 000000000..1185260cd --- /dev/null +++ b/src/services/project/core/autosaveRecovery.ts @@ -0,0 +1,53 @@ +import type { ProjectFile } from '../types'; + +function clipCount(project: ProjectFile): number { + return project.compositions.reduce((count, composition) => count + composition.clips.length, 0); +} + +function generatedItemCount(project: ProjectFile): number { + return (project.textItems?.length ?? 0) + + (project.solidItems?.length ?? 0) + + (project.meshItems?.length ?? 0) + + (project.cameraItems?.length ?? 0) + + (project.splatEffectorItems?.length ?? 0); +} + +export function hasMeaningfulContent(project: ProjectFile): boolean { + return project.media.length > 0 + || project.folders.length > 0 + || project.compositions.length > 1 + || clipCount(project) > 0 + || generatedItemCount(project) > 0 + || Boolean(project.flashboard?.boards?.some((board) => board.nodes.length > 0)); +} + +export function looksLikeFreshEmptyProject(project: ProjectFile): boolean { + return project.media.length === 0 + && project.folders.length === 0 + && project.compositions.length <= 1 + && clipCount(project) === 0 + && generatedItemCount(project) === 0; +} + +export function shouldPreferAutosave(projectData: ProjectFile, autosaveData: ProjectFile | null): boolean { + if (!autosaveData) { + return false; + } + + const projectUpdatedAt = Date.parse(projectData.updatedAt); + const autosaveUpdatedAt = Date.parse(autosaveData.updatedAt); + + if (autosaveUpdatedAt > projectUpdatedAt) { + return true; + } + + return looksLikeFreshEmptyProject(projectData) && hasMeaningfulContent(autosaveData); +} + +export function shouldSkipEmptyProjectSave(projectData: ProjectFile, autosaveData: ProjectFile | null): boolean { + if (!autosaveData) { + return false; + } + + return looksLikeFreshEmptyProject(projectData) && hasMeaningfulContent(autosaveData); +} diff --git a/src/services/project/projectLoad.ts b/src/services/project/projectLoad.ts index 6ef7b4256..b0c282741 100644 --- a/src/services/project/projectLoad.ts +++ b/src/services/project/projectLoad.ts @@ -26,6 +26,7 @@ import { type ProjectComposition, type ProjectFolder, } from '../projectFileService'; +import { withProjectStoreSyncGuard } from './projectSave'; import { fileSystemService } from '../fileSystemService'; import { projectDB } from '../projectDB'; import { @@ -67,6 +68,29 @@ function removeLocalStorageKey(key: string): void { storage.setItem(key, ''); } +function isAbsoluteFilePath(value: string | undefined): boolean { + return Boolean(value && (value.startsWith('/') || /^[A-Za-z]:[/\\]/.test(value))); +} + +type ProjectFileServiceRawResolver = typeof projectFileService & { + resolveRawFilePath?: (relativePath: string | undefined) => string | null; + resolveRawFileUrl?: (relativePath: string | undefined) => string | null; +}; + +function resolveProjectRawFilePath(relativePath: string | undefined): string | null { + const resolver = (projectFileService as ProjectFileServiceRawResolver).resolveRawFilePath; + return typeof resolver === 'function' + ? resolver.call(projectFileService, relativePath) + : null; +} + +function resolveProjectRawFileUrl(relativePath: string | undefined): string | null { + const resolver = (projectFileService as ProjectFileServiceRawResolver).resolveRawFileUrl; + return typeof resolver === 'function' + ? resolver.call(projectFileService, relativePath) + : null; +} + /** * Calculate coverage ratio from time ranges vs total duration (0-1). */ @@ -147,7 +171,15 @@ async function restoreSequenceFrameFromHandle( /** * Convert ProjectMediaFile to MediaFile format */ -async function convertProjectMediaToStore(projectMedia: ProjectMediaFile[]): Promise { +type ConvertProjectMediaOptions = { + hydrateFiles?: boolean; +}; + +async function convertProjectMediaToStore( + projectMedia: ProjectMediaFile[], + options: ConvertProjectMediaOptions = {}, +): Promise { + const hydrateFiles = options.hydrateFiles !== false; const files: MediaFile[] = []; for (const pm of projectMedia) { @@ -157,18 +189,20 @@ async function convertProjectMediaToStore(projectMedia: ProjectMediaFile[]): Pro let url = ''; let thumbnailUrl: string | undefined; - // Prefer the project-local RAW copy. This is the canonical source for imported media. - const storedProjectHandle = await getStoredProjectFileHandle(pm.id); - if (storedProjectHandle) { - try { - file = await storedProjectHandle.getFile(); - handle = storedProjectHandle; - url = URL.createObjectURL(file); - resolvedProjectPath = resolvedProjectPath || `Raw/${storedProjectHandle.name}`; - await cacheProjectFileHandle(pm.id, storedProjectHandle, true); - log.info('Restored file from project RAW handle:', pm.name); - } catch (e) { - log.warn(`Could not access project RAW handle: ${pm.name}`, e); + if (hydrateFiles) { + // Prefer the project-local RAW copy. This is the canonical source for imported media. + const storedProjectHandle = await getStoredProjectFileHandle(pm.id); + if (storedProjectHandle) { + try { + file = await storedProjectHandle.getFile(); + handle = storedProjectHandle; + url = URL.createObjectURL(file); + resolvedProjectPath = resolvedProjectPath || `Raw/${storedProjectHandle.name}`; + await cacheProjectFileHandle(pm.id, storedProjectHandle, true); + log.info('Restored file from project RAW handle:', pm.name); + } catch (e) { + log.warn(`Could not access project RAW handle: ${pm.name}`, e); + } } } @@ -179,8 +213,12 @@ async function convertProjectMediaToStore(projectMedia: ProjectMediaFile[]): Pro const frame = pm.modelSequence.frames[frameIndex]; let frameFile = frame.file; let modelUrl = frame.modelUrl; + const frameFileUrl = resolveProjectRawFileUrl(frame.projectPath); + if (!hydrateFiles && !modelUrl && frameFileUrl) { + modelUrl = frameFileUrl; + } - if (!frameFile && frame.projectPath && projectFileService.isProjectOpen()) { + if (hydrateFiles && !frameFile && frame.projectPath && projectFileService.isProjectOpen()) { try { const result = await projectFileService.getFileFromRaw(frame.projectPath); if (result?.file) { @@ -195,7 +233,7 @@ async function convertProjectMediaToStore(projectMedia: ProjectMediaFile[]): Pro } } - if (!frameFile) { + if (hydrateFiles && !frameFile) { const restoredFrame = await restoreSequenceFrameFromHandle(pm.id, frameIndex); if (restoredFrame) { frameFile = restoredFrame.file; @@ -207,7 +245,7 @@ async function convertProjectMediaToStore(projectMedia: ProjectMediaFile[]): Pro name: frame.name, projectPath: frame.projectPath, sourcePath: frame.sourcePath, - absolutePath: frame.absolutePath, + absolutePath: frame.absolutePath ?? resolveProjectRawFilePath(frame.projectPath) ?? undefined, file: frameFile, modelUrl, }); @@ -225,8 +263,12 @@ async function convertProjectMediaToStore(projectMedia: ProjectMediaFile[]): Pro const frame = pm.gaussianSplatSequence.frames[frameIndex]; let frameFile = frame.file; let splatUrl = frame.splatUrl; + const frameFileUrl = resolveProjectRawFileUrl(frame.projectPath); + if (!hydrateFiles && !splatUrl && frameFileUrl) { + splatUrl = frameFileUrl; + } - if (!frameFile && frame.projectPath && projectFileService.isProjectOpen()) { + if (hydrateFiles && !frameFile && frame.projectPath && projectFileService.isProjectOpen()) { try { const result = await projectFileService.getFileFromRaw(frame.projectPath); if (result?.file) { @@ -241,7 +283,7 @@ async function convertProjectMediaToStore(projectMedia: ProjectMediaFile[]): Pro } } - if (!frameFile) { + if (hydrateFiles && !frameFile) { const restoredFrame = await restoreSequenceFrameFromHandle(pm.id, frameIndex); if (restoredFrame) { frameFile = restoredFrame.file; @@ -253,7 +295,7 @@ async function convertProjectMediaToStore(projectMedia: ProjectMediaFile[]): Pro name: frame.name, projectPath: frame.projectPath, sourcePath: frame.sourcePath, - absolutePath: frame.absolutePath, + absolutePath: frame.absolutePath ?? resolveProjectRawFilePath(frame.projectPath) ?? undefined, file: frameFile, splatUrl, splatCount: frame.splatCount, @@ -268,7 +310,7 @@ async function convertProjectMediaToStore(projectMedia: ProjectMediaFile[]): Pro frames: sequenceFrames, }; } - if (!file && projectFileService.isProjectOpen()) { + if (hydrateFiles && !file && projectFileService.isProjectOpen()) { for (const candidatePath of getProjectRawPathCandidates({ mediaFileId: pm.id, projectPath: pm.projectPath, @@ -298,18 +340,28 @@ async function convertProjectMediaToStore(projectMedia: ProjectMediaFile[]): Pro } const representativeFile = file ?? modelSequence?.frames[0]?.file ?? gaussianSplatSequence?.frames[0]?.file; + const representativeProjectPath = + resolvedProjectPath ?? + modelSequence?.frames[0]?.projectPath ?? + gaussianSplatSequence?.frames[0]?.projectPath; + const nativeRepresentativeUrl = + !hydrateFiles && representativeProjectPath + ? resolveProjectRawFileUrl(representativeProjectPath) ?? '' + : ''; const representativeUrl = url || modelSequence?.frames[0]?.modelUrl || gaussianSplatSequence?.frames[0]?.splatUrl || + nativeRepresentativeUrl || ''; - const representativeProjectPath = - resolvedProjectPath ?? - modelSequence?.frames[0]?.projectPath ?? - gaussianSplatSequence?.frames[0]?.projectPath; + const representativeAbsolutePath = + resolveProjectRawFilePath(representativeProjectPath) ?? + (isAbsoluteFilePath(pm.sourcePath) ? pm.sourcePath : undefined) ?? + modelSequence?.frames[0]?.absolutePath ?? + gaussianSplatSequence?.frames[0]?.absolutePath; // Fall back to the primary file handle for non-project media or legacy data. - if (!file) { + if (hydrateFiles && !file) { handle = fileSystemService.getFileHandle(pm.id); if (!handle) { @@ -411,8 +463,9 @@ async function convertProjectMediaToStore(projectMedia: ProjectMediaFile[]): Pro modelSequence, gaussianSplatSequence, proxyStatus: pm.hasProxy ? 'ready' : 'none', - hasFileHandle: !!handle, + hasFileHandle: !!handle || (!!representativeAbsolutePath && projectFileService.activeBackend === 'native'), filePath: pm.sourcePath, + absolutePath: representativeAbsolutePath, projectPath: representativeProjectPath, vectorAnimation: pm.vectorAnimation, labelColor: pm.labelColor as import('../../stores/mediaStore/types').LabelColor | undefined, @@ -658,19 +711,27 @@ function hydrateFlashBoardFromProject(data: ProjectFlashBoardState): void { * Load project data from projectFileService into stores */ export async function loadProjectToStores(): Promise { - const projectData = projectFileService.getProjectData(); - if (!projectData) { - log.error(' No project data to load'); - return; - } + await withProjectStoreSyncGuard(async () => { + const projectData = projectFileService.getProjectData(); + if (!projectData) { + log.error(' No project data to load'); + return; + } - // Convert and load data - const files = await convertProjectMediaToStore(projectData.media); - const compositions = convertProjectCompositionToStore( - projectData.compositions, - projectData.uiState?.compositionViewState - ); - const folders = convertProjectFolderToStore(projectData.folders); + // Firefox/native helper must not synchronously download every RAW file or + // 3D sequence frame before the project appears in the UI. + const hydrateFiles = projectFileService.activeBackend !== 'native'; + if (!hydrateFiles) { + log.info('Native backend detected; deferring media file hydration until after project metadata is loaded'); + } + + // Convert and load data + const files = await convertProjectMediaToStore(projectData.media, { hydrateFiles }); + const compositions = convertProjectCompositionToStore( + projectData.compositions, + projectData.uiState?.compositionViewState + ); + const folders = convertProjectFolderToStore(projectData.folders); // Clear timeline first const timelineStore = useTimelineStore.getState(); @@ -832,12 +893,17 @@ export async function loadProjectToStores(): Promise { log.info(' Loaded project to stores:', projectData.name); - // Auto-relink missing files from Raw folder - await autoRelinkFromRawFolder(); + if (hydrateFiles) { + // Auto-relink missing files from Raw folder + await autoRelinkFromRawFolder(); + } else { + log.info('Skipping eager auto-relink for native backend during initial project load'); + } - // Restore thumbnails and refresh metadata in the background - restoreMediaThumbnails(); - refreshMediaMetadata(); + // Restore thumbnails and refresh metadata in the background + restoreMediaThumbnails(); + refreshMediaMetadata(); + }); } // ============================================ diff --git a/src/services/project/projectSave.ts b/src/services/project/projectSave.ts index 119a88666..f26f02bd0 100644 --- a/src/services/project/projectSave.ts +++ b/src/services/project/projectSave.ts @@ -55,6 +55,15 @@ export function isProjectStoreSyncInProgress(): boolean { return projectStoreSyncInProgress; } +export async function withProjectStoreSyncGuard(work: () => Promise): Promise { + projectStoreSyncInProgress = true; + try { + return await work(); + } finally { + projectStoreSyncInProgress = false; + } +} + // ============================================ // CONVERTER HELPERS (store → project format) // ============================================ @@ -351,8 +360,7 @@ function readMediaPanelViewMode(): 'classic' | 'icons' | 'board' | undefined { * Sync current store state to projectFileService */ export async function syncStoresToProject(): Promise { - projectStoreSyncInProgress = true; - try { + await withProjectStoreSyncGuard(async () => { const mediaState = useMediaStore.getState(); const timelineStore = useTimelineStore.getState(); @@ -480,9 +488,7 @@ export async function syncStoresToProject(): Promise { } log.info(' Synced stores to project'); - } finally { - projectStoreSyncInProgress = false; - } + }); } /** diff --git a/src/services/project/relinkMedia.ts b/src/services/project/relinkMedia.ts index dfd5dedd8..957fedb32 100644 --- a/src/services/project/relinkMedia.ts +++ b/src/services/project/relinkMedia.ts @@ -37,6 +37,14 @@ function normalizePath(value: string): string { return value.replace(/\\/g, '/'); } +function isAbsolutePath(value: string | undefined): boolean { + return Boolean(value && (value.startsWith('/') || /^[A-Za-z]:[/\\]/.test(value))); +} + +function getNativeHandlePath(handle: FileSystemFileHandle | undefined): string | undefined { + return (handle as (FileSystemFileHandle & { __nativePath?: string }) | undefined)?.__nativePath; +} + function getBaseName(value: string | undefined): string | undefined { if (!value) return undefined; const normalized = normalizePath(value); @@ -156,6 +164,7 @@ export async function createRelinkCandidateMapFromHandles( const candidate: RelinkCandidate = { name: handle.name, handle, + absolutePath: getNativeHandlePath(handle), }; candidates.set(candidate.name.toLowerCase(), candidate); } @@ -241,6 +250,46 @@ async function copyCandidateToProject( return { file, handle, projectPath }; } +function hasResolvableFramePath(frame: ModelSequenceFrame | GaussianSplatSequenceFrame): boolean { + return Boolean( + frame.projectPath || + isAbsolutePath(frame.absolutePath) || + isAbsolutePath(frame.sourcePath), + ); +} + +export function isNativeProjectLinkedMedia(mediaFile: MediaFile): boolean { + if (projectFileService.activeBackend !== 'native') { + return false; + } + + if (mediaFile.file) { + return true; + } + + if ( + mediaFile.projectPath || + isAbsolutePath(mediaFile.absolutePath) || + isAbsolutePath(mediaFile.filePath) + ) { + return true; + } + + if (mediaFile.modelSequence?.frames.length) { + return mediaFile.modelSequence.frames.every(hasResolvableFramePath); + } + + if (mediaFile.gaussianSplatSequence?.frames.length) { + return mediaFile.gaussianSplatSequence.frames.every(hasResolvableFramePath); + } + + return false; +} + +export function mediaNeedsRelink(mediaFile: MediaFile): boolean { + return !mediaFile.file && !isNativeProjectLinkedMedia(mediaFile); +} + function isBlobUrl(url: string | undefined): boolean { return typeof url === 'string' && url.startsWith('blob:'); } @@ -261,7 +310,35 @@ function replaceMediaFile(mediaFileId: string, nextFile: Partial): vo })); } +async function applyNativeSingleRelink( + mediaFile: MediaFile, + match: Extract, +): Promise { + const targetPath = mediaFile.projectPath ?? match.candidate.name; + const absolutePath = + match.candidate.absolutePath ?? + projectFileService.resolveRawFilePath(targetPath) ?? + mediaFile.absolutePath; + + await storeHandle(mediaFile.id, match.candidate.handle); + + replaceMediaFile(mediaFile.id, { + file: undefined, + url: '', + filePath: absolutePath ?? targetPath, + absolutePath, + projectPath: targetPath, + fileSize: mediaFile.fileSize, + }); + + return true; +} + async function applySingleRelink(mediaFile: MediaFile, match: Extract): Promise { + if (projectFileService.activeBackend === 'native' && !match.candidate.file) { + return applyNativeSingleRelink(mediaFile, match); + } + const targetPath = mediaFile.projectPath ?? match.candidate.name; const restored = await copyCandidateToProject(match.candidate, targetPath); const url = URL.createObjectURL(restored.file); @@ -284,10 +361,69 @@ async function applySingleRelink(mediaFile: MediaFile, match: Extract, +): Promise { + const sequence = mediaFile.modelSequence; + if (!sequence) return false; + + const frames = [...sequence.frames]; + for (const { index, candidate } of match.frames) { + const existingFrame = frames[index]; + if (!existingFrame) continue; + + const projectPath = buildSequenceRawTarget( + existingFrame.projectPath, + sequence.sequenceName, + candidate.name, + 'glb-sequence', + ); + const absolutePath = + candidate.absolutePath ?? + projectFileService.resolveRawFilePath(projectPath) ?? + existingFrame.absolutePath; + + await storeHandle(`${mediaFile.id}_frame_${index}`, candidate.handle); + if (index === 0) { + await storeHandle(mediaFile.id, candidate.handle); + await storeHandle(`${mediaFile.id}_project`, candidate.handle); + } + + frames[index] = { + ...existingFrame, + name: candidate.name, + sourcePath: absolutePath ?? candidate.name, + absolutePath, + projectPath, + }; + } + + const firstFrame = frames[0]; + replaceMediaFile(mediaFile.id, { + file: undefined, + url: '', + modelSequence: { + ...sequence, + frames, + }, + filePath: firstFrame?.sourcePath, + absolutePath: firstFrame?.absolutePath, + projectPath: firstFrame?.projectPath, + fileSize: mediaFile.fileSize, + }); + + return true; +} + async function applyModelSequenceRelink( mediaFile: MediaFile, match: Extract, ): Promise { + if (projectFileService.activeBackend === 'native' && match.frames.every(({ candidate }) => !candidate.file)) { + return applyNativeModelSequenceRelink(mediaFile, match); + } + const sequence = mediaFile.modelSequence; if (!sequence) return false; @@ -349,10 +485,72 @@ async function applyModelSequenceRelink( return true; } +async function applyNativeGaussianSplatSequenceRelink( + mediaFile: MediaFile, + match: Extract, +): Promise { + const sequence = mediaFile.gaussianSplatSequence; + if (!sequence) return false; + + const frames = [...sequence.frames]; + for (const { index, candidate } of match.frames) { + const existingFrame = frames[index]; + if (!existingFrame) continue; + + const projectPath = buildSequenceRawTarget( + existingFrame.projectPath, + sequence.sequenceName, + candidate.name, + 'splat-sequence', + ); + const absolutePath = + candidate.absolutePath ?? + projectFileService.resolveRawFilePath(projectPath) ?? + existingFrame.absolutePath; + + await storeHandle(`${mediaFile.id}_frame_${index}`, candidate.handle); + if (index === 0) { + await storeHandle(mediaFile.id, candidate.handle); + await storeHandle(`${mediaFile.id}_project`, candidate.handle); + } + + frames[index] = { + ...existingFrame, + name: candidate.name, + sourcePath: absolutePath ?? candidate.name, + absolutePath, + projectPath, + }; + } + + const firstFrame = frames[0]; + const totalFileSize = frames.reduce((sum, frame) => sum + (frame.fileSize ?? 0), 0); + replaceMediaFile(mediaFile.id, { + file: undefined, + url: '', + gaussianSplatSequence: { + ...sequence, + frames, + totalFileSize: totalFileSize || sequence.totalFileSize, + }, + filePath: firstFrame?.sourcePath, + absolutePath: firstFrame?.absolutePath, + projectPath: firstFrame?.projectPath, + fileSize: totalFileSize || mediaFile.fileSize, + splatFrameCount: sequence.frameCount, + }); + + return true; +} + async function applyGaussianSplatSequenceRelink( mediaFile: MediaFile, match: Extract, ): Promise { + if (projectFileService.activeBackend === 'native' && match.frames.every(({ candidate }) => !candidate.file)) { + return applyNativeGaussianSplatSequenceRelink(mediaFile, match); + } + const sequence = mediaFile.gaussianSplatSequence; if (!sequence) return false; diff --git a/src/stores/timeline/clip/addGaussianSplatClip.ts b/src/stores/timeline/clip/addGaussianSplatClip.ts index 50d4843d9..d8274961f 100644 --- a/src/stores/timeline/clip/addGaussianSplatClip.ts +++ b/src/stores/timeline/clip/addGaussianSplatClip.ts @@ -1,4 +1,4 @@ -// Gaussian Splat clip addition — PLY/splat/ksplat scene files +// Gaussian Splat clip addition - PLY/splat/ksplat scene files // Creates a timeline clip with is3D=true that renders via the gaussian splat pipeline import type { GaussianSplatSequenceData, TimelineClip } from '../../../types'; @@ -20,6 +20,9 @@ export interface AddGaussianSplatClipParams { estimatedDuration: number; mediaFileId?: string; gaussianSplatSequence?: GaussianSplatSequenceData; + gaussianSplatUrl?: string; + gaussianSplatFileName?: string; + gaussianSplatRuntimeKey?: string; } /** @@ -27,7 +30,16 @@ export interface AddGaussianSplatClipParams { * Auto-sets is3D=true so it renders via the 3D pipeline. */ export function createGaussianSplatClipPlaceholder(params: AddGaussianSplatClipParams): TimelineClip { - const { trackId, file, startTime, estimatedDuration, gaussianSplatSequence } = params; + const { + trackId, + file, + startTime, + estimatedDuration, + gaussianSplatSequence, + gaussianSplatUrl, + gaussianSplatFileName, + gaussianSplatRuntimeKey, + } = params; const clipId = generateClipId('clip-gsplat'); const naturalDuration = gaussianSplatSequence ? estimatedDuration || DEFAULT_SPLAT_DURATION @@ -48,16 +60,19 @@ export function createGaussianSplatClipPlaceholder(params: AddGaussianSplatClipP mediaFileId: params.mediaFileId, threeDEffectorsEnabled: true, ...(gaussianSplatSequence ? { gaussianSplatSequence } : {}), + ...(gaussianSplatUrl ? { gaussianSplatUrl } : {}), + ...(gaussianSplatFileName ? { gaussianSplatFileName } : {}), + ...(gaussianSplatRuntimeKey ? { gaussianSplatRuntimeKey } : {}), gaussianSplatSettings: resolveGaussianSplatSettingsForSource(undefined, { - fileName: file.name, + fileName: gaussianSplatFileName ?? file.name, sequence: gaussianSplatSequence, }), }, mediaFileId: params.mediaFileId, transform: { ...DEFAULT_TRANSFORM }, effects: [], - is3D: true, // Auto-enable 3D for gaussian splat clips - isLoading: true, // Splat takes time to load + is3D: true, + isLoading: true, }; } @@ -67,38 +82,48 @@ export interface LoadGaussianSplatMediaParams { } /** - * "Load" gaussian splat media — creates blob URL for the renderer to load later. - * No HTMLVideoElement or HTMLImageElement needed. + * "Load" gaussian splat media by attaching a URL for the renderer to fetch. + * Restored Native projects may only have a reference URL, so an empty + * placeholder File must not be preferred over that URL. */ export function loadGaussianSplatMedia(params: LoadGaussianSplatMediaParams): void { const { clip, updateClip } = params; - if (!clip.file) { - console.error('[GaussianSplat] loadGaussianSplatMedia: clip.file is missing — cannot create blob URL', clip.id); - updateClip(clip.id, { isLoading: false }); - return; - } - try { - // Create a blob URL that the gaussian splat renderer can fetch const sequenceFrame = clip.source?.gaussianSplatSequence?.frames[0]; - const gaussianSplatUrl = sequenceFrame?.splatUrl ?? blobUrlManager.create(clip.id, clip.file, 'model'); + const renderableFile = clip.file?.size ? clip.file : undefined; + const gaussianSplatUrl = sequenceFrame?.splatUrl + ?? clip.source?.gaussianSplatUrl + ?? (renderableFile ? blobUrlManager.create(clip.id, renderableFile, 'model') : undefined); + const gaussianSplatFileName = + sequenceFrame?.name ?? + clip.source?.gaussianSplatFileName ?? + clip.file?.name ?? + 'gaussian-splat'; const runtimeKey = sequenceFrame?.projectPath ?? sequenceFrame?.absolutePath ?? sequenceFrame?.sourcePath ?? - sequenceFrame?.name; + sequenceFrame?.name ?? + clip.source?.gaussianSplatRuntimeKey ?? + clip.source?.gaussianSplatUrl; + + if (!gaussianSplatUrl) { + console.error('[GaussianSplat] loadGaussianSplatMedia: no renderable file or URL', clip.id); + updateClip(clip.id, { isLoading: false }); + return; + } updateClip(clip.id, { source: { ...clip.source!, gaussianSplatUrl, - gaussianSplatFileName: sequenceFrame?.name ?? clip.file.name, + gaussianSplatFileName, gaussianSplatRuntimeKey: runtimeKey, gaussianSplatSettings: resolveGaussianSplatSettingsForSource( clip.source?.gaussianSplatSettings, { - fileName: sequenceFrame?.name ?? clip.file.name, + fileName: gaussianSplatFileName, sequence: clip.source?.gaussianSplatSequence, }, ), @@ -108,9 +133,9 @@ export function loadGaussianSplatMedia(params: LoadGaussianSplatMediaParams): vo prewarmGaussianSplatRuntime({ cacheKey: runtimeKey || clip.mediaFileId || clip.source?.mediaFileId || clip.id, - file: clip.file, + file: renderableFile, url: gaussianSplatUrl, - fileName: sequenceFrame?.name ?? clip.file.name, + fileName: gaussianSplatFileName, gaussianSplatSequence: clip.source?.gaussianSplatSequence, requestedMaxSplats: clip.source?.gaussianSplatSettings?.render.maxSplats ?? 0, }); diff --git a/src/stores/timeline/clip/addModelClip.ts b/src/stores/timeline/clip/addModelClip.ts index 522abdf8a..5bcec548b 100644 --- a/src/stores/timeline/clip/addModelClip.ts +++ b/src/stores/timeline/clip/addModelClip.ts @@ -16,6 +16,8 @@ export interface AddModelClipParams { estimatedDuration: number; mediaFileId?: string; modelSequence?: ModelSequenceData; + modelUrl?: string; + modelFileName?: string; } /** @@ -23,7 +25,7 @@ export interface AddModelClipParams { * Auto-sets is3D=true so it renders via the shared 3D scene. */ export function createModelClipPlaceholder(params: AddModelClipParams): TimelineClip { - const { trackId, file, startTime, estimatedDuration, modelSequence } = params; + const { trackId, file, startTime, estimatedDuration, modelSequence, modelUrl, modelFileName } = params; const clipId = generateClipId('clip-3d'); const naturalDuration = modelSequence ? estimatedDuration || DEFAULT_MODEL_DURATION @@ -44,6 +46,8 @@ export function createModelClipPlaceholder(params: AddModelClipParams): Timeline mediaFileId: params.mediaFileId, threeDEffectorsEnabled: true, ...(modelSequence ? { modelSequence } : {}), + ...(modelUrl ? { modelUrl } : {}), + ...(modelFileName ? { modelFileName } : {}), }, mediaFileId: params.mediaFileId, transform: { ...DEFAULT_TRANSFORM }, @@ -65,14 +69,23 @@ export interface LoadModelMediaParams { export function loadModelMedia(params: LoadModelMediaParams): void { const { clip, updateClip } = params; const sequenceModelUrl = clip.source?.modelSequence?.frames[0]?.modelUrl; + const renderableFile = clip.file?.size ? clip.file : undefined; // Create a blob URL that the shared scene renderer can fetch - const modelUrl = sequenceModelUrl ?? blobUrlManager.create(clip.id, clip.file, 'model'); + const modelUrl = sequenceModelUrl + ?? clip.source?.modelUrl + ?? (renderableFile ? blobUrlManager.create(clip.id, renderableFile, 'model') : undefined); + + if (!modelUrl) { + updateClip(clip.id, { isLoading: false }); + return; + } updateClip(clip.id, { source: { ...clip.source!, modelUrl, + modelFileName: clip.source?.modelFileName ?? clip.file.name, }, isLoading: false, }); diff --git a/src/stores/timeline/clipSlice.ts b/src/stores/timeline/clipSlice.ts index dd5ea1ab9..24cba710d 100644 --- a/src/stores/timeline/clipSlice.ts +++ b/src/stores/timeline/clipSlice.ts @@ -101,6 +101,11 @@ export const createClipSlice: SliceCreator = (set, get) => ({ modelSequence?: import('../../types').ModelSequenceData; gaussianSplatSequence?: import('../../types').GaussianSplatSequenceData; vectorAnimation?: import('../../types').VectorAnimationMetadata; + url?: string; + name?: string; + projectPath?: string; + absolutePath?: string; + filePath?: string; } | undefined; if (mediaFileId) { @@ -285,6 +290,8 @@ export const createClipSlice: SliceCreator = (set, get) => ({ estimatedDuration: modelSequenceDuration ?? providedDuration ?? 10, mediaFileId, modelSequence: sourceMediaFile?.modelSequence, + modelUrl: sourceMediaFile?.url, + modelFileName: sourceMediaFile?.name ?? file.name, }); modelClip.mediaFileId = mediaFileId; // Link to MediaFile for nested comp lookup set({ clips: [...clips, modelClip] }); @@ -314,6 +321,13 @@ export const createClipSlice: SliceCreator = (set, get) => ({ estimatedDuration: gaussianSplatSequenceDuration ?? providedDuration ?? 30, mediaFileId, gaussianSplatSequence: sourceMediaFile?.gaussianSplatSequence, + gaussianSplatUrl: sourceMediaFile?.url, + gaussianSplatFileName: sourceMediaFile?.name ?? file.name, + gaussianSplatRuntimeKey: + sourceMediaFile?.projectPath ?? + sourceMediaFile?.absolutePath ?? + sourceMediaFile?.filePath ?? + sourceMediaFile?.url, }); splatClip.mediaFileId = mediaFileId; // Link to MediaFile for nested comp lookup set({ clips: [...clips, splatClip] }); diff --git a/src/stores/timeline/serializationUtils.ts b/src/stores/timeline/serializationUtils.ts index 4f1404004..e7ba193f1 100644 --- a/src/stores/timeline/serializationUtils.ts +++ b/src/stores/timeline/serializationUtils.ts @@ -13,9 +13,11 @@ import { DEFAULT_TRACKS, MAX_NESTING_DEPTH } from './constants'; import { useMediaStore } from '../mediaStore'; import { calculateNestedClipBoundaries, buildClipSegments } from './clip/addCompClip'; import { projectFileService } from '../../services/projectFileService'; +import { mediaNeedsRelink } from '../../services/project/relinkMedia'; import { Logger } from '../../services/logger'; import { engine } from '../../engine/WebGPUEngine'; import { layerBuilder } from '../../services/layerBuilder'; +import { NativeHelperClient } from '../../services/nativeHelper/NativeHelperClient'; import { sanitizePlayheadPosition } from '../../services/layerBuilder/PlayheadState'; import { thumbnailCacheService } from '../../services/thumbnailCacheService'; import type { WebCodecsPlayer } from '../../engine/WebCodecsPlayer'; @@ -547,7 +549,8 @@ export const createSerializationUtils: SliceCreator = (set, const mf = mediaStore.files.find(f => f.id === nsc.mediaFileId); if (!mf) continue; - const hf = !!(mf.file); + const hasBrowserFile = !!mf.file; + const needsReload = mediaNeedsRelink(mf); const nc: TimelineClip = { id: `nested-${parentClipId}-${nsc.id}`, trackId: nsc.trackId, @@ -557,7 +560,7 @@ export const createSerializationUtils: SliceCreator = (set, duration: nsc.duration, inPoint: nsc.inPoint, outPoint: nsc.outPoint, - source: hf ? null : { + source: hasBrowserFile ? null : { type: nsc.sourceType || 'video', naturalDuration: nsc.naturalDuration || nsc.duration, mediaFileId: nsc.mediaFileId, @@ -573,12 +576,12 @@ export const createSerializationUtils: SliceCreator = (set, is3D: nsc.is3D, meshType: nsc.meshType, text3DProperties: nsc.text3DProperties ? { ...nsc.text3DProperties } : undefined, - isLoading: hf, - needsReload: !hf, + isLoading: hasBrowserFile, + needsReload, }; result.push(nc); // Load media element for sub-nested clips - if (hf) { + if (hasBrowserFile) { const subFileUrl = URL.createObjectURL(mf.file!); const subType = nsc.sourceType; if (subType === 'video') { @@ -689,7 +692,7 @@ export const createSerializationUtils: SliceCreator = (set, } const nestedMediaFile = mediaStore.files.find(f => f.id === nestedSerializedClip.mediaFileId); - const hasFile = !!(nestedMediaFile?.file); + const hasBrowserFile = !!(nestedMediaFile?.file); if (!nestedMediaFile) { log.warn('Skipping nested clip - media file entry not found', { @@ -710,7 +713,7 @@ export const createSerializationUtils: SliceCreator = (set, duration: nestedSerializedClip.duration, inPoint: nestedSerializedClip.inPoint, outPoint: nestedSerializedClip.outPoint, - source: hasFile ? null : { + source: hasBrowserFile ? null : { type: nestedSerializedClip.sourceType || 'video', naturalDuration: nestedSerializedClip.naturalDuration || nestedSerializedClip.duration, mediaFileId: nestedSerializedClip.mediaFileId, @@ -725,15 +728,26 @@ export const createSerializationUtils: SliceCreator = (set, is3D: nestedSerializedClip.is3D, meshType: nestedSerializedClip.meshType, text3DProperties: nestedSerializedClip.text3DProperties ? { ...nestedSerializedClip.text3DProperties } : undefined, - isLoading: hasFile, - needsReload: !hasFile, + isLoading: hasBrowserFile, + needsReload: mediaNeedsRelink(nestedMediaFile), }; nestedClips.push(nestedClip); // Only load media element if file is available - if (!hasFile) { - log.warn('Nested clip needs reload - file not available', { + if (!hasBrowserFile) { + if (nestedClip.needsReload) { + log.warn('Nested clip needs reload - file not available', { + clipName: nestedSerializedClip.name, + trackId: nestedSerializedClip.trackId, + mediaFileId: nestedSerializedClip.mediaFileId, + }); + } + continue; + } + + if (!nestedMediaFile.file) { + log.warn('Nested clip skipped - file object not available', { clipName: nestedSerializedClip.name, trackId: nestedSerializedClip.trackId, mediaFileId: nestedSerializedClip.mediaFileId, @@ -1179,8 +1193,9 @@ export const createSerializationUtils: SliceCreator = (set, continue; } - // Create the clip - even if file is missing (needs reload after refresh) - const needsReload = !mediaFile.file; + // Create the clip - even if the browser File object is not in memory. + // Native-helper projects can restore from persisted project/absolute paths. + const needsReload = mediaNeedsRelink(mediaFile); if (needsReload) { log.debug('Clip needs reload (file permission required)', { clip: serializedClip.name }); } @@ -1289,7 +1304,7 @@ export const createSerializationUtils: SliceCreator = (set, }); } - // Skip media loading if file needs reload (no valid File object) + // Skip media loading if the media has no stored path/handle to recover from. if (needsReload) { log.debug('Skipping media load for clip that needs reload', { clip: clip.name }); continue; @@ -1297,7 +1312,62 @@ export const createSerializationUtils: SliceCreator = (set, // Load media element async const type = serializedClip.sourceType; - const fileUrl = URL.createObjectURL(mediaFile.file!); + let loadFile = mediaFile.file; + let fileUrl = loadFile ? URL.createObjectURL(loadFile) : mediaFile.url; + + if ( + !loadFile && + fileUrl && + (type === 'video' || type === 'audio' || type === 'image' || type === 'lottie') && + NativeHelperClient.parseFileReferenceUrl(fileUrl) + ) { + const referencedFile = await NativeHelperClient.getReferencedFile(fileUrl, mediaFile.name); + if (referencedFile) { + loadFile = referencedFile; + fileUrl = URL.createObjectURL(referencedFile); + useMediaStore.setState((state) => ({ + files: state.files.map((currentFile) => + currentFile.id === mediaFile.id + ? { + ...currentFile, + file: referencedFile, + url: fileUrl, + hasFileHandle: true, + } + : currentFile + ), + })); + } + } + + if (type === 'video' && !loadFile && mediaFile.absolutePath && projectFileService.activeBackend === 'native') { + set(state => ({ + clips: state.clips.map(c => + c.id === clip.id + ? { + ...c, + source: { + type: 'video', + naturalDuration: serializedClip.naturalDuration || mediaFile.duration || clip.duration, + mediaFileId: serializedClip.mediaFileId, + filePath: mediaFile.absolutePath, + }, + isLoading: false, + needsReload: false, + } + : c + ), + })); + continue; + } + + if (!fileUrl && (type === 'video' || type === 'audio' || type === 'image' || type === 'model' || type === 'gaussian-splat')) { + log.warn('Skipping media load - no file URL available', { clip: clip.name, mediaFileId: mediaFile.id }); + set(state => ({ + clips: state.clips.map(c => c.id === clip.id ? { ...c, isLoading: false } : c), + })); + continue; + } if (type === 'video') { const video = document.createElement('video'); @@ -1380,7 +1450,7 @@ export const createSerializationUtils: SliceCreator = (set, if (hasWebCodecs && mediaId) { try { const webCodecsPlayer = await getOrCreateWcp( - mediaId, video, clip.name, mediaFile.file || undefined + mediaId, video, clip.name, loadFile || undefined ); if (webCodecsPlayer) { set(state => ({ @@ -1448,11 +1518,23 @@ export const createSerializationUtils: SliceCreator = (set, wakePreviewAfterRestore(); }, { once: true }); } else if (type === 'lottie') { + if (!loadFile) { + log.warn('Skipping lottie restore - file object not available', { clip: clip.name }); + set((state) => ({ + clips: state.clips.map((currentClip) => + currentClip.id === clip.id + ? { ...currentClip, isLoading: false, needsReload: mediaNeedsRelink(mediaFile) } + : currentClip + ), + })); + continue; + } + void (async () => { try { const runtimeClip: TimelineClip = { ...clip, - file: mediaFile.file!, + file: loadFile, source: { type: 'lottie', mediaFileId: serializedClip.mediaFileId, @@ -1460,14 +1542,14 @@ export const createSerializationUtils: SliceCreator = (set, vectorAnimationSettings: serializedClip.vectorAnimationSettings, }, }; - const metadata = await readLottieMetadata(mediaFile.file!); - const runtime = await lottieRuntimeManager.prepareClipSource(runtimeClip, mediaFile.file!); + const metadata = await readLottieMetadata(loadFile); + const runtime = await lottieRuntimeManager.prepareClipSource(runtimeClip, loadFile); set((state) => ({ clips: state.clips.map((currentClip) => currentClip.id === clip.id ? { ...currentClip, - file: mediaFile.file!, + file: loadFile, source: { type: 'lottie', textCanvas: runtime.canvas, diff --git a/tests/unit/autosaveRecovery.test.ts b/tests/unit/autosaveRecovery.test.ts new file mode 100644 index 000000000..617de2066 --- /dev/null +++ b/tests/unit/autosaveRecovery.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { + shouldPreferAutosave, + shouldSkipEmptyProjectSave, +} from '../../src/services/project/core/autosaveRecovery'; +import type { ProjectFile } from '../../src/services/project/types'; + +function createComposition(id = 'comp-1', clips: ProjectFile['compositions'][number]['clips'] = []): ProjectFile['compositions'][number] { + return { + id, + name: id, + width: 1920, + height: 1080, + frameRate: 30, + duration: 60, + backgroundColor: '#000000', + folderId: null, + tracks: [], + clips, + markers: [], + }; +} + +function createProject(overrides: Partial = {}): ProjectFile { + return { + version: 1, + name: 'Recovery Test', + createdAt: '2026-04-30T12:00:00.000Z', + updatedAt: '2026-04-30T12:00:00.000Z', + settings: { + width: 1920, + height: 1080, + frameRate: 30, + sampleRate: 48000, + }, + media: [], + compositions: [createComposition()], + folders: [], + activeCompositionId: 'comp-1', + openCompositionIds: ['comp-1'], + expandedFolderIds: [], + ...overrides, + }; +} + +describe('autosave recovery', () => { + it('prefers a newer autosave', () => { + const project = createProject({ updatedAt: '2026-04-30T12:00:00.000Z' }); + const autosave = createProject({ updatedAt: '2026-04-30T12:01:00.000Z' }); + + expect(shouldPreferAutosave(project, autosave)).toBe(true); + }); + + it('prefers a meaningful autosave over a freshly overwritten empty project', () => { + const project = createProject({ updatedAt: '2026-05-01T12:55:38.332Z' }); + const autosave = createProject({ + updatedAt: '2026-04-30T14:45:20.310Z', + folders: [{ id: 'folder-1', name: 'Shots', parentId: null }], + }); + + expect(shouldPreferAutosave(project, autosave)).toBe(true); + }); + + it('keeps a non-empty project when the autosave is older', () => { + const project = createProject({ + updatedAt: '2026-05-01T12:55:38.332Z', + folders: [{ id: 'folder-1', name: 'Current', parentId: null }], + }); + const autosave = createProject({ + updatedAt: '2026-04-30T14:45:20.310Z', + folders: [{ id: 'folder-2', name: 'Older', parentId: null }], + }); + + expect(shouldPreferAutosave(project, autosave)).toBe(false); + }); + + it('blocks saving an empty project over a meaningful autosave', () => { + const project = createProject({ updatedAt: '2026-05-01T13:11:48.000Z' }); + const autosave = createProject({ + updatedAt: '2026-04-30T14:45:20.310Z', + media: [{ id: 'media-1', name: 'clip.mp4' } as ProjectFile['media'][number]], + }); + + expect(shouldSkipEmptyProjectSave(project, autosave)).toBe(true); + }); +}); diff --git a/tests/unit/projectFileServiceNativeBackend.test.ts b/tests/unit/projectFileServiceNativeBackend.test.ts new file mode 100644 index 000000000..42290a39e --- /dev/null +++ b/tests/unit/projectFileServiceNativeBackend.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const nativeCore = { + createProjectAtPath: vi.fn(async () => true), + loadProject: vi.fn(async () => true), + restoreLastProject: vi.fn(async () => true), + isSupported: vi.fn(() => true), + getProjectPath: vi.fn(() => null), + getProjectData: vi.fn(() => null), + isProjectOpen: vi.fn(() => false), + hasUnsavedChanges: vi.fn(() => false), + markDirty: vi.fn(), + needsPermission: vi.fn(() => false), + getPendingProjectName: vi.fn(() => null), + requestPendingPermission: vi.fn(async () => false), + createProject: vi.fn(async () => true), + saveProject: vi.fn(async () => true), + closeProject: vi.fn(), + createBackup: vi.fn(async () => true), + renameProject: vi.fn(async () => true), + saveKeysFile: vi.fn(async () => undefined), + loadKeysFile: vi.fn(async () => false), + updateProjectData: vi.fn(), + updateMedia: vi.fn(), + updateCompositions: vi.fn(), + updateFolders: vi.fn(), + }; + + return { + nativeCore, + nativeClient: { + isConnected: vi.fn(() => false), + connect: vi.fn(async () => true), + hasFsCommands: vi.fn(async () => true), + getProjectRoot: vi.fn(async () => 'C:\\Users\\tester\\Documents\\MasterSelects'), + grantPath: vi.fn(async () => true), + pickFolderDetailed: vi.fn(async () => ({ + path: 'C:\\Projects', + cancelled: false, + })), + }, + NativeProjectCoreService: vi.fn(function NativeProjectCoreService() { + return nativeCore; + }), + }; +}); + +vi.mock('../../src/services/nativeHelper/NativeHelperClient', () => ({ + NativeHelperClient: mocks.nativeClient, +})); + +vi.mock('../../src/services/project/core/NativeProjectCoreService', () => ({ + NativeProjectCoreService: mocks.NativeProjectCoreService, +})); + +async function importFreshProjectFileService() { + vi.resetModules(); + const mod = await import('../../src/services/project/ProjectFileService'); + return mod.projectFileService; +} + +function disableFileSystemAccessApi(): void { + delete (window as Window & { showDirectoryPicker?: unknown }).showDirectoryPicker; + delete (window as Window & { showSaveFilePicker?: unknown }).showSaveFilePicker; +} + +describe('ProjectFileService native backend', () => { + beforeEach(() => { + vi.clearAllMocks(); + disableFileSystemAccessApi(); + mocks.nativeClient.isConnected.mockReturnValue(false); + mocks.nativeClient.connect.mockResolvedValue(true); + mocks.nativeClient.hasFsCommands.mockResolvedValue(true); + mocks.nativeClient.getProjectRoot.mockResolvedValue('C:\\Users\\tester\\Documents\\MasterSelects'); + mocks.nativeClient.grantPath.mockResolvedValue(true); + mocks.nativeClient.pickFolderDetailed.mockResolvedValue({ + path: 'C:\\Projects', + cancelled: false, + }); + mocks.nativeCore.createProjectAtPath.mockResolvedValue(true); + mocks.nativeCore.loadProject.mockResolvedValue(true); + mocks.nativeCore.restoreLastProject.mockResolvedValue(true); + }); + + it('creates projects through the native backend when FSA is unavailable', async () => { + const projectFileService = await importFreshProjectFileService(); + + const created = await projectFileService.createProject('Firefox Project'); + + expect(created).toBe(true); + expect(mocks.nativeClient.connect).toHaveBeenCalledTimes(1); + expect(mocks.nativeClient.hasFsCommands).toHaveBeenCalledTimes(1); + expect(mocks.nativeClient.pickFolderDetailed).toHaveBeenCalledWith( + 'Choose where to save your project', + 'C:/Users/tester/Documents/MasterSelects', + ); + expect(mocks.nativeCore.createProjectAtPath).toHaveBeenCalledWith( + 'C:/Projects', + 'Firefox Project', + ); + expect(mocks.nativeClient.grantPath).toHaveBeenCalledWith('C:/Projects'); + expect(projectFileService.activeBackend).toBe('native'); + }); + + it('falls back to manual path entry when the native folder picker is unavailable', async () => { + const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('C:\\Manual\\Existing'); + mocks.nativeClient.pickFolderDetailed.mockResolvedValue({ + path: null, + cancelled: false, + error: 'Folder picker failed', + }); + const projectFileService = await importFreshProjectFileService(); + + const opened = await projectFileService.openProject(); + + expect(opened).toBe(true); + expect(promptSpy).toHaveBeenCalledWith( + expect.stringContaining('Enter the folder path manually'), + 'C:/Users/tester/Documents/MasterSelects', + ); + expect(mocks.nativeCore.loadProject).toHaveBeenCalledWith('C:/Manual/Existing'); + expect(mocks.nativeClient.grantPath).toHaveBeenCalledWith('C:/Manual/Existing'); + }); + + it('restores the last project through the native backend on Firefox refresh', async () => { + const projectFileService = await importFreshProjectFileService(); + + const restored = await projectFileService.restoreLastProject(); + + expect(restored).toBe(true); + expect(mocks.nativeClient.connect).toHaveBeenCalledTimes(1); + expect(mocks.nativeCore.restoreLastProject).toHaveBeenCalledTimes(1); + expect(projectFileService.activeBackend).toBe('native'); + }); +}); diff --git a/tools/native-helper/src/protocol/commands.rs b/tools/native-helper/src/protocol/commands.rs index 0ea9eb861..47258b9a3 100644 --- a/tools/native-helper/src/protocol/commands.rs +++ b/tools/native-helper/src/protocol/commands.rs @@ -131,6 +131,12 @@ pub enum Command { new_path: String, }, + /// Grant access to a path the browser already persisted from a user-picked project + GrantPath { + id: String, + path: String, + }, + /// Open a native OS folder picker dialog PickFolder { id: String, diff --git a/tools/native-helper/src/server.rs b/tools/native-helper/src/server.rs index 922f9a5b8..397535672 100644 --- a/tools/native-helper/src/server.rs +++ b/tools/native-helper/src/server.rs @@ -189,20 +189,24 @@ async fn run_http_server(port: u16, state: Arc, allowed_origins: Arc>()) + .and(with_state(state_for_file)) .and_then(serve_file); // POST /upload?path=... — write binary body to file (AUTH REQUIRED) + let state_for_upload = state.clone(); let require_auth_upload = require_auth.clone(); let upload_route = warp::path("upload") .and(warp::post()) .and(require_auth_upload) .and(warp::query::>()) .and(warp::body::bytes()) + .and(with_state(state_for_upload)) .and_then(handle_upload); // GET /project-root — return the default project root path (NO AUTH - safe metadata) @@ -421,6 +425,7 @@ fn guess_content_type(path: &std::path::Path) -> &'static str { async fn serve_file( params: std::collections::HashMap, + state: Arc, ) -> Result { let path = params.get("path").ok_or_else(warp::reject::not_found)?; let path = PathBuf::from(path); @@ -429,7 +434,7 @@ async fn serve_file( return Err(warp::reject::not_found()); } - if !utils::is_path_allowed(&path) { + if !state.is_path_allowed(&path) { warn!("HTTP: Rejected file request for: {}", path.display()); return Err(warp::reject::not_found()); } @@ -453,6 +458,7 @@ async fn serve_file( async fn handle_upload( params: std::collections::HashMap, body: warp::hyper::body::Bytes, + state: Arc, ) -> Result { let path = params.get("path").ok_or_else(warp::reject::not_found)?; let path = PathBuf::from(path); @@ -462,7 +468,7 @@ async fn handle_upload( return Err(warp::reject::not_found()); } - if !utils::is_path_allowed(&path) { + if !state.is_path_allowed(&path) { warn!("HTTP upload: Rejected path: {}", path.display()); return Err(warp::reject::not_found()); } @@ -572,6 +578,7 @@ fn get_command_id(cmd: &Command) -> &str { | Command::Delete { id, .. } | Command::Exists { id, .. } | Command::Rename { id, .. } + | Command::GrantPath { id, .. } | Command::PickFolder { id, .. } | Command::MatAnyoneStatus { id } | Command::MatAnyoneSetup { id, .. } diff --git a/tools/native-helper/src/session.rs b/tools/native-helper/src/session.rs index 82b8e5cae..462b0c483 100644 --- a/tools/native-helper/src/session.rs +++ b/tools/native-helper/src/session.rs @@ -1,8 +1,8 @@ //! Per-connection session management use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; use tokio::sync::{oneshot, Mutex}; use tracing::{debug, info, warn}; @@ -98,6 +98,7 @@ pub struct AppState { pub auth_token: Option, editor_client: Mutex>, pending_ai_requests: Mutex>>, + granted_paths: RwLock>, pub matanyone_process: Mutex, } @@ -107,10 +108,28 @@ impl AppState { auth_token, editor_client: Mutex::new(None), pending_ai_requests: Mutex::new(HashMap::new()), + granted_paths: RwLock::new(Vec::new()), matanyone_process: Mutex::new(matanyone::process::MatAnyoneProcess::new()), } } + pub fn grant_path(&self, path: PathBuf) { + if !path.is_absolute() { + return; + } + + let mut granted = self.granted_paths.write().unwrap_or_else(|e| e.into_inner()); + if !granted.iter().any(|existing| existing == &path) { + info!("Granted file access root: {}", path.display()); + granted.push(path); + } + } + + pub fn is_path_allowed(&self, path: &Path) -> bool { + let granted = self.granted_paths.read().unwrap_or_else(|e| e.into_inner()); + utils::is_path_allowed_with_extra(path, &granted) + } + pub async fn register_editor_client(&self, client: EditorClient) { let mut editor = self.editor_client.lock().await; *editor = Some(client); @@ -232,6 +251,20 @@ impl Session { Some(self.handle_rename(&id, &old_path, &new_path)) } + Command::GrantPath { id, path } => { + let path = PathBuf::from(path); + if !path.is_absolute() { + Some(Response::error( + &id, + error_codes::INVALID_PATH, + "Path must be absolute", + )) + } else { + self.state.grant_path(path); + Some(Response::ok(&id, serde_json::json!({ "granted": true }))) + } + } + Command::PickFolder { id, title, default_path } => { let title = title.unwrap_or_else(|| "Select folder".to_string()); let default_path = default_path.clone(); @@ -243,10 +276,13 @@ impl Session { .await; match result { - Ok(Ok(Some(path))) => Some(Response::ok( - &id, - serde_json::json!({ "path": path.to_string_lossy() }), - )), + Ok(Ok(Some(path))) => { + self.state.grant_path(path.clone()); + Some(Response::ok( + &id, + serde_json::json!({ "path": path.to_string_lossy() }), + )) + } Ok(Ok(None)) => Some(Response::ok( &id, serde_json::json!({ "path": serde_json::Value::Null, "cancelled": true }), @@ -376,7 +412,7 @@ impl Session { // within the helper's allowed directory policy. for dir in extra_dirs { let p = PathBuf::from(dir); - if p.is_absolute() && p.is_dir() && utils::is_path_allowed(&p) { + if p.is_absolute() && p.is_dir() && self.state.is_path_allowed(&p) { search_dirs.push(p); } } @@ -472,7 +508,7 @@ impl Session { return Response::error(id, error_codes::INVALID_PATH, "Path must be absolute"); } - if !utils::is_path_allowed(path) { + if !self.state.is_path_allowed(path) { return Response::error( id, error_codes::PERMISSION_DENIED, @@ -518,7 +554,7 @@ impl Session { return Response::error(id, error_codes::INVALID_PATH, "Path must be absolute"); } - if !utils::is_path_allowed(path) { + if !self.state.is_path_allowed(path) { return Response::error(id, error_codes::PERMISSION_DENIED, "Path not in allowed directory"); } @@ -574,7 +610,7 @@ impl Session { return Response::error(id, error_codes::INVALID_PATH, "Path must be absolute"); } - if !utils::is_path_allowed(path) { + if !self.state.is_path_allowed(path) { return Response::error(id, error_codes::PERMISSION_DENIED, "Path not in allowed directory"); } @@ -607,7 +643,7 @@ impl Session { return Response::error(id, error_codes::INVALID_PATH, "Path must be absolute"); } - if !utils::is_path_allowed(path) { + if !self.state.is_path_allowed(path) { return Response::error(id, error_codes::PERMISSION_DENIED, "Path not in allowed directory"); } @@ -651,7 +687,7 @@ impl Session { return Response::error(id, error_codes::INVALID_PATH, "Path must be absolute"); } - if !utils::is_path_allowed(path) { + if !self.state.is_path_allowed(path) { return Response::error(id, error_codes::PERMISSION_DENIED, "Path not in allowed directory"); } @@ -692,7 +728,7 @@ impl Session { return Response::error(id, error_codes::INVALID_PATH, "Path must be absolute"); } - if !utils::is_path_allowed(path) { + if !self.state.is_path_allowed(path) { return Response::error(id, error_codes::PERMISSION_DENIED, "Path not in allowed directory"); } @@ -798,7 +834,7 @@ impl Session { return Response::error(id, error_codes::INVALID_PATH, "Paths must be absolute"); } - if !utils::is_path_allowed(old) || !utils::is_path_allowed(new) { + if !self.state.is_path_allowed(old) || !self.state.is_path_allowed(new) { return Response::error(id, error_codes::PERMISSION_DENIED, "Path not in allowed directory"); } diff --git a/tools/native-helper/src/utils.rs b/tools/native-helper/src/utils.rs index 91c13f478..db1017463 100644 --- a/tools/native-helper/src/utils.rs +++ b/tools/native-helper/src/utils.rs @@ -137,50 +137,55 @@ fn path_is_within_allowed_prefix(path: &Path, prefix: &Path) -> bool { .all(|(path_component, prefix_component)| path_component == prefix_component) } +fn canonicalize_existing_ancestor(path: &Path) -> Option { + if let Ok(canonical) = path.canonicalize() { + return Some(canonical); + } + + for ancestor in path.ancestors().skip(1) { + if let Ok(canonical_ancestor) = ancestor.canonicalize() { + let suffix = path.strip_prefix(ancestor).ok()?; + return Some(canonical_ancestor.join(suffix)); + } + } + + None +} + /// Check if a path is within allowed directories. /// /// Rejects paths with `..` traversal segments and attempts path canonicalization -/// to prevent symlink or alias-based escapes. Fails closed: if canonicalization -/// fails and the path doesn't exist, the path is rejected. -pub fn is_path_allowed(path: &std::path::Path) -> bool { +/// to prevent symlink or alias-based escapes. For new files or directories, it +/// canonicalizes the nearest existing ancestor and appends the missing suffix so +/// project roots can be created lazily under approved parent folders. +pub fn is_path_allowed_with_extra(path: &std::path::Path, extra_prefixes: &[PathBuf]) -> bool { + if !path.is_absolute() { + return false; + } + // Reject any path with traversal segments if has_traversal_segments(path) { return false; } - let allowed = get_allowed_prefixes(); - - // Try to canonicalize the path for safer comparison. - // If the path exists, use the canonical version. - // If the path doesn't exist, use the raw path but only if it has no suspicious segments. - let effective_path = match path.canonicalize() { - Ok(canonical) => canonical, - Err(_) => { - // Path doesn't exist yet (e.g., writing a new file). - // Check the parent directory instead if possible. - if let Some(parent) = path.parent() { - if let Ok(canonical_parent) = parent.canonicalize() { - if let Some(file_name) = path.file_name() { - canonical_parent.join(file_name) - } else { - return false; // No filename component - } - } else { - // Neither path nor parent can be canonicalized — fail closed - return false; - } - } else { - return false; // No parent (root path or relative) - } - } + let mut allowed = get_allowed_prefixes(); + allowed.extend(extra_prefixes.iter().cloned()); + + let effective_path = match canonicalize_existing_ancestor(path) { + Some(canonical) => canonical, + None => return false, }; allowed.iter().any(|prefix| { - let prefix_canonical = prefix.canonicalize().unwrap_or_else(|_| prefix.clone()); + let prefix_canonical = canonicalize_existing_ancestor(prefix).unwrap_or_else(|| prefix.clone()); path_is_within_allowed_prefix(&effective_path, &prefix_canonical) }) } +pub fn is_path_allowed(path: &std::path::Path) -> bool { + is_path_allowed_with_extra(path, &[]) +} + #[cfg(test)] mod tests { use super::*; @@ -290,4 +295,37 @@ mod tests { let _ = std::fs::remove_file(&sibling_path); let _ = std::fs::remove_dir_all(&sibling_dir); } + + #[test] + fn test_allowed_nonexistent_project_dir_under_documents() { + if let Some(docs) = dirs::document_dir() { + let test_path = docs + .join(format!("masterselects-missing-{}", std::process::id())) + .join("Untitled") + .join("project.json"); + + assert!(!test_path.exists(), "test path should not already exist"); + assert!( + is_path_allowed(&test_path), + "new project paths under Documents should be allowed before the project folder exists" + ); + } + } + + #[test] + fn test_allowed_by_extra_picked_root() { + if let Some(home) = dirs::home_dir() { + let picked_root = home.join(format!("masterselects-picked-{}", std::process::id())); + let test_path = picked_root.join("project.json"); + + assert!( + !is_path_allowed(&test_path), + "picked root should not be statically allowed before it is granted" + ); + assert!( + is_path_allowed_with_extra(&test_path, &[picked_root]), + "paths under a user-picked root should be allowed" + ); + } + } } From 925a301a4ed4ea4b7f29ba15abee736c38d4b967 Mon Sep 17 00:00:00 2001 From: Sportinger Date: Fri, 1 May 2026 16:02:48 +0200 Subject: [PATCH 2/2] Release 1.6.8 --- src/changelog-data.json | 10 ++++++++++ src/version.ts | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/changelog-data.json b/src/changelog-data.json index c187b8637..3b66d629b 100644 --- a/src/changelog-data.json +++ b/src/changelog-data.json @@ -1,4 +1,14 @@ [ + { + "date": "2026-05-01", + "type": "fix", + "title": "Firefox Native Project Restore Is Fast Again", + "description": "Native Helper project open now connects quickly in Firefox, restores project media without eager-loading every Raw asset, keeps relinked GLB and PLY sequences available after refresh, and lets restored media import into the timeline again.", + "section": "Native Helper / Project Persistence", + "commits": [ + "c67aacfb" + ] + }, { "date": "2026-05-01", "type": "new", diff --git a/src/version.ts b/src/version.ts index 006041aae..b252247eb 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,6 +1,6 @@ // App version // Format: MAJOR.MINOR.PATCH -export const APP_VERSION = '1.6.7'; +export const APP_VERSION = '1.6.8'; export interface ChangelogNotice { type: 'info' | 'warning' | 'success' | 'danger';