g the widget is always one click away.
2. Implementation Steps
Step A: Protocol Registration
Register the custom scheme during application initialization. This ensures the OS knows to route specific URLs to your application.
// src/main/protocol-registry.ts
import { app, protocol } from 'electron';
import { is } from '@electron-toolkit/utils';
const SCHEME_NAME = 'deskcal';
export function registerProtocol(): void {
if (is.dev) {
protocol.registerSchemesAsPrivileged([
{ scheme: SCHEME_NAME, privileges: { standard: true, secure: true } }
]);
}
app.whenReady().then(() => {
// Set as default protocol client for the current user
if (process.defaultApp) {
app.setAsDefaultProtocolClient(SCHEME_NAME);
}
});
}
Step B: OAuth Interception and Token Exchange
The main process must listen for the protocol event. When the user completes authentication in the external browser, the callback URL triggers this handler. The code is exchanged for tokens securely within the main process.
// src/main/auth/oauth-interceptor.ts
import { app, BrowserWindow } from 'electron';
import { exchangeCodeForTokens } from './token-service';
export function setupOAuthInterceptor(): void {
// Handle second instance (Windows/Linux deep link)
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
return;
}
app.on('second-instance', (_event, commandLine) => {
const url = commandLine.find(arg => arg.startsWith(`${SCHEME_NAME}://`));
if (url) handleCallbackUrl(url);
});
// Handle macOS open-url event
app.on('open-url', (_event, url) => {
handleCallbackUrl(url);
});
}
function handleCallbackUrl(url: string): void {
const parsedUrl = new URL(url);
if (parsedUrl.hostname === 'auth/callback') {
const authCode = parsedUrl.searchParams.get('code');
if (authCode) {
exchangeCodeForTokens(authCode)
.then(tokens => {
const mainWindow = BrowserWindow.getAllWindows()[0];
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('auth:success', tokens);
}
})
.catch(err => {
console.error('Token exchange failed:', err);
});
}
}
}
Step C: System Tray and Window Management
The tray icon serves as the primary entry point. The window should support "always on top" behavior for glanceability but must respect focus rules to avoid stealing input from other applications.
// src/main/ui/tray-manager.ts
import { Tray, Menu, app, BrowserWindow } from 'electron';
import path from 'path';
export function initializeTray(mainWindow: BrowserWindow): Tray {
const iconPath = path.join(__dirname, '../../assets/tray-icon.png');
const tray = new Tray(iconPath);
const contextMenu = Menu.buildFromTemplate([
{
label: 'Toggle Schedule',
click: () => {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
mainWindow.focus();
}
}
},
{ type: 'separator' },
{ label: 'Quit', click: () => app.quit() }
]);
tray.setToolTip('DeskCal - Persistent Schedule');
tray.setContextMenu(contextMenu);
// Double-click to toggle
tray.on('double-click', () => {
mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show();
});
return tray;
}
Step D: Window Configuration
Configure the BrowserWindow to minimize to the tray rather than closing, and enable developer tools only in non-production builds.
// src/main/ui/window-config.ts
import { BrowserWindow } from 'electron';
import { is } from '@electron-toolkit/utils';
export function createMainWindow(): BrowserWindow {
const win = new BrowserWindow({
width: 400,
height: 600,
show: false,
frame: false,
transparent: true,
alwaysOnTop: true,
skipTaskbar: true,
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true
}
});
// Hide on close to keep running in tray
win.on('close', (event) => {
if (!app.isQuitting) {
event.preventDefault();
win.hide();
}
});
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(process.env['ELECTRON_RENDERER_URL']);
} else {
win.loadFile(path.join(__dirname, '../renderer/index.html'));
}
return win;
}
Pitfall Guide
Production-grade desktop widgets encounter specific challenges that do not appear in web development. The following pitfalls are derived from real-world deployment experience.
-
Protocol Registration Conflicts
- Issue: Another application may already claim the custom URI scheme, causing the OS to route callbacks to the wrong app.
- Fix: Use a highly unique scheme name (e.g., include a namespace or reverse domain). Verify registration status on startup and prompt the user if a conflict is detected.
-
OAuth Redirect URI Mismatch
- Issue: Google Cloud Console requires an exact match for redirect URIs. A typo in the scheme or path results in immediate
redirect_uri_mismatch errors.
- Fix: Maintain a single source of truth for the scheme constant. Ensure the Google Console configuration matches
deskcal://auth/callback character-for-character. Test with both uppercase and lowercase variations, as some OS implementations normalize URLs differently.
-
Insecure Token Storage
- Issue: Storing OAuth tokens in plain text files or
localStorage exposes credentials to malware or unauthorized access.
- Fix: Use the OS-native keychain. Libraries like
keytar (or electron-safe-storage) integrate with Windows Credential Manager, macOS Keychain, and Linux Secret Service. Tokens should never be written to disk in readable format.
-
Window Focus Stealing
- Issue: A widget with
alwaysOnTop can interfere with user input if it captures focus unexpectedly, disrupting typing or mouse actions.
- Fix: Implement "glance mode" where the window does not steal focus. Use
focusable: false for overlay widgets, or ensure the window only appears on explicit tray interaction. Respect OS accessibility settings regarding focus behavior.
-
Timezone Drift and DST Bugs
- Issue: Widgets often display events in the wrong time slot due to improper handling of Daylight Saving Time or server vs. client timezone mismatches.
- Fix: Store and transmit all event data in UTC. Perform timezone conversion exclusively in the renderer process using the user's local timezone settings. Validate logic during DST transition dates.
-
Electron Auto-Update Failures
- Issue: Silent updates may fail if the application is running, or code signing issues may block installation on macOS/Windows.
- Fix: Implement
electron-updater with proper code signing certificates. Configure the update server to serve delta updates. Handle update-downloaded events to prompt the user to restart, ensuring the new version is applied cleanly.
-
Memory Leaks in WebContents
- Issue: Repeatedly creating and destroying windows or webviews without proper cleanup leads to gradual memory consumption, causing system slowdowns.
- Fix: Ensure all event listeners are removed when windows close. Use
webContents.close() explicitly. Monitor memory usage with Chrome DevTools in production builds to detect leaks early.
Production Bundle
Action Checklist
Decision Matrix
Selecting the right technology stack depends on project constraints regarding binary size, development speed, and native integration depth.
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Rapid Multi-Platform Release | Electron | Single JS/TS codebase; mature ecosystem; instant Windows/Mac/Linux support. | Higher binary size (~100MB); moderate RAM usage. |
| Minimal Footprint Required | Tauri | Rust backend; tiny binaries (~5MB); lower memory usage. | Steeper learning curve; plugin ecosystem less mature than Electron. |
| Maximum Native Performance | Platform Native (Swift/WinUI) | Best integration with OS features; optimal resource usage. | 3x development effort; requires separate teams per platform. |
| Internal Tool / Prototype | Electron + Preload | Fast iteration; access to Node.js APIs via preload scripts. | Security risks if preload is misconfigured; not recommended for public distribution without hardening. |
Configuration Template
Use this template for package.json to ensure correct protocol registration and build configuration.
{
"name": "deskcal-widget",
"version": "1.0.0",
"description": "Cross-platform scheduling widget",
"main": "dist/main/index.js",
"protocols": [
{
"name": "DeskCal",
"schemes": ["deskcal"]
}
],
"build": {
"appId": "com.example.deskcal",
"productName": "DeskCal",
"mac": {
"category": "public.app-category.productivity",
"target": ["dmg", "zip"]
},
"win": {
"target": ["nsis", "portable"],
"icon": "assets/icon.ico"
},
"linux": {
"target": ["AppImage", "deb"],
"category": "Office"
},
"files": [
"dist/**/*",
"assets/**/*",
"package.json"
]
},
"dependencies": {
"electron-updater": "^6.1.0",
"keytar": "^7.9.0"
}
}
Quick Start Guide
- Initialize Project: Run
npm init and install dependencies: electron, @electron-toolkit/utils, electron-builder, and keytar.
- Setup Structure: Create
src/main, src/preload, and src/renderer directories. Implement the protocol registration and window configuration code provided in the Core Solution.
- Configure OAuth: Create a project in Google Cloud Console. Enable the Calendar API. Create OAuth 2.0 credentials and add
deskcal://auth/callback as an authorized redirect URI.
- Run Development: Execute
npm run dev to launch the application. Verify that clicking the tray icon opens the window and that the OAuth flow redirects correctly to the custom protocol.
- Build Binaries: Run
npm run build to generate installers for your target platforms. Test the installers on clean environments to ensure protocol registration and auto-updates function correctly.