Skip to content
Open
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
4 changes: 4 additions & 0 deletions code-review/document-viewers.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
- Direct preview and durable preparation are independent. A direct DOCX view
may succeed while searchable extraction is pending or failed; neither state
may falsely complete the other.
- Transient external files open with a temporary preview grant scoped strictly
to the requesting window identity. They are read-only, out-of-folder, and
stay out of library indexing and preparation pipelines. Browser-loaded grant
assets carry the window context in their URL path prefix (`/asset-preview-grant/__window/<windowId>/<grantId>`).

## Trust Boundary

Expand Down
10 changes: 5 additions & 5 deletions code-review/file-transactions.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,12 @@
source baseline. The asynchronously rendered dirty indicator is not a
durability authority and cannot make context release skip a fresh edit.
- A byte-identical save is a no-op and retains the current version.
- Markdown and JSON persistence preserves supported BOM and line-ending
- Markdown, JSON, and CSV persistence preserves supported BOM and line-ending
conventions without manufacturing unrelated source changes.
- JSON Tree operations enter this same save path as minimal source patches.
They preserve untouched whitespace, property order, escape spelling, numeric
lexemes, and trailing-newline state; no whole-document serializer is a save
authority.
- JSON Tree and CSV Table operations enter this same save path as minimal source
patches. They preserve untouched whitespace, property order, escape spelling,
numeric lexemes, custom delimiters, quoting styles, leading zeros, and
trailing-newline state; no whole-document serializer is a save authority.
- A `FILE_CHANGED` conflict must never automatically retry without
`baseVersion`. The dirty editor buffer and newer disk source both remain
recoverable until an explicit reload, merge, or overwrite decision.
Expand Down
2 changes: 1 addition & 1 deletion code-review/renderer-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ semantic readiness.

The initial renderer contains only window chrome and the minimum workspace
shell. Feature surfaces that open on demand remain dynamic entries. The
authoritative budget is `418 KiB` of initial static JavaScript, and the current
authoritative budget is `423 KiB` of initial static JavaScript, and the current
required dynamic-entry set lives in `scripts/check-renderer-chunks.mjs`.
Change that list or budget only when the ownership of eager shell behavior
changes, never to make an accidental dependency pass.
Expand Down
9 changes: 7 additions & 2 deletions code-review/window-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,13 @@ after readiness, a save failure or timeout keeps the window open.
Reload and Force Reload menu and keyboard bypasses are absent. Recovery crosses
main's awaited save barrier; if the failed renderer can no longer answer,
reload requires a second explicit risk confirmation.
- Closing one window releases only that window's folder and Agent state. Shared
server, daemon, settings, MCP, and other windows remain live.
- Closing one window releases only that window's folder and Agent state,
revokes all active preview grants registered for it, and cleans up its
pending native-open queue. Shared server, daemon, settings, MCP, and other
windows remain live.
- Native file-open requests from OS events, CLI arguments, and second-instance
launches are queued for one target window identity rather than drained
globally, so cold startup and focused windows receive only their own files.
- Removing a library folder flushes every window showing it, commits membership
removal, and broadcasts the transition. Recovery may rebind only if durable
membership still contains the folder.
Expand Down
249 changes: 247 additions & 2 deletions electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const {
WINDOW_ID_ARG_PREFIX,
classifyProtocolLaunch,
createApplicationMenuTemplate,
createNativeOpenQueueCoordinator,
createRendererFlushCoordinator,
createRendererFlushReadiness,
createSafeReloadCoordinator,
Expand Down Expand Up @@ -253,6 +254,117 @@ registerBugReportReviewIpc({

const APP_CONFIG_FILE = path.join(os.homedir(), '.stashbase', 'config.json');

const VIEWABLE_FILE_EXTENSIONS = new Set([
'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'
]);

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 (['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 nativeOpenQueue = createNativeOpenQueueCoordinator();

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) => {
res.resume(); // drain body to free the socket
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) => {
res.resume(); // drain body to free the socket
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;
const canonical = canonicalizeFilePath(arg);
if (canonical) {
const ext = path.extname(canonical).toLowerCase().slice(1);
if (VIEWABLE_FILE_EXTENSIONS.has(ext)) {
filePaths.push(canonical);
}
}
}
return filePaths;
}

function shellQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
Expand Down Expand Up @@ -896,6 +1008,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;
Expand All @@ -904,6 +1017,9 @@ async function createWindow(initialFolder) {
offerClipboardImage(win, true);
startClipboardPolling();
});
win.on('blur', () => {
stopClipboardPolling();
});
win.on('close', (event) => {
if (approvedWindowCloses.has(win) || !rendererFlushReadiness.shouldRequest()) return;
event.preventDefault();
Expand Down Expand Up @@ -931,9 +1047,16 @@ async function createWindow(initialFolder) {
rendererFlush.cancel(webContentsId);
rendererFlushReadinessByWebContents.delete(webContentsId);
bugReports.discardUnreviewedDraftsForSource(webContentsId);
nativeOpenQueue.cleanup(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;
}
Expand Down Expand Up @@ -966,7 +1089,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();
win.webContents.send('updates:state', desktopUpdates.getState());
Expand Down Expand Up @@ -1262,6 +1389,110 @@ ipcMain.on('clipboard:setAgentComposerFocused', (event, focused) => {
else agentComposerFocusedContents.delete(event.sender.id);
});

function getLastMainWindow() {
const focused = BrowserWindow.getFocusedWindow();
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) {
queueFilesForWindow(win, [canonical]);
} else {
nativeOpenQueue.handleStartupFiles([canonical]);
if (app.isReady()) {
void initialWindowFlight.run().then(() => { focusLastMainWindow(); });
}
}
}

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 = 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)) {
throw new Error(`Unsupported file type: .${ext}`);
}

const activeFolder = windowRegistry.folderForWindowId(windowId);
if (activeFolder) {
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, '/') };
}
}

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 });

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,
grantId,
name,
format,
absolutePath: canonicalPath,
};
});

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) => {
nativeOpenQueue.markReady(event.sender.id, (paths) => {
event.sender.send('window:open-external-files', paths);
});
});

const initialWindowFlight = createSingleFlight(() => app.whenReady().then(() => createWindow()));

function focusOAuthReturn() {
Expand Down Expand Up @@ -1319,8 +1550,20 @@ 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) {
const win = getLastMainWindow();
if (win) {
focusWindow(win);
queueFilesForWindow(win, filePaths);
} else {
nativeOpenQueue.handleStartupFiles(filePaths);
void initialWindowFlight.run().then(() => { focusLastMainWindow(); });
}
} else {
if (!focusLastMainWindow()) {
void initialWindowFlight.run().then(() => { focusLastMainWindow(); });
}
}
});

Expand Down Expand Up @@ -1351,6 +1594,8 @@ if (!hasSingleInstanceLock) {
console.warn(`[electron] MCP wrapper refresh failed: ${err && err.message ? err.message : err}`);
}
installApplicationMenu();
const startupFiles = getFilePathsFromArgs(process.argv);
nativeOpenQueue.handleStartupFiles(startupFiles);
await initialWindowFlight.run();
await desktopUpdates.start();
if (initialProtocolLaunch === 'oauth-return') focusOAuthReturn();
Expand Down
Loading
Loading