feat: Electron screen share picker, instance selector, and system audio loopback
- Custom screen share picker for Electron (ScreenSharePicker.tsx) with Aether Drift design: glass-bubble footer, adaptive grid, pill tabs, border-based selection (avoids overflow clipping), hover brightness - Instance URL picker so Electron connects to any Backspace server - System audio loopback on Windows/Linux via desktopCapturer callback - macOS: video-only callback (OS blocks system audio capture) - IPC bridge for screen source enumeration and selection - Purge stale service worker caches on Electron launch
This commit is contained in:
@@ -4,6 +4,7 @@ directories:
|
||||
output: dist-electron
|
||||
files:
|
||||
- dist/**/*
|
||||
- resources/**/*
|
||||
- "!node_modules"
|
||||
publish:
|
||||
- provider: generic
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Backspace — Connect</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap');
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #0b0b10;
|
||||
color: #d8d8de;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
user-select: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Subtle radial gradient at top — matches login page */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at top, rgba(124,108,246,0.06) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ── Titlebar drag strip ── */
|
||||
.titlebar {
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
background: #0b0b10;
|
||||
-webkit-app-region: drag;
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* ── Main content area ── */
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 0 2rem 2rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
-webkit-app-region: no-drag;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
/* ── Card — solid elevated surface (matches login) ── */
|
||||
.card {
|
||||
background: #252530;
|
||||
border-radius: 6px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #efefef;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 0.875rem;
|
||||
color: #5c5c68;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* ── Form field ── */
|
||||
.field {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: #a0a0aa;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: #111118;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 4px;
|
||||
color: #efefef;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
transition: box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.field input::placeholder {
|
||||
color: #5c5c68;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
box-shadow: 0 0 0 2px #7c6cf6, inset 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
border-color: rgba(124, 108, 246, 0.3);
|
||||
}
|
||||
|
||||
.field input:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ── Button — accent-primary with white text (matches login) ── */
|
||||
.connect-btn {
|
||||
width: 100%;
|
||||
padding: 0.625rem;
|
||||
background: #7c6cf6;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.connect-btn:hover:not(:disabled) {
|
||||
background: rgba(124, 108, 246, 0.8);
|
||||
}
|
||||
|
||||
.connect-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Error display — matches login error style ── */
|
||||
.error-box {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(253, 164, 175, 0.1);
|
||||
border: 1px solid rgba(253, 164, 175, 0.3);
|
||||
border-radius: 4px;
|
||||
color: #fca5a5;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Instance info — success card with mint accent ── */
|
||||
.instance-info {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(134, 239, 172, 0.1);
|
||||
border: 1px solid rgba(134, 239, 172, 0.25);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.instance-info .name {
|
||||
font-weight: 600;
|
||||
color: #efefef;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.instance-info .version {
|
||||
font-size: 0.75rem;
|
||||
color: #a0a0aa;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
/* ── Loading state ── */
|
||||
.loading-text {
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: #5c5c68;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(92, 92, 104, 0.3);
|
||||
border-top-color: #5c5c68;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
vertical-align: middle;
|
||||
margin-right: 0.375rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Autofill dark theme ── */
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus {
|
||||
-webkit-text-fill-color: #efefef;
|
||||
-webkit-box-shadow: 0 0 0px 1000px #111118 inset;
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
/* ── Selection ── */
|
||||
::selection {
|
||||
background: rgba(134, 239, 172, 0.25);
|
||||
color: #efefef;
|
||||
}
|
||||
|
||||
/* ── Accessibility: reduced transparency ── */
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.card {
|
||||
background: #252530;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="titlebar"></div>
|
||||
|
||||
<div class="content">
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h1>Welcome to Backspace</h1>
|
||||
<p>Connect to your instance</p>
|
||||
</div>
|
||||
|
||||
<form id="picker-form">
|
||||
<div class="field">
|
||||
<label for="url-input">Instance URL</label>
|
||||
<input
|
||||
id="url-input"
|
||||
type="text"
|
||||
placeholder="backspace.example.com"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="connect-btn" id="connect-btn">Connect</button>
|
||||
</form>
|
||||
|
||||
<div id="instance-info" class="instance-info" style="display: none;">
|
||||
<div class="name" id="instance-name"></div>
|
||||
<div class="version" id="instance-version"></div>
|
||||
</div>
|
||||
|
||||
<div id="error-box" class="error-box" style="display: none;"></div>
|
||||
|
||||
<div id="loading" class="loading-text" style="display: none;">
|
||||
<span class="spinner"></span> Connecting...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('picker-form');
|
||||
const input = document.getElementById('url-input');
|
||||
const btn = document.getElementById('connect-btn');
|
||||
const errorBox = document.getElementById('error-box');
|
||||
const loadingEl = document.getElementById('loading');
|
||||
const infoEl = document.getElementById('instance-info');
|
||||
const nameEl = document.getElementById('instance-name');
|
||||
const versionEl = document.getElementById('instance-version');
|
||||
|
||||
function normalizeUrl(raw) {
|
||||
let url = raw.trim();
|
||||
if (!url) return '';
|
||||
// Strip trailing slashes
|
||||
url = url.replace(/\/+$/, '');
|
||||
// Prepend https:// if no protocol
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
url = 'https://' + url;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function showError(text) {
|
||||
errorBox.textContent = text;
|
||||
errorBox.style.display = 'block';
|
||||
loadingEl.style.display = 'none';
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
errorBox.style.display = 'none';
|
||||
}
|
||||
|
||||
function setLoading(loading) {
|
||||
input.disabled = loading;
|
||||
btn.disabled = loading;
|
||||
loadingEl.style.display = loading ? 'block' : 'none';
|
||||
if (loading) {
|
||||
hideError();
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const url = normalizeUrl(input.value);
|
||||
if (!url) {
|
||||
showError('Please enter an instance URL');
|
||||
return;
|
||||
}
|
||||
|
||||
infoEl.style.display = 'none';
|
||||
hideError();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(url + '/api/instance/info', {
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Server returned ' + res.status);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.name) {
|
||||
throw new Error('Not a Backspace instance');
|
||||
}
|
||||
|
||||
// Show instance info
|
||||
nameEl.textContent = data.name;
|
||||
versionEl.textContent = data.version ? 'v' + data.version : '';
|
||||
infoEl.style.display = 'block';
|
||||
loadingEl.style.display = 'none';
|
||||
|
||||
// Save and navigate
|
||||
if (window.backspace && window.backspace.setInstanceUrl) {
|
||||
await window.backspace.setInstanceUrl(url);
|
||||
}
|
||||
} catch (err) {
|
||||
setLoading(false);
|
||||
infoEl.style.display = 'none';
|
||||
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
||||
showError('Connection timed out — check the URL and try again');
|
||||
} else if (err.message === 'Failed to fetch' || err.name === 'TypeError') {
|
||||
showError('Could not connect — check the URL and try again');
|
||||
} else {
|
||||
showError('Could not connect — ' + err.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
ipcMain,
|
||||
shell,
|
||||
screen,
|
||||
session,
|
||||
desktopCapturer,
|
||||
} from 'electron';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
@@ -17,9 +19,36 @@ let tray: Tray | null = null;
|
||||
let isQuitting = false;
|
||||
let pendingDeepLink: string | null = null;
|
||||
|
||||
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);
|
||||
// ─── Instance URL Persistence ────────────────────────────────────────────────
|
||||
|
||||
function getInstanceUrlPath(): string {
|
||||
return path.join(app.getPath('userData'), 'instance-url.json');
|
||||
}
|
||||
|
||||
function loadInstanceUrl(): string | null {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(getInstanceUrlPath(), 'utf-8'));
|
||||
return typeof data.url === 'string' ? data.url : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveInstanceUrl(url: string): void {
|
||||
fs.writeFileSync(getInstanceUrlPath(), JSON.stringify({ url }));
|
||||
}
|
||||
|
||||
function clearInstanceUrl(): void {
|
||||
try {
|
||||
fs.unlinkSync(getInstanceUrlPath());
|
||||
} catch {
|
||||
// File may not exist — ignore
|
||||
}
|
||||
}
|
||||
|
||||
function getPickerPath(): string {
|
||||
return path.join(__dirname, '..', 'resources', 'instance-picker.html');
|
||||
}
|
||||
|
||||
// ─── Window State Persistence ───────────────────────────────────────────────
|
||||
|
||||
@@ -171,7 +200,21 @@ function createWindow(): void {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
|
||||
mainWindow.loadURL(SERVER_URL);
|
||||
// URL resolution priority:
|
||||
// 1. BACKSPACE_URL env var (managed deployments)
|
||||
// 2. Saved instance URL from picker
|
||||
// 3. No URL → show instance picker
|
||||
const envUrl = process.env.BACKSPACE_URL;
|
||||
if (envUrl) {
|
||||
mainWindow.loadURL(envUrl);
|
||||
} else {
|
||||
const savedUrl = loadInstanceUrl();
|
||||
if (savedUrl) {
|
||||
mainWindow.loadURL(savedUrl);
|
||||
} else {
|
||||
mainWindow.loadFile(getPickerPath());
|
||||
}
|
||||
}
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow?.show();
|
||||
@@ -248,6 +291,15 @@ function createTray(): void {
|
||||
mainWindow?.hide();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Change Instance',
|
||||
click: () => {
|
||||
clearInstanceUrl();
|
||||
mainWindow?.loadFile(getPickerPath());
|
||||
mainWindow?.show();
|
||||
mainWindow?.focus();
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Quit',
|
||||
@@ -319,6 +371,39 @@ function registerIpcHandlers(): void {
|
||||
mainWindow?.close();
|
||||
});
|
||||
|
||||
// Instance URL management
|
||||
ipcMain.handle('get-instance-url', () => loadInstanceUrl());
|
||||
|
||||
ipcMain.handle('set-instance-url', (_event, url: string) => {
|
||||
saveInstanceUrl(url);
|
||||
if (mainWindow) {
|
||||
mainWindow.loadURL(url);
|
||||
// Force Electron to re-evaluate drag regions after navigation
|
||||
mainWindow.webContents.once('did-finish-load', () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
const bounds = mainWindow.getBounds();
|
||||
mainWindow.setSize(bounds.width + 1, bounds.height);
|
||||
mainWindow.setSize(bounds.width, bounds.height);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('clear-instance-url', () => {
|
||||
clearInstanceUrl();
|
||||
if (mainWindow) {
|
||||
mainWindow.loadFile(getPickerPath());
|
||||
// Force Electron to re-evaluate drag regions after navigation
|
||||
mainWindow.webContents.once('did-finish-load', () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
const bounds = mainWindow.getBounds();
|
||||
mainWindow.setSize(bounds.width + 1, bounds.height);
|
||||
mainWindow.setSize(bounds.width, bounds.height);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-update IPC
|
||||
ipcMain.on('install-update', () => {
|
||||
try {
|
||||
@@ -337,6 +422,12 @@ function registerIpcHandlers(): void {
|
||||
// Auto-updater not available
|
||||
}
|
||||
});
|
||||
|
||||
// Screen share picker coordination (used by setDisplayMediaRequestHandler)
|
||||
ipcMain.on('screen-share-selected', (_event, sourceId: string | null) => {
|
||||
// Handled via ipcMain.once in the display media handler — this is just
|
||||
// a safety net to prevent unhandled-message warnings
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Auto-Update ────────────────────────────────────────────────────────────
|
||||
@@ -420,7 +511,125 @@ if (!gotTheLock) {
|
||||
|
||||
// ─── App Lifecycle ──────────────────────────────────────────────────────────
|
||||
|
||||
app.whenReady().then(() => {
|
||||
app.whenReady().then(async () => {
|
||||
// macOS application menu with "Change Instance"
|
||||
if (process.platform === 'darwin') {
|
||||
const appMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: 'about' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Change Instance',
|
||||
click: () => {
|
||||
clearInstanceUrl();
|
||||
mainWindow?.loadFile(getPickerPath());
|
||||
mainWindow?.show();
|
||||
mainWindow?.focus();
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'hide' },
|
||||
{ role: 'hideOthers' },
|
||||
{ role: 'unhide' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'quit' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{ role: 'undo' },
|
||||
{ role: 'redo' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut' },
|
||||
{ role: 'copy' },
|
||||
{ role: 'paste' },
|
||||
{ role: 'selectAll' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Window',
|
||||
submenu: [
|
||||
{ role: 'minimize' },
|
||||
{ role: 'zoom' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'front' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
Menu.setApplicationMenu(appMenu);
|
||||
}
|
||||
|
||||
// Purge ALL stale caches so Electron always loads fresh code on launch
|
||||
await session.defaultSession.clearStorageData({ storages: ['serviceworkers'] });
|
||||
await session.defaultSession.clearCache();
|
||||
|
||||
// Intercept getDisplayMedia() — show custom picker in renderer
|
||||
session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => {
|
||||
console.log('[Main:ScreenShare] Handler invoked');
|
||||
try {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['screen', 'window'],
|
||||
thumbnailSize: { width: 320, height: 180 },
|
||||
fetchWindowIcons: true,
|
||||
});
|
||||
console.log('[Main:ScreenShare] Got', sources.length, 'sources');
|
||||
|
||||
if (sources.length === 0) {
|
||||
console.warn('[Main:ScreenShare] No sources — macOS Screen Recording permission may not be granted');
|
||||
// @ts-ignore — Electron throws if we pass {} when video was requested; pass nothing to deny
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = sources.map((source) => ({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
thumbnailDataUrl: source.thumbnail.toDataURL(),
|
||||
appIconDataUrl: source.appIcon && !source.appIcon.isEmpty()
|
||||
? source.appIcon.toDataURL() : null,
|
||||
isScreen: source.id.startsWith('screen:'),
|
||||
}));
|
||||
|
||||
// Send sources to renderer, wait for user selection
|
||||
mainWindow?.webContents.send('screen-share-sources', serialized);
|
||||
|
||||
const sourceId = await new Promise<string | null>((resolve) => {
|
||||
ipcMain.once('screen-share-selected', (_event, id: string | null) => {
|
||||
resolve(id);
|
||||
});
|
||||
});
|
||||
console.log('[Main:ScreenShare] User selected:', sourceId);
|
||||
|
||||
if (!sourceId) {
|
||||
// @ts-ignore — deny the request without crashing
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = sources.find((s) => s.id === sourceId);
|
||||
if (!selected) {
|
||||
// @ts-ignore — deny the request without crashing
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Provide the selected source — Electron creates the MediaStream
|
||||
// Enable system audio loopback on Windows/Linux (macOS blocks at OS level)
|
||||
if (process.platform === 'darwin') {
|
||||
callback({ video: selected });
|
||||
} else {
|
||||
callback({ video: selected, audio: 'loopback' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Main:ScreenShare] Handler error:', err);
|
||||
// @ts-ignore — deny the request without crashing
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
registerIpcHandlers();
|
||||
createWindow();
|
||||
createTray();
|
||||
|
||||
@@ -49,4 +49,17 @@ contextBridge.exposeInMainWorld('backspace', {
|
||||
onDeepLink: (callback: (url: string) => void) => {
|
||||
ipcRenderer.on('deep-link', (_event, url) => callback(url));
|
||||
},
|
||||
|
||||
// Screen share picker coordination
|
||||
onScreenShareSources: (callback: (sources: unknown[]) => void) => {
|
||||
ipcRenderer.on('screen-share-sources', (_event, sources) => callback(sources));
|
||||
},
|
||||
selectScreenSource: (sourceId: string | null) => {
|
||||
ipcRenderer.send('screen-share-selected', sourceId);
|
||||
},
|
||||
|
||||
// Instance URL management
|
||||
getInstanceUrl: () => ipcRenderer.invoke('get-instance-url'),
|
||||
setInstanceUrl: (url: string) => ipcRenderer.invoke('set-instance-url', url),
|
||||
clearInstanceUrl: () => ipcRenderer.invoke('clear-instance-url'),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user