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_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
|
||||
|
||||
+4
-2
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+320
-61
@@ -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<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 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<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);
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 { 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<string | null>(null);
|
||||
|
||||
@@ -292,6 +298,8 @@ export function AppLayout() {
|
||||
<PictureInPicture />
|
||||
<SoundController />
|
||||
<GlobalAudioRenderer />
|
||||
<NotificationController />
|
||||
<UpdateToast />
|
||||
|
||||
{/* User Profile Popout */}
|
||||
{userProfilePopout.user && userProfilePopout.position && (
|
||||
|
||||
@@ -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 (
|
||||
<Modal isOpen={isOpen} onClose={closeModal} title="Invite Friends">
|
||||
<p className="text-txt-secondary text-sm mb-4">
|
||||
@@ -81,6 +101,26 @@ export function InviteModal() {
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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/*"]
|
||||
}
|
||||
},
|
||||
"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"]
|
||||
}
|
||||
|
||||
Generated
+46
-9
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user