mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-07-16 06:42:23 -05:00
On X11/XWayland (Electron's default on Wayland sessions) the window had no icon, so KDE Plasma showed the generic placeholder. Point BrowserWindow at the packaged icon.png so the compositor has an icon to render.
567 lines
16 KiB
JavaScript
567 lines
16 KiB
JavaScript
const {
|
|
app,
|
|
BrowserWindow,
|
|
globalShortcut,
|
|
ipcMain,
|
|
Menu,
|
|
nativeImage,
|
|
shell,
|
|
Tray,
|
|
screen,
|
|
} = require('electron')
|
|
const path = require('path')
|
|
const fs = require('fs')
|
|
const express = require('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 ZOOM_STEP = 0.5
|
|
const ZOOM_CONFIG_FILE = 'zoom.json'
|
|
|
|
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
|
|
let currentShortcut = null
|
|
let zoomLevel = 0
|
|
const DEFAULT_QUICK_ENTRY_SHORTCUT = 'CmdOrCtrl+Shift+A'
|
|
const launchedWithQuickEntry = process.argv.includes('--quick-entry')
|
|
|
|
// Ensure single instance so deep links reach the running app on Windows/Linux
|
|
const gotTheLock = app.requestSingleInstanceLock()
|
|
if (!gotTheLock) {
|
|
app.quit()
|
|
// Must exit the process immediately — app.quit() is async and the rest of this
|
|
// file would still execute, potentially opening a blank window.
|
|
process.exit(0)
|
|
}
|
|
|
|
// Register the custom protocol for deep links
|
|
if (process.defaultApp) {
|
|
// During development, register with the path to the script
|
|
if (process.argv.length >= 2) {
|
|
app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [path.resolve(process.argv[1])])
|
|
}
|
|
} else {
|
|
app.setAsDefaultProtocolClient(PROTOCOL)
|
|
}
|
|
|
|
// Handle deep link on macOS (app already running or launched via URL)
|
|
app.on('open-url', (event, url) => {
|
|
event.preventDefault()
|
|
if (mainWindow) {
|
|
handleDeepLink(url)
|
|
} else {
|
|
// Window not ready yet — buffer the URL for processing after createMainWindow()
|
|
pendingDeepLinkUrl = url
|
|
}
|
|
})
|
|
|
|
// Handle deep link on Windows/Linux when a second instance is launched
|
|
app.on('second-instance', (_event, argv) => {
|
|
// Handle --quick-entry flag from second instance
|
|
if (argv.includes('--quick-entry')) {
|
|
if (serverPort) {
|
|
toggleQuickEntry()
|
|
}
|
|
return
|
|
}
|
|
|
|
// Reveal the main window. It may be hidden in the tray (not just minimized),
|
|
// so show() is required — focus() alone won't surface a hidden window, which
|
|
// made the app look dead when relaunched while running in the tray.
|
|
if (mainWindow) {
|
|
if (mainWindow.isMinimized()) mainWindow.restore()
|
|
mainWindow.show()
|
|
mainWindow.focus()
|
|
} else if (serverPort) {
|
|
createMainWindow()
|
|
}
|
|
|
|
// Find the deep link URL in argv
|
|
const deepLinkUrl = argv.find(arg => arg.startsWith(`${PROTOCOL}://`))
|
|
if (deepLinkUrl) {
|
|
handleDeepLink(deepLinkUrl)
|
|
}
|
|
})
|
|
|
|
function handleDeepLink(url) {
|
|
try {
|
|
const parsed = new URL(url)
|
|
if (parsed.hostname === 'callback') {
|
|
const code = parsed.searchParams.get('code')
|
|
if (code && mainWindow) {
|
|
// Store the apiUrl that was used to start login so we can
|
|
// exchange the code at the correct endpoint
|
|
const apiUrl = pendingApiUrl
|
|
if (!apiUrl) {
|
|
mainWindow.webContents.send('oauth:error', 'No pending login session')
|
|
return
|
|
}
|
|
|
|
oauth.exchangeCodeForTokens(apiUrl, code)
|
|
.then(tokens => {
|
|
mainWindow.webContents.send('oauth:tokens', tokens)
|
|
})
|
|
.catch(err => {
|
|
mainWindow.webContents.send('oauth:error', err.message)
|
|
})
|
|
}
|
|
}
|
|
} catch {
|
|
// Invalid URL, ignore
|
|
}
|
|
}
|
|
|
|
// IPC: Start OAuth login flow
|
|
ipcMain.handle('oauth:start-login', async (_event, apiUrl) => {
|
|
pendingApiUrl = apiUrl
|
|
const authUrl = oauth.startLogin(apiUrl)
|
|
await shell.openExternal(authUrl)
|
|
})
|
|
|
|
// IPC: Refresh access token
|
|
ipcMain.handle('oauth:refresh-token', async (_event, apiUrl, refreshToken) => {
|
|
return oauth.refreshAccessToken(apiUrl, refreshToken)
|
|
})
|
|
|
|
// ─── 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)
|
|
})
|
|
})
|
|
}
|
|
|
|
// ─── Zoom ────────────────────────────────────────────────────────────
|
|
function zoomConfigPath() {
|
|
return path.join(app.getPath('userData'), ZOOM_CONFIG_FILE)
|
|
}
|
|
|
|
function loadZoomLevel() {
|
|
try {
|
|
const raw = fs.readFileSync(zoomConfigPath(), 'utf8')
|
|
const parsed = JSON.parse(raw)
|
|
if (typeof parsed.zoomLevel === 'number' && Number.isFinite(parsed.zoomLevel)) {
|
|
return parsed.zoomLevel
|
|
}
|
|
} catch {
|
|
// First run or unreadable file, fall back to default
|
|
}
|
|
return 0
|
|
}
|
|
|
|
function saveZoomLevel(level) {
|
|
try {
|
|
fs.writeFileSync(zoomConfigPath(), JSON.stringify({zoomLevel: level}))
|
|
} catch (err) {
|
|
console.warn('Failed to persist zoom level:', err.message)
|
|
}
|
|
}
|
|
|
|
function applyZoom(webContents, level) {
|
|
zoomLevel = level
|
|
webContents.setZoomLevel(level)
|
|
saveZoomLevel(level)
|
|
}
|
|
|
|
function wireZoomHandlers(win) {
|
|
win.webContents.on('before-input-event', (event, input) => {
|
|
if (input.type !== 'keyDown' || !input.control || input.alt || input.meta) return
|
|
const key = input.key
|
|
if (key === '=' || key === '+') {
|
|
applyZoom(win.webContents, zoomLevel + ZOOM_STEP)
|
|
event.preventDefault()
|
|
} else if (key === '-') {
|
|
applyZoom(win.webContents, zoomLevel - ZOOM_STEP)
|
|
event.preventDefault()
|
|
} else if (key === '0') {
|
|
applyZoom(win.webContents, 0)
|
|
event.preventDefault()
|
|
}
|
|
})
|
|
|
|
win.webContents.on('zoom-changed', (_event, direction) => {
|
|
const delta = direction === 'in' ? ZOOM_STEP : -ZOOM_STEP
|
|
applyZoom(win.webContents, zoomLevel + delta)
|
|
})
|
|
}
|
|
|
|
// ─── Main window ─────────────────────────────────────────────────────
|
|
function createMainWindow() {
|
|
mainWindow = new BrowserWindow({
|
|
width: 1680,
|
|
height: 960,
|
|
// Without an explicit window icon, X11/XWayland compositors (e.g. KDE
|
|
// Plasma) fall back to a generic placeholder when WM_CLASS doesn't match
|
|
// an installed .desktop file. icon.png lives at the app root because
|
|
// build/ is electron-builder's buildResources dir and isn't packaged.
|
|
icon: path.join(__dirname, 'icon.png'),
|
|
webPreferences: {
|
|
...BASE_WEB_PREFERENCES,
|
|
preload: path.join(__dirname, 'preload.js'),
|
|
},
|
|
})
|
|
|
|
mainWindow.webContents.setWindowOpenHandler(({url}) => {
|
|
safeOpenExternal(url)
|
|
return {action: 'deny'}
|
|
})
|
|
|
|
// Prevent same-window navigation to external origins.
|
|
// Only allow navigation to the local express server on the exact port.
|
|
mainWindow.webContents.on('will-navigate', (event, navigationUrl) => {
|
|
const parsedUrl = new URL(navigationUrl)
|
|
if (parsedUrl.origin === `http://127.0.0.1:${serverPort}`) {
|
|
return
|
|
}
|
|
event.preventDefault()
|
|
})
|
|
|
|
mainWindow.setMenuBarVisibility(false)
|
|
|
|
mainWindow.on('close', (e) => {
|
|
if (!isQuitting && tray) {
|
|
e.preventDefault()
|
|
mainWindow.hide()
|
|
}
|
|
})
|
|
|
|
mainWindow.on('closed', () => {
|
|
mainWindow = null
|
|
})
|
|
|
|
mainWindow.loadURL(`http://127.0.0.1:${serverPort}`)
|
|
|
|
wireZoomHandlers(mainWindow)
|
|
mainWindow.webContents.on('did-finish-load', () => {
|
|
mainWindow.webContents.setZoomLevel(zoomLevel)
|
|
})
|
|
|
|
// 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
|
|
}
|
|
})
|
|
}
|
|
|
|
// ─── 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),
|
|
}
|
|
}
|
|
|
|
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) {
|
|
// NOTE: load the icon from the app root, not build/. The build/ directory is
|
|
// electron-builder's buildResources dir and is NOT packaged into the app, so
|
|
// referencing build/icon.png here works in dev but yields an empty tray icon
|
|
// in packaged releases (see issue #2668).
|
|
const iconPath = path.join(__dirname, 'icon.png')
|
|
const icon = nativeImage.createFromPath(iconPath).resize({width: 16, height: 16})
|
|
tray = new Tray(icon)
|
|
tray.setToolTip('Vikunja')
|
|
tray.on('click', () => {
|
|
if (mainWindow) {
|
|
mainWindow.show()
|
|
mainWindow.focus()
|
|
} else {
|
|
createMainWindow()
|
|
}
|
|
})
|
|
}
|
|
|
|
const contextMenu = Menu.buildFromTemplate([
|
|
{
|
|
label: 'Show Vikunja',
|
|
click: () => {
|
|
if (mainWindow) {
|
|
mainWindow.show()
|
|
mainWindow.focus()
|
|
} else {
|
|
createMainWindow()
|
|
}
|
|
},
|
|
},
|
|
{
|
|
label: 'Quick Add Task',
|
|
accelerator: currentShortcut || undefined,
|
|
click: () => showQuickEntry(),
|
|
},
|
|
{type: 'separator'},
|
|
{
|
|
label: 'Quit',
|
|
click: () => {
|
|
isQuitting = true
|
|
app.quit()
|
|
},
|
|
},
|
|
])
|
|
|
|
tray.setContextMenu(contextMenu)
|
|
}
|
|
|
|
// ─── 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)
|
|
})
|
|
|
|
ipcMain.on('quick-entry:show-main-window', () => {
|
|
if (mainWindow) {
|
|
mainWindow.show()
|
|
mainWindow.focus()
|
|
} else {
|
|
createMainWindow()
|
|
}
|
|
})
|
|
|
|
// ─── Shortcut management ────────────────────────────────────────────
|
|
function registerQuickEntryShortcut(shortcut) {
|
|
if (currentShortcut) {
|
|
globalShortcut.unregister(currentShortcut)
|
|
}
|
|
|
|
if (!shortcut) {
|
|
currentShortcut = null
|
|
return
|
|
}
|
|
|
|
const registered = globalShortcut.register(shortcut, toggleQuickEntry)
|
|
if (registered) {
|
|
currentShortcut = shortcut
|
|
} else {
|
|
console.warn(`Failed to register global shortcut ${shortcut} — it may be in use by another application`)
|
|
currentShortcut = null
|
|
}
|
|
}
|
|
|
|
ipcMain.on('desktop:update-quick-entry-shortcut', (_event, shortcut) => {
|
|
registerQuickEntryShortcut(shortcut)
|
|
// Rebuild tray menu to reflect the new accelerator
|
|
if (tray) {
|
|
setupTray()
|
|
}
|
|
})
|
|
|
|
// ─── App lifecycle ───────────────────────────────────────────────────
|
|
app.whenReady().then(() => {
|
|
zoomLevel = loadZoomLevel()
|
|
|
|
startServer(() => {
|
|
createMainWindow()
|
|
createQuickEntryWindow()
|
|
setupTray()
|
|
|
|
registerQuickEntryShortcut(DEFAULT_QUICK_ENTRY_SHORTCUT)
|
|
|
|
// If launched with --quick-entry, show the quick entry window immediately
|
|
if (launchedWithQuickEntry) {
|
|
showQuickEntry()
|
|
}
|
|
})
|
|
|
|
app.on('activate', () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
if (serverPort) {
|
|
createMainWindow()
|
|
}
|
|
} else if (mainWindow) {
|
|
mainWindow.show()
|
|
mainWindow.focus()
|
|
}
|
|
})
|
|
})
|
|
|
|
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()
|
|
}
|
|
})
|
|
|
|
// Quit on termination signals (DE/systemd shutdown, `kill`). Without an explicit
|
|
// handler the app ignores SIGTERM because the tray and express server keep the
|
|
// event loop alive — leaving users to `kill -9`. isQuitting must be set first so
|
|
// the hide-to-tray close handler doesn't swallow the quit.
|
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
process.on(signal, () => {
|
|
isQuitting = true
|
|
app.quit()
|
|
})
|
|
}
|