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:
@@ -25,6 +25,13 @@ LIVEKIT_URL=
|
|||||||
LIVEKIT_API_KEY=
|
LIVEKIT_API_KEY=
|
||||||
LIVEKIT_API_SECRET=
|
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 ────────────────────────────────────────
|
# ─── Docker Compose ────────────────────────────────────────
|
||||||
# Uncomment to enable the LiveKit service:
|
# Uncomment to enable the LiveKit service:
|
||||||
# COMPOSE_PROFILES=voice
|
# COMPOSE_PROFILES=voice
|
||||||
|
|||||||
+4
-2
@@ -10,10 +10,12 @@
|
|||||||
"build:shared": "pnpm --filter @backspace/shared build",
|
"build:shared": "pnpm --filter @backspace/shared build",
|
||||||
"build:server": "pnpm --filter @backspace/server build",
|
"build:server": "pnpm --filter @backspace/server build",
|
||||||
"build:web": "pnpm --filter @backspace/web 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": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": ["better-sqlite3", "esbuild", "sharp"]
|
"onlyBuiltDependencies": ["better-sqlite3", "esbuild", "electron", "sharp"]
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0",
|
"node": ">=20.0.0",
|
||||||
|
|||||||
@@ -5,22 +5,30 @@ directories:
|
|||||||
files:
|
files:
|
||||||
- dist/**/*
|
- dist/**/*
|
||||||
- "!node_modules"
|
- "!node_modules"
|
||||||
|
publish:
|
||||||
|
- provider: generic
|
||||||
|
url: "${BACKSPACE_UPDATE_URL}"
|
||||||
|
useMultipleRangeRequest: false
|
||||||
|
protocols:
|
||||||
|
- name: Backspace
|
||||||
|
schemes:
|
||||||
|
- backspace
|
||||||
mac:
|
mac:
|
||||||
category: public.app-category.social-networking
|
category: public.app-category.social-networking
|
||||||
target:
|
target:
|
||||||
- dmg
|
- dmg
|
||||||
- zip
|
- zip
|
||||||
icon: build/icon.icns
|
# icon: build/icon.icns # Uncomment when final icon is designed
|
||||||
win:
|
win:
|
||||||
target:
|
target:
|
||||||
- nsis
|
- nsis
|
||||||
icon: build/icon.ico
|
# icon: build/icon.ico # Uncomment when final icon is designed
|
||||||
linux:
|
linux:
|
||||||
target:
|
target:
|
||||||
- AppImage
|
- AppImage
|
||||||
- deb
|
- deb
|
||||||
category: Network
|
category: Network
|
||||||
icon: build/icons
|
# icon: build/icons # Uncomment when final icons are designed
|
||||||
nsis:
|
nsis:
|
||||||
oneClick: false
|
oneClick: false
|
||||||
allowToChangeInstallationDirectory: true
|
allowToChangeInstallationDirectory: true
|
||||||
|
|||||||
@@ -6,11 +6,12 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build:ts": "tsc",
|
"build:ts": "tsc",
|
||||||
"dev": "tsc && electron .",
|
"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",
|
"build": "tsc && electron-builder",
|
||||||
"clean": "rm -rf dist dist-electron"
|
"clean": "rm -rf dist dist-electron"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron-is-dev": "^3.0.1"
|
"electron-updater": "^6.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^33.2.0",
|
"electron": "^33.2.0",
|
||||||
|
|||||||
+312
-53
@@ -7,61 +7,99 @@ import {
|
|||||||
nativeImage,
|
nativeImage,
|
||||||
ipcMain,
|
ipcMain,
|
||||||
shell,
|
shell,
|
||||||
|
screen,
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let tray: Tray | null = null;
|
let tray: Tray | null = null;
|
||||||
let isQuitting = false;
|
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 {
|
// ─── Window State Persistence ───────────────────────────────────────────────
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
mainWindow.loadURL(SERVER_URL);
|
interface WindowState {
|
||||||
|
width: number;
|
||||||
mainWindow.once('ready-to-show', () => {
|
height: number;
|
||||||
mainWindow?.show();
|
x?: number;
|
||||||
});
|
y?: number;
|
||||||
|
isMaximized: boolean;
|
||||||
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' };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTray(): void {
|
const DEFAULT_WINDOW_STATE: WindowState = {
|
||||||
// Create a 16x16 tray icon (blue circle for Backspace)
|
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<WindowState>;
|
||||||
|
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 size = 16;
|
||||||
const canvas = Buffer.alloc(size * size * 4);
|
const canvas = Buffer.alloc(size * size * 4);
|
||||||
const cx = size / 2;
|
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<typeof setTimeout> | 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);
|
tray = new Tray(icon);
|
||||||
|
|
||||||
const contextMenu = Menu.buildFromTemplate([
|
const contextMenu = Menu.buildFromTemplate([
|
||||||
@@ -124,6 +271,8 @@ function createTray(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Notifications ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function showNotification(title: string, body: string): void {
|
function showNotification(title: string, body: string): void {
|
||||||
if (Notification.isSupported()) {
|
if (Notification.isSupported()) {
|
||||||
const notification = new Notification({
|
const notification = new Notification({
|
||||||
@@ -141,6 +290,8 @@ function showNotification(title: string, body: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── IPC Handlers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function registerIpcHandlers(): void {
|
function registerIpcHandlers(): void {
|
||||||
ipcMain.on('show-notification', (_event, data: { title: string; body: string }) => {
|
ipcMain.on('show-notification', (_event, data: { title: string; body: string }) => {
|
||||||
showNotification(data.title, data.body);
|
showNotification(data.title, data.body);
|
||||||
@@ -167,28 +318,136 @@ function registerIpcHandlers(): void {
|
|||||||
ipcMain.on('close-window', () => {
|
ipcMain.on('close-window', () => {
|
||||||
mainWindow?.close();
|
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', () => {
|
// ─── Auto-Update ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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();
|
registerIpcHandlers();
|
||||||
createWindow();
|
createWindow();
|
||||||
createTray();
|
createTray();
|
||||||
});
|
initAutoUpdater();
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
// 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') {
|
if (process.platform !== 'darwin') {
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (mainWindow === null) {
|
if (mainWindow === null) {
|
||||||
createWindow();
|
createWindow();
|
||||||
} else {
|
} else {
|
||||||
mainWindow.show();
|
mainWindow.show();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
isQuitting = true;
|
isQuitting = true;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron';
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('backspace', {
|
contextBridge.exposeInMainWorld('backspace', {
|
||||||
|
// Platform info
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
showNotification: (title: string, body: string) => {
|
|
||||||
ipcRenderer.send('show-notification', { title, body });
|
// Window controls
|
||||||
},
|
|
||||||
setBadgeCount: (count: number) => {
|
|
||||||
ipcRenderer.send('set-badge-count', count);
|
|
||||||
},
|
|
||||||
minimize: () => {
|
minimize: () => {
|
||||||
ipcRenderer.send('minimize-window');
|
ipcRenderer.send('minimize-window');
|
||||||
},
|
},
|
||||||
@@ -17,4 +14,39 @@ contextBridge.exposeInMainWorld('backspace', {
|
|||||||
close: () => {
|
close: () => {
|
||||||
ipcRenderer.send('close-window');
|
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));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -21,12 +21,15 @@ import { IncomingCallModal } from '../voice/IncomingCallModal';
|
|||||||
import { PictureInPicture } from '../voice/PictureInPicture';
|
import { PictureInPicture } from '../voice/PictureInPicture';
|
||||||
import { SoundController } from '../voice/SoundController';
|
import { SoundController } from '../voice/SoundController';
|
||||||
import { GlobalAudioRenderer } from '../voice/GlobalAudioRenderer';
|
import { GlobalAudioRenderer } from '../voice/GlobalAudioRenderer';
|
||||||
|
import { NotificationController } from '../NotificationController';
|
||||||
import { UserProfilePopout } from '../ui/UserProfilePopout';
|
import { UserProfilePopout } from '../ui/UserProfilePopout';
|
||||||
import { ToastContainer } from '../ui/ToastContainer';
|
import { ToastContainer } from '../ui/ToastContainer';
|
||||||
|
import { UpdateToast } from '../ui/UpdateToast';
|
||||||
import { useAuth } from '../../hooks/useAuth';
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||||
import { useFederationToasts } from '../../hooks/useFederationToasts';
|
import { useFederationToasts } from '../../hooks/useFederationToasts';
|
||||||
import { useLiveKit } from '../../hooks/useLiveKit';
|
import { useLiveKit } from '../../hooks/useLiveKit';
|
||||||
|
import { useDeepLinkHandler } from '../../platform/deepLink';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
@@ -115,6 +118,9 @@ export function AppLayout() {
|
|||||||
// Federation toast notifications for remote instance connection state changes
|
// Federation toast notifications for remote instance connection state changes
|
||||||
useFederationToasts();
|
useFederationToasts();
|
||||||
|
|
||||||
|
// Deep link handler for Electron (backspace:// protocol)
|
||||||
|
useDeepLinkHandler();
|
||||||
|
|
||||||
// Track the last channel we attempted to connect to, to prevent effect loops
|
// Track the last channel we attempted to connect to, to prevent effect loops
|
||||||
const lastAttemptedRef = React.useRef<string | null>(null);
|
const lastAttemptedRef = React.useRef<string | null>(null);
|
||||||
|
|
||||||
@@ -292,6 +298,8 @@ export function AppLayout() {
|
|||||||
<PictureInPicture />
|
<PictureInPicture />
|
||||||
<SoundController />
|
<SoundController />
|
||||||
<GlobalAudioRenderer />
|
<GlobalAudioRenderer />
|
||||||
|
<NotificationController />
|
||||||
|
<UpdateToast />
|
||||||
|
|
||||||
{/* User Profile Popout */}
|
{/* User Profile Popout */}
|
||||||
{userProfilePopout.user && userProfilePopout.position && (
|
{userProfilePopout.user && userProfilePopout.position && (
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { Modal } from '../ui/Modal';
|
import { Modal } from '../ui/Modal';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
|
import { isElectron } from '../../platform/platform';
|
||||||
|
|
||||||
export function InviteModal() {
|
export function InviteModal() {
|
||||||
const [inviteCode, setInviteCode] = useState('');
|
const [inviteCode, setInviteCode] = useState('');
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [copiedDeepLink, setCopiedDeepLink] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const activeModal = useUIStore((s) => s.activeModal);
|
const activeModal = useUIStore((s) => s.activeModal);
|
||||||
@@ -19,6 +21,13 @@ export function InviteModal() {
|
|||||||
const isOpen = activeModal === 'invite';
|
const isOpen = activeModal === 'invite';
|
||||||
const inviteUrl = inviteCode ? `${instanceOrigin || window.location.origin}/join/${inviteCode}` : '';
|
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(() => {
|
useEffect(() => {
|
||||||
if (isOpen && currentSpaceId) {
|
if (isOpen && currentSpaceId) {
|
||||||
setIsLoading(true);
|
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 (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={closeModal} title="Invite Friends">
|
<Modal isOpen={isOpen} onClose={closeModal} title="Invite Friends">
|
||||||
<p className="text-txt-secondary text-sm mb-4">
|
<p className="text-txt-secondary text-sm mb-4">
|
||||||
@@ -81,6 +101,26 @@ export function InviteModal() {
|
|||||||
{copied ? 'Copied!' : 'Copy'}
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{isElectron() && deepLinkUrl && (
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={deepLinkUrl}
|
||||||
|
readOnly
|
||||||
|
className="input-standard flex-1 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyDeepLink}
|
||||||
|
className={`px-4 py-2 text-sm font-medium rounded transition-colors ${
|
||||||
|
copiedDeepLink
|
||||||
|
? 'bg-status-online text-white'
|
||||||
|
: 'bg-surface-elevated hover:bg-surface-elevated/80 text-txt-secondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{copiedDeepLink ? 'Copied!' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useRegisterSW } from 'virtual:pwa-register/react';
|
import { useRegisterSW } from 'virtual:pwa-register/react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { isElectron } from '../../platform/platform';
|
||||||
|
|
||||||
export function SwAutoUpdate() {
|
export function SwAutoUpdate() {
|
||||||
|
const inElectron = isElectron();
|
||||||
|
|
||||||
useRegisterSW({
|
useRegisterSW({
|
||||||
onRegisteredSW(_swUrl, registration) {
|
onRegisteredSW(_swUrl, registration) {
|
||||||
if (!registration) return;
|
if (!registration || inElectron) return;
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
registration.update();
|
registration.update();
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
@@ -12,11 +15,12 @@ export function SwAutoUpdate() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (inElectron) return;
|
||||||
if (!navigator.serviceWorker) return;
|
if (!navigator.serviceWorker) return;
|
||||||
const onControllerChange = () => window.location.reload();
|
const onControllerChange = () => window.location.reload();
|
||||||
navigator.serviceWorker.addEventListener('controllerchange', onControllerChange);
|
navigator.serviceWorker.addEventListener('controllerchange', onControllerChange);
|
||||||
return () => navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange);
|
return () => navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange);
|
||||||
}, []);
|
}, [inElectron]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isElectron() || !window.backspace) return;
|
||||||
|
|
||||||
|
window.backspace.onUpdateDownloaded((info) => {
|
||||||
|
setDownloadedVersion(info.version);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!downloadedVersion) return null;
|
||||||
|
|
||||||
|
const handleRestart = () => {
|
||||||
|
window.backspace?.installUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-6 left-6 z-[300] animate-slide-up">
|
||||||
|
<div className="glass-pill rounded-xl px-4 py-3 flex items-center gap-3 max-w-[340px]">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-txt-primary">Update ready</p>
|
||||||
|
<p className="text-xs text-txt-secondary truncate">
|
||||||
|
Version {downloadedVersion} has been downloaded
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleRestart}
|
||||||
|
className="shrink-0 px-3 py-1.5 text-xs font-medium rounded-lg bg-accent-primary hover:bg-accent-primary/80 text-white transition-colors"
|
||||||
|
>
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDownloadedVersion(null)}
|
||||||
|
className="shrink-0 p-1 text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -20,6 +20,6 @@
|
|||||||
"@/*": ["./src/*"]
|
"@/*": ["./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"]
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+46
-9
@@ -10,9 +10,9 @@ importers:
|
|||||||
|
|
||||||
packages/desktop:
|
packages/desktop:
|
||||||
dependencies:
|
dependencies:
|
||||||
electron-is-dev:
|
electron-updater:
|
||||||
specifier: ^3.0.1
|
specifier: ^6.3.0
|
||||||
version: 3.0.1
|
version: 6.8.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
electron:
|
electron:
|
||||||
specifier: ^33.2.0
|
specifier: ^33.2.0
|
||||||
@@ -2354,6 +2354,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==}
|
resolution: {integrity: sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==}
|
||||||
engines: {node: '>=12.0.0'}
|
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:
|
builder-util@25.1.7:
|
||||||
resolution: {integrity: sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==}
|
resolution: {integrity: sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==}
|
||||||
|
|
||||||
@@ -2845,16 +2849,15 @@ packages:
|
|||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
electron-is-dev@3.0.1:
|
|
||||||
resolution: {integrity: sha512-8TjjAh8Ec51hUi3o4TaU0mD3GMTOESi866oRNavj9A3IQJ7pmv+MJVmdZBFGw4GFT36X7bkqnuDNYvkQgvyI8Q==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
|
|
||||||
electron-publish@25.1.7:
|
electron-publish@25.1.7:
|
||||||
resolution: {integrity: sha512-+jbTkR9m39eDBMP4gfbqglDd6UvBC7RLh5Y0MhFSsc6UkGHj9Vj9TWobxevHYMMqmoujL11ZLjfPpMX+Pt6YEg==}
|
resolution: {integrity: sha512-+jbTkR9m39eDBMP4gfbqglDd6UvBC7RLh5Y0MhFSsc6UkGHj9Vj9TWobxevHYMMqmoujL11ZLjfPpMX+Pt6YEg==}
|
||||||
|
|
||||||
electron-to-chromium@1.5.286:
|
electron-to-chromium@1.5.286:
|
||||||
resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==}
|
resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==}
|
||||||
|
|
||||||
|
electron-updater@6.8.3:
|
||||||
|
resolution: {integrity: sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==}
|
||||||
|
|
||||||
electron@33.4.11:
|
electron@33.4.11:
|
||||||
resolution: {integrity: sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==}
|
resolution: {integrity: sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==}
|
||||||
engines: {node: '>= 12.20.55'}
|
engines: {node: '>= 12.20.55'}
|
||||||
@@ -3658,6 +3661,9 @@ packages:
|
|||||||
lodash.difference@4.5.0:
|
lodash.difference@4.5.0:
|
||||||
resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==}
|
resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==}
|
||||||
|
|
||||||
|
lodash.escaperegexp@4.1.2:
|
||||||
|
resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==}
|
||||||
|
|
||||||
lodash.flatten@4.4.0:
|
lodash.flatten@4.4.0:
|
||||||
resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==}
|
resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==}
|
||||||
|
|
||||||
@@ -3667,6 +3673,10 @@ packages:
|
|||||||
lodash.isboolean@3.0.3:
|
lodash.isboolean@3.0.3:
|
||||||
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
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:
|
lodash.isinteger@4.0.4:
|
||||||
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
|
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
|
||||||
|
|
||||||
@@ -4859,6 +4869,9 @@ packages:
|
|||||||
thread-stream@3.1.0:
|
thread-stream@3.1.0:
|
||||||
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
|
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
|
||||||
|
|
||||||
|
tiny-typed-emitter@2.1.0:
|
||||||
|
resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==}
|
||||||
|
|
||||||
tinybench@2.9.0:
|
tinybench@2.9.0:
|
||||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
@@ -7435,6 +7448,13 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
builder-util@25.1.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
7zip-bin: 5.2.0
|
7zip-bin: 5.2.0
|
||||||
@@ -7933,8 +7953,6 @@ snapshots:
|
|||||||
- electron-builder-squirrel-windows
|
- electron-builder-squirrel-windows
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
electron-is-dev@3.0.1: {}
|
|
||||||
|
|
||||||
electron-publish@25.1.7:
|
electron-publish@25.1.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/fs-extra': 9.0.13
|
'@types/fs-extra': 9.0.13
|
||||||
@@ -7949,6 +7967,19 @@ snapshots:
|
|||||||
|
|
||||||
electron-to-chromium@1.5.286: {}
|
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:
|
electron@33.4.11:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@electron/get': 2.0.3
|
'@electron/get': 2.0.3
|
||||||
@@ -8986,12 +9017,16 @@ snapshots:
|
|||||||
|
|
||||||
lodash.difference@4.5.0: {}
|
lodash.difference@4.5.0: {}
|
||||||
|
|
||||||
|
lodash.escaperegexp@4.1.2: {}
|
||||||
|
|
||||||
lodash.flatten@4.4.0: {}
|
lodash.flatten@4.4.0: {}
|
||||||
|
|
||||||
lodash.includes@4.3.0: {}
|
lodash.includes@4.3.0: {}
|
||||||
|
|
||||||
lodash.isboolean@3.0.3: {}
|
lodash.isboolean@3.0.3: {}
|
||||||
|
|
||||||
|
lodash.isequal@4.5.0: {}
|
||||||
|
|
||||||
lodash.isinteger@4.0.4: {}
|
lodash.isinteger@4.0.4: {}
|
||||||
|
|
||||||
lodash.isnumber@3.0.3: {}
|
lodash.isnumber@3.0.3: {}
|
||||||
@@ -10556,6 +10591,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
real-require: 0.2.0
|
real-require: 0.2.0
|
||||||
|
|
||||||
|
tiny-typed-emitter@2.1.0: {}
|
||||||
|
|
||||||
tinybench@2.9.0: {}
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
tinyexec@1.0.2: {}
|
tinyexec@1.0.2: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user