From 68fcabba1b87ecb2fc8d9e5dacfa2d40d1d32a76 Mon Sep 17 00:00:00 2001 From: krit22 Date: Tue, 11 Aug 2026 19:04:16 +0000 Subject: [PATCH 1/8] feat(desktop): add transient external files and window-scoped preview grants --- electron/main.cjs | 207 +++++++++++++++++- electron/multi-window.cjs | 3 + electron/preload.cjs | 14 +- server/index.ts | 5 + server/routes/internal-grants.ts | 97 ++++++++ web-src/src/api.ts | 7 +- web-src/src/components/MainPane.tsx | 11 +- web-src/src/components/TabStrip.tsx | 2 +- web-src/src/store/AppContext.tsx | 57 +++++ web-src/src/store/state.ts | 6 +- web-src/src/store/stateReducer.ts | 6 +- web-src/src/store/useActiveFolderWorkspace.ts | 2 + web-src/src/store/useDocumentActions.ts | 73 ++++++ 13 files changed, 481 insertions(+), 9 deletions(-) create mode 100644 server/routes/internal-grants.ts diff --git a/electron/main.cjs b/electron/main.cjs index c181bfef..b812e928 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -173,6 +173,108 @@ registerBugReportReviewIpc({ const APP_CONFIG_FILE = path.join(os.homedir(), '.stashbase', 'config.json'); +const VIEWABLE_FILE_EXTENSIONS = new Set([ + 'md', 'markdown', 'html', 'htm', 'pdf', + 'png', 'jpg', 'jpeg', 'webp', 'docx', + 'mp3', 'wav', 'm4a', 'flac', 'ogg', 'opus', 'aac', 'aiff', 'aif', + 'mp4', 'mov', 'm4v', 'webm', 'mkv', 'avi' +]); + +function getFileFormat(filePath) { + const ext = path.extname(filePath).toLowerCase().slice(1); + if (ext === 'md' || ext === 'markdown') return 'md'; + if (ext === 'html' || ext === 'htm') return 'html'; + if (ext === 'pdf') return 'pdf'; + if (ext === 'docx') return 'docx'; + if (['png', 'jpg', 'jpeg', 'webp'].includes(ext)) return 'image'; + return 'audio'; +} + +const activePreviewGrants = new Map(); +const pendingFilesToOpen = []; +const rendererReadyWindows = new Set(); + +function sendInternalPost(requestPath, bodyObj) { + return new Promise((resolve) => { + const bodyStr = JSON.stringify(bodyObj); + const req = http.request( + { + host: SERVER_HOST, + port: SERVER_PORT, + path: requestPath, + method: 'POST', + headers: { + 'x-stashbase-shutdown-token': SERVER_SHUTDOWN_TOKEN, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(bodyStr), + }, + timeout: 1000, + }, + (res) => { + resolve({ statusCode: res.statusCode }); + } + ); + req.on('error', () => resolve({ statusCode: 500 })); + req.on('timeout', () => { + req.destroy(); + resolve({ statusCode: 504 }); + }); + req.write(bodyStr); + req.end(); + }); +} + +function sendInternalDelete(requestPath) { + return new Promise((resolve) => { + const req = http.request( + { + host: SERVER_HOST, + port: SERVER_PORT, + path: requestPath, + method: 'DELETE', + headers: { + 'x-stashbase-shutdown-token': SERVER_SHUTDOWN_TOKEN, + }, + timeout: 1000, + }, + (res) => { + resolve({ statusCode: res.statusCode }); + } + ); + req.on('error', () => resolve({ statusCode: 500 })); + req.on('timeout', () => { + req.destroy(); + resolve({ statusCode: 504 }); + }); + req.end(); + }); +} + +function getFilePathsFromArgs(argv) { + const filePaths = []; + const startIndex = app.isPackaged ? 1 : 2; + for (let i = startIndex; i < argv.length; i++) { + const arg = argv[i]; + if (arg.startsWith('-')) continue; + if (arg.startsWith(WINDOW_ID_ARG_PREFIX)) continue; + try { + const absPath = path.resolve(arg); + if (fs.existsSync(absPath)) { + const st = fs.statSync(absPath); + if (st.isFile()) { + const ext = path.extname(absPath).toLowerCase().slice(1); + if (VIEWABLE_FILE_EXTENSIONS.has(ext)) { + filePaths.push(absPath); + } + } + } + } catch (err) { + // Ignore invalid paths + } + } + return filePaths; +} + function shellQuote(value) { return `'${String(value).replace(/'/g, `'\\''`)}'`; } @@ -847,9 +949,16 @@ async function createWindow(initialFolder) { rendererFlush.cancel(webContentsId); rendererFlushReadinessByWebContents.delete(webContentsId); bugReports.discardUnreviewedDraftsForSource(webContentsId); + rendererReadyWindows.delete(webContentsId); mainWindows.delete(win); windowRegistry.remove(windowId); releaseWindowContext(windowId); + for (const [grantId, grant] of activePreviewGrants) { + if (grant.windowId === windowId) { + activePreviewGrants.delete(grantId); + void sendInternalDelete(`/api/internal/grants/${encodeURIComponent(grantId)}`); + } + } if (lastMainWindow === win) { lastMainWindow = [...mainWindows].find((candidate) => isLiveMainWindow(candidate)) ?? null; } @@ -1112,6 +1221,89 @@ ipcMain.on('clipboard:setAgentComposerFocused', (event, focused) => { else agentComposerFocusedContents.delete(event.sender.id); }); +function getLastMainWindow() { + const focused = BrowserWindow.getFocusedWindow(); + const win = isLiveMainWindow(focused) + ? focused + : isLiveMainWindow(lastMainWindow) + ? lastMainWindow + : [...mainWindows].find((candidate) => isLiveMainWindow(candidate)); + return isLiveMainWindow(win) ? win : null; +} + +function handleNativeFileOpenRequest(filePath) { + const win = getLastMainWindow(); + if (win && rendererReadyWindows.has(win.webContents.id)) { + win.webContents.send('window:open-external-files', [filePath]); + } else { + pendingFilesToOpen.push(filePath); + } +} + +app.on('open-file', (event, filePath) => { + event.preventDefault(); + handleNativeFileOpenRequest(filePath); +}); + +ipcMain.handle('grant:register', async (event, filePath) => { + const senderWindow = BrowserWindow.fromWebContents(event.sender); + const windowId = windowRegistry.idForWindow(senderWindow); + if (!windowId || typeof filePath !== 'string') throw new Error('Invalid arguments'); + + const canonicalPath = path.resolve(filePath); + if (!fs.existsSync(canonicalPath)) throw new Error('File does not exist'); + const st = fs.statSync(canonicalPath); + if (!st.isFile()) throw new Error('Not a file'); + + const ext = path.extname(canonicalPath).toLowerCase().slice(1); + if (!VIEWABLE_FILE_EXTENSIONS.has(ext)) { + throw new Error(`Unsupported file type: .${ext}`); + } + + const activeFolder = windowRegistry.folderForWindowId(windowId); + if (activeFolder) { + const relative = path.relative(activeFolder, canonicalPath); + const isInternal = !relative.startsWith('..') && !path.isAbsolute(relative); + if (isInternal) { + return { isInternal: true, relPath: relative.replace(/\\/g, '/') }; + } + } + + const grantId = crypto.randomUUID(); + const format = getFileFormat(canonicalPath); + const name = path.basename(canonicalPath); + + activePreviewGrants.set(grantId, { windowId, filePath: canonicalPath }); + + await sendInternalPost('/api/internal/grants', { grantId, windowId, filePath: canonicalPath }); + + return { + isInternal: false, + grantId, + name, + format, + absolutePath: canonicalPath, + }; +}); + +ipcMain.handle('grant:revoke', async (event, grantId) => { + if (typeof grantId !== 'string') return false; + activePreviewGrants.delete(grantId); + await sendInternalDelete(`/api/internal/grants/${encodeURIComponent(grantId)}`); + return true; +}); + +ipcMain.on('renderer:ready-for-native-files', (event) => { + const webContentsId = event.sender.id; + rendererReadyWindows.add(webContentsId); + const win = BrowserWindow.fromWebContents(event.sender); + if (win && pendingFilesToOpen.length > 0) { + const paths = [...pendingFilesToOpen]; + pendingFilesToOpen.length = 0; + win.webContents.send('window:open-external-files', paths); + } +}); + const initialWindowFlight = createSingleFlight(() => app.whenReady().then(() => createWindow())); function focusOAuthReturn() { @@ -1169,8 +1361,15 @@ if (!hasSingleInstanceLock) { // A malformed or unsupported stashbase: URL must not fall through to the // ordinary second-launch focus/create behavior. if (protocolLaunch === 'inert') return; - if (!focusLastMainWindow()) { - void initialWindowFlight.run().then(() => { focusLastMainWindow(); }); + const filePaths = getFilePathsFromArgs(argv); + if (filePaths.length > 0) { + for (const filePath of filePaths) { + handleNativeFileOpenRequest(filePath); + } + } else { + if (!focusLastMainWindow()) { + void initialWindowFlight.run().then(() => { focusLastMainWindow(); }); + } } }); @@ -1201,6 +1400,10 @@ if (!hasSingleInstanceLock) { console.warn(`[electron] MCP wrapper refresh failed: ${err && err.message ? err.message : err}`); } installApplicationMenu(); + const startupFiles = getFilePathsFromArgs(process.argv); + for (const filePath of startupFiles) { + pendingFilesToOpen.push(filePath); + } await initialWindowFlight.run(); if (initialProtocolLaunch === 'oauth-return') focusOAuthReturn(); }); diff --git a/electron/multi-window.cjs b/electron/multi-window.cjs index 70912deb..6b97eee8 100644 --- a/electron/multi-window.cjs +++ b/electron/multi-window.cjs @@ -180,6 +180,9 @@ function createWindowRegistry({ platform = process.platform } = {}) { .filter((record) => record.folderKey === wanted) .map((record) => record.win); }, + folderForWindowId(windowId) { + return records.get(windowId)?.folderKey ?? null; + }, }; } diff --git a/electron/preload.cjs b/electron/preload.cjs index 2d813aaa..0d4bd99f 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -4,7 +4,7 @@ * `window.electron`. Only what the renderer actually needs goes here — * never the raw ipcRenderer. */ -const { contextBridge, ipcRenderer } = require('electron'); +const { contextBridge, ipcRenderer, webUtils } = require('electron'); const { windowIdFromArgv } = require('./multi-window.cjs'); const windowId = windowIdFromArgv(process.argv); @@ -129,4 +129,16 @@ contextBridge.exposeInMainWorld('electron', { /** While the Agent composer owns focus, pasted images become temporary * chat context instead of candidates for library import. */ setAgentComposerFocused: (focused) => ipcRenderer.send('clipboard:setAgentComposerFocused', focused === true), + /** Get absolute path for file from sandboxed renderer drop. */ + getPathForFile: (file) => webUtils.getPathForFile(file), + registerPreviewGrant: (filePath) => ipcRenderer.invoke('grant:register', filePath), + revokePreviewGrant: (grantId) => ipcRenderer.invoke('grant:revoke', grantId), + onOpenExternalFiles: (handler) => { + const wrapped = (_event, paths) => { + if (Array.isArray(paths)) handler(paths); + }; + ipcRenderer.on('window:open-external-files', wrapped); + return () => ipcRenderer.removeListener('window:open-external-files', wrapped); + }, + notifyRendererReadyForNativeFiles: () => ipcRenderer.send('renderer:ready-for-native-files'), }); diff --git a/server/index.ts b/server/index.ts index 1387e454..bb0982a9 100644 --- a/server/index.ts +++ b/server/index.ts @@ -50,6 +50,7 @@ import { closeStateDb } from './state-db.ts'; import { requireFolder, withWindowContext } from './http.ts'; import { mount as mountWindowContextRoutes } from './routes/window-context.ts'; import { mountInternalShutdownRoute } from './routes/internal-shutdown.ts'; +import { mountInternalGrantsRoute } from './routes/internal-grants.ts'; import { mount as mountLibraryRoutes } from './routes/library.ts'; import { mount as mountEmbedderRoutes } from './routes/embedder.ts'; import { mount as mountAppearanceRoutes } from './routes/appearance.ts'; @@ -269,6 +270,8 @@ mountInternalShutdownRoute(app, { shutdown: () => { void shutdown('Electron request'); }, }); +mountInternalGrantsRoute(app, process.env.STASHBASE_SHUTDOWN_TOKEN ?? ''); + // Static layer is mounted before the API routes for renderer bundle // requests, but data routes must bypass it entirely. In packaged asar // builds, serve-static can still issue directory-normalisation redirects @@ -285,6 +288,8 @@ if (!DEV_VITE) { req.path.startsWith('/asset/') || req.path === '/asset-audio-preview' || req.path.startsWith('/asset-audio-preview/') || + req.path === '/asset-preview-grant' || + req.path.startsWith('/asset-preview-grant/') || req.path === '/mcp' ) { return next(); diff --git a/server/routes/internal-grants.ts b/server/routes/internal-grants.ts new file mode 100644 index 00000000..778d957c --- /dev/null +++ b/server/routes/internal-grants.ts @@ -0,0 +1,97 @@ +import crypto from 'node:crypto'; +import express from 'express'; +import fs from 'node:fs'; +import path from 'node:path'; +import { analyzeHtml } from '../html.ts'; +import { sendError } from '../http.ts'; +import { currentWindowId } from '../folder.ts'; + +interface Grant { + windowId: string; + filePath: string; +} + +const serverPreviewGrants = new Map(); + +export function getGrant(grantId: string): Grant | undefined { + return serverPreviewGrants.get(grantId); +} + +function tokenMatches(actual: string | undefined, expected: string): boolean { + if (!actual || !expected) return false; + const left = Buffer.from(actual); + const right = Buffer.from(expected); + return left.length === right.length && crypto.timingSafeEqual(left, right); +} + +export function mountInternalGrantsRoute( + app: express.Express, + token: string, +): void { + // Register a grant + app.post('/api/internal/grants', (req, res) => { + if (!tokenMatches(req.header('x-stashbase-shutdown-token'), token)) { + return res.status(403).json({ error: 'forbidden' }); + } + const { grantId, windowId, filePath } = req.body; + if (typeof grantId !== 'string' || typeof windowId !== 'string' || typeof filePath !== 'string') { + return res.status(400).json({ error: 'invalid payload' }); + } + serverPreviewGrants.set(grantId, { windowId, filePath }); + res.json({ ok: true }); + }); + + // Revoke a grant + app.delete('/api/internal/grants/:grantId', (req, res) => { + if (!tokenMatches(req.header('x-stashbase-shutdown-token'), token)) { + return res.status(403).json({ error: 'forbidden' }); + } + serverPreviewGrants.delete(req.params.grantId); + res.json({ ok: true }); + }); + + // Get external file text content + app.get('/api/grant/:grantId/text', (req, res) => { + const grant = getGrant(req.params.grantId); + if (!grant) return res.status(404).end(); + + const reqWindowId = currentWindowId(); + if (grant.windowId !== reqWindowId) { + return res.status(403).json({ error: 'forbidden' }); + } + + try { + const content = fs.readFileSync(grant.filePath, 'utf8'); + res.json({ content }); + } catch (err: unknown) { + sendError(res, err); + } + }); + + // Serve transient file assets + app.get('/asset-preview-grant/:grantId', (req, res) => { + const grant = getGrant(req.params.grantId); + if (!grant) return res.status(404).end(); + + const reqWindowId = currentWindowId(); + if (grant.windowId !== reqWindowId) { + return res.status(403).end(); + } + + const abs = grant.filePath; + const ext = path.extname(abs).toLowerCase(); + + if (ext === '.html' || ext === '.htm') { + try { + const raw = fs.readFileSync(abs, 'utf8'); + const { preparedHtml } = analyzeHtml(raw); + res.type('text/html').send(preparedHtml); + } catch (err: unknown) { + sendError(res, err); + } + return; + } + + res.sendFile(abs); + }); +} diff --git a/web-src/src/api.ts b/web-src/src/api.ts index 696a77e9..068e112c 100644 --- a/web-src/src/api.ts +++ b/web-src/src/api.ts @@ -348,6 +348,8 @@ export const api = { send('PATCH', agentSessionBase(agent) + '/' + encodeURIComponent(id) + sessionScopeQuery(scope), { title }), deleteSession: (id: string, agent: 'claude' | 'codex' = 'claude', scope?: SessionScopeParams) => send>('DELETE', agentSessionBase(agent) + '/' + encodeURIComponent(id) + sessionScopeQuery(scope)), + getExternalFileText: (grantId: string): Promise<{ content: string }> => + getJson<{ content: string }>(`/api/grant/${encodeURIComponent(grantId)}/text`), }; function agentSessionBase(agent: 'claude' | 'codex'): string { @@ -383,7 +385,10 @@ export function assetUrl(name: string, folder?: string): string { return assetScopePrefix('/asset/', folder) + encodePath(name); } -export function versionedAssetUrl(name: string, version: string, folder?: string): string { +export function versionedAssetUrl(name: string, version: string, folder?: string, grantId?: string): string { + if (grantId) { + return `/asset-preview-grant/${encodeURIComponent(grantId)}?v=${encodeURIComponent(version)}`; + } const url = assetUrl(name, folder); const sep = url.includes('?') ? '&' : '?'; return `${url}${sep}v=${encodeURIComponent(version)}`; diff --git a/web-src/src/components/MainPane.tsx b/web-src/src/components/MainPane.tsx index e93b8afc..7929eefe 100644 --- a/web-src/src/components/MainPane.tsx +++ b/web-src/src/components/MainPane.tsx @@ -89,6 +89,13 @@ export function MainPane({ workspaceHidden = false }: { workspaceHidden?: boolea )} + {cur?.isExternal && ( + + + External File — Read-only. Sourced from {cur.absolutePath} + + + )} {/* Content host for every viewer (iframe preview / split editor). * Single-cell grid because a Chromium quirk: when an iframe with * `position: absolute; inset: 0` sits inside a flex child, its @@ -228,7 +235,7 @@ export function MainPane({ workspaceHidden = false }: { workspaceHidden?: boolea )} - {cur && (cur.format === 'md' || cur.format === 'json') && !cur.folder && ( + {cur && (cur.format === 'md' || cur.format === 'json') && !cur.folder && !cur.isExternal && ( /* Floating actions in the main pane's top-right — sits below the * tab strip (unconditionally present whenever there's an open * file, so a fixed offset is safe). The edit toggle lives here on @@ -265,7 +272,7 @@ export function MainPane({ workspaceHidden = false }: { workspaceHidden?: boolea // so we don't waste a row on viewer chrome. The out-of-folder // banner (min-h-8) pushes the slot down when present.
)} diff --git a/web-src/src/components/TabStrip.tsx b/web-src/src/components/TabStrip.tsx index 8c892e7f..e5487b06 100644 --- a/web-src/src/components/TabStrip.tsx +++ b/web-src/src/components/TabStrip.tsx @@ -132,7 +132,7 @@ export function TabStrip() { aria-controls="document-panel" tabIndex={isActive ? 0 : -1} draggable - title={t.file ? (t.file.folder ? `${t.file.folder}/${t.file.name}` : t.file.name) : 'Empty tab'} + title={t.file ? (t.file.isExternal ? t.file.absolutePath : (t.file.folder ? `${t.file.folder}/${t.file.name}` : t.file.name)) : 'Empty tab'} onClick={() => { void actions.activateTab(t.id); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { diff --git a/web-src/src/store/AppContext.tsx b/web-src/src/store/AppContext.tsx index 685fc5b0..7ca5e326 100644 --- a/web-src/src/store/AppContext.tsx +++ b/web-src/src/store/AppContext.tsx @@ -101,6 +101,8 @@ export interface AppActions { * shortcuts (`⌘W`) and UI buttons that don't have a tab id handy. */ closeActiveTab: () => Promise; activateTab: (id: string) => Promise; + openExternalFilePath: (filePath: string) => Promise; + openExternalFiles: (files: File[]) => Promise; /** Cross-file link nav: open `name` (with optional anchor) and push a * new entry into the back/forward stack. Used by preview iframes * forwarding `` clicks. */ @@ -330,6 +332,60 @@ export function AppProvider({ children }: { children: ReactNode }) { const sameDocument = (file: { name: string; folder?: string } | null | undefined) => !!file && file.name === name && file.folder === libraryFolder; try { + if (tab.file.isExternal) { + const grantId = tab.file.grantId!; + try { + if ( + tab.file.format === 'pdf' + || tab.file.format === 'image' + || tab.file.format === 'docx' + || tab.file.format === 'audio' + ) { + const response = await fetch(`/asset-preview-grant/${encodeURIComponent(grantId)}`, { method: 'HEAD' }); + if (!response.ok) throw new Error('Unavailable'); + return; + } + const body = await api.getExternalFileText(grantId); + if (stateRef.current.folderPath !== folderPathAtStart) return; + const latestActive = getActiveTab(stateRef.current); + if (!latestActive?.file?.isExternal || latestActive.file.grantId !== grantId) return; + + if (opts.force) { + dispatch({ + type: 'FILE_OPEN', + body: { + name: latestActive.file.name, + format: latestActive.file.format, + content: body.content, + version: 'transient', + isExternal: true, + isReadOnly: true, + grantId, + absolutePath: latestActive.file.absolutePath, + }, + }); + dispatch({ type: 'SAVE_STATUS', status: { text: 'Reloaded from disk', cls: 'saved' } }); + return; + } + if (body.content === latestActive.file.content) return; + dispatch({ + type: 'FILE_PATCH', + patch: { content: body.content }, + }); + } catch { + if (stateRef.current.folderPath !== folderPathAtStart) return; + const latestActive = getActiveTab(stateRef.current); + if (latestActive?.file?.isExternal && latestActive.file.grantId === tab.file.grantId) { + if (latestActive.file.format === 'md' || latestActive.file.format === 'html') { + dispatch({ + type: 'FILE_PATCH', + patch: { content: '⚠️ This external file is no longer available.' } + }); + } + } + } + return; + } if ( tab.file.format === 'pdf' || tab.file.format === 'image' @@ -409,6 +465,7 @@ export function AppProvider({ children }: { children: ReactNode }) { openLibraryFile: workspace.openLibraryFile, openInNewTab: workspace.openInNewTab, newTab: workspace.newTab, closeTab: workspace.closeTab, closeActiveTab: workspace.closeActiveTab, activateTab: workspace.activateTab, + openExternalFilePath: workspace.openExternalFilePath, openExternalFiles: workspace.openExternalFiles, navigateTo: workspace.navigateTo, consumePendingScroll: workspace.consumePendingScroll, consumePendingHighlight: workspace.consumePendingHighlight, updateTabPdfPage: workspace.updateTabPdfPage, diff --git a/web-src/src/store/state.ts b/web-src/src/store/state.ts index 366e836c..0441c40d 100644 --- a/web-src/src/store/state.ts +++ b/web-src/src/store/state.ts @@ -97,6 +97,10 @@ export interface OpenFile { * selection / recents / pruning flows, and every fetch they cause must * carry this folder explicitly. Undefined for ordinary tabs. */ folder?: string; + isExternal?: boolean; + isReadOnly?: boolean; + grantId?: string; + absolutePath?: string; } export interface CtxMenu { @@ -410,7 +414,7 @@ export type Action = * since a sidebar click opens a persistent tab. Omitting `newTab` * replaces the active tab in place (blank-tab reuse, back/forward, * in-place anchor nav). */ - | { type: 'FILE_OPEN'; body: FileBody; newTab?: boolean; libraryFolder?: string } + | { type: 'FILE_OPEN'; body: FileBody & { isExternal?: boolean; isReadOnly?: boolean; grantId?: string; absolutePath?: string }; newTab?: boolean; libraryFolder?: string } | { type: 'FILE_PATCH'; patch: Partial } | { type: 'DOCUMENT_DIRTY'; dirty: boolean } | { type: 'PRUNE_MISSING_FILE_TABS'; names: string[] } diff --git a/web-src/src/store/stateReducer.ts b/web-src/src/store/stateReducer.ts index 0e048218..ffaf123d 100644 --- a/web-src/src/store/stateReducer.ts +++ b/web-src/src/store/stateReducer.ts @@ -90,11 +90,15 @@ export function reducer(s: State, a: Action): State { content: a.body.content, version: a.body.version, ...(a.libraryFolder ? { folder: a.libraryFolder } : {}), + ...(a.body.isExternal ? { isExternal: true } : {}), + ...(a.body.isReadOnly ? { isReadOnly: true } : {}), + ...(a.body.grantId ? { grantId: a.body.grantId } : {}), + ...(a.body.absolutePath ? { absolutePath: a.body.absolutePath } : {}), }; // Out-of-folder tabs are strictly read-only viewers: never Live // Editing, never the tree's focused row, never in the folder-local // recents (Quick Open would resolve the rel name in the wrong folder). - const outOfFolder = Boolean(a.libraryFolder); + const outOfFolder = Boolean(a.libraryFolder) || Boolean(file.isExternal); const liveEditing = file.format === 'md' && !outOfFolder; // New-tab mode (the normal sidebar open, or `+` then a click): // create a fresh tab and load into it. Without `newTab` the file diff --git a/web-src/src/store/useActiveFolderWorkspace.ts b/web-src/src/store/useActiveFolderWorkspace.ts index 17269c05..c05ec2ed 100644 --- a/web-src/src/store/useActiveFolderWorkspace.ts +++ b/web-src/src/store/useActiveFolderWorkspace.ts @@ -62,6 +62,8 @@ export interface ActiveFolderWorkspace { scheduleSave: () => void; flushSave: () => Promise; registerEditor: (handle: EditorHandle | null) => void; + openExternalFilePath: (filePath: string) => Promise; + openExternalFiles: (files: File[]) => Promise; } interface WorkspaceDependencies { diff --git a/web-src/src/store/useDocumentActions.ts b/web-src/src/store/useDocumentActions.ts index 8c9e1e07..e4946804 100644 --- a/web-src/src/store/useDocumentActions.ts +++ b/web-src/src/store/useDocumentActions.ts @@ -20,6 +20,15 @@ const cancelTimeout = (timer: ReturnType) => clearTimeout(tim type Dispatch = (action: Action) => void; type Toast = (message: string, opts?: ToastOptions) => string; +interface ElectronBridge { + getPathForFile: (file: File) => string; + registerPreviewGrant: (filePath: string) => Promise< + | { isInternal: true; relPath: string } + | { isInternal: false; grantId: string; name: string; format: 'md' | 'html' | 'pdf' | 'image' | 'docx' | 'audio'; absolutePath: string } + >; + revokePreviewGrant: (grantId: string) => Promise; +} + interface DocumentActionRefs { state: MutableRefObject; editor: MutableRefObject; @@ -352,8 +361,13 @@ export function useDocumentActions( const closeTab = useCallback(async (id: string) => { const currentState = state.current; + const tab = currentState.tabs.find((t) => t.id === id); if (currentState.activeTabId === id && editor.current && !(await flushSave())) return; dispatch({ type: 'CLOSE_TAB', id }); + if (tab?.file?.isExternal && tab.file.grantId) { + const bridge = (window as { electron?: ElectronBridge }).electron; + void bridge?.revokePreviewGrant(tab.file.grantId); + } }, [dispatch, editor, flushSave, state]); const closeActiveTab = useCallback(async () => { @@ -458,6 +472,63 @@ export function useDocumentActions( editor.current = handle; }, [editor]); + const openExternalFilePath = useCallback(async (filePath: string) => { + try { + const bridge = (window as { electron?: ElectronBridge }).electron; + if (!bridge) throw new Error('Electron bridge not available'); + + const result = await bridge.registerPreviewGrant(filePath); + if (result.isInternal) { + await selectFile(result.relPath); + } else { + const existing = state.current.tabs.find( + (t) => t.file?.isExternal && t.file.absolutePath === result.absolutePath + ); + if (existing) { + if (state.current.activeTabId !== existing.id) { + dispatch({ type: 'ACTIVATE_TAB', id: existing.id }); + } + return; + } + + const body = { + name: result.name, + format: result.format, + content: '', + version: 'transient', + isExternal: true, + isReadOnly: true, + grantId: result.grantId, + absolutePath: result.absolutePath, + }; + + if (result.format === 'md' || result.format === 'html') { + const contentResult = await api.getExternalFileText(result.grantId); + body.content = contentResult.content; + } + + dispatch({ + type: 'FILE_OPEN', + body, + newTab: true, + }); + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + toast(`Could not open external file: ${msg}`, { level: 'error' }); + } + }, [dispatch, selectFile, state, toast]); + + const openExternalFiles = useCallback(async (files: File[]) => { + for (const file of files) { + const bridge = (window as { electron?: ElectronBridge }).electron; + const filePath = bridge?.getPathForFile(file); + if (filePath) { + await openExternalFilePath(filePath); + } + } + }, [openExternalFilePath]); + const updateTabPdfPage = useCallback((tabId: string, page: number) => { dispatch({ type: 'TAB_PDF_PAGE', id: tabId, page }); }, [dispatch]); @@ -476,6 +547,8 @@ export function useDocumentActions( consumePendingHighlight, consumePendingScroll, flushSave, + openExternalFilePath, + openExternalFiles, navigateTo, newTab, openInNewTab, From d47af9ad6a27f61a4c2941aca9a5d371c0905fa4 Mon Sep 17 00:00:00 2001 From: krit22 Date: Tue, 11 Aug 2026 19:04:22 +0000 Subject: [PATCH 2/8] feat(desktop): add split drag overlay and iframe drop support --- web-src/src/App.tsx | 23 ++++++- web-src/src/components/DropVeil.tsx | 18 +++++- web-src/src/components/ManagedDropVeil.tsx | 46 ++++++++++++-- web-src/src/hooks/useGlobalDragDrop.ts | 70 +++++++++++++++++++--- 4 files changed, 139 insertions(+), 18 deletions(-) diff --git a/web-src/src/App.tsx b/web-src/src/App.tsx index 3c0cc64d..9a3a3742 100644 --- a/web-src/src/App.tsx +++ b/web-src/src/App.tsx @@ -63,8 +63,8 @@ export function App() { } function AppBody() { - const veilHot = useGlobalDragDrop(); - const { state, dispatch } = useApp(); + const { hot: veilHot, activeZone } = useGlobalDragDrop(); + const { state, actions, dispatch } = useApp(); const { previewImage, closePreviewImage } = usePreviewMessages(); const { clipboardOffer, saveClipboardOffer, dismissClipboardOffer } = useClipboardImageOffer(); const initialFolderPending = useRef(new URLSearchParams(window.location.search).has('folder')); @@ -131,6 +131,18 @@ function AppBody() { // late boot push could clobber the harness's registration. if (state.booted) document.body.dataset.bootSettled = '1'; }, [state.booted, state.folderPath]); + useEffect(() => { + const bridge = (window as { electron?: any }).electron; + if (!bridge) return; + bridge.notifyRendererReadyForNativeFiles?.(); + return bridge.onOpenExternalFiles?.((paths: string[]) => { + void (async () => { + for (const filePath of paths) { + await actions.openExternalFilePath(filePath); + } + })(); + }); + }, [actions]); // macOS fullscreen toggles the `is-fullscreen` body class so the sidebar // can drop its traffic-light drag zone. That's owned entirely by the // preload (registered before page load, so it catches the initial state @@ -177,7 +189,12 @@ function AppBody() { )}
- + {state.ctxMenu && ( import('./ManagedDropVeil')); /** Drag-import veil. Visibility flows from the global drag handler in * the parent (`useGlobalDragDrop`) via the `hot` prop. Motion is loaded only * when a drag begins, so an optional visual enhancement does not tax startup. */ -export function DropVeil({ hot }: { hot: boolean }) { +export function DropVeil({ + hot, + activeZone, + sidebarWidth, + sidebarCollapsed, +}: { + hot: boolean; + activeZone: 'sidebar' | 'main' | null; + sidebarWidth: number; + sidebarCollapsed: boolean; +}) { if (!hot) return null; return ( Release to import}> - + ); } diff --git a/web-src/src/components/ManagedDropVeil.tsx b/web-src/src/components/ManagedDropVeil.tsx index 2c2fcee6..a42a9856 100644 --- a/web-src/src/components/ManagedDropVeil.tsx +++ b/web-src/src/components/ManagedDropVeil.tsx @@ -1,16 +1,54 @@ import { MotionConfig, motion } from 'motion/react'; -/** Loaded only during an active import drag; opacity is retained for reduced motion. */ -export default function ManagedDropVeil() { +export default function ManagedDropVeil({ + activeZone, + sidebarWidth, + sidebarCollapsed, +}: { + activeZone: 'sidebar' | 'main' | null; + sidebarWidth: number; + sidebarCollapsed: boolean; +}) { + const showSidebarZone = !sidebarCollapsed; + const sidebarTotalWidth = sidebarWidth + 44; return ( - Release to import + {showSidebarZone && ( +
+ Copy to library + Release to import +
+ )} +
+ Open temporarily + Release to open without copying +
); diff --git a/web-src/src/hooks/useGlobalDragDrop.ts b/web-src/src/hooks/useGlobalDragDrop.ts index 7efa3a11..55537058 100644 --- a/web-src/src/hooks/useGlobalDragDrop.ts +++ b/web-src/src/hooks/useGlobalDragDrop.ts @@ -20,8 +20,9 @@ import { useApp } from '../store/AppContext'; * * Returns the boolean veil-visibility flag for `` to read. */ -export function useGlobalDragDrop(): boolean { +export function useGlobalDragDrop(): { hot: boolean; activeZone: 'sidebar' | 'main' | null } { const [veilHot, setVeilHot] = useState(false); + const [activeZone, setActiveZone] = useState<'sidebar' | 'main' | null>(null); const { actions } = useApp(); const dragDepth = useRef(0); const hotRef = useRef(false); @@ -44,6 +45,7 @@ export function useGlobalDragDrop(): boolean { hotRef.current = false; dropTargetFolder.current = ''; setVeilHot(false); + setActiveZone(null); clearDropHighlights(); } // The chat panel (AgentView) manages its own file drops — files @@ -59,6 +61,10 @@ export function useGlobalDragDrop(): boolean { dragDepth.current += 1; hotRef.current = true; setVeilHot(true); + + const tgt = e.target instanceof Element ? e.target : null; + const isSidebar = tgt && !!tgt.closest('.sidebar'); + setActiveZone(isSidebar ? 'sidebar' : 'main'); } function onDragLeave() { dragDepth.current = Math.max(0, dragDepth.current - 1); @@ -79,6 +85,13 @@ export function useGlobalDragDrop(): boolean { if (e.dataTransfer && !acceptsKnowledgeBaseDrop(e.dataTransfer)) return; e.preventDefault(); const tgt = e.target instanceof Element ? e.target : null; + const isSidebar = tgt && !!tgt.closest('.sidebar'); + const nextZone = isSidebar ? 'sidebar' : 'main'; + setActiveZone(nextZone); + if (e.dataTransfer) { + e.dataTransfer.dropEffect = isSidebar ? 'copy' : 'link'; + } + const folderEl = tgt?.closest('.tree-row.folder') as HTMLElement | null; const headEl = !folderEl ? (tgt?.closest('#sideHead') as HTMLElement | null) : null; const newTarget = folderEl?.dataset?.path ?? ''; @@ -95,6 +108,8 @@ export function useGlobalDragDrop(): boolean { async function onDrop(e: DragEvent) { if (inChatPanel(e)) { hideVeil(); return; } // panel handles its own drop e.preventDefault(); + const tgt = e.target instanceof Element ? e.target : null; + const isSidebar = tgt && !!tgt.closest('.sidebar'); const targetDir = dropTargetFolder.current; hideVeil(); @@ -111,19 +126,56 @@ export function useGlobalDragDrop(): boolean { const entry = items[i].webkitGetAsEntry?.(); if (entry) entries.push(entry); } - const collected: { file: File; relPath: string }[] = []; - for (const entry of entries) { - await walkEntry(entry, '', collected); + + if (isSidebar) { + const collected: { file: File; relPath: string }[] = []; + for (const entry of entries) { + await walkEntry(entry, '', collected); + } + if (collected.length) await actions.upload(collected, targetDir); + } else { + const files: File[] = []; + let hasDirectories = false; + for (const entry of entries) { + if (entry.isFile) { + const file = await new Promise((res, rej) => + (entry as FileSystemFileEntry).file(res, rej), + ); + files.push(file); + } else if (entry.isDirectory) { + hasDirectories = true; + } + } + if (hasDirectories) { + actions.toast('Directories cannot be opened temporarily; drop folders onto the Files sidebar to import them.', { level: 'warning' }); + } + if (files.length > 0) { + await actions.openExternalFiles(files); + } } - if (collected.length) await actions.upload(collected, targetDir); } async function onIframeDrop(e: Event) { const { entries } = (e as CustomEvent<{ entries: FileSystemEntry[] }>).detail; hideVeil(); - const collected: { file: File; relPath: string }[] = []; - for (const entry of entries) await walkEntry(entry, '', collected); - if (collected.length) await actions.upload(collected, ''); + const files: File[] = []; + let hasDirectories = false; + for (const entry of entries) { + if (entry.isFile) { + const file = await new Promise((res, rej) => + (entry as FileSystemFileEntry).file(res, rej), + ); + files.push(file); + } else if (entry.isDirectory) { + hasDirectories = true; + } + } + if (hasDirectories) { + actions.toast('Directories cannot be opened temporarily; drop folders onto the Files sidebar to import them.', { level: 'warning' }); + } + if (files.length > 0) { + await actions.openExternalFiles(files); + } } window.addEventListener('dragenter', onDragEnter); @@ -144,7 +196,7 @@ export function useGlobalDragDrop(): boolean { }; }, [actions]); - return veilHot; + return { hot: veilHot, activeZone }; } async function walkEntry( From 0f06a8ef41d17be183269c1c27572f3e2fe98fa1 Mon Sep 17 00:00:00 2001 From: krit22 Date: Tue, 11 Aug 2026 19:04:27 +0000 Subject: [PATCH 3/8] test(desktop): verify transient file reducer mapping and preview grant lifecycle --- package.json | 2 +- server/routes/internal-grants.test.ts | 98 +++++++++++++++++++++++ web-src/src/store/__tests__/state.test.ts | 25 ++++++ 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 server/routes/internal-grants.test.ts diff --git a/package.json b/package.json index 4a5cf7b3..0b43324f 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "test:renderer-chunks": "node scripts/check-renderer-chunks.mjs", "test:docs": "node scripts/check-docs.mjs", "test:conversion-scheduler": "node --import tsx --test server/filesystem-path.test.ts server/folder-relative-path.test.ts server/folder-window.test.ts server/window-context-route.test.ts server/internal-shutdown-route.test.ts server/files.test.ts server/file-formats.test.ts server/file-hash.test.ts server/http.test.ts server/markdown-source-format.test.ts server/keyword-search.test.ts server/index-status.test.ts server/indexer-mfs-path.test.ts server/semantic-workload.test.ts server/session-path.test.ts server/conversion-scheduler.test.ts server/conversion-auxiliary.test.ts server/conversion-status.test.ts server/conversion.test.ts server/extractor-process.test.ts server/audio-transcription.test.ts server/transcription-models.test.ts server/transcription-provider.test.ts server/transcription-runtime.test.ts server/transcription-tools.test.ts server/upload.test.ts", - "test:library-files": "node --import tsx --test server/library-file-mutations.test.ts server/library-operations/index.test.ts server/routes/library-files.test.ts", + "test:library-files": "node --import tsx --test server/library-file-mutations.test.ts server/library-operations/index.test.ts server/routes/library-files.test.ts server/routes/internal-grants.test.ts", "test:retrieval": "node --import tsx --test server/retrieval/index.test.ts", "test:agent": "node --import tsx --test server/__tests__/agent.test.ts server/__tests__/agent-contract.test.ts server/__tests__/agent-history-routes.test.ts server/__tests__/agent-projects.test.ts server/__tests__/agent-runtime-installer.test.ts server/__tests__/codex-agent.test.ts", "test:agent:native": "node --import tsx --test server/__tests__/agent-native-smoke.test.ts", diff --git a/server/routes/internal-grants.test.ts b/server/routes/internal-grants.test.ts new file mode 100644 index 00000000..4b3e5746 --- /dev/null +++ b/server/routes/internal-grants.test.ts @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import fs from 'node:fs'; +import path from 'node:path'; +import express from 'express'; +import { mountInternalGrantsRoute } from './internal-grants.ts'; +import { runWithWindowId } from '../folder.ts'; + +test('internal preview grants API lifecycle', async () => { + const app = express(); + app.use(express.json()); + + app.use((req, res, next) => { + const winId = req.header('x-stashbase-window-id') || 'default'; + runWithWindowId(winId, next); + }); + + const testToken = 'test-token-12345'; + mountInternalGrantsRoute(app, testToken); + + const server = app.listen(0, '127.0.0.1'); + await new Promise((resolve, reject) => { + server.once('listening', resolve); + server.once('error', reject); + }); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const baseUrl = `http://127.0.0.1:${address.port}`; + + const tempFile = path.resolve('/tmp/test-grant-spec.md'); + fs.writeFileSync(tempFile, '# Grant Content\nHello World', 'utf8'); + + try { + const grantId = 'test-grant-id'; + const windowId = 'win-test-456'; + + const resFailToken = await fetch(`${baseUrl}/api/internal/grants`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ grantId, windowId, filePath: tempFile }), + }); + assert.equal(resFailToken.status, 403); + + const resReg = await fetch(`${baseUrl}/api/internal/grants`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-stashbase-shutdown-token': testToken, + }, + body: JSON.stringify({ grantId, windowId, filePath: tempFile }), + }); + assert.equal(resReg.status, 200); + + const resWrongWin = await fetch(`${baseUrl}/api/grant/${grantId}/text`, { + headers: { 'x-stashbase-window-id': 'win-wrong' }, + }); + assert.equal(resWrongWin.status, 403); + + const resCorrectWin = await fetch(`${baseUrl}/api/grant/${grantId}/text`, { + headers: { 'x-stashbase-window-id': windowId }, + }); + assert.equal(resCorrectWin.status, 200); + const body = await resCorrectWin.json() as { content: string }; + assert.equal(body.content, '# Grant Content\nHello World'); + + const resAssetWrong = await fetch(`${baseUrl}/asset-preview-grant/${grantId}`, { + headers: { 'x-stashbase-window-id': 'win-wrong' }, + }); + assert.equal(resAssetWrong.status, 403); + + const resAssetCorrect = await fetch(`${baseUrl}/asset-preview-grant/${grantId}`, { + headers: { 'x-stashbase-window-id': windowId }, + }); + assert.equal(resAssetCorrect.status, 200); + const text = await resAssetCorrect.text(); + assert.equal(text, '# Grant Content\nHello World'); + + const resRevokeFail = await fetch(`${baseUrl}/api/internal/grants/${grantId}`, { + method: 'DELETE', + }); + assert.equal(resRevokeFail.status, 403); + + const resRevoke = await fetch(`${baseUrl}/api/internal/grants/${grantId}`, { + method: 'DELETE', + headers: { 'x-stashbase-shutdown-token': testToken }, + }); + assert.equal(resRevoke.status, 200); + + const resAfterRevoke = await fetch(`${baseUrl}/api/grant/${grantId}/text`, { + headers: { 'x-stashbase-window-id': windowId }, + }); + assert.equal(resAfterRevoke.status, 404); + + } finally { + fs.rmSync(tempFile, { force: true }); + await new Promise((resolve) => server.close(() => resolve())); + } +}); diff --git a/web-src/src/store/__tests__/state.test.ts b/web-src/src/store/__tests__/state.test.ts index f1ffa567..ae33eee2 100644 --- a/web-src/src/store/__tests__/state.test.ts +++ b/web-src/src/store/__tests__/state.test.ts @@ -487,3 +487,28 @@ test('UNSUPPORTED_MODAL toggle action updates state', () => { state = reducer(state, { type: 'UNSUPPORTED_MODAL', open: false }); assert.equal(state.unsupportedModalOpen, false); }); + +test('FILE_OPEN with isExternal sets outOfFolder and external properties', () => { + let state = reducer(freshState(), { + type: 'FILE_OPEN', + body: { + name: 'ext.md', + format: 'md', + content: 'hello', + isExternal: true, + isReadOnly: true, + grantId: 'grant-123', + absolutePath: '/tmp/ext.md', + }, + }); + + const tab = state.tabs[0]; + assert.equal(tab.file?.name, 'ext.md'); + assert.equal(tab.file?.folder, undefined); + assert.equal(tab.file?.isExternal, true); + assert.equal(tab.file?.isReadOnly, true); + assert.equal(tab.file?.grantId, 'grant-123'); + assert.equal(tab.file?.absolutePath, '/tmp/ext.md'); + assert.equal(tab.editMode, false); + assert.deepEqual(state.recentFilePaths, []); +}); From 9b9bd5ec90e3f0fb5d1660ed2f63e3ccbb979c0a Mon Sep 17 00:00:00 2001 From: krit22 Date: Tue, 11 Aug 2026 19:04:33 +0000 Subject: [PATCH 4/8] docs(desktop): document transient external files and preview grant invariants --- code-review/architecture.md | 189 ++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) diff --git a/code-review/architecture.md b/code-review/architecture.md index 202d6aee..6a0dbbe8 100644 --- a/code-review/architecture.md +++ b/code-review/architecture.md @@ -53,6 +53,195 @@ presentation, and Agent tabs. ownership, flags, or ACLs on the user's behalf. ## Primary Data Flow +membership, hidden-derived filtering, and writable-path validation for the MCP +and library HTTP surface. `server/library-directory.ts` owns member-folder +listing, `server/library-file-reader.ts` owns direct versus derived reads and +the conversion-not-ready contract, and `server/library-file-mutations.ts` owns +library write, edit, move, and delete transactions. `server/file-save.ts` +provides the shared editable-file save and index-update path, and +`server/markdown-source-format.ts` preserves Markdown UTF-8 BOM and line-ending +conventions at that boundary. +`server/file-operation-guard.ts` establishes the cancellation barrier that +releases conversion-owned file handles before rename/delete, and +`server/file-hash.ts` owns streamed BLAKE3 for large source files. +`server/routes/library-files.ts` keeps request and response +orchestration. The active-folder filesystem facade is `server/files.ts`; +`server/file-paths.ts` owns current-root resolution, portable filename +sanitization, folder-relative containment, and case-only rename hops, +`server/active-file-operations.ts` owns active-folder reads, writes, file/folder +mutations, asset resolution, and legacy derived-artifact cleanup, and +`server/file-listing.ts` owns recursive sidebar listing, preview metadata +caching, attachment-bundle hiding, legacy-derived compatibility hiding, and +folder-rename scan inputs. That compatibility vocabulary excludes audio; +audio writes AppData-derived output and does not own sibling-derived filenames. The +active-folder HTTP surface composes note CRUD and reveal in +`server/routes/files.ts`, rename/delete/preview transactions in +`server/routes/file-mutations.ts`, asset and derived-preview serving in +`server/routes/file-assets.ts`, and sidebar-order HTTP handling in +`server/routes/file-order.ts`. Filesystem, scheduler, membership, state, and +daemon adapters cross these modules. Identity, containment, migration, and protocol invariants live in +[data-layer §8.2](data-layer.md#82-conversion-scheduler-and-renderer-notification). + +## 2.2 Library Scope + +One installation has **one library**: the set of opened folders indexed into one collection and exposed by one MCP server. + +Search defaults to the whole library for MCP callers and for the in-app search +popup alike. Either can narrow scope by folder root or path prefix; file-type +categories are an agent-facing parameter the in-app popup does not expose. The +detailed filtering contract lives in [§6.2](#62-scope). + +On server boot, StashBase binds every library folder into the daemon and then reconciles them in the background. The sidebar library list also reconciles the non-current library folders with a short cooldown and polls their status while it is visible and idle. While a folder is actively opening, that polling and reconcile are deferred so navigation does not compete with preparation work. + +Opening a folder is a navigation action first and a preparation action second. Once the server accepts the target folder, the renderer enters the folder view before recursive file listing, file ordering, or index status finish. Those follow in the background. There is no landing page: a window that boots without a current folder auto-opens the most recent library folder, and an empty library leaves the window on the no-folder workspace with the sidebar's add-folder affordance. + +Each opened folder can carry a short optional description in app config. The description is orientation metadata for humans and Agents: it explains what the folder is for, but it is not indexed content and it does not define access scope. It can be written by the user first and later generated or refreshed by AI. Removing a folder from "Your Folders" removes its description with the folder membership record. + +Renderer state orchestration lives under `web-src/src/store/`. `state.ts` is the stable state-model and action-contract facade, `stateHelpers.ts` owns reusable pure transitions and layout bounds, and `stateReducer.ts` applies the action union. `useActiveFolderWorkspace.ts` is the active-folder workspace module: it owns folder, document, retrieval, and lifecycle ordering, including polling, focus reconciliation, binary refresh, and context-release persistence. It composes `useDocumentActions.ts`, `useFileActions.ts`, `useSearchActions.ts`, and `useFolderActions.ts` as private implementation details; presentation callers must use the workspace interface rather than coordinate those actions or freshness guards themselves. The interface must retain its identity when its commands are unchanged: document surfaces use it in effects, and a replacement while Find is open re-registers the controller and can create a render loop. Its lifecycle seam has two adapters: Electron context-release callbacks and the browser unload fallback. `AppContext.tsx` composes that workspace with shell-owned chat, feedback, and Find interaction. `useFindActions.ts` and `useFeedbackActions.ts` remain shell presentation protocols; search-hit navigation remains in the workspace. `indexStatusRequest.ts` is the narrow request-lifecycle seam that classifies status responses against folder-transition state without owning scheduling, recovery, or renderer state. `web-src/src/components/MainPane.tsx` dynamically imports format- and mode-specific heavy viewers/editors, including audio, PDF, DOCX, Markdown preview, and the Markdown editor, so the initial renderer chunk carries the common browsing surface first. `web-src/src/App.tsx` also dynamically imports the chat pane; the sidebar's New Chat entry stays in the initial shell, while Agent transcript rendering and the CodeMirror mention composer load only after chat is opened. The library search popup follows the Quick Open loading shape: `LibrarySearch.tsx` keeps the open-event listener eager and dynamically imports `LibrarySearchDialog.tsx` on request. Its remembered query, mode, scope, and results live in module memory (`web-src/src/librarySearch.ts`), deliberately outside the reducer: both folder-switch reset paths wipe reducer search fields, and the popup's own cross-folder result-opens must not clear it. The sidebar dynamically imports the document-outline list when a Markdown outline is visible. Its eager files surface checks semantic-indexing and unsupported-file state before importing those normally absent disclosures. Quick Open keeps its shortcut listener in the initial shell but dynamically imports ranking and picker rendering after an open request, so the first shortcut cannot race module loading. Context-menu and image-lightbox implementations also load only after their state-backed requests exist. The embedder-key gate keeps folder probing and overlay ownership eager, then loads its form only when setup must open. `scripts/check-renderer-chunks.mjs` requires these twelve designated dynamic entries and caps the entry chunk plus its recursive static imports at 416 KiB. `web-src/src/components/ErrorBoundary.tsx` retries each dynamic import once and contains a persistent failure inside the affected interaction, chat, search, Quick Open, or document surface; a changed request, document identity/version, chat surface, or sidebar view clears that local failure, while the root boundary remains the final recovery path for unrelated renderer errors. `web-src/src/components/ChatPane.tsx` keeps tab navigation outside one boundary per mounted Agent session, so a render failure in one tab cannot hide the controls needed to switch or close it. + +Toast lifecycle is intentionally not reducer state: the shared Base UI toast +manager owns timeout, close, announcement priority, and viewport navigation. +`useFeedbackActions.ts` preserves the stable `actions.toast` interface, while +the UI adapter preserves duplicate collapsing. + +Blocking renderer surfaces register with the shared overlay stack before any +lazy implementation loads. The stack is the single topmost-owner seam for +sibling and nested dialogs; Base UI still owns focus trapping and dismissal +inside a loaded surface, while the native-modal loading adapter preserves the +same modality during chunk loading. Feature code must gate close intent through +the layer result rather than add document-level Escape listeners. + +Quick Open is a renderer-only active-folder navigation surface. It ranks the +already visible source-file list and accepts through `selectFile`; it must not +bypass that action's save guard, folder-generation check, or preview-tab +semantics. Its keyboard owner is active only while topmost: Settings announces +its separate local blocking state to the picker, while reducer-backed confirms, +cascade prompts, context menus, inline rename, modal veils, and explicitly +marked local dialogs prevent invocation. Open-file recency is folder-local and +separate from tab-strip order. Dismissal restores the element that invoked the +picker. Its `>` provider is Command Palette, also entered directly with +Cmd/Ctrl+Shift+P or F1. Command definitions have stable identities and +availability predicates, call established renderer actions, and keep command +recency in picker-local session memory only. Do not turn either provider into a +retrieval, cross-library, Agent-permission, or destructive-operation surface. +Content retrieval has its own surface: the library search popup +(`LibrarySearch.tsx` / `LibrarySearchDialog.tsx`) shares the picker chrome and +the same topmost/blocking rules (its veil carries `quick-open-blocking`) and +opens results through `openLibraryFile` — same-folder hits route to +`selectFileWithHighlight`, cross-folder hits open an out-of-folder tab, and +NEITHER path may switch the window's folder (only the no-folder workspace +binds the picked folder). Save guards and generation checks stay intact. + +Out-of-folder tabs (`OpenFile.folder` set — a search hit viewed without +switching the window's folder) carry hard invariants: document identity is +(folder, rel name), never rel name alone (`isFolderFileTab` excludes them); +they are strictly read-only (`FILE_OPEN` never arms Live Editing, +`EDIT_MODE`/`toggleEditMode`/`flushSave` all refuse, the palette hides Toggle +Editing) because every write route resolves against the WINDOW's folder; they +never enter `selectedPath`, `recentFilePaths`, `PRUNE_MISSING_FILE_TABS`, or +`REMAP_PATHS`/delete cascades keyed on active-folder rel paths; and every +fetch they cause carries the folder explicitly — `?folder=` on the file +read/stat/audio JSON routes, the reserved `__folder//` +path token after `__window//` on `/asset*` URLs (path-carried because +``, iframe sub-assets, and the pdfjs worker cannot send headers). +The server validates membership on both forms and scopes resolution with the +refcounted `runWithFolderRoot` binding; write routes never accept either. +Links inside such a document resolve back to its own folder via the same +token (`resolveMilkdownLink` folder capture, preview-iframe `stashbase-nav` +`folder` field). The document banner's "Open Folder in New Window" is the +escape hatch to full editing. + +Transient external tabs (`OpenFile.isExternal` set) represent files opened from outside the library (via drag-and-drop or native OS requests). They carry the following invariants: +- The desktop main process owns the preview grant registry (`activePreviewGrants` mapping `grantId` to `{ windowId, filePath }`). +- The Express server acts as the validator, verifying that the request's window ID (`currentWindowId()`) matches the grant's window ID before serving the file under `/asset-preview-grant/:grantId` or `/api/grant/:grantId/text`. Sibling files in the same directory are blocked. +- Closing the tab in the renderer or closing the window in the main process revokes the grant, making the file inaccessible. +- Transient tabs are strictly read-only: editing, saving, renaming, deleting, and reprocessing are disabled. +- They are completely isolated from library membership, search indexes, Quick Open, recents, MCP, and Agent context unless explicitly imported by the user. +- During a drag-and-drop event, `useGlobalDragDrop` distinguishes the Files sidebar (copy/import) from the main document pane (open temporarily) using the `.sidebar` CSS class target, setting the cursor dropEffect to `copy` or `link` respectively. The DropVeil visualizes these zones side-by-side using the `sidebarWidth` state. +- Native open requests (macOS `open-file`, CLI arguments, second-instance args) are queued by the main process until the renderer registers readiness via `renderer:ready-for-native-files`, preventing race conditions. Subsequent requests are dispatched to the most recently focused window. +- Directories dropped on the main pane or app icon are rejected and not recursively imported. + + +Editor History (`state.editorHistory`, `web-src/src/editorHistory.ts`, +`EditorHistoryNavigator.tsx`) is `state.tabs`' id-level most-recently-activated +order, separate from `recentFilePaths` (Quick Open's folder-local file recency, +which can outlive a closed tab) and from tab-strip order (`TABS_REORDER` never +touches it). Every tab-creating or tab-activating action records itself; +`CLOSE_TAB` / `PRUNE_MISSING_FILE_TABS` / `TABS_RESET` drop entries so the +navigator never offers a tab that no longer exists. The Ctrl+Tab chord binds +the literal Control key on every platform, including macOS, matching VS +Code's own default — Cmd+Tab is the OS application switcher and never reaches +an Electron window. + +Hotkeys owns the raw keydown for the chord (mirroring how it dispatches +Quick Open's Cmd+O) and dispatches `stashbase-open-editor-history` on every +qualifying Tab press while Ctrl is held, not just the first. The navigator +tells opening from cycling apart by an internal `closed`/`pending`/`open` +phase: the first press arms a pending switch (list computed, index picked) +without rendering anything; releasing Control within `REVEAL_DELAY_MS` +(150ms) commits that pending switch directly, so a quick tap never paints +the overlay. Only a hold past that window, or a second Tab tap arriving +first, reveals the overlay and enters cycling mode. While pending, nothing +owns keyboard focus yet, so a `document`-level listener handles release-to- +commit and Escape-to-cancel; once open, the navigator's focused root owns +Tab/Shift+Tab (cycle)/Enter/Escape/Control-release through React's synthetic +handlers and stops propagation, the same topmost-owns-input contract Quick +Open follows. It carries Quick Open's `quick-open-blocking` marker class for +mutual exclusion and reuses `activateTab` to commit, preserving that +action's dirty-buffer save guard. There are no editor groups, so this is one +navigator over one list, not a per-group picker. + +The renderer's local HTTP boundary keeps `web-src/src/api.ts` as the stable endpoint facade. `shared/conversion.ts` and `shared/transcription.ts` own the preparation and transcription contracts consumed on both sides of that boundary; `apiTypes.ts` re-exports them and owns the remaining renderer-only request/response shapes. `apiTransport.ts` owns per-window request identity, JSON/error normalization, retry policy, and folder-relative path encoding. `web-src/src/preparation-copy.ts` translates queued and yielded preparation waits into shared user-facing copy without exposing scheduler lanes or positions. `web-src/src/audio-transcript.ts` maps semantic text or an explicit keyword-result millisecond timestamp back to the exact structured transcript segment and owns the remaining transcription-specific status copy, while `web-src/src/audio-playback.ts` retains logical playback position across direct-to-fallback source replacement. + +Electron assigns each `BrowserWindow` a stable identity through its preload +arguments. The renderer uses that identity for HTTP headers, asset URLs, and +Agent WebSockets, and reports folder transitions back to the main process. The +main-process registry uses those transitions to focus an existing matching +folder window, excluding the sender when the user explicitly asks to open its +current folder in another window. Native close first requests an awaited +renderer save acknowledgement; failure or timeout leaves the window open. +Only then does close remove the registry entry and retry server cleanup. The +server retires the identity with a bounded tombstone before clearing its +folder and Agent context, so a late open request cannot recreate a ghost +window. Folder removal uses the same save barrier for every matching window, +then broadcasts the committed membership change so those renderers return +Home; 412 recovery checks durable membership before attempting a restart +rebind. An individual close never tears down the shared server. A +single-instance lock plus a single-flight initial-window operation prevents a +second launch during startup from creating a duplicate. The application menu +owns the window lifecycle commands using the platform mappings documented in +[Local File Workspace](../design-docs/design/library.md). The renderer must +yield those native window chords without narrowing the established modifier +handling of unrelated document commands. The menu advertises the platform +accelerator, while the `BrowserWindow` input boundary dispatches it and owns +the secondary non-macOS binding that cannot fit on the same menu item. macOS +activation recreates a window after the last one closes, while non-macOS +platforms quit after the last window closes. The real Electron lifecycle smoke +must send the platform accelerator input, enforce a parent-owned deadline, and +delete its isolated profile only after the child process exits. + +The preload marks the renderer with both `is-electron` and the exact +`platform-${process.platform}` class. The HTML chrome remains draggable on all +desktop platforms, but macOS traffic-light spacing and fullscreen compensation +must be selected through `platform-darwin`; Windows and Linux must not inherit +that inset. + +Electron owns the child server through a random per-launch shutdown token. +Quit sends an authenticated loopback shutdown request and waits for the server +cleanup ladder to exit before terminating Electron. Signals are timeout +fallbacks only, because Windows child-process signals are forceful rather than +graceful. + +--- + +# 3. Storage + +The file system is the source of truth. Converted content, indexes, and app state are derived from local files. + +## 3.1 Source and Derived Data + +User-visible files stay in the folder tree. StashBase has one user-level config file under the user's home directory. Derived state stays in AppData. +>>>>>>> cbf1692 (docs(desktop): document transient external files and preview grant invariants) ```text source file From 0309268153426a7bd3a00c3263c0962643b7d635 Mon Sep 17 00:00:00 2001 From: krit22 Date: Tue, 11 Aug 2026 19:14:58 +0000 Subject: [PATCH 5/8] fix(desktop): correct video format classification, drain HTTP responses, and revoke leaked grants --- electron/main.cjs | 3 +++ web-src/src/store/useDocumentActions.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/electron/main.cjs b/electron/main.cjs index b812e928..472c6954 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -187,6 +187,7 @@ function getFileFormat(filePath) { if (ext === 'pdf') return 'pdf'; if (ext === 'docx') return 'docx'; if (['png', 'jpg', 'jpeg', 'webp'].includes(ext)) return 'image'; + if (['mp4', 'mov', 'm4v', 'webm', 'mkv', 'avi'].includes(ext)) return 'video'; return 'audio'; } @@ -211,6 +212,7 @@ function sendInternalPost(requestPath, bodyObj) { timeout: 1000, }, (res) => { + res.resume(); // drain body to free the socket resolve({ statusCode: res.statusCode }); } ); @@ -238,6 +240,7 @@ function sendInternalDelete(requestPath) { timeout: 1000, }, (res) => { + res.resume(); // drain body to free the socket resolve({ statusCode: res.statusCode }); } ); diff --git a/web-src/src/store/useDocumentActions.ts b/web-src/src/store/useDocumentActions.ts index e4946804..2d3ca12b 100644 --- a/web-src/src/store/useDocumentActions.ts +++ b/web-src/src/store/useDocumentActions.ts @@ -488,6 +488,7 @@ export function useDocumentActions( if (state.current.activeTabId !== existing.id) { dispatch({ type: 'ACTIVATE_TAB', id: existing.id }); } + void bridge.revokePreviewGrant(result.grantId); return; } From 7a0020924084231da3f84ff196ab481a3274bb68 Mon Sep 17 00:00:00 2001 From: krit22 Date: Sun, 16 Aug 2026 09:31:27 +0000 Subject: [PATCH 6/8] fix(desktop): resolve review requests for transient external files and preview grants --- electron/main.cjs | 131 ++++++++++++------ electron/multi-window.cjs | 54 ++++++++ server/http.ts | 2 + server/routes/internal-grants.ts | 35 ++++- web-src/src/api.ts | 2 +- web-src/src/components/AudioPreview.tsx | 28 ++-- web-src/src/components/DocxPreview.tsx | 18 ++- web-src/src/components/FileTree.tsx | 2 +- web-src/src/components/HtmlPreview.tsx | 3 +- web-src/src/components/ImagePreview.tsx | 11 +- web-src/src/components/MainPane.tsx | 16 +-- web-src/src/components/ManagedQuickOpen.tsx | 4 +- web-src/src/components/PdfPreview.tsx | 15 +- .../audio/useAudioFallbackController.ts | 7 +- .../audio/useAudioTranscriptController.ts | 9 +- web-src/src/electronBridge.ts | 13 ++ web-src/src/store/AppContext.tsx | 8 +- web-src/src/store/appContextHelpers.ts | 6 +- web-src/src/store/state.ts | 3 +- web-src/src/store/stateReducer.ts | 12 +- web-src/src/store/useActiveFolderWorkspace.ts | 2 +- web-src/src/store/useDocumentActions.ts | 73 ++++++---- web-src/src/store/useFolderActions.ts | 9 ++ 23 files changed, 332 insertions(+), 131 deletions(-) diff --git a/electron/main.cjs b/electron/main.cjs index 472c6954..2aa804da 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -33,6 +33,7 @@ const { WINDOW_ID_ARG_PREFIX, classifyProtocolLaunch, createApplicationMenuTemplate, + createNativeOpenQueueCoordinator, createRendererFlushCoordinator, createRendererFlushReadiness, createSingleFlight, @@ -174,7 +175,7 @@ registerBugReportReviewIpc({ const APP_CONFIG_FILE = path.join(os.homedir(), '.stashbase', 'config.json'); const VIEWABLE_FILE_EXTENSIONS = new Set([ - 'md', 'markdown', 'html', 'htm', 'pdf', + 'md', 'markdown', 'html', 'htm', 'json', 'csv', 'pdf', 'png', 'jpg', 'jpeg', 'webp', 'docx', 'mp3', 'wav', 'm4a', 'flac', 'ogg', 'opus', 'aac', 'aiff', 'aif', 'mp4', 'mov', 'm4v', 'webm', 'mkv', 'avi' @@ -184,16 +185,29 @@ function getFileFormat(filePath) { const ext = path.extname(filePath).toLowerCase().slice(1); if (ext === 'md' || ext === 'markdown') return 'md'; if (ext === 'html' || ext === 'htm') return 'html'; + if (ext === 'json') return 'json'; + if (ext === 'csv') return 'csv'; if (ext === 'pdf') return 'pdf'; if (ext === 'docx') return 'docx'; if (['png', 'jpg', 'jpeg', 'webp'].includes(ext)) return 'image'; - if (['mp4', 'mov', 'm4v', 'webm', 'mkv', 'avi'].includes(ext)) return 'video'; - return 'audio'; + if (['mp3', 'wav', 'm4a', 'flac', 'ogg', 'opus', 'aac', 'aiff', 'aif', 'mp4', 'mov', 'm4v', 'webm', 'mkv', 'avi'].includes(ext)) return 'audio'; + return null; +} + +function canonicalizeFilePath(rawPath) { + if (typeof rawPath !== 'string' || !rawPath) return null; + try { + const real = fs.realpathSync(rawPath); + const st = fs.statSync(real); + if (st.isFile()) return real; + } catch { + // Path does not exist or is not accessible + } + return null; } const activePreviewGrants = new Map(); -const pendingFilesToOpen = []; -const rendererReadyWindows = new Set(); +const nativeOpenQueue = createNativeOpenQueueCoordinator(); function sendInternalPost(requestPath, bodyObj) { return new Promise((resolve) => { @@ -260,19 +274,12 @@ function getFilePathsFromArgs(argv) { const arg = argv[i]; if (arg.startsWith('-')) continue; if (arg.startsWith(WINDOW_ID_ARG_PREFIX)) continue; - try { - const absPath = path.resolve(arg); - if (fs.existsSync(absPath)) { - const st = fs.statSync(absPath); - if (st.isFile()) { - const ext = path.extname(absPath).toLowerCase().slice(1); - if (VIEWABLE_FILE_EXTENSIONS.has(ext)) { - filePaths.push(absPath); - } - } + const canonical = canonicalizeFilePath(arg); + if (canonical) { + const ext = path.extname(canonical).toLowerCase().slice(1); + if (VIEWABLE_FILE_EXTENSIONS.has(ext)) { + filePaths.push(canonical); } - } catch (err) { - // Ignore invalid paths } } return filePaths; @@ -917,6 +924,7 @@ async function createWindow(initialFolder) { const webContentsId = win.webContents.id; const rendererFlushReadiness = createRendererFlushReadiness(); rendererFlushReadinessByWebContents.set(webContentsId, rendererFlushReadiness); + nativeOpenQueue.attachStartupFilesToWindow(webContentsId); mainWindows.add(win); windowRegistry.add(windowId, win, initialFolder); lastMainWindow = win; @@ -925,6 +933,9 @@ async function createWindow(initialFolder) { offerClipboardImage(win); startClipboardPolling(); }); + win.on('blur', () => { + stopClipboardPolling(); + }); win.on('close', (event) => { if (approvedWindowCloses.has(win) || !rendererFlushReadiness.shouldRequest()) return; event.preventDefault(); @@ -952,7 +963,7 @@ async function createWindow(initialFolder) { rendererFlush.cancel(webContentsId); rendererFlushReadinessByWebContents.delete(webContentsId); bugReports.discardUnreviewedDraftsForSource(webContentsId); - rendererReadyWindows.delete(webContentsId); + nativeOpenQueue.cleanup(webContentsId); mainWindows.delete(win); windowRegistry.remove(windowId); releaseWindowContext(windowId); @@ -994,7 +1005,11 @@ async function createWindow(initialFolder) { } win.on('enter-full-screen', pushFullscreen); win.on('leave-full-screen', pushFullscreen); + win.webContents.on('did-start-loading', () => { + nativeOpenQueue.resetReadiness(webContentsId); + }); win.webContents.on('did-finish-load', () => { + nativeOpenQueue.resetReadiness(webContentsId); rendererFlushReadiness.markDocumentLoaded(); pushFullscreen(); }); @@ -1226,20 +1241,31 @@ ipcMain.on('clipboard:setAgentComposerFocused', (event, focused) => { function getLastMainWindow() { const focused = BrowserWindow.getFocusedWindow(); - const win = isLiveMainWindow(focused) - ? focused - : isLiveMainWindow(lastMainWindow) - ? lastMainWindow - : [...mainWindows].find((candidate) => isLiveMainWindow(candidate)); - return isLiveMainWindow(win) ? win : null; + if (isLiveMainWindow(focused) && !pendingWindowCloses.has(focused)) return focused; + if (isLiveMainWindow(lastMainWindow) && !pendingWindowCloses.has(lastMainWindow)) return lastMainWindow; + return [...mainWindows].find((win) => isLiveMainWindow(win) && !pendingWindowCloses.has(win)) ?? null; +} + +function queueFilesForWindow(win, filePaths) { + if (!win || !isLiveMainWindow(win) || !Array.isArray(filePaths) || filePaths.length === 0) return; + nativeOpenQueue.queueFilesForWindow(win.webContents.id, filePaths, (paths) => { + if (!win.isDestroyed()) win.webContents.send('window:open-external-files', paths); + }); } function handleNativeFileOpenRequest(filePath) { + const canonical = canonicalizeFilePath(filePath); + if (!canonical) return; + const ext = path.extname(canonical).toLowerCase().slice(1); + if (!VIEWABLE_FILE_EXTENSIONS.has(ext)) return; const win = getLastMainWindow(); - if (win && rendererReadyWindows.has(win.webContents.id)) { - win.webContents.send('window:open-external-files', [filePath]); + if (win) { + queueFilesForWindow(win, [canonical]); } else { - pendingFilesToOpen.push(filePath); + nativeOpenQueue.handleStartupFiles([canonical]); + if (app.isReady()) { + void initialWindowFlight.run().then(() => { focusLastMainWindow(); }); + } } } @@ -1253,10 +1279,8 @@ ipcMain.handle('grant:register', async (event, filePath) => { const windowId = windowRegistry.idForWindow(senderWindow); if (!windowId || typeof filePath !== 'string') throw new Error('Invalid arguments'); - const canonicalPath = path.resolve(filePath); - if (!fs.existsSync(canonicalPath)) throw new Error('File does not exist'); - const st = fs.statSync(canonicalPath); - if (!st.isFile()) throw new Error('Not a file'); + const canonicalPath = canonicalizeFilePath(filePath); + if (!canonicalPath) throw new Error('File does not exist'); const ext = path.extname(canonicalPath).toLowerCase().slice(1); if (!VIEWABLE_FILE_EXTENSIONS.has(ext)) { @@ -1265,7 +1289,13 @@ ipcMain.handle('grant:register', async (event, filePath) => { const activeFolder = windowRegistry.folderForWindowId(windowId); if (activeFolder) { - const relative = path.relative(activeFolder, canonicalPath); + let canonicalFolder = null; + try { + canonicalFolder = fs.realpathSync(activeFolder); + } catch { + canonicalFolder = path.resolve(activeFolder); + } + const relative = path.relative(canonicalFolder, canonicalPath); const isInternal = !relative.startsWith('..') && !path.isAbsolute(relative); if (isInternal) { return { isInternal: true, relPath: relative.replace(/\\/g, '/') }; @@ -1274,11 +1304,18 @@ ipcMain.handle('grant:register', async (event, filePath) => { const grantId = crypto.randomUUID(); const format = getFileFormat(canonicalPath); + if (!format) { + throw new Error(`Unsupported file type: .${ext}`); + } const name = path.basename(canonicalPath); activePreviewGrants.set(grantId, { windowId, filePath: canonicalPath }); - await sendInternalPost('/api/internal/grants', { grantId, windowId, filePath: canonicalPath }); + const res = await sendInternalPost('/api/internal/grants', { grantId, windowId, filePath: canonicalPath }); + if (res.statusCode !== 200) { + activePreviewGrants.delete(grantId); + throw new Error('Failed to register preview grant on server'); + } return { isInternal: false, @@ -1291,20 +1328,19 @@ ipcMain.handle('grant:register', async (event, filePath) => { ipcMain.handle('grant:revoke', async (event, grantId) => { if (typeof grantId !== 'string') return false; + const senderWindow = BrowserWindow.fromWebContents(event.sender); + const windowId = windowRegistry.idForWindow(senderWindow); + const grant = activePreviewGrants.get(grantId); + if (grant && (!windowId || grant.windowId !== windowId)) return false; activePreviewGrants.delete(grantId); await sendInternalDelete(`/api/internal/grants/${encodeURIComponent(grantId)}`); return true; }); ipcMain.on('renderer:ready-for-native-files', (event) => { - const webContentsId = event.sender.id; - rendererReadyWindows.add(webContentsId); - const win = BrowserWindow.fromWebContents(event.sender); - if (win && pendingFilesToOpen.length > 0) { - const paths = [...pendingFilesToOpen]; - pendingFilesToOpen.length = 0; - win.webContents.send('window:open-external-files', paths); - } + nativeOpenQueue.markReady(event.sender.id, (paths) => { + event.sender.send('window:open-external-files', paths); + }); }); const initialWindowFlight = createSingleFlight(() => app.whenReady().then(() => createWindow())); @@ -1366,8 +1402,13 @@ if (!hasSingleInstanceLock) { if (protocolLaunch === 'inert') return; const filePaths = getFilePathsFromArgs(argv); if (filePaths.length > 0) { - for (const filePath of filePaths) { - handleNativeFileOpenRequest(filePath); + const win = getLastMainWindow(); + if (win) { + focusWindow(win); + queueFilesForWindow(win, filePaths); + } else { + nativeOpenQueue.handleStartupFiles(filePaths); + void initialWindowFlight.run().then(() => { focusLastMainWindow(); }); } } else { if (!focusLastMainWindow()) { @@ -1404,9 +1445,7 @@ if (!hasSingleInstanceLock) { } installApplicationMenu(); const startupFiles = getFilePathsFromArgs(process.argv); - for (const filePath of startupFiles) { - pendingFilesToOpen.push(filePath); - } + nativeOpenQueue.handleStartupFiles(startupFiles); await initialWindowFlight.run(); if (initialProtocolLaunch === 'oauth-return') focusOAuthReturn(); }); diff --git a/electron/multi-window.cjs b/electron/multi-window.cjs index 6b97eee8..34f697a3 100644 --- a/electron/multi-window.cjs +++ b/electron/multi-window.cjs @@ -361,11 +361,65 @@ function createRendererFlushReadiness() { }; } +function createNativeOpenQueueCoordinator() { + const pendingByWebContents = new Map(); + const readyWebContents = new Set(); + const pendingStartupFiles = []; + + return { + queueFilesForWindow(webContentsId, filePaths, sendFn) { + if (!Array.isArray(filePaths) || filePaths.length === 0) return; + if (readyWebContents.has(webContentsId)) { + sendFn(filePaths); + } else { + const list = pendingByWebContents.get(webContentsId) ?? []; + list.push(...filePaths); + pendingByWebContents.set(webContentsId, list); + } + }, + handleStartupFiles(filePaths) { + if (Array.isArray(filePaths)) { + pendingStartupFiles.push(...filePaths); + } + }, + attachStartupFilesToWindow(webContentsId) { + if (pendingStartupFiles.length === 0) return; + const files = [...pendingStartupFiles]; + pendingStartupFiles.length = 0; + const list = pendingByWebContents.get(webContentsId) ?? []; + list.push(...files); + pendingByWebContents.set(webContentsId, list); + }, + markReady(webContentsId, sendFn) { + readyWebContents.add(webContentsId); + const pending = pendingByWebContents.get(webContentsId); + if (pending && pending.length > 0) { + pendingByWebContents.delete(webContentsId); + sendFn(pending); + } + }, + cleanup(webContentsId) { + readyWebContents.delete(webContentsId); + pendingByWebContents.delete(webContentsId); + }, + resetReadiness(webContentsId) { + readyWebContents.delete(webContentsId); + }, + isReady(webContentsId) { + return readyWebContents.has(webContentsId); + }, + getPending(webContentsId) { + return pendingByWebContents.get(webContentsId) ?? []; + }, + }; +} + module.exports = { WINDOW_ID_ARG_PREFIX, buildElectronSmokeArgs, classifyProtocolLaunch, createApplicationMenuTemplate, + createNativeOpenQueueCoordinator, createRendererFlushCoordinator, createRendererFlushReadiness, createSingleFlight, diff --git a/server/http.ts b/server/http.ts index 559a448a..b2dbcf07 100644 --- a/server/http.ts +++ b/server/http.ts @@ -85,6 +85,8 @@ function assetWindowIdFromPath(reqPath: string): string | undefined { ? '/asset-audio-preview/__window/' : reqPath.startsWith('/asset-derived/__window/') ? '/asset-derived/__window/' + : reqPath.startsWith('/asset-preview-grant/__window/') + ? '/asset-preview-grant/__window/' : reqPath.startsWith('/asset/__window/') ? '/asset/__window/' : null; diff --git a/server/routes/internal-grants.ts b/server/routes/internal-grants.ts index 778d957c..dfd77043 100644 --- a/server/routes/internal-grants.ts +++ b/server/routes/internal-grants.ts @@ -37,8 +37,13 @@ export function mountInternalGrantsRoute( if (typeof grantId !== 'string' || typeof windowId !== 'string' || typeof filePath !== 'string') { return res.status(400).json({ error: 'invalid payload' }); } - serverPreviewGrants.set(grantId, { windowId, filePath }); - res.json({ ok: true }); + try { + const canonical = fs.realpathSync(filePath); + serverPreviewGrants.set(grantId, { windowId, filePath: canonical }); + res.json({ ok: true }); + } catch { + return res.status(400).json({ error: 'invalid file path' }); + } }); // Revoke a grant @@ -56,11 +61,15 @@ export function mountInternalGrantsRoute( if (!grant) return res.status(404).end(); const reqWindowId = currentWindowId(); - if (grant.windowId !== reqWindowId) { + if (!reqWindowId || grant.windowId !== reqWindowId) { return res.status(403).json({ error: 'forbidden' }); } try { + const real = fs.realpathSync(grant.filePath); + if (real !== grant.filePath) { + return res.status(403).json({ error: 'forbidden' }); + } const content = fs.readFileSync(grant.filePath, 'utf8'); res.json({ content }); } catch (err: unknown) { @@ -69,16 +78,30 @@ export function mountInternalGrantsRoute( }); // Serve transient file assets - app.get('/asset-preview-grant/:grantId', (req, res) => { - const grant = getGrant(req.params.grantId); + app.get('/asset-preview-grant/*', (req, res) => { + const rawPath = (req.params as Record)[0] ?? ''; + const windowPrefixMatch = rawPath.match(/^__window\/[^/]+\/(.+)$/); + const grantId = windowPrefixMatch ? windowPrefixMatch[1] : rawPath; + + const grant = getGrant(grantId); if (!grant) return res.status(404).end(); const reqWindowId = currentWindowId(); - if (grant.windowId !== reqWindowId) { + if (!reqWindowId || grant.windowId !== reqWindowId) { return res.status(403).end(); } const abs = grant.filePath; + try { + if (!fs.existsSync(abs)) return res.status(404).end(); + const real = fs.realpathSync(abs); + if (real !== abs) { + return res.status(403).end(); + } + } catch { + return res.status(404).end(); + } + const ext = path.extname(abs).toLowerCase(); if (ext === '.html' || ext === '.htm') { diff --git a/web-src/src/api.ts b/web-src/src/api.ts index 068e112c..20e70b62 100644 --- a/web-src/src/api.ts +++ b/web-src/src/api.ts @@ -387,7 +387,7 @@ export function assetUrl(name: string, folder?: string): string { export function versionedAssetUrl(name: string, version: string, folder?: string, grantId?: string): string { if (grantId) { - return `/asset-preview-grant/${encodeURIComponent(grantId)}?v=${encodeURIComponent(version)}`; + return `${assetWindowPrefix('/asset-preview-grant/')}${encodeURIComponent(grantId)}?v=${encodeURIComponent(version)}`; } const url = assetUrl(name, folder); const sep = url.includes('?') ? '&' : '?'; diff --git a/web-src/src/components/AudioPreview.tsx b/web-src/src/components/AudioPreview.tsx index a5d0131b..afda5463 100644 --- a/web-src/src/components/AudioPreview.tsx +++ b/web-src/src/components/AudioPreview.tsx @@ -22,12 +22,17 @@ import { StatusMessage } from './ui/status'; export function AudioPreview({ name }: { name: string }) { const { state, activeTab, actions } = useApp(); + const isExternal = activeTab?.file?.name === name && Boolean(activeTab.file.isExternal); const version = activeTab?.file?.name === name ? activeTab.file.version ?? '' : ''; // Out-of-folder tab: every URL and prepare/transcript request must carry // the file's own member folder instead of the window's. const sourceFolder = activeTab?.file?.name === name ? activeTab.file.folder : undefined; + const sourceGrantId = activeTab?.file?.name === name ? activeTab.file.grantId : undefined; const requestFolder = sourceFolder ?? state.folderPath; - const directSrc = useMemo(() => versionedAssetUrl(name, version, sourceFolder), [name, version, sourceFolder]); + const directSrc = useMemo( + () => versionedAssetUrl(name, version, sourceFolder, sourceGrantId), + [name, version, sourceFolder, sourceGrantId], + ); const fallbackSrc = useMemo(() => audioPreviewAssetUrl(name, version, sourceFolder), [name, version, sourceFolder]); const [positionMs, setPositionMs] = useState(0); const audioRef = useRef(null); @@ -37,18 +42,20 @@ export function AudioPreview({ name }: { name: string }) { folder: requestFolder, directSrc, fallbackSrc, + enabled: !isExternal, }); const transcription = useAudioTranscriptController({ name, folder: requestFolder, version, conversionRevision: state.conversionRevision, + enabled: !isExternal, }); useEffect(() => { setPositionMs(0); - playbackPositionRef.current.setSourceIdentity(JSON.stringify([requestFolder, name, version])); - }, [name, requestFolder, version]); + playbackPositionRef.current.setSourceIdentity(JSON.stringify([requestFolder, name, version, sourceGrantId])); + }, [name, requestFolder, version, sourceGrantId]); useEffect(() => { const highlight = activeTab?.pendingHighlight; @@ -139,7 +146,7 @@ export function AudioPreview({ name }: { name: string }) { )} - {(transcription.state?.status === 'ready' || transcription.state?.status === 'failed' || transcription.state?.status === 'cancelled') && ( + {!isExternal && (transcription.state?.status === 'ready' || transcription.state?.status === 'failed' || transcription.state?.status === 'cancelled') && (