* feat(electron): error handling foundation
Adds the infrastructure for a coherent client-side error story:
- `lib/errors.ts`: canonical ApiError + QuotaExceededError, `toUserMessage`
(friendly strings for ApiError/ZodError/network errors, strips Electron
IPC message prefixes), `logError` (expected), `reportError` (unexpected).
- `lib/query-client.ts`: QueryClient factory with sane retry defaults (no
retry on 4xx except 408/429, 2 retries otherwise; 0 mutation retries),
`QueryCache` onError logs + opts in via `meta.toastOnError`, and
`MutationCache` onError toasts `toUserMessage(err)` by default with
`meta.suppressToast` as the opt-out.
- `components/app-error-boundary.tsx` + `error-fallback.tsx`: two boundaries
(top-level outside the router, route-level inside) with a Card-based
fallback offering 'Go home' + 'Try again'. Route boundary resets on
pathname change and clears React Query error cache on retry.
- `main/ipc-utils.ts` + main.ts migration: `safeHandle` wraps ipcMain.handle
so main-process failures log with full stack and surface a sanitized
message to the renderer. `link:fetch-metadata` keeps its null contract
but now logs.
- `useCreateParticle` opts out of the global toast (compose-overlay renders
its own quota UX) so nothing double-toasts.
Render crashes now have a recovery UI, every mutation gets a free error
toast, and silent-catch cleanup + Sentry land in follow-up PRs.
* refactor(errors): defer mutation errors to global handler
Now that MutationCache toasts via toUserMessage by default, the per-hook
onError duplicates drop away. Also normalizes inline query-error UI and
surfaces a previously-silent failure.
Mutations — removed redundant onError toasts:
- network-selector: useAcceptInvitation, createNetwork (inline)
- network-settings: useInviteMembers, useRevokeInvitation, useRemoveMember
- network-billing: useCreateCheckoutSession, useCreatePortalSession
(onSuccess toasts stay — they carry domain context like network name)
Queries — consistent inline error UX via toUserMessage:
- network-selector: failed-to-load state gets a "Try again" button
- network-billing: "Couldn't load billing" includes friendly reason
- network-settings: useNetworkInvitations failure now surfaces a hint
(previously rendered as "0 pending" — silently wrong)
Trimmed noisy JSDoc from PR 1 files (errors.ts, query-client.ts,
app-error-boundary.tsx, error-fallback.tsx, ipc-utils.ts).
* refactor(errors): route silent catches through logError/reportError
Every catch now either surfaces, re-throws, or calls logError with a scope
tag. No more empty catches or bare console.error:
- auth-store: signInToFirebase / restoreSession / signOut paths gain
logError context. Behavior is unchanged (best-effort local sign-out,
fall back to login on restore failure).
- use-stream-autoplay: Audio.play() and download-URL fetches log their
failures instead of dropping silently (both are nice-to-haves so UX
stays silent — but we can now trace "why didn't autoplay trigger?").
- pusher-client: ws errors / parse failures / server errors / listener
crashes all routed through logError, and listener bugs (which silently
break user flows) now go through reportError so they're actually
surfaced in observability.
- settings-page: email-notifications toggle now toasts on failure
instead of silently reverting with no explanation.
- huddle-app: screen-share failures use logError.
* feat(errors): add Sentry observability behind a sinks facade
logError / reportError now route through a pair of sinks installed at
bootstrap by each process (main + every renderer entry). No call site
knows about Sentry — if sentryDsn is empty, the sinks simply aren't
installed and logError/reportError stay console-only.
- appConfig.sentryDsn: per-env string (empty for now — populate when
ops creates the DSNs). Empty is the no-op mode for dev.
- lib/errors.ts: installErrorSinks({ capture, breadcrumb }) gates
Sentry.captureException / Sentry.addBreadcrumb. Everything flows
through toUserMessage and the two existing call types.
- lib/sentry.ts: initSentryRenderer() for the main window + autoplay,
huddle, and screen-record renderers.
- main/sentry.ts: initSentryMain() runs before anything else in
main.ts to catch bootstrap failures. Captures uncaught exceptions
and the crash reporter automatically.
- main/ipc-utils.ts::safeHandle now routes through reportError.
- main.ts::fetchLinkMetadata logs via logError.
* chore: cleanup reexports
* fix(errors): use relative import so main-process bundle resolves
Rollup in vite.main.config.ts doesn't know about the `@/` alias (only the
renderer configs do). After PR 4 made `lib/errors.ts` reachable from main,
its `@/config/env` import broke the main build with:
[vite]: Rollup failed to resolve import "@/config/env" from "lib/errors.ts"
Swap to a relative import — the module is now genuinely isomorphic.
* build(vite): resolve @/ alias in main + preload configs
The four renderer vite configs all resolve "@" to ./src; main and preload
didn't, so any main-reachable file using @/ imports would break the
bundle. Mirror the same alias block in both configs and revert
lib/errors.ts + src/main/* to the project's @/ convention.
* fix invalid tsconfig field
* comment
* feat(errors): fallback handlers for unhandled rejections and errors
Sentry's global integrations natively capture window.onerror /
unhandledrejection (renderer) and uncaughtException / unhandledRejection
(main). When a DSN is configured we let Sentry own those paths to avoid
double-capturing. When it isn't (dev, unconfigured prod), we attach
minimal listeners that route through reportError so stray promise
rejections and uncaught errors at least hit the console and the facade.
This closes the one gap where a bare click-handler promise rejection
(no try/catch, not a useMutation) would otherwise be invisible.
* Revert "feat(errors): fallback handlers for unhandled rejections and errors"
This reverts commit ed84b29c6b.
---------
Co-authored-by: Claude <[email protected]>
Co-authored-by: Arjun from Flowy Labs <[email protected]>
586 lines
18 KiB
TypeScript
586 lines
18 KiB
TypeScript
import { app, BrowserWindow, desktopCapturer, ipcMain, screen, session, shell } from 'electron';
|
|
import path from 'node:path';
|
|
import started from 'electron-squirrel-startup';
|
|
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
|
|
|
|
import type { LinkMetadata } from './lib/link-metadata';
|
|
import { appConfig } from './config/env';
|
|
import { logError } from './lib/errors';
|
|
import { safeHandle } from './main/ipc-utils';
|
|
import { initSentryMain } from './main/sentry';
|
|
|
|
initSentryMain();
|
|
|
|
if (app.isPackaged) {
|
|
updateElectronApp({
|
|
updateSource: {
|
|
type: UpdateSourceType.StaticStorage,
|
|
baseUrl: `https://storage.googleapis.com/flowy-releases/llink/${process.platform}/${process.arch}`
|
|
}
|
|
});
|
|
}
|
|
|
|
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
|
if (started) {
|
|
app.quit();
|
|
}
|
|
|
|
// Set the dock icon for development mode on macOS.
|
|
if (process.platform === 'darwin' && !app.isPackaged) {
|
|
app.dock?.setIcon(path.join(__dirname, '../../assets/icon.png'));
|
|
}
|
|
|
|
// In dev, `LLINK_PROFILE=foo yarn start` spins up a second instance with an
|
|
// isolated userData dir so it can coexist with the default one (separate auth,
|
|
// cookies, leveldb locks).
|
|
const devProfile = !app.isPackaged ? process.env.LLINK_PROFILE : undefined;
|
|
if (devProfile) {
|
|
app.setPath('userData', `${app.getPath('userData')}-${devProfile}`);
|
|
}
|
|
|
|
// Single-instance lock: on Windows/Linux, clicking a llink:// URL launches a new
|
|
// process. The lock makes the losing instance quit and fires `second-instance` on
|
|
// the primary, so we focus the existing window instead of spawning a duplicate.
|
|
// macOS uses `open-url` instead and doesn't need this, but the lock is harmless.
|
|
// Skip the lock when running a named dev profile — those instances are meant to
|
|
// run alongside the default one.
|
|
if (!devProfile && !app.requestSingleInstanceLock()) {
|
|
app.quit();
|
|
}
|
|
|
|
// Register llink:// as the default handler for this app. macOS also gets this
|
|
// declaratively via CFBundleURLTypes (forge.config.ts `protocols`); Windows relies
|
|
// on this runtime call (Squirrel doesn't register protocols on install).
|
|
if (!app.isDefaultProtocolClient('llink')) {
|
|
app.setAsDefaultProtocolClient('llink');
|
|
}
|
|
|
|
let mainWindow: BrowserWindow | null = null;
|
|
let autoplayWindow: BrowserWindow | null = null;
|
|
let huddleWindow: BrowserWindow | null = null;
|
|
let screenRecordWindow: BrowserWindow | null = null;
|
|
|
|
function hardenWindow(win: BrowserWindow) {
|
|
const wc = win.webContents;
|
|
|
|
// Disable pinch / Cmd+scroll zoom; our layouts assume DPR=1.
|
|
wc.setVisualZoomLevelLimits(1, 1);
|
|
|
|
// Block devtools, reload, and zoom keyboard shortcuts in packaged builds.
|
|
wc.on('before-input-event', (event, input) => {
|
|
if (input.type !== 'keyDown') return;
|
|
const key = input.key.toLowerCase();
|
|
const cmdOrCtrl = input.meta || input.control;
|
|
|
|
const isDevtools =
|
|
key === 'f12' ||
|
|
(cmdOrCtrl && input.alt && key === 'i') ||
|
|
(cmdOrCtrl && input.shift && key === 'i') ||
|
|
(cmdOrCtrl && input.shift && key === 'c') ||
|
|
(cmdOrCtrl && input.shift && key === 'j');
|
|
const isReload = cmdOrCtrl && (key === 'r' || key === 'f5');
|
|
const isZoom = cmdOrCtrl && (key === '=' || key === '+' || key === '-' || key === '0');
|
|
|
|
if (app.isPackaged && (isDevtools || isReload)) {
|
|
event.preventDefault();
|
|
}
|
|
if (isZoom) {
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
|
|
// Route window.open / target=_blank through the OS browser instead of a new BrowserWindow.
|
|
wc.setWindowOpenHandler(({ url }) => {
|
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
shell.openExternal(url);
|
|
}
|
|
return { action: 'deny' };
|
|
});
|
|
|
|
// Freeze the window to its initial origin — block any full-page navigation.
|
|
// In-app routing uses pushState / hash which don't trigger will-navigate.
|
|
wc.on('will-navigate', (event, url) => {
|
|
const currentUrl = wc.getURL();
|
|
if (!currentUrl) return;
|
|
try {
|
|
if (new URL(url).origin !== new URL(currentUrl).origin) {
|
|
event.preventDefault();
|
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
shell.openExternal(url);
|
|
}
|
|
}
|
|
} catch {
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
}
|
|
|
|
const createWindow = () => {
|
|
mainWindow = new BrowserWindow({
|
|
width: 800,
|
|
height: 600,
|
|
resizable: true,
|
|
fullscreenable: true,
|
|
frame: false,
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.js'),
|
|
backgroundThrottling: false,
|
|
},
|
|
});
|
|
hardenWindow(mainWindow);
|
|
|
|
mainWindow.on('maximize', () => {
|
|
mainWindow?.webContents.send('window:maximize-changed', true);
|
|
});
|
|
mainWindow.on('unmaximize', () => {
|
|
mainWindow?.webContents.send('window:maximize-changed', false);
|
|
});
|
|
|
|
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
|
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
|
|
mainWindow.webContents.openDevTools();
|
|
} else {
|
|
mainWindow.loadFile(
|
|
path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`),
|
|
);
|
|
}
|
|
|
|
mainWindow.on('closed', () => {
|
|
mainWindow = null;
|
|
app.quit();
|
|
});
|
|
};
|
|
|
|
const createAutoplayWindow = () => {
|
|
if (autoplayWindow) return;
|
|
|
|
autoplayWindow = new BrowserWindow({
|
|
width: 320,
|
|
height: 88,
|
|
resizable: false,
|
|
frame: false,
|
|
alwaysOnTop: true,
|
|
skipTaskbar: true,
|
|
focusable: false,
|
|
show: false,
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.js'),
|
|
},
|
|
});
|
|
autoplayWindow.setVisibleOnAllWorkspaces(true);
|
|
hardenWindow(autoplayWindow);
|
|
|
|
if (AUTOPLAY_WINDOW_VITE_DEV_SERVER_URL) {
|
|
autoplayWindow.loadURL(AUTOPLAY_WINDOW_VITE_DEV_SERVER_URL);
|
|
} else {
|
|
autoplayWindow.loadFile(
|
|
path.join(__dirname, `../renderer/${AUTOPLAY_WINDOW_VITE_NAME}/index.html`),
|
|
);
|
|
}
|
|
|
|
autoplayWindow.on('closed', () => {
|
|
autoplayWindow = null;
|
|
});
|
|
};
|
|
|
|
const createHuddleWindow = () => {
|
|
if (huddleWindow) return;
|
|
|
|
huddleWindow = new BrowserWindow({
|
|
width: 1024,
|
|
height: 768,
|
|
resizable: true,
|
|
fullscreenable: true,
|
|
frame: true,
|
|
title: 'Huddle',
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.js'),
|
|
},
|
|
});
|
|
hardenWindow(huddleWindow);
|
|
|
|
huddleWindow.on('closed', () => {
|
|
huddleWindow = null;
|
|
});
|
|
};
|
|
|
|
// Connection data is passed via URL hash so it's available synchronously on
|
|
// renderer mount — avoids the IPC race where `huddle:connect` could be sent
|
|
// before React attached its listener.
|
|
const loadHuddleWindow = (data: { token: string; serverUrl: string }) => {
|
|
if (!huddleWindow) return;
|
|
const params = new URLSearchParams({ token: data.token, serverUrl: data.serverUrl });
|
|
const hash = params.toString();
|
|
if (HUDDLE_WINDOW_VITE_DEV_SERVER_URL) {
|
|
huddleWindow.loadURL(`${HUDDLE_WINDOW_VITE_DEV_SERVER_URL}#${hash}`);
|
|
} else {
|
|
huddleWindow.loadFile(
|
|
path.join(__dirname, `../renderer/${HUDDLE_WINDOW_VITE_NAME}/index.html`),
|
|
{ hash },
|
|
);
|
|
}
|
|
};
|
|
|
|
function positionAutoplayWindow() {
|
|
if (!autoplayWindow) return;
|
|
const { width } = screen.getPrimaryDisplay().workAreaSize;
|
|
const [winW] = autoplayWindow.getSize();
|
|
autoplayWindow.setPosition(width - winW - 16, 16);
|
|
}
|
|
|
|
const createScreenRecordWindow = () => {
|
|
if (screenRecordWindow) return;
|
|
|
|
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
|
|
const winW = 240;
|
|
const winH = 48;
|
|
|
|
screenRecordWindow = new BrowserWindow({
|
|
width: winW,
|
|
height: winH,
|
|
x: Math.round((width - winW) / 2),
|
|
y: height - winH - 32,
|
|
resizable: false,
|
|
frame: false,
|
|
alwaysOnTop: true,
|
|
skipTaskbar: true,
|
|
focusable: true,
|
|
show: false,
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.js'),
|
|
},
|
|
});
|
|
screenRecordWindow.setVisibleOnAllWorkspaces(true);
|
|
hardenWindow(screenRecordWindow);
|
|
|
|
if (SCREEN_RECORD_WINDOW_VITE_DEV_SERVER_URL) {
|
|
screenRecordWindow.loadURL(SCREEN_RECORD_WINDOW_VITE_DEV_SERVER_URL);
|
|
} else {
|
|
screenRecordWindow.loadFile(
|
|
path.join(__dirname, `../renderer/${SCREEN_RECORD_WINDOW_VITE_NAME}/index.html`),
|
|
);
|
|
}
|
|
|
|
screenRecordWindow.on('closed', () => {
|
|
screenRecordWindow = null;
|
|
});
|
|
};
|
|
|
|
// Window control IPC handlers
|
|
ipcMain.on('window:minimize', (event) => {
|
|
BrowserWindow.fromWebContents(event.sender)?.minimize();
|
|
});
|
|
ipcMain.on('window:maximize', (event) => {
|
|
const win = BrowserWindow.fromWebContents(event.sender);
|
|
if (win?.isMaximized()) {
|
|
win.unmaximize();
|
|
} else {
|
|
win?.maximize();
|
|
}
|
|
});
|
|
ipcMain.on('window:close', (event) => {
|
|
BrowserWindow.fromWebContents(event.sender)?.close();
|
|
});
|
|
ipcMain.on('window:fullscreen', (event) => {
|
|
const win = BrowserWindow.fromWebContents(event.sender);
|
|
if (win) win.setFullScreen(!win.isFullScreen());
|
|
});
|
|
|
|
// Secondary window IPC handlers
|
|
ipcMain.on('window:open-huddle', (_event, data: { token: string; serverUrl: string }) => {
|
|
createHuddleWindow();
|
|
loadHuddleWindow(data);
|
|
huddleWindow?.focus();
|
|
});
|
|
ipcMain.on('window:close-huddle', () => {
|
|
huddleWindow?.close();
|
|
});
|
|
|
|
safeHandle('screen:get-sources', async () => {
|
|
const sources = await desktopCapturer.getSources({
|
|
types: ['screen', 'window'],
|
|
thumbnailSize: { width: 320, height: 180 },
|
|
});
|
|
return sources.map((source) => ({
|
|
id: source.id,
|
|
name: source.name,
|
|
thumbnailDataUrl: source.thumbnail.toDataURL(),
|
|
appIconDataUrl: source.appIcon?.toDataURL() ?? null,
|
|
}));
|
|
});
|
|
|
|
// Screen recording IPC handlers
|
|
ipcMain.on('screen-record:start', () => {
|
|
createScreenRecordWindow();
|
|
if (!screenRecordWindow) return;
|
|
|
|
const winW = 240;
|
|
const winH = 48;
|
|
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
|
|
screenRecordWindow.setSize(winW, winH);
|
|
screenRecordWindow.setPosition(
|
|
Math.round((width - winW) / 2),
|
|
height - winH - 32,
|
|
);
|
|
|
|
const send = () => {
|
|
screenRecordWindow?.webContents.send('screen-record:init');
|
|
screenRecordWindow?.showInactive();
|
|
};
|
|
|
|
if (screenRecordWindow.webContents.isLoading()) {
|
|
screenRecordWindow.webContents.once('did-finish-load', send);
|
|
} else {
|
|
send();
|
|
}
|
|
});
|
|
ipcMain.on('screen-record:stop', () => {
|
|
mainWindow?.webContents.send('screen-record:stopped');
|
|
screenRecordWindow?.close();
|
|
mainWindow?.focus();
|
|
});
|
|
ipcMain.on('screen-record:cancel', () => {
|
|
screenRecordWindow?.close();
|
|
});
|
|
|
|
// Autoplay IPC handlers
|
|
ipcMain.on('autoplay:play', (_event, payload) => {
|
|
if (!autoplayWindow) createAutoplayWindow();
|
|
if (!autoplayWindow) return;
|
|
|
|
const isVideo = typeof payload?.mimeType === 'string' && payload.mimeType.startsWith('video/');
|
|
autoplayWindow.setSize(320, isVideo ? 260 : 88);
|
|
positionAutoplayWindow();
|
|
|
|
autoplayWindow.webContents.send('autoplay:play', payload);
|
|
autoplayWindow.showInactive();
|
|
});
|
|
|
|
ipcMain.on('autoplay:dismiss', () => {
|
|
autoplayWindow?.webContents.send('autoplay:stop');
|
|
autoplayWindow?.hide();
|
|
});
|
|
|
|
ipcMain.on('autoplay:navigate', (_event, data) => {
|
|
autoplayWindow?.webContents.send('autoplay:stop');
|
|
autoplayWindow?.hide();
|
|
if (mainWindow) {
|
|
mainWindow.webContents.send('autoplay:navigate', data);
|
|
mainWindow.focus();
|
|
}
|
|
});
|
|
|
|
// --- Link metadata ---
|
|
|
|
const metadataCache = new Map<string, LinkMetadata>();
|
|
|
|
function getMetaContent(html: string, property: string): string | null {
|
|
// Match both property="..." and name="..." attributes
|
|
const regex = new RegExp(
|
|
`<meta[^>]*(?:property|name)=["']${property}["'][^>]*content=["']([^"']*)["']|<meta[^>]*content=["']([^"']*)["'][^>]*(?:property|name)=["']${property}["']`,
|
|
'i',
|
|
);
|
|
const match = html.match(regex);
|
|
return match?.[1] ?? match?.[2] ?? null;
|
|
}
|
|
|
|
function getTitle(html: string): string | null {
|
|
const match = html.match(/<title[^>]*>([^<]*)<\/title>/i);
|
|
return match?.[1]?.trim() ?? null;
|
|
}
|
|
|
|
function getFavicon(html: string, baseUrl: string): string | null {
|
|
const match = html.match(/<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']*)["']/i)
|
|
?? html.match(/<link[^>]*href=["']([^"']*)["'][^>]*rel=["'](?:shortcut )?icon["']/i);
|
|
if (!match?.[1]) {
|
|
// Fall back to /favicon.ico
|
|
try {
|
|
const url = new URL(baseUrl);
|
|
return `${url.protocol}//${url.host}/favicon.ico`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
try {
|
|
return new URL(match[1], baseUrl).href;
|
|
} catch {
|
|
return match[1];
|
|
}
|
|
}
|
|
|
|
function resolveUrl(src: string | null, baseUrl: string): string | null {
|
|
if (!src) return null;
|
|
try {
|
|
return new URL(src, baseUrl).href;
|
|
} catch {
|
|
return src;
|
|
}
|
|
}
|
|
|
|
async function fetchLinkMetadata(url: string): Promise<LinkMetadata | null> {
|
|
const cached = metadataCache.get(url);
|
|
if (cached) return cached;
|
|
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
|
|
const response = await fetch(url, {
|
|
signal: controller.signal,
|
|
headers: {
|
|
'User-Agent': 'Mozilla/5.0 (compatible; llink/1.0)',
|
|
'Accept': 'text/html',
|
|
},
|
|
redirect: 'follow',
|
|
});
|
|
clearTimeout(timeout);
|
|
|
|
if (!response.ok) return null;
|
|
|
|
// Only read the first ~50KB to get <head> content
|
|
const reader = response.body?.getReader();
|
|
if (!reader) return null;
|
|
|
|
let html = '';
|
|
const decoder = new TextDecoder();
|
|
while (html.length < 50_000) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
html += decoder.decode(value, { stream: true });
|
|
}
|
|
reader.cancel();
|
|
|
|
const domain = new URL(url).hostname.replace(/^www\./, '');
|
|
const metadata: LinkMetadata = {
|
|
url,
|
|
title: getMetaContent(html, 'og:title') ?? getTitle(html),
|
|
description: getMetaContent(html, 'og:description') ?? getMetaContent(html, 'description'),
|
|
image: resolveUrl(getMetaContent(html, 'og:image'), url),
|
|
favicon: getFavicon(html, url),
|
|
domain,
|
|
};
|
|
|
|
metadataCache.set(url, metadata);
|
|
return metadata;
|
|
} catch (err) {
|
|
// Metadata is a progressive enhancement — keep the null contract, but log
|
|
// so upstream failures (DNS, TLS, aborted fetches) aren't invisible.
|
|
logError(err, { scope: 'link.fetchMetadata', url });
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// --- Dock badge ---
|
|
|
|
ipcMain.on('app:set-dock-badge', (_event, count: number) => {
|
|
if (process.platform === 'darwin') {
|
|
app.dock?.setBadge(count > 0 ? String(count) : '');
|
|
}
|
|
});
|
|
|
|
safeHandle('app:get-version', () => app.getVersion());
|
|
|
|
safeHandle('link:fetch-metadata', async (_event, url) => {
|
|
if (typeof url !== 'string') return null;
|
|
try {
|
|
new URL(url);
|
|
} catch {
|
|
return null;
|
|
}
|
|
return fetchLinkMetadata(url);
|
|
});
|
|
|
|
safeHandle('link:open-external', async (_event, url) => {
|
|
if (typeof url !== 'string') return;
|
|
// Only allow http(s) URLs for security
|
|
if (!url.startsWith('http://') && !url.startsWith('https://')) return;
|
|
await shell.openExternal(url);
|
|
});
|
|
|
|
// --- Attachment download ---
|
|
// Triggers a native download with save-as dialog. Cross-origin safe — unlike
|
|
// the web `<a download>` hack, which is ignored for cross-origin URLs.
|
|
ipcMain.on(
|
|
'attachment:download',
|
|
(event, payload: { url: string; filename?: string }) => {
|
|
const win = BrowserWindow.fromWebContents(event.sender);
|
|
if (!win) return;
|
|
const { url, filename } = payload ?? {};
|
|
if (typeof url !== 'string') return;
|
|
if (!url.startsWith('http://') && !url.startsWith('https://')) return;
|
|
|
|
const dlSession = win.webContents.session;
|
|
const onWillDownload = (_e: Electron.Event, item: Electron.DownloadItem) => {
|
|
if (filename) item.setSaveDialogOptions({ defaultPath: filename });
|
|
dlSession.removeListener('will-download', onWillDownload);
|
|
};
|
|
dlSession.on('will-download', onWillDownload);
|
|
win.webContents.downloadURL(url);
|
|
},
|
|
);
|
|
|
|
// This method will be called when Electron has finished
|
|
// initialization and is ready to create browser windows.
|
|
// Some APIs can only be used after this event occurs.
|
|
app.on('ready', () => {
|
|
// Allow CORS for API requests from the renderer process.
|
|
// The server doesn't handle OPTIONS preflight, so we intercept at the
|
|
// Electron network layer: inject CORS headers and return 200 for preflight.
|
|
session.defaultSession.webRequest.onHeadersReceived(
|
|
{ urls: [`${appConfig.orionUrl}/*`, 'https://storage.googleapis.com/*'] },
|
|
(details, callback) => {
|
|
const headers = { ...details.responseHeaders };
|
|
headers['access-control-allow-origin'] = ['*'];
|
|
headers['access-control-allow-headers'] = ['Content-Type', 'Authorization'];
|
|
headers['access-control-allow-methods'] = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'];
|
|
|
|
if (details.method === 'OPTIONS') {
|
|
callback({ responseHeaders: headers, statusLine: 'HTTP/1.1 200 OK' });
|
|
} else {
|
|
callback({ responseHeaders: headers });
|
|
}
|
|
},
|
|
);
|
|
|
|
createWindow();
|
|
createAutoplayWindow();
|
|
});
|
|
|
|
app.on('window-all-closed', () => {
|
|
app.quit();
|
|
});
|
|
|
|
app.on('activate', () => {
|
|
if (!mainWindow) {
|
|
createWindow();
|
|
} else {
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
}
|
|
});
|
|
|
|
function focusMainWindow() {
|
|
if (!mainWindow) return;
|
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
if (!mainWindow.isVisible()) mainWindow.show();
|
|
mainWindow.focus();
|
|
}
|
|
|
|
// macOS delivers llink:// URLs via this event, both when the app is already
|
|
// running and on cold start (after `ready`). We prevent the default to silence
|
|
// Electron's warning and focus the window — OS-level focus alone won't restore
|
|
// a hidden or minimized window. Cold start is handled by createWindow().
|
|
app.on('open-url', (event) => {
|
|
event.preventDefault();
|
|
focusMainWindow();
|
|
});
|
|
|
|
// Windows/Linux: the OS launches a second process with the URL in argv; the
|
|
// single-instance lock diverts it here on the primary instance.
|
|
app.on('second-instance', () => {
|
|
focusMainWindow();
|
|
});
|
|
|
|
// In this file you can include the rest of your app's specific main process
|
|
// code. You can also put them in separate files and import them here.
|