import { app, BrowserWindow, Menu, ipcMain, dialog, shell } from 'electron'; import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs/promises'; import started from 'electron-squirrel-startup'; const APP_PROTOCOL_SCHEME = 'io.github.eagraf'; /** * Expand a path that may start with `~` to the user's home directory. * Node.js fs does not expand `~` automatically. */ function expandPath(inputPath: string): string { if (inputPath === '~' || inputPath.startsWith('~/')) { return path.join(os.homedir(), inputPath.slice(1)); } if (inputPath.startsWith('~\\')) { return path.join(os.homedir(), inputPath.slice(1)); } return inputPath; } // Handle creating/removing shortcuts on Windows when installing/uninstalling. if (started) { app.quit(); } // Allow parallel instances for testing via TEXTILE_USER_DATA env var. // Each instance gets its own localStorage, auth state, and session storage. const customUserData = process.env.TEXTILE_USER_DATA; if (customUserData) { app.setPath('userData', customUserData); } else { // Default: single instance lock to prevent accidental duplicates. if (!app.requestSingleInstanceLock()) { app.quit(); } } app.setName('Textile'); let mainWindow: BrowserWindow | null = null; /** OAuth flow UI; closed automatically when sign-in completes or is cancelled. */ let authWindow: BrowserWindow | null = null; let pendingAuthCallback: | { redirectUri: string; reject: (error: Error) => void; resolve: (callbackUrl: string) => void; timeoutId: ReturnType; } | undefined; const queuedProtocolUrls: string[] = []; function registerAppProtocol() { if (process.defaultApp && process.argv[1]) { app.setAsDefaultProtocolClient(APP_PROTOCOL_SCHEME, process.execPath, [ path.resolve(process.argv[1]), ]); return; } app.setAsDefaultProtocolClient(APP_PROTOCOL_SCHEME); } function closeAuthWindow() { if (authWindow && !authWindow.isDestroyed()) { for (const child of authWindow.getChildWindows()) { if (!child.isDestroyed()) { child.close(); } } authWindow.close(); } authWindow = null; } function focusMainWindow() { if (!mainWindow) return; if (mainWindow.isMinimized()) { mainWindow.restore(); } mainWindow.show(); mainWindow.focus(); } function routeIncomingProtocolUrl(url: string) { if (pendingAuthCallback && url.startsWith(pendingAuthCallback.redirectUri)) { clearTimeout(pendingAuthCallback.timeoutId); const { resolve } = pendingAuthCallback; pendingAuthCallback = undefined; closeAuthWindow(); resolve(url); focusMainWindow(); return; } queuedProtocolUrls.push(url); focusMainWindow(); } function installApplicationMenu() { if (process.platform !== 'darwin') return; const template: Electron.MenuItemConstructorOptions[] = [ { label: app.getName(), submenu: [ { label: 'Settings…', // No accelerator here — hotkeys are managed by the renderer's // ActionRegistry + HotkeyManager so users can customize bindings. click: () => { const target = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; target?.webContents.send('app:open-settings'); }, }, { type: 'separator' }, { role: 'about' }, { type: 'separator' }, { role: 'services' }, { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }, { role: 'quit' }, ], }, { role: 'editMenu', }, { role: 'viewMenu', }, { role: 'windowMenu', }, ]; Menu.setApplicationMenu(Menu.buildFromTemplate(template)); } function attachOAuthRedirectCapture(w: BrowserWindow, redirectUri: string) { const onRedirectLikeUrl = (event: Electron.Event, url: string) => { if (url.startsWith(redirectUri)) { event.preventDefault(); routeIncomingProtocolUrl(url); } }; w.webContents.on('will-navigate', onRedirectLikeUrl); w.webContents.on('will-redirect', onRedirectLikeUrl); } function startHabitatLogin(authUrl: string, redirectUri: string) { const queuedMatchIndex = queuedProtocolUrls.findIndex((url) => url.startsWith(redirectUri), ); if (queuedMatchIndex >= 0) { const [queuedMatch] = queuedProtocolUrls.splice(queuedMatchIndex, 1); return Promise.resolve(queuedMatch); } if (pendingAuthCallback) { return Promise.reject(new Error('A sign-in is already in progress.')); } return new Promise((resolve, reject) => { const timeoutId = setTimeout( () => { pendingAuthCallback = undefined; closeAuthWindow(); reject(new Error('Authentication timed out. Please try again.')); }, 5 * 60 * 1000, ); pendingAuthCallback = { redirectUri, reject, resolve, timeoutId, }; const win = new BrowserWindow({ title: 'Sign in', width: 520, height: 720, show: false, ...(mainWindow && !mainWindow.isDestroyed() ? { parent: mainWindow, modal: true } : {}), webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: true, }, }); authWindow = win; win.webContents.setWindowOpenHandler(() => ({ action: 'allow', overrideBrowserWindowOptions: { title: 'Sign in', parent: win, width: 520, height: 720, webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: true, }, }, })); win.webContents.on('did-create-window', (childWindow) => { attachOAuthRedirectCapture(childWindow, redirectUri); }); attachOAuthRedirectCapture(win, redirectUri); win.once('closed', () => { authWindow = null; if (!pendingAuthCallback) return; clearTimeout(pendingAuthCallback.timeoutId); const { reject: rejectPending } = pendingAuthCallback; pendingAuthCallback = undefined; rejectPending(new Error('Sign-in was cancelled.')); }); win .loadURL(authUrl) .then(() => { if (!win.isDestroyed()) { win.show(); } }) .catch((error) => { clearTimeout(timeoutId); pendingAuthCallback = undefined; closeAuthWindow(); reject(error instanceof Error ? error : new Error(String(error))); }); }); } const createWindow = () => { // TODO: Unify window chrome setup once non-macOS support is implemented. const isMac = process.platform === 'darwin'; // Create the browser window. mainWindow = new BrowserWindow({ width: 800, height: 600, // macOS: `titleBarOverlay` enables Window Controls Overlay + `titlebar-area-*` CSS env so // custom UI can live in the real title bar strip next to the traffic lights. ...(isMac ? { titleBarStyle: 'hidden' as const, titleBarOverlay: true, } : {}), webPreferences: { preload: path.join(__dirname, 'preload.js'), }, }); // and load the index.html of the app. if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL); } else { mainWindow.loadFile( path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`), ); } // Open the DevTools. mainWindow.webContents.openDevTools(); mainWindow.on('closed', () => { mainWindow = null; }); }; // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. app.on('ready', () => { registerAppProtocol(); installApplicationMenu(); createWindow(); }); ipcMain.handle('vault:show-open-dialog', async (_event) => { if (!mainWindow) return { canceled: true, filePaths: [] }; const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory', 'createDirectory'], }); return result; }); ipcMain.handle('vault:readdir', async (_event, { dirPath }) => { const expanded = expandPath(dirPath); // Ensure the directory exists before listing; create it if missing. await fs.mkdir(expanded, { recursive: true }); const entries = await fs.readdir(expanded, { withFileTypes: true }); return entries.map((e) => ({ name: e.name, isDirectory: e.isDirectory(), isFile: e.isFile(), })); }); ipcMain.handle('vault:read-file', async (_event, { filePath }) => { const expanded = expandPath(filePath); const content = await fs.readFile(expanded, 'utf-8'); return content; }); ipcMain.handle('vault:write-file', async (_event, { filePath, content }) => { const expanded = expandPath(filePath); // Ensure parent directories exist before writing. await fs.mkdir(path.dirname(expanded), { recursive: true }); await fs.writeFile(expanded, content, 'utf-8'); }); ipcMain.handle('vault:file-exists', async (_event, { filePath }) => { try { await fs.access(expandPath(filePath)); return true; } catch { return false; } }); ipcMain.handle('vault:mkdir', async (_event, { dirPath }) => { await fs.mkdir(expandPath(dirPath), { recursive: true }); }); ipcMain.handle('vault:rename', async (_event, { oldPath, newPath }) => { await fs.rename(expandPath(oldPath), expandPath(newPath)); }); ipcMain.handle('vault:delete', async (_event, { filePath }) => { await fs.rm(expandPath(filePath), { recursive: true, force: true }); }); ipcMain.handle('vault:copy-file', async (_event, { srcPath, destPath }) => { await fs.copyFile(expandPath(srcPath), expandPath(destPath)); }); ipcMain.handle( 'app:open-external', async (_event, { url }: { url: string }) => { // Only follow web/mail links — never file:// or app-internal schemes. if (/^(https?|mailto):/i.test(url)) { await shell.openExternal(url); } }, ); ipcMain.handle( 'context-menu:show', async ( event, { items, }: { items: Array<{ id: string; label: string; type?: 'normal' | 'separator'; }>; }, ) => { const win = BrowserWindow.fromWebContents(event.sender); if (!win) return null; return new Promise((resolve) => { let resolved = false; const menu = Menu.buildFromTemplate( items.map((item) => ({ label: item.label, type: item.type === 'separator' ? ('separator' as const) : ('normal' as const), click: () => { if (!resolved) { resolved = true; resolve(item.id); } }, })), ); menu.popup({ window: win, callback: () => { if (!resolved) { resolved = true; resolve(null); } }, }); }); }, ); ipcMain.handle( 'auth:start-habitat-login', async (_event, payload: { redirectUri: string; url: string }) => { return startHabitatLogin(payload.url, payload.redirectUri); }, ); app.on('open-url', (event, url) => { event.preventDefault(); routeIncomingProtocolUrl(url); }); app.on('second-instance', (_event, argv) => { const callbackUrl = argv.find((arg) => arg.startsWith(`${APP_PROTOCOL_SCHEME}:/`), ); if (callbackUrl) { routeIncomingProtocolUrl(callbackUrl); } else { focusMainWindow(); } }); // Quit when all windows are closed, except on macOS. There, it's common // for applications and their menu bar to stay active until the user quits // explicitly with Cmd + Q. app.on('window-all-closed', () => { // TODO: Revisit platform-specific quit behavior when cross-platform UX is finalized. if (process.platform !== 'darwin') { app.quit(); } }); app.on('activate', () => { // On OS X it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); // In this file you can include the rest of your app's specific main process // code. You can also put them in separate files and import them here.