diff --git a/.env.example b/.env.example index 94095e55..46193f4d 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,13 @@ LIVEKIT_URL= LIVEKIT_API_KEY= LIVEKIT_API_SECRET= +# ─── Desktop App (Electron) ─────────────────────────────── +# Override the URL that the desktop app loads (auto-detects in dev/prod) +# BACKSPACE_URL=https://my-instance.com + +# Auto-update server URL for electron-updater (optional) +# BACKSPACE_UPDATE_URL=https://releases.example.com + # ─── Docker Compose ──────────────────────────────────────── # Uncomment to enable the LiveKit service: # COMPOSE_PROFILES=voice diff --git a/package.json b/package.json index a350afda..32959c42 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,12 @@ "build:shared": "pnpm --filter @backspace/shared build", "build:server": "pnpm --filter @backspace/server build", "build:web": "pnpm --filter @backspace/web build", - "build": "pnpm --filter @backspace/shared build && pnpm --filter @backspace/server build & pnpm --filter @backspace/web build" + "build": "pnpm --filter @backspace/shared build && pnpm --filter @backspace/server build & pnpm --filter @backspace/web build", + "dev:desktop": "pnpm --filter @backspace/desktop dev", + "build:desktop": "pnpm --filter @backspace/desktop build" }, "pnpm": { - "onlyBuiltDependencies": ["better-sqlite3", "esbuild", "sharp"] + "onlyBuiltDependencies": ["better-sqlite3", "esbuild", "electron", "sharp"] }, "engines": { "node": ">=20.0.0", diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index f9b1f467..7bc26517 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -5,22 +5,30 @@ directories: files: - dist/**/* - "!node_modules" +publish: + - provider: generic + url: "${BACKSPACE_UPDATE_URL}" + useMultipleRangeRequest: false +protocols: + - name: Backspace + schemes: + - backspace mac: category: public.app-category.social-networking target: - dmg - zip - icon: build/icon.icns + # icon: build/icon.icns # Uncomment when final icon is designed win: target: - nsis - icon: build/icon.ico + # icon: build/icon.ico # Uncomment when final icon is designed linux: target: - AppImage - deb category: Network - icon: build/icons + # icon: build/icons # Uncomment when final icons are designed nsis: oneClick: false allowToChangeInstallationDirectory: true diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 527ee36e..a37e4db6 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -6,11 +6,12 @@ "scripts": { "build:ts": "tsc", "dev": "tsc && electron .", + "prebuild": "mkdir -p build && cp ../web/public/icons/icon-512.png build/icon.png 2>/dev/null || true", "build": "tsc && electron-builder", "clean": "rm -rf dist dist-electron" }, "dependencies": { - "electron-is-dev": "^3.0.1" + "electron-updater": "^6.3.0" }, "devDependencies": { "electron": "^33.2.0", diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 80608f49..f52d509c 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -7,61 +7,99 @@ import { nativeImage, ipcMain, shell, + screen, } from 'electron'; import path from 'path'; +import fs from 'fs'; let mainWindow: BrowserWindow | null = null; let tray: Tray | null = null; let isQuitting = false; +let pendingDeepLink: string | null = null; -const SERVER_URL = process.env.BACKSPACE_URL || 'http://localhost:3000'; +const DEV_URL = 'http://localhost:5173'; +const PROD_URL = 'http://localhost:3000'; +const SERVER_URL = process.env.BACKSPACE_URL || (app.isPackaged ? PROD_URL : DEV_URL); -function createWindow(): void { - mainWindow = new BrowserWindow({ - width: 1280, - height: 800, - minWidth: 940, - minHeight: 500, - title: 'Backspace', - titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', - backgroundColor: '#313338', - show: false, - webPreferences: { - preload: path.join(__dirname, 'preload.js'), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - }); +// ─── Window State Persistence ─────────────────────────────────────────────── - mainWindow.loadURL(SERVER_URL); - - mainWindow.once('ready-to-show', () => { - mainWindow?.show(); - }); - - mainWindow.on('close', (event) => { - if (!isQuitting) { - event.preventDefault(); - mainWindow?.hide(); - } - }); - - mainWindow.on('closed', () => { - mainWindow = null; - }); - - // Open external links in default browser - mainWindow.webContents.setWindowOpenHandler(({ url }) => { - if (url.startsWith('http://') || url.startsWith('https://')) { - shell.openExternal(url); - } - return { action: 'deny' }; - }); +interface WindowState { + width: number; + height: number; + x?: number; + y?: number; + isMaximized: boolean; } -function createTray(): void { - // Create a 16x16 tray icon (blue circle for Backspace) +const DEFAULT_WINDOW_STATE: WindowState = { + width: 1280, + height: 800, + isMaximized: false, +}; + +function getWindowStatePath(): string { + return path.join(app.getPath('userData'), 'window-state.json'); +} + +function loadWindowState(): WindowState { + try { + const raw = fs.readFileSync(getWindowStatePath(), 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + return { + width: typeof parsed.width === 'number' ? parsed.width : DEFAULT_WINDOW_STATE.width, + height: typeof parsed.height === 'number' ? parsed.height : DEFAULT_WINDOW_STATE.height, + x: typeof parsed.x === 'number' ? parsed.x : undefined, + y: typeof parsed.y === 'number' ? parsed.y : undefined, + isMaximized: typeof parsed.isMaximized === 'boolean' ? parsed.isMaximized : false, + }; + } catch { + return { ...DEFAULT_WINDOW_STATE }; + } +} + +function validateWindowBounds(state: WindowState): WindowState { + if (state.x === undefined || state.y === undefined) return state; + + const bounds = { x: state.x, y: state.y, width: state.width, height: state.height }; + const display = screen.getDisplayMatching(bounds); + const { x, y, width, height } = display.workArea; + + // Check if window is at least partially visible on the display + const visible = + bounds.x + bounds.width > x && + bounds.x < x + width && + bounds.y + bounds.height > y && + bounds.y < y + height; + + if (!visible) { + // Strip position — let Electron auto-center + return { width: state.width, height: state.height, isMaximized: state.isMaximized }; + } + + return state; +} + +function saveWindowState(win: BrowserWindow): void { + try { + const isMaximized = win.isMaximized(); + // Use the bounds from before maximize, to restore the un-maximized size + const bounds = isMaximized ? win.getNormalBounds() : win.getBounds(); + const state: WindowState = { + width: bounds.width, + height: bounds.height, + x: bounds.x, + y: bounds.y, + isMaximized, + }; + fs.writeFileSync(getWindowStatePath(), JSON.stringify(state)); + } catch { + // Non-critical — silently ignore write failures + } +} + +// ─── Tray Icon ────────────────────────────────────────────────────────────── + +function generateFallbackTrayIcon(): Electron.NativeImage { const size = 16; const canvas = Buffer.alloc(size * size * 4); const cx = size / 2; @@ -84,7 +122,116 @@ function createTray(): void { } } } - const icon = nativeImage.createFromBuffer(canvas, { width: size, height: size }); + return nativeImage.createFromBuffer(canvas, { width: size, height: size }); +} + +function loadTrayIcon(): Electron.NativeImage { + const iconPath = path.join(__dirname, '..', 'build', 'tray-icon.png'); + try { + const icon = nativeImage.createFromPath(iconPath); + if (!icon.isEmpty()) { + const resized = icon.resize({ width: 16, height: 16 }); + if (process.platform === 'darwin') { + resized.setTemplateImage(true); + } + return resized; + } + } catch { + // Fall through to generated icon + } + return generateFallbackTrayIcon(); +} + +// ─── Window & Tray Creation ───────────────────────────────────────────────── + +function createWindow(): void { + const savedState = validateWindowBounds(loadWindowState()); + + mainWindow = new BrowserWindow({ + width: savedState.width, + height: savedState.height, + ...(savedState.x !== undefined && savedState.y !== undefined + ? { x: savedState.x, y: savedState.y } + : {}), + minWidth: 940, + minHeight: 500, + title: 'Backspace', + titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', + backgroundColor: '#313338', + show: false, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + + if (savedState.isMaximized) { + mainWindow.maximize(); + } + + mainWindow.loadURL(SERVER_URL); + + mainWindow.once('ready-to-show', () => { + mainWindow?.show(); + + // Send any pending deep link that launched the app + if (pendingDeepLink && mainWindow) { + mainWindow.webContents.send('deep-link', pendingDeepLink); + pendingDeepLink = null; + } + }); + + // Window state persistence — debounced save on resize/move + let saveTimeout: ReturnType | null = null; + const debouncedSave = () => { + if (saveTimeout) clearTimeout(saveTimeout); + saveTimeout = setTimeout(() => { + if (mainWindow && !mainWindow.isDestroyed()) { + saveWindowState(mainWindow); + } + }, 300); + }; + + mainWindow.on('resize', debouncedSave); + mainWindow.on('move', debouncedSave); + + mainWindow.on('close', (event) => { + // Save state before close + if (mainWindow && !mainWindow.isDestroyed()) { + saveWindowState(mainWindow); + } + + if (!isQuitting) { + event.preventDefault(); + mainWindow?.hide(); + } + }); + + mainWindow.on('closed', () => { + mainWindow = null; + }); + + // Window focus IPC for notification suppression + mainWindow.on('focus', () => { + mainWindow?.webContents.send('window-focus-changed', true); + }); + mainWindow.on('blur', () => { + mainWindow?.webContents.send('window-focus-changed', false); + }); + + // Open external links in default browser + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if (url.startsWith('http://') || url.startsWith('https://')) { + shell.openExternal(url); + } + return { action: 'deny' }; + }); +} + +function createTray(): void { + const icon = loadTrayIcon(); tray = new Tray(icon); const contextMenu = Menu.buildFromTemplate([ @@ -124,6 +271,8 @@ function createTray(): void { }); } +// ─── Notifications ────────────────────────────────────────────────────────── + function showNotification(title: string, body: string): void { if (Notification.isSupported()) { const notification = new Notification({ @@ -141,6 +290,8 @@ function showNotification(title: string, body: string): void { } } +// ─── IPC Handlers ─────────────────────────────────────────────────────────── + function registerIpcHandlers(): void { ipcMain.on('show-notification', (_event, data: { title: string; body: string }) => { showNotification(data.title, data.body); @@ -167,28 +318,136 @@ function registerIpcHandlers(): void { ipcMain.on('close-window', () => { mainWindow?.close(); }); + + // Auto-update IPC + ipcMain.on('install-update', () => { + try { + const { autoUpdater } = require('electron-updater'); + autoUpdater.quitAndInstall(); + } catch { + // Auto-updater not available + } + }); + + ipcMain.on('check-for-updates', () => { + try { + const { autoUpdater } = require('electron-updater'); + autoUpdater.checkForUpdates().catch(() => {}); + } catch { + // Auto-updater not available + } + }); } -app.on('ready', () => { - registerIpcHandlers(); - createWindow(); - createTray(); -}); +// ─── Auto-Update ──────────────────────────────────────────────────────────── -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit(); +function initAutoUpdater(): void { + try { + const { autoUpdater } = require('electron-updater'); + autoUpdater.autoDownload = true; + autoUpdater.autoInstallOnAppQuit = true; + + autoUpdater.on('update-available', (info: { version: string }) => { + mainWindow?.webContents.send('update-available', { version: info.version }); + }); + + autoUpdater.on('update-downloaded', (info: { version: string }) => { + mainWindow?.webContents.send('update-downloaded', { version: info.version }); + }); + + autoUpdater.on('error', (err: Error) => { + mainWindow?.webContents.send('update-error', err.message); + }); + + // Initial check with 10s delay + setTimeout(() => { + autoUpdater.checkForUpdates().catch(() => {}); + }, 10_000); + + // Periodic check every 4 hours + setInterval(() => { + autoUpdater.checkForUpdates().catch(() => {}); + }, 4 * 60 * 60 * 1000); + } catch { + // Graceful degradation — no update URL configured or electron-updater not available } -}); +} -app.on('activate', () => { - if (mainWindow === null) { - createWindow(); - } else { +// ─── Deep Linking ─────────────────────────────────────────────────────────── + +function handleDeepLink(url: string): void { + if (!url.startsWith('backspace://')) return; + + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('deep-link', url); mainWindow.show(); + mainWindow.focus(); + } else { + // App not ready yet — store for later + pendingDeepLink = url; } +} + +// Set as default protocol handler +app.setAsDefaultProtocolClient('backspace'); + +// macOS: open-url event +app.on('open-url', (event, url) => { + event.preventDefault(); + handleDeepLink(url); }); -app.on('before-quit', () => { - isQuitting = true; -}); +// Windows/Linux: single instance lock — deep links come as second-instance args +const gotTheLock = app.requestSingleInstanceLock(); + +if (!gotTheLock) { + app.quit(); +} else { + app.on('second-instance', (_event, commandLine) => { + // Find the deep link URL in the command line args + const deepLinkArg = commandLine.find((arg) => arg.startsWith('backspace://')); + if (deepLinkArg) { + handleDeepLink(deepLinkArg); + } + + // Focus the existing window + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); + } + }); + + // ─── App Lifecycle ────────────────────────────────────────────────────────── + + app.whenReady().then(() => { + registerIpcHandlers(); + createWindow(); + createTray(); + initAutoUpdater(); + + // Check if the app was launched with a deep link (Windows/Linux) + const launchArg = process.argv.find((arg) => arg.startsWith('backspace://')); + if (launchArg) { + pendingDeepLink = launchArg; + } + }); + + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } + }); + + app.on('activate', () => { + if (mainWindow === null) { + createWindow(); + } else { + mainWindow.show(); + } + }); + + app.on('before-quit', () => { + isQuitting = true; + }); +} diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 754cf12c..5c21a8d9 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -1,13 +1,10 @@ import { contextBridge, ipcRenderer } from 'electron'; contextBridge.exposeInMainWorld('backspace', { + // Platform info platform: process.platform, - showNotification: (title: string, body: string) => { - ipcRenderer.send('show-notification', { title, body }); - }, - setBadgeCount: (count: number) => { - ipcRenderer.send('set-badge-count', count); - }, + + // Window controls minimize: () => { ipcRenderer.send('minimize-window'); }, @@ -17,4 +14,39 @@ contextBridge.exposeInMainWorld('backspace', { close: () => { ipcRenderer.send('close-window'); }, + + // Notifications & badge + showNotification: (title: string, body: string) => { + ipcRenderer.send('show-notification', { title, body }); + }, + setBadgeCount: (count: number) => { + ipcRenderer.send('set-badge-count', count); + }, + + // Auto-update + onUpdateAvailable: (callback: (info: { version: string }) => void) => { + ipcRenderer.on('update-available', (_event, info) => callback(info)); + }, + onUpdateDownloaded: (callback: (info: { version: string }) => void) => { + ipcRenderer.on('update-downloaded', (_event, info) => callback(info)); + }, + onUpdateError: (callback: (error: string) => void) => { + ipcRenderer.on('update-error', (_event, error) => callback(error)); + }, + installUpdate: () => { + ipcRenderer.send('install-update'); + }, + checkForUpdates: () => { + ipcRenderer.send('check-for-updates'); + }, + + // Window focus + onWindowFocusChange: (callback: (focused: boolean) => void) => { + ipcRenderer.on('window-focus-changed', (_event, focused) => callback(focused)); + }, + + // Deep linking + onDeepLink: (callback: (url: string) => void) => { + ipcRenderer.on('deep-link', (_event, url) => callback(url)); + }, }); diff --git a/packages/web/src/components/NotificationController.tsx b/packages/web/src/components/NotificationController.tsx new file mode 100644 index 00000000..fc218805 --- /dev/null +++ b/packages/web/src/components/NotificationController.tsx @@ -0,0 +1,99 @@ +import { useEffect, useRef } from 'react'; +import { useChatStore } from '../stores/chatStore'; +import { useVoiceStore } from '../stores/voiceStore'; +import { useAuthStore } from '../stores/authStore'; +import { isElectron } from '../platform/platform'; +import { sendNotification, updateBadgeCount } from '../platform/notifications'; + +/** + * Headless component that bridges store events to native OS notifications and badge counts. + * Renders nothing — lives alongside SoundController in AppLayout. + */ +export function NotificationController() { + const currentUser = useAuthStore((s) => s.user); + const isInitialMount = useRef(true); + const windowFocused = useRef(true); + + // Track window focus state + useEffect(() => { + if (isElectron() && window.backspace) { + window.backspace.onWindowFocusChange((focused) => { + windowFocused.current = focused; + }); + } + + // Browser fallback focus tracking + const onFocus = () => { windowFocused.current = true; }; + const onBlur = () => { windowFocused.current = false; }; + const onVisibility = () => { + windowFocused.current = document.visibilityState === 'visible' && document.hasFocus(); + }; + + window.addEventListener('focus', onFocus); + window.addEventListener('blur', onBlur); + document.addEventListener('visibilitychange', onVisibility); + + // Sync initial state + windowFocused.current = document.hasFocus(); + + return () => { + window.removeEventListener('focus', onFocus); + window.removeEventListener('blur', onBlur); + document.removeEventListener('visibilitychange', onVisibility); + }; + }, []); + + // Message notifications + useEffect(() => { + const timer = setTimeout(() => { + isInitialMount.current = false; + }, 1000); + + const unsubscribeChat = useChatStore.subscribe((state, prevState) => { + if (isInitialMount.current) return; + if (windowFocused.current) return; + + if (state.realtimeMessageEvents.length > prevState.realtimeMessageEvents.length) { + const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length); + for (const { message } of newEvents) { + if (message.userId !== currentUser?.id) { + const displayName = message.user?.displayName || message.user?.username || 'Someone'; + const body = message.content + ? message.content.replace(/[*_~`>#\-\[\]]/g, '').slice(0, 100) + : 'Sent an attachment'; + sendNotification(displayName, body); + break; // one notification per batch + } + } + } + }); + + return () => { + clearTimeout(timer); + unsubscribeChat(); + }; + }, [currentUser?.id]); + + // Badge count (Electron only) + useEffect(() => { + const unsubscribe = useChatStore.subscribe((state) => { + updateBadgeCount(state.unreadChannels.size); + }); + return unsubscribe; + }, []); + + // DM call notification + useEffect(() => { + let prevIncoming: { dmChannelId: string; callerId: string; callerName: string } | null = null; + + const unsubscribe = useVoiceStore.subscribe((state) => { + if (state.incomingCall && !prevIncoming && !windowFocused.current) { + sendNotification('Incoming Call', `${state.incomingCall.callerName} is calling you`); + } + prevIncoming = state.incomingCall; + }); + return unsubscribe; + }, []); + + return null; +} diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index 9d2f29bc..d45397c6 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -21,12 +21,15 @@ import { IncomingCallModal } from '../voice/IncomingCallModal'; import { PictureInPicture } from '../voice/PictureInPicture'; import { SoundController } from '../voice/SoundController'; import { GlobalAudioRenderer } from '../voice/GlobalAudioRenderer'; +import { NotificationController } from '../NotificationController'; import { UserProfilePopout } from '../ui/UserProfilePopout'; import { ToastContainer } from '../ui/ToastContainer'; +import { UpdateToast } from '../ui/UpdateToast'; import { useAuth } from '../../hooks/useAuth'; import { useWebSocket } from '../../hooks/useWebSocket'; import { useFederationToasts } from '../../hooks/useFederationToasts'; import { useLiveKit } from '../../hooks/useLiveKit'; +import { useDeepLinkHandler } from '../../platform/deepLink'; import { useSpaceStore } from '../../stores/spaceStore'; import { useChatStore } from '../../stores/chatStore'; import { useUIStore } from '../../stores/uiStore'; @@ -115,6 +118,9 @@ export function AppLayout() { // Federation toast notifications for remote instance connection state changes useFederationToasts(); + // Deep link handler for Electron (backspace:// protocol) + useDeepLinkHandler(); + // Track the last channel we attempted to connect to, to prevent effect loops const lastAttemptedRef = React.useRef(null); @@ -292,6 +298,8 @@ export function AppLayout() { + + {/* User Profile Popout */} {userProfilePopout.user && userProfilePopout.position && ( diff --git a/packages/web/src/components/modals/InviteModal.tsx b/packages/web/src/components/modals/InviteModal.tsx index 890e680e..d0791925 100644 --- a/packages/web/src/components/modals/InviteModal.tsx +++ b/packages/web/src/components/modals/InviteModal.tsx @@ -2,10 +2,12 @@ import React, { useState, useEffect } from 'react'; import { Modal } from '../ui/Modal'; import { useUIStore } from '../../stores/uiStore'; import { useSpaceStore } from '../../stores/spaceStore'; +import { isElectron } from '../../platform/platform'; export function InviteModal() { const [inviteCode, setInviteCode] = useState(''); const [copied, setCopied] = useState(false); + const [copiedDeepLink, setCopiedDeepLink] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); const activeModal = useUIStore((s) => s.activeModal); @@ -19,6 +21,13 @@ export function InviteModal() { const isOpen = activeModal === 'invite'; const inviteUrl = inviteCode ? `${instanceOrigin || window.location.origin}/join/${inviteCode}` : ''; + // Deep link for Electron desktop app + const deepLinkUrl = inviteCode + ? instanceOrigin + ? `backspace://join/${inviteCode}@${new URL(instanceOrigin).host}` + : `backspace://join/${inviteCode}` + : ''; + useEffect(() => { if (isOpen && currentSpaceId) { setIsLoading(true); @@ -52,6 +61,17 @@ export function InviteModal() { } }; + const handleCopyDeepLink = async () => { + if (!deepLinkUrl) return; + try { + await navigator.clipboard.writeText(deepLinkUrl); + setCopiedDeepLink(true); + setTimeout(() => setCopiedDeepLink(false), 2000); + } catch { + // silently fail + } + }; + return (

@@ -81,6 +101,26 @@ export function InviteModal() { {copied ? 'Copied!' : 'Copy'} + {isElectron() && deepLinkUrl && ( +

+ + +
+ )}
); } diff --git a/packages/web/src/components/ui/SwUpdatePrompt.tsx b/packages/web/src/components/ui/SwUpdatePrompt.tsx index ce72576f..bd4ebd30 100644 --- a/packages/web/src/components/ui/SwUpdatePrompt.tsx +++ b/packages/web/src/components/ui/SwUpdatePrompt.tsx @@ -1,10 +1,13 @@ import { useRegisterSW } from 'virtual:pwa-register/react'; import { useEffect } from 'react'; +import { isElectron } from '../../platform/platform'; export function SwAutoUpdate() { + const inElectron = isElectron(); + useRegisterSW({ onRegisteredSW(_swUrl, registration) { - if (!registration) return; + if (!registration || inElectron) return; setInterval(() => { registration.update(); }, 60_000); @@ -12,11 +15,12 @@ export function SwAutoUpdate() { }); useEffect(() => { + if (inElectron) return; if (!navigator.serviceWorker) return; const onControllerChange = () => window.location.reload(); navigator.serviceWorker.addEventListener('controllerchange', onControllerChange); return () => navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange); - }, []); + }, [inElectron]); return null; } diff --git a/packages/web/src/components/ui/UpdateToast.tsx b/packages/web/src/components/ui/UpdateToast.tsx new file mode 100644 index 00000000..e506439b --- /dev/null +++ b/packages/web/src/components/ui/UpdateToast.tsx @@ -0,0 +1,52 @@ +import React, { useState, useEffect } from 'react'; +import { isElectron } from '../../platform/platform'; + +/** + * Persistent toast shown when an Electron auto-update has been downloaded. + * Renders nothing in browser environments. + */ +export function UpdateToast() { + const [downloadedVersion, setDownloadedVersion] = useState(null); + + useEffect(() => { + if (!isElectron() || !window.backspace) return; + + window.backspace.onUpdateDownloaded((info) => { + setDownloadedVersion(info.version); + }); + }, []); + + if (!downloadedVersion) return null; + + const handleRestart = () => { + window.backspace?.installUpdate(); + }; + + return ( +
+
+
+

Update ready

+

+ Version {downloadedVersion} has been downloaded +

+
+ + +
+
+ ); +} diff --git a/packages/web/src/platform/deepLink.ts b/packages/web/src/platform/deepLink.ts new file mode 100644 index 00000000..39693a28 --- /dev/null +++ b/packages/web/src/platform/deepLink.ts @@ -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]); +} diff --git a/packages/web/src/platform/electron.d.ts b/packages/web/src/platform/electron.d.ts new file mode 100644 index 00000000..69af55b5 --- /dev/null +++ b/packages/web/src/platform/electron.d.ts @@ -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; +} diff --git a/packages/web/src/platform/notifications.ts b/packages/web/src/platform/notifications.ts new file mode 100644 index 00000000..6cd6802e --- /dev/null +++ b/packages/web/src/platform/notifications.ts @@ -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 { + 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); + } +} diff --git a/packages/web/src/platform/platform.ts b/packages/web/src/platform/platform.ts new file mode 100644 index 00000000..3739b4ea --- /dev/null +++ b/packages/web/src/platform/platform.ts @@ -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; +} diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json index 31836722..03c26360 100644 --- a/packages/web/tsconfig.json +++ b/packages/web/tsconfig.json @@ -20,6 +20,6 @@ "@/*": ["./src/*"] } }, - "include": ["src/**/*", "node_modules/vite-plugin-pwa/client.d.ts"], + "include": ["src/**/*", "src/platform/electron.d.ts", "node_modules/vite-plugin-pwa/client.d.ts"], "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c12bc978..26af7f99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,9 +10,9 @@ importers: packages/desktop: dependencies: - electron-is-dev: - specifier: ^3.0.1 - version: 3.0.1 + electron-updater: + specifier: ^6.3.0 + version: 6.8.3 devDependencies: electron: specifier: ^33.2.0 @@ -2354,6 +2354,10 @@ packages: resolution: {integrity: sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==} engines: {node: '>=12.0.0'} + builder-util-runtime@9.5.1: + resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==} + engines: {node: '>=12.0.0'} + builder-util@25.1.7: resolution: {integrity: sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==} @@ -2845,16 +2849,15 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - electron-is-dev@3.0.1: - resolution: {integrity: sha512-8TjjAh8Ec51hUi3o4TaU0mD3GMTOESi866oRNavj9A3IQJ7pmv+MJVmdZBFGw4GFT36X7bkqnuDNYvkQgvyI8Q==} - engines: {node: '>=18'} - electron-publish@25.1.7: resolution: {integrity: sha512-+jbTkR9m39eDBMP4gfbqglDd6UvBC7RLh5Y0MhFSsc6UkGHj9Vj9TWobxevHYMMqmoujL11ZLjfPpMX+Pt6YEg==} electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + electron-updater@6.8.3: + resolution: {integrity: sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==} + electron@33.4.11: resolution: {integrity: sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==} engines: {node: '>= 12.20.55'} @@ -3658,6 +3661,9 @@ packages: lodash.difference@4.5.0: resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + lodash.flatten@4.4.0: resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} @@ -3667,6 +3673,10 @@ packages: lodash.isboolean@3.0.3: resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.isinteger@4.0.4: resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} @@ -4859,6 +4869,9 @@ packages: thread-stream@3.1.0: resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + tiny-typed-emitter@2.1.0: + resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -7435,6 +7448,13 @@ snapshots: transitivePeerDependencies: - supports-color + builder-util-runtime@9.5.1: + dependencies: + debug: 4.4.3 + sax: 1.4.4 + transitivePeerDependencies: + - supports-color + builder-util@25.1.7: dependencies: 7zip-bin: 5.2.0 @@ -7933,8 +7953,6 @@ snapshots: - electron-builder-squirrel-windows - supports-color - electron-is-dev@3.0.1: {} - electron-publish@25.1.7: dependencies: '@types/fs-extra': 9.0.13 @@ -7949,6 +7967,19 @@ snapshots: electron-to-chromium@1.5.286: {} + electron-updater@6.8.3: + dependencies: + builder-util-runtime: 9.5.1 + fs-extra: 10.1.0 + js-yaml: 4.1.1 + lazy-val: 1.0.5 + lodash.escaperegexp: 4.1.2 + lodash.isequal: 4.5.0 + semver: 7.7.4 + tiny-typed-emitter: 2.1.0 + transitivePeerDependencies: + - supports-color + electron@33.4.11: dependencies: '@electron/get': 2.0.3 @@ -8986,12 +9017,16 @@ snapshots: lodash.difference@4.5.0: {} + lodash.escaperegexp@4.1.2: {} + lodash.flatten@4.4.0: {} lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} + lodash.isequal@4.5.0: {} + lodash.isinteger@4.0.4: {} lodash.isnumber@3.0.3: {} @@ -10556,6 +10591,8 @@ snapshots: dependencies: real-require: 0.2.0 + tiny-typed-emitter@2.1.0: {} + tinybench@2.9.0: {} tinyexec@1.0.2: {}