diff --git a/desktop/main.js b/desktop/main.js index 02c913d3a..b5cc779b2 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -1,15 +1,56 @@ -const {app, BrowserWindow, shell, ipcMain} = require('electron') +const { + app, + BrowserWindow, + globalShortcut, + ipcMain, + Menu, + nativeImage, + shell, + Tray, + screen, +} = require('electron') const path = require('path') const express = require('express') -const eApp = express() const portInUse = require('./portInUse.js') const oauth = require('./oauth.js') const frontendPath = 'frontend/' const PROTOCOL = 'vikunja-desktop' +const SAFE_PROTOCOLS = new Set([ + 'http:', 'https:', 'mailto:', + 'ftp:', 'git:', 'obsidian:', 'notion:', 'message:', +]) +const QUICK_ENTRY_WIDTH = 680 +const QUICK_ENTRY_COLLAPSED_HEIGHT = 56 + +const BASE_WEB_PREFERENCES = { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + webviewTag: false, + navigateOnDragDrop: false, +} + +function safeOpenExternal(url) { + try { + const parsed = new URL(url) + if (SAFE_PROTOCOLS.has(parsed.protocol)) { + shell.openExternal(url) + } + } catch { + // Ignore malformed URLs + } +} + +// Module-scope state let mainWindow = null +let quickEntryWindow = null +let tray = null +let serverPort = null +let isQuitting = false let pendingDeepLinkUrl = null +let pendingApiUrl = null // Ensure single instance so deep links reach the running app on Windows/Linux const gotTheLock = app.requestSingleInstanceLock() @@ -36,7 +77,7 @@ app.on('open-url', (event, url) => { if (mainWindow) { handleDeepLink(url) } else { - // Window not ready yet — buffer the URL for processing after createWindow() + // Window not ready yet — buffer the URL for processing after createMainWindow() pendingDeepLinkUrl = url } }) @@ -84,8 +125,6 @@ function handleDeepLink(url) { } } -let pendingApiUrl = null - // IPC: Start OAuth login flow ipcMain.handle('oauth:start-login', async (_event, apiUrl) => { pendingApiUrl = apiUrl @@ -98,108 +137,291 @@ ipcMain.handle('oauth:refresh-token', async (_event, apiUrl, refreshToken) => { return oauth.refreshAccessToken(apiUrl, refreshToken) }) -function createWindow() { - // Create the browser window. +// ─── Express server ────────────────────────────────────────────────── +function startServer(callback) { + const eApp = express() + let port = 45735 + + portInUse(port, (used) => { + if (used) { + console.log(`Port ${port} already used, switching to a random one`) + port = 0 + } + + eApp.use(express.static(path.join(__dirname, frontendPath))) + eApp.use((request, response) => { + response.sendFile(path.join(__dirname, frontendPath, 'index.html')) + }) + + const server = eApp.listen(port, '127.0.0.1', () => { + serverPort = server.address().port + console.log(`Server started on port ${serverPort}`) + callback(serverPort) + }) + }) +} + +// ─── Main window ───────────────────────────────────────────────────── +function createMainWindow() { mainWindow = new BrowserWindow({ width: 1680, height: 960, webPreferences: { + ...BASE_WEB_PREFERENCES, preload: path.join(__dirname, 'preload.js'), - nodeIntegration: false, - contextIsolation: true, - sandbox: true, - webviewTag: false, - navigateOnDragDrop: false, - } + }, }) - // Open external links in the browser, but only allow protocols - // that the TipTap editor also allows (see frontend/src/components/input/editor/TipTap.vue). - // TipTap allows: http, https (built-in) + ftp, git, obsidian, notion, message - // We also allow mailto since it's a standard safe protocol for email links. - mainWindow.webContents.setWindowOpenHandler(({ url }) => { - try { - const parsedUrl = new URL(url); - const allowedProtocols = [ - 'http:', 'https:', 'mailto:', - 'ftp:', 'git:', 'obsidian:', 'notion:', 'message:', - ]; - if (allowedProtocols.includes(parsedUrl.protocol)) { - shell.openExternal(url); - } - } catch { - // Invalid URL, ignore silently - } - return { action: 'deny' }; - }); + mainWindow.webContents.setWindowOpenHandler(({url}) => { + safeOpenExternal(url) + return {action: 'deny'} + }) // Prevent same-window navigation to external origins. - // Only allow navigation to the local express server. + // Only allow navigation to the local express server on the exact port. mainWindow.webContents.on('will-navigate', (event, navigationUrl) => { - const parsedUrl = new URL(navigationUrl); - // Allow navigations to the local express server - if (parsedUrl.hostname === '127.0.0.1' || parsedUrl.hostname === 'localhost') { - return; + const parsedUrl = new URL(navigationUrl) + if (parsedUrl.origin === `http://127.0.0.1:${serverPort}`) { + return } - event.preventDefault(); - }); + event.preventDefault() + }) - // Hide the toolbar mainWindow.setMenuBarVisibility(false) + mainWindow.on('close', (e) => { + if (!isQuitting && tray) { + e.preventDefault() + mainWindow.hide() + } + }) + mainWindow.on('closed', () => { mainWindow = null }) - // We try to use the same port every time and only use a different one if that does not succeed. - let port = 45735 - portInUse(port, used => { - if(used) { - console.log(`Port ${port} already used, switching to a random one`) - port = 0 // This lets express choose a random port + mainWindow.loadURL(`http://127.0.0.1:${serverPort}`) + + // Process any deep link that arrived before the page was ready, + // either buffered from open-url or passed via process.argv on first launch + mainWindow.webContents.once('did-finish-load', () => { + if (!pendingDeepLinkUrl) { + pendingDeepLinkUrl = process.argv.find(arg => arg.startsWith(`${PROTOCOL}://`)) || null + } + if (pendingDeepLinkUrl) { + handleDeepLink(pendingDeepLinkUrl) + pendingDeepLinkUrl = null } - - // Start a local express server to serve static files - eApp.use(express.static(path.join(__dirname, frontendPath))) - // Handle urls set by the frontend - use app.use as catch-all instead of route pattern - eApp.use((request, response) => { - response.sendFile(path.join(__dirname, frontendPath, 'index.html')) - }) - const server = eApp.listen(port, '127.0.0.1', () => { - console.log(`Server started on port ${server.address().port}`) - mainWindow.loadURL(`http://127.0.0.1:${server.address().port}`) - - // Process any deep link that arrived before the page was ready, - // either buffered from open-url or passed via process.argv on first launch - mainWindow.webContents.once('did-finish-load', () => { - if (!pendingDeepLinkUrl) { - pendingDeepLinkUrl = process.argv.find(arg => arg.startsWith(`${PROTOCOL}://`)) || null - } - if (pendingDeepLinkUrl) { - handleDeepLink(pendingDeepLinkUrl) - pendingDeepLinkUrl = 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.whenReady().then(() => { - createWindow() +// ─── Quick Entry window ────────────────────────────────────────────── +function getQuickEntryPosition() { + const cursorPoint = screen.getCursorScreenPoint() + const display = screen.getDisplayNearestPoint(cursorPoint) + const {x: areaX, y: areaY, width: areaWidth, height: areaHeight} = display.workArea + return { + x: Math.round(areaX + (areaWidth - QUICK_ENTRY_WIDTH) / 2), + y: Math.round(areaY + areaHeight / 3 - QUICK_ENTRY_COLLAPSED_HEIGHT / 2), + } +} - app.on('activate', function () { - // On macOS 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() +function createQuickEntryWindow() { + const {x, y} = getQuickEntryPosition() + + quickEntryWindow = new BrowserWindow({ + width: QUICK_ENTRY_WIDTH, + height: QUICK_ENTRY_COLLAPSED_HEIGHT, + x, + y, + frame: false, + transparent: true, + alwaysOnTop: true, + skipTaskbar: true, + resizable: false, + show: false, + webPreferences: { + ...BASE_WEB_PREFERENCES, + preload: path.join(__dirname, 'preload-quick-entry.js'), + }, + }) + + quickEntryWindow.webContents.setWindowOpenHandler(({url}) => { + safeOpenExternal(url) + return {action: 'deny'} + }) + + quickEntryWindow.webContents.on('will-navigate', (event, navigationUrl) => { + const parsedUrl = new URL(navigationUrl) + if (parsedUrl.origin === `http://127.0.0.1:${serverPort}`) { + return + } + event.preventDefault() + }) + + quickEntryWindow.loadURL(`http://127.0.0.1:${serverPort}/?mode=quick-add`) + + // Hide on blur (user clicked outside) + let blurTimeout = null + quickEntryWindow.on('blur', () => { + // Debounce to avoid hiding during DevTools focus changes + blurTimeout = setTimeout(() => hideQuickEntry(), 100) + }) + quickEntryWindow.on('focus', () => { + if (blurTimeout) { + clearTimeout(blurTimeout) + blurTimeout = null + } + }) + + quickEntryWindow.on('closed', () => { + quickEntryWindow = null + }) +} + +function showQuickEntry() { + if (!quickEntryWindow) { + createQuickEntryWindow() + quickEntryWindow.once('ready-to-show', () => { + quickEntryWindow.show() + quickEntryWindow.focus() + quickEntryWindow.webContents.focus() + }) + return + } + + // Reset size and move to the active display + quickEntryWindow.setSize(QUICK_ENTRY_WIDTH, QUICK_ENTRY_COLLAPSED_HEIGHT) + const {x, y} = getQuickEntryPosition() + quickEntryWindow.setPosition(x, y) + + // Reload to reset Vue state (clear previous input) + quickEntryWindow.loadURL(`http://127.0.0.1:${serverPort}/?mode=quick-add`) + // Wait for page to finish loading before showing, so the input gets focused + quickEntryWindow.webContents.once('did-finish-load', () => { + quickEntryWindow.show() + quickEntryWindow.focus() + quickEntryWindow.webContents.focus() + }) +} + +function hideQuickEntry() { + if (quickEntryWindow && quickEntryWindow.isVisible()) { + quickEntryWindow.hide() + } +} + +function toggleQuickEntry() { + if (quickEntryWindow && quickEntryWindow.isVisible()) { + hideQuickEntry() + } else { + showQuickEntry() + } +} + +// ─── System tray ───────────────────────────────────────────────────── +function setupTray() { + if (tray) { + tray.destroy() + } + const iconPath = path.join(__dirname, 'build', 'icon.png') + const icon = nativeImage.createFromPath(iconPath).resize({width: 16, height: 16}) + tray = new Tray(icon) + + const contextMenu = Menu.buildFromTemplate([ + { + label: 'Show Vikunja', + click: () => { + if (mainWindow) { + mainWindow.show() + mainWindow.focus() + } else { + createMainWindow() + } + }, + }, + { + label: 'Quick Add Task', + accelerator: 'CmdOrCtrl+Shift+A', + click: () => showQuickEntry(), + }, + {type: 'separator'}, + { + label: 'Quit', + click: () => { + isQuitting = true + app.quit() + }, + }, + ]) + + tray.setToolTip('Vikunja') + tray.setContextMenu(contextMenu) + + tray.on('click', () => { + if (mainWindow) { + mainWindow.show() + mainWindow.focus() + } else { + createMainWindow() + } + }) +} + +// ─── IPC handlers ──────────────────────────────────────────────────── +ipcMain.on('quick-entry:close', () => { + hideQuickEntry() +}) + +ipcMain.on('quick-entry:resize', (_event, width, height) => { + if (!quickEntryWindow) return + if (!Number.isFinite(width) || !Number.isFinite(height)) return + + const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) + const maxWidth = display.workAreaSize.width + const maxHeight = display.workAreaSize.height + + const w = Math.max(100, Math.min(Math.round(width), maxWidth)) + const h = Math.max(40, Math.min(Math.round(height), maxHeight)) + quickEntryWindow.setSize(w, h) +}) + +// ─── App lifecycle ─────────────────────────────────────────────────── +app.whenReady().then(() => { + startServer((port) => { + createMainWindow() + createQuickEntryWindow() + setupTray() + + const registered = globalShortcut.register('CmdOrCtrl+Shift+A', toggleQuickEntry) + if (!registered) { + console.warn('Failed to register global shortcut CmdOrCtrl+Shift+A — it may be in use by another application') + } + }) + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + if (serverPort) { + createMainWindow() + } + } else if (mainWindow) { + mainWindow.show() + mainWindow.focus() + } }) }) -// 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', () => { - if (process.platform !== 'darwin') app.quit() +app.on('before-quit', () => { + isQuitting = true +}) + +app.on('will-quit', () => { + globalShortcut.unregisterAll() +}) + +app.on('window-all-closed', () => { + // Don't quit if tray exists (user can still use global shortcut) + if (process.platform !== 'darwin' && !tray) { + app.quit() + } }) diff --git a/desktop/preload-quick-entry.js b/desktop/preload-quick-entry.js index 813793e4e..bad2de093 100644 --- a/desktop/preload-quick-entry.js +++ b/desktop/preload-quick-entry.js @@ -3,4 +3,5 @@ const { contextBridge, ipcRenderer } = require('electron') contextBridge.exposeInMainWorld('quickEntry', { close: () => ipcRenderer.send('quick-entry:close'), + resize: (width, height) => ipcRenderer.send('quick-entry:resize', width, height), }) diff --git a/frontend/src/types/quick-entry.d.ts b/frontend/src/types/quick-entry.d.ts index b3d7ad87e..553c3e145 100644 --- a/frontend/src/types/quick-entry.d.ts +++ b/frontend/src/types/quick-entry.d.ts @@ -1,5 +1,6 @@ interface Window { quickEntry?: { close: () => void + resize: (width: number, height: number) => void } }