Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454import { 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<typeof setTimeout>; } | 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<string>((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<string | null>((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.