feat: Electron desktop app — hardening, IPC bridge, and dev launch fixes
- Dev/prod URL auto-detection (Vite 5173 in dev, server 3000 in prod) - Typed IPC bridge via preload (notifications, badge, window controls, updates, deep links) - Native OS notifications via NotificationController with window focus suppression - Auto-update via electron-updater with UpdateToast UI - Deep linking (backspace:// protocol) for macOS and Windows/Linux - Window state persistence (position, size, maximize across restarts) - Tray icon with graceful fallback when icon asset missing - Suppress PWA service worker polling/reloads inside Electron - Platform detection layer (isElectron, getElectronAPI) - Root workspace scripts (dev:desktop, build:desktop) - Document BACKSPACE_URL and BACKSPACE_UPDATE_URL env vars
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { isElectron } from './platform';
|
||||
|
||||
/**
|
||||
* Listens for deep link events from the Electron main process and navigates accordingly.
|
||||
*
|
||||
* Supported routes:
|
||||
* backspace://join/{code} → /join/{code}
|
||||
* backspace://join/{code}@{host} → /join/{code}@{host}
|
||||
* backspace://channel/{spaceId}/{channelId} → /channels/{spaceId}/{channelId}
|
||||
*/
|
||||
export function useDeepLinkHandler(): void {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron()) return;
|
||||
|
||||
const api = window.backspace!;
|
||||
api.onDeepLink((url: string) => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
console.warn('[DeepLink] Invalid URL:', url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'backspace:') return;
|
||||
|
||||
// URL host + pathname gives us the route
|
||||
// backspace://join/code → host="join", pathname="/code"
|
||||
// backspace://channel/spaceId/channelId → host="channel", pathname="/spaceId/channelId"
|
||||
const host = parsed.hostname;
|
||||
const pathParts = parsed.pathname.split('/').filter(Boolean);
|
||||
|
||||
if (host === 'join' && pathParts.length >= 1) {
|
||||
const code = pathParts[0]!;
|
||||
navigate(`/join/${code}`);
|
||||
} else if (host === 'channel' && pathParts.length >= 2) {
|
||||
const spaceId = pathParts[0]!;
|
||||
const channelId = pathParts[1]!;
|
||||
navigate(`/channels/${spaceId}/${channelId}`);
|
||||
} else {
|
||||
console.warn('[DeepLink] Unknown route:', url);
|
||||
}
|
||||
});
|
||||
}, [navigate]);
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/** Type augmentation for the Electron IPC bridge exposed by preload.ts */
|
||||
|
||||
interface BackspaceElectronAPI {
|
||||
// Platform info
|
||||
platform: NodeJS.Platform;
|
||||
|
||||
// Window controls
|
||||
minimize: () => void;
|
||||
maximize: () => void;
|
||||
close: () => void;
|
||||
|
||||
// Notifications & badge
|
||||
showNotification: (title: string, body: string) => void;
|
||||
setBadgeCount: (count: number) => void;
|
||||
|
||||
// Auto-update (Task 2.1)
|
||||
onUpdateAvailable: (callback: (info: { version: string }) => void) => void;
|
||||
onUpdateDownloaded: (callback: (info: { version: string }) => void) => void;
|
||||
onUpdateError: (callback: (error: string) => void) => void;
|
||||
installUpdate: () => void;
|
||||
checkForUpdates: () => void;
|
||||
|
||||
// Window focus (Task 2.2)
|
||||
onWindowFocusChange: (callback: (focused: boolean) => void) => void;
|
||||
|
||||
// Deep linking (Task 2.3)
|
||||
onDeepLink: (callback: (url: string) => void) => void;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
backspace?: BackspaceElectronAPI;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { isElectron } from './platform';
|
||||
|
||||
export function sendNotification(title: string, body: string): void {
|
||||
if (isElectron()) {
|
||||
window.backspace!.showNotification(title, body);
|
||||
} else if ('Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification(title, { body, icon: '/icons/icon-192.png' });
|
||||
}
|
||||
}
|
||||
|
||||
export function requestNotificationPermission(): Promise<boolean> {
|
||||
if (isElectron()) return Promise.resolve(true);
|
||||
if (!('Notification' in window)) return Promise.resolve(false);
|
||||
return Notification.requestPermission().then((p) => p === 'granted');
|
||||
}
|
||||
|
||||
export function updateBadgeCount(count: number): void {
|
||||
if (isElectron()) {
|
||||
window.backspace!.setBadgeCount(count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function isElectron(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.backspace !== 'undefined';
|
||||
}
|
||||
|
||||
export function getElectronAPI(): BackspaceElectronAPI | null {
|
||||
return window.backspace ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user