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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/Features/Native-Helper.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

---

Expand Down
6 changes: 5 additions & 1 deletion docs/Features/Project-Persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions src/changelog-data.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
43 changes: 31 additions & 12 deletions src/components/common/NativeHelperStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -146,6 +147,7 @@ export function NativeHelperStatus({ variant = 'toolbar' }: NativeHelperStatusPr

const {
turboModeEnabled,
nativeHelperPort,
nativeDecodeEnabled,
setNativeDecodeEnabled,
setNativeHelperConnected,
Expand All @@ -161,14 +163,15 @@ export function NativeHelperStatus({ variant = 'toolbar' }: NativeHelperStatusPr
}

try {
NativeHelperClient.configure({ port: nativeHelperPort });
const available = await isNativeHelperAvailable();
setStatus(available ? 'connected' : 'disconnected');
setNativeHelperConnected(available);
} catch {
setStatus('disconnected');
setNativeHelperConnected(false);
}
}, [helperEnabled, setNativeHelperConnected]);
}, [helperEnabled, nativeHelperPort, setNativeHelperConnected]);

useEffect(() => {
if (nativeDecodeEnabled) {
Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -408,27 +425,29 @@ function NativeHelperDialog({
{isConnected ? 'Connected session' : 'Published release'}
</div>
<div className="native-helper-card-title">
{isConnected && connectedVersion
? `Helper v${connectedVersion}`
{isConnected
? (connectedVersion ? `Helper v${connectedVersion}` : 'Helper connected')
: publishedVersion
? `GitHub release v${publishedVersion}`
: 'Native Helper releases'}
</div>
</div>
<span className={`native-helper-chip ${isConnected && !expectedVersionInstalled ? 'is-warn' : ''}`}>
{isConnected ? (expectedVersionInstalled ? 'Up to date' : 'Update available') : (helperEnabled ? 'Waiting for helper' : 'Helper disabled')}
<span className={`native-helper-chip ${helperNeedsUpdate ? 'is-warn' : ''}`}>
{isConnected
? (versionKnown ? (expectedVersionInstalled ? 'Up to date' : 'Update available') : 'Connected')
: (helperEnabled ? 'Waiting for helper' : 'Helper disabled')}
</span>
</div>

<p className="native-helper-card-note">
{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.'
)}
Expand All @@ -441,11 +460,11 @@ function NativeHelperDialog({
</StatusPill>
))}
{publishedVersion && (
<StatusPill tone={publishedVersion === NATIVE_HELPER_VERSION ? 'good' : 'warn'}>
<StatusPill tone={publishedMatchesTarget ? 'good' : 'warn'}>
Public GitHub: v{publishedVersion}
</StatusPill>
)}
{publishedVersion && publishedVersion !== NATIVE_HELPER_VERSION && (
{publishedVersion && !publishedMatchesTarget && (
<StatusPill tone="neutral">
App target: v{NATIVE_HELPER_VERSION}
</StatusPill>
Expand Down
36 changes: 34 additions & 2 deletions src/components/common/RelinkDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
applyRelinkMatch,
createRelinkCandidateMapFromHandles,
findRelinkMatch,
mediaNeedsRelink,
type RelinkCandidate,
type RelinkCandidateMap,
type RelinkMatch,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 => ({
Expand All @@ -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.
Expand Down Expand Up @@ -121,6 +125,7 @@ export function RelinkDialog({ onClose }: RelinkDialogProps) {
}
}

if (cancelled) return;
setFileStatuses(updatedStatuses);
if (searched.length > 0) {
setSearchedFolders(searched);
Expand All @@ -129,6 +134,9 @@ export function RelinkDialog({ onClose }: RelinkDialogProps) {
};

initializeStatuses();
return () => {
cancelled = true;
};
}, [files]);

// Scan a folder for missing files
Expand Down Expand Up @@ -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',
Expand All @@ -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) => {
Expand Down
Loading
Loading