refactor: organize desktop vs. mobile into separate folders
This commit is contained in:
@@ -0,0 +1,585 @@
|
||||
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.
|
||||
Reference in New Issue
Block a user