643 lines
20 KiB
TypeScript
643 lines
20 KiB
TypeScript
import { app, BrowserWindow, clipboard, dialog, ipcMain, protocol, shell } from 'electron';
|
|
import { lookup } from 'node:dns/promises';
|
|
import { spawn, type ChildProcess } from 'node:child_process';
|
|
import { randomBytes } from 'node:crypto';
|
|
import { readFileSync } from 'node:fs';
|
|
import { createInterface } from 'node:readline';
|
|
import * as net from 'node:net';
|
|
import * as path from 'node:path';
|
|
|
|
const MIME: Record<string, string> = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.svg': 'image/svg+xml',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.ico': 'image/x-icon',
|
|
'.woff': 'font/woff',
|
|
'.woff2': 'font/woff2',
|
|
'.ttf': 'font/ttf',
|
|
'.json': 'application/json',
|
|
'.map': 'application/json',
|
|
};
|
|
|
|
protocol.registerSchemesAsPrivileged([
|
|
{ scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true } },
|
|
]);
|
|
|
|
/**
|
|
* Content-Security-Policy applied to every page this app loads.
|
|
*
|
|
* The production policy forbids inline scripts entirely (the Vite bundle is
|
|
* external), so an injected `<script>` in rendered content cannot execute. The
|
|
* dev-server policy keeps `'unsafe-inline'` because Vite's React-refresh
|
|
* preamble is an inline script, but still pins network access to localhost
|
|
* (HMR websocket included).
|
|
*/
|
|
const CSP_PROD =
|
|
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
|
|
"connect-src 'self'; img-src 'self' data: https:; object-src 'none'; " +
|
|
"base-uri 'none'; form-action 'none'";
|
|
const CSP_DEV =
|
|
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; " +
|
|
"connect-src 'self' ws://localhost:* http://localhost:*; img-src 'self' data: https:; " +
|
|
"object-src 'none'; base-uri 'none'; form-action 'none'";
|
|
|
|
/** The CSP for a URL this window may load, or `null` for anywhere else. */
|
|
function cspForUrl(url: string): string | null {
|
|
if (url.startsWith('app://')) {
|
|
return CSP_PROD;
|
|
}
|
|
const devServer = process.env.NOSTR_GUI_DEV_URL;
|
|
if (devServer) {
|
|
try {
|
|
if (new URL(url).origin === new URL(devServer).origin) {
|
|
return CSP_DEV;
|
|
}
|
|
} catch {
|
|
// Fall through: not a URL we recognise.
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
interface PendingRequest {
|
|
resolve: (value: unknown) => void;
|
|
reject: (reason: Error) => void;
|
|
}
|
|
|
|
let backend: ChildProcess | null = null;
|
|
let backendStarted = false;
|
|
const pending = new Map<number, PendingRequest>();
|
|
let nextId = 1;
|
|
|
|
function resolveBackendPath(): string {
|
|
if (app.isPackaged) {
|
|
return path.join(process.resourcesPath, 'keynectr');
|
|
}
|
|
// Development: the crate builds to <project>/target/release.
|
|
return path.join(app.getAppPath(), '..', 'target', 'release', 'keynectr');
|
|
}
|
|
|
|
function startBackend(): void {
|
|
if (backendStarted && backend && backend.exitCode === null) {
|
|
return;
|
|
}
|
|
backendStarted = true;
|
|
|
|
const bin = resolveBackendPath();
|
|
backend = spawn(bin, ['serve'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
|
|
const stdout = backend.stdout;
|
|
const stderr = backend.stderr;
|
|
const stdin = backend.stdin;
|
|
if (!stdout || !stderr || !stdin) {
|
|
console.error('[backend] failed to capture backend streams.');
|
|
return;
|
|
}
|
|
|
|
const reader = createInterface({ input: stdout });
|
|
reader.on('line', (line) => {
|
|
let envelope: { id?: number };
|
|
try {
|
|
envelope = JSON.parse(line);
|
|
} catch {
|
|
return;
|
|
}
|
|
if (typeof envelope.id === 'number') {
|
|
const entry = pending.get(envelope.id);
|
|
if (entry) {
|
|
pending.delete(envelope.id);
|
|
entry.resolve(envelope);
|
|
}
|
|
}
|
|
});
|
|
|
|
stderr.on('data', (chunk: Buffer) => {
|
|
// Backend diagnostics go to stderr only; never secrets, never forwarded.
|
|
console.error('[backend]', chunk.toString().trim());
|
|
});
|
|
|
|
backend.on('error', (err) => {
|
|
console.error('[backend] failed to start:', err.message);
|
|
});
|
|
|
|
backend.on('exit', (code) => {
|
|
const error = new Error(
|
|
`The Rust backend exited unexpectedly${code === null ? '' : ` (code ${code})`}.`,
|
|
);
|
|
for (const [, entry] of pending) {
|
|
entry.reject(error);
|
|
}
|
|
pending.clear();
|
|
backend = null;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Upper bound for one backend round-trip. Generous on purpose: a publish can
|
|
* wait for each relay in turn (10s connect + 15s send each). A hung backend
|
|
* still gets reaped instead of leaking promises forever.
|
|
*/
|
|
const BACKEND_TIMEOUT_MS = 120_000;
|
|
|
|
async function backendRequest(method: string, params: Record<string, unknown>): Promise<unknown> {
|
|
startBackend();
|
|
const stdin = backend?.stdin;
|
|
if (!backend || !stdin || stdin.destroyed) {
|
|
throw new Error('The Rust backend is not available.');
|
|
}
|
|
const id = nextId++;
|
|
const payload = { id, method, ...params };
|
|
let timer!: ReturnType<typeof setTimeout>;
|
|
const response = new Promise<unknown>((resolve, reject) => {
|
|
pending.set(id, {
|
|
resolve,
|
|
reject,
|
|
});
|
|
timer = setTimeout(() => {
|
|
pending.delete(id);
|
|
reject(new Error('The background service did not respond in time.'));
|
|
}, BACKEND_TIMEOUT_MS);
|
|
});
|
|
stdin.write(`${JSON.stringify(payload)}\n`);
|
|
return response.finally(() => clearTimeout(timer));
|
|
}
|
|
|
|
/**
|
|
* Methods the renderer is allowed to invoke, enforced in the main process so a
|
|
* compromised page cannot invent new backend calls. Everything else is
|
|
* rejected. Sensitive methods are listed because their screens need them; they
|
|
* remain gated by the vault password on the backend side.
|
|
*/
|
|
const RENDERER_METHODS: ReadonlySet<string> = new Set([
|
|
// Handled natively by Electron main (dialogs, HTTP).
|
|
'pick_image',
|
|
'link_preview',
|
|
'upload_image',
|
|
// Forwarded to the Rust backend over stdio.
|
|
'init',
|
|
'get_state',
|
|
'create_profile',
|
|
'select_profile',
|
|
'publish_profile_metadata',
|
|
'set_profile_picture',
|
|
'delete_profile',
|
|
'undo_delete',
|
|
'publish_note',
|
|
'feed_get',
|
|
'relay_add',
|
|
'relay_remove',
|
|
'relay_set_enabled',
|
|
'relay_test',
|
|
'settings_update',
|
|
'backup_now',
|
|
'set_vault_password',
|
|
'unlock_vault',
|
|
'lock_vault',
|
|
'remove_vault_password',
|
|
'reveal_secret_key',
|
|
'signer_connect',
|
|
'signer_disconnect',
|
|
'signer_status',
|
|
'signer_approve',
|
|
]);
|
|
|
|
/** True when `method` may be dispatched. Unknown methods never reach the backend. */
|
|
function isAllowedMethod(method: unknown): method is string {
|
|
return typeof method === 'string' && RENDERER_METHODS.has(method);
|
|
}
|
|
|
|
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif'];
|
|
|
|
/** A file chosen through the native dialog, known only to the main process. */
|
|
interface PickedFile {
|
|
path: string;
|
|
name: string;
|
|
mime: string;
|
|
}
|
|
|
|
/**
|
|
* One-time tokens handed to the renderer in place of filesystem paths.
|
|
* `upload_image` accepts only these, so a compromised page can never point an
|
|
* upload at an arbitrary local file — only at files the user explicitly picked,
|
|
* and each exactly once.
|
|
*/
|
|
const pickedTokens = new Map<string, PickedFile>();
|
|
|
|
/** Mint a single-use upload token for a freshly picked file. */
|
|
function issueToken(file: PickedFile): string {
|
|
const token = randomBytes(16).toString('hex');
|
|
pickedTokens.set(token, file);
|
|
return token;
|
|
}
|
|
|
|
/** How long to wait for a link-preview page before giving up. */
|
|
const LINK_PREVIEW_TIMEOUT_MS = 10_000;
|
|
/** Cap on how much HTML we parse for meta tags. */
|
|
const LINK_PREVIEW_MAX_BYTES = 1_000_000;
|
|
|
|
/** Whether an IPv4 address is loopback, private, or otherwise non-routable. */
|
|
function ipv4IsPrivate(ip: string): boolean {
|
|
const parts = ip.split('.').map(Number);
|
|
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) {
|
|
return true; // Malformed: treat as unsafe.
|
|
}
|
|
const [a, b] = parts;
|
|
return (
|
|
a === 0 ||
|
|
a === 10 ||
|
|
a === 127 ||
|
|
(a === 100 && b >= 64 && b <= 127) ||
|
|
(a === 169 && b === 254) ||
|
|
(a === 172 && b >= 16 && b <= 31) ||
|
|
(a === 192 && b === 168)
|
|
);
|
|
}
|
|
|
|
/** Whether an IPv6 address is loopback, link-local, unique-local, or v4-mapped private. */
|
|
function ipv6IsPrivate(ip: string): boolean {
|
|
const addr = ip.toLowerCase();
|
|
if (addr === '::' || addr === '::1') {
|
|
return true;
|
|
}
|
|
const mapped = addr.startsWith('::ffff:') ? addr.slice(7) : null;
|
|
if (mapped) {
|
|
return net.isIPv4(mapped) ? ipv4IsPrivate(mapped) : true;
|
|
}
|
|
// fc00::/7 (unique local) and fe80::/10 (link local).
|
|
return /^f[cd]/.test(addr) || /^fe[89ab]/.test(addr);
|
|
}
|
|
|
|
/** Whether `ip` points at the local machine or a private network. */
|
|
function isPrivateAddress(ip: string): boolean {
|
|
return net.isIPv4(ip) ? ipv4IsPrivate(ip) : ipv6IsPrivate(ip);
|
|
}
|
|
|
|
/**
|
|
* Resolve `url`'s host and refuse loopback/private targets, so a crafted note
|
|
* link cannot make the app probe the user's localhost or LAN ("SSRF"). Hosts
|
|
* are checked at their resolved addresses, not just by name.
|
|
*/
|
|
async function resolvesToPrivateAddress(url: URL): Promise<boolean> {
|
|
const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) {
|
|
return true;
|
|
}
|
|
let addresses: string[];
|
|
if (net.isIP(host)) {
|
|
addresses = [host];
|
|
} else {
|
|
try {
|
|
addresses = (await lookup(host, { all: true, verbatim: true })).map((a) => a.address);
|
|
} catch {
|
|
return true; // Unresolvable: nothing useful to preview anyway.
|
|
}
|
|
}
|
|
return addresses.some(isPrivateAddress);
|
|
}
|
|
|
|
/** A best-effort MIME type derived from the file name. */
|
|
function mimeForPath(filePath: string): string {
|
|
switch (path.extname(filePath).toLowerCase()) {
|
|
case '.png':
|
|
return 'image/png';
|
|
case '.jpg':
|
|
case '.jpeg':
|
|
return 'image/jpeg';
|
|
case '.gif':
|
|
return 'image/gif';
|
|
case '.webp':
|
|
return 'image/webp';
|
|
case '.avif':
|
|
return 'image/avif';
|
|
default:
|
|
return 'application/octet-stream';
|
|
}
|
|
}
|
|
|
|
/** Show an open dialog and return one-time tokens for the chosen images. */
|
|
async function pickImage(): Promise<{ token: string; name: string; mime: string }[]> {
|
|
const result = await dialog.showOpenDialog({
|
|
title: 'Attach image(s)',
|
|
filters: [{ name: 'Images', extensions: IMAGE_EXTENSIONS }],
|
|
properties: ['openFile', 'multiSelections'],
|
|
});
|
|
if (result.canceled) {
|
|
return [];
|
|
}
|
|
return result.filePaths.map((filePath) => {
|
|
const file: PickedFile = {
|
|
path: filePath,
|
|
name: path.basename(filePath),
|
|
mime: mimeForPath(filePath),
|
|
};
|
|
return { token: issueToken(file), name: file.name, mime: file.mime };
|
|
});
|
|
}
|
|
|
|
/** Upload a picked image to nostr.build and return the public URL + MIME type. */
|
|
async function uploadImage(file: PickedFile): Promise<{ url: string; mime: string }> {
|
|
const uploadUrl = 'https://nostr.build/api/v2/upload/files';
|
|
const data = readFileSync(file.path);
|
|
const mime = file.mime;
|
|
|
|
// nostr.build requires a NIP-98 auth token signed with the active profile's key.
|
|
const authEnvelope = (await backendRequest('upload_auth', {
|
|
url: uploadUrl,
|
|
http_method: 'POST',
|
|
})) as { status: string; data?: { authorization?: string }; message?: string };
|
|
if (authEnvelope.status !== 'ok' || !authEnvelope.data?.authorization) {
|
|
throw new Error(authEnvelope.message ?? 'Could not authorize the upload.');
|
|
}
|
|
|
|
const form = new FormData();
|
|
form.append(
|
|
'fileToUpload',
|
|
new Blob([data], { type: mime }),
|
|
path.basename(file.name) || 'image',
|
|
);
|
|
const response = await fetch(uploadUrl, {
|
|
method: 'POST',
|
|
headers: { authorization: authEnvelope.data.authorization },
|
|
body: form,
|
|
});
|
|
const payload = (await response.json().catch(() => ({}))) as {
|
|
status?: string;
|
|
message?: string;
|
|
data?: { url?: string; mime?: string }[];
|
|
};
|
|
const uploaded = payload.data?.[0];
|
|
if (!response.ok || payload.status !== 'success' || !uploaded?.url) {
|
|
throw new Error(
|
|
payload.message || `The image host rejected the upload (HTTP ${response.status}).`,
|
|
);
|
|
}
|
|
return { url: uploaded.url, mime: uploaded.mime ?? mime };
|
|
}
|
|
|
|
interface LinkPreview {
|
|
url: string;
|
|
title: string;
|
|
description: string | null;
|
|
image: string | null;
|
|
site_name: string | null;
|
|
}
|
|
|
|
function decodeHtmlEntities(value: string): string {
|
|
return value
|
|
.replace(/&/gi, '&')
|
|
.replace(/</gi, '<')
|
|
.replace(/>/gi, '>')
|
|
.replace(/"/gi, '"')
|
|
.replace(/'/gi, "'")
|
|
.replace(/ /gi, ' ');
|
|
}
|
|
|
|
/** Parse `<meta>` tags into a lower-cased property/name -> content map. */
|
|
function parseMetaTags(html: string): Record<string, string> {
|
|
const tags: Record<string, string> = {};
|
|
const attr = (tag: string, name: string): string | null => {
|
|
const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(["'])(.*?)\\1`, 'i'));
|
|
return match ? match[2] : null;
|
|
};
|
|
for (const match of html.matchAll(/<meta\b[^>]*>/gi)) {
|
|
const key = attr(match[0], 'property') ?? attr(match[0], 'name');
|
|
const value = attr(match[0], 'content');
|
|
if (key && value && !(key.toLowerCase() in tags)) {
|
|
tags[key.toLowerCase()] = decodeHtmlEntities(value.trim());
|
|
}
|
|
}
|
|
return tags;
|
|
}
|
|
|
|
/** Fetch a web page and pull an OpenGraph/Twitter card for the preview. */
|
|
async function fetchLinkPreview(rawUrl: string): Promise<LinkPreview | null> {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(rawUrl);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
return null;
|
|
}
|
|
if (await resolvesToPrivateAddress(url)) {
|
|
return null;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), LINK_PREVIEW_TIMEOUT_MS);
|
|
try {
|
|
const response = await fetch(url.toString(), {
|
|
headers: {
|
|
accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.8',
|
|
'user-agent':
|
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) NostrFeedManager/0.1.0',
|
|
},
|
|
redirect: 'follow',
|
|
signal: controller.signal,
|
|
});
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
const contentType = response.headers.get('content-type') ?? '';
|
|
if (!contentType.toLowerCase().includes('text/html')) {
|
|
return null;
|
|
}
|
|
const html = (await response.text()).slice(0, LINK_PREVIEW_MAX_BYTES);
|
|
const meta = parseMetaTags(html);
|
|
const base = new URL(response.url);
|
|
|
|
const resolveUrl = (value: string | undefined): string | null => {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
try {
|
|
return new URL(value, base).toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const image = resolveUrl(meta['og:image'] ?? meta['twitter:image']);
|
|
const fallbackTitle = extractTitle(html);
|
|
const title =
|
|
decodeHtmlEntities(meta['og:title'] ?? meta['twitter:title']) ||
|
|
(fallbackTitle ? decodeHtmlEntities(fallbackTitle) : base.hostname);
|
|
const description =
|
|
decodeHtmlEntities(meta['og:description'] ?? meta['twitter:description'] ?? '') || null;
|
|
const site_name = decodeHtmlEntities(meta['og:site_name'] ?? '') || null;
|
|
|
|
if (!image && !title) {
|
|
return null;
|
|
}
|
|
return { url: base.toString(), title, description, image, site_name };
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function extractTitle(html: string): string {
|
|
const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
return match ? decodeHtmlEntities(match[1].trim()) : '';
|
|
}
|
|
|
|
function createWindow(): void {
|
|
const window = new BrowserWindow({
|
|
width: 1160,
|
|
height: 760,
|
|
minWidth: 920,
|
|
minHeight: 640,
|
|
title: 'Keynectr',
|
|
backgroundColor: '#f6f4f0',
|
|
autoHideMenuBar: true,
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.js'),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
},
|
|
});
|
|
|
|
const devServer = process.env.NOSTR_GUI_DEV_URL;
|
|
if (devServer) {
|
|
void window.loadURL(devServer);
|
|
} else {
|
|
void window.loadURL('app://nfm/index.html');
|
|
}
|
|
|
|
// Stamp every top-level document with the matching CSP.
|
|
window.webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
|
if (details.resourceType !== 'mainFrame') {
|
|
callback({});
|
|
return;
|
|
}
|
|
const csp = cspForUrl(details.url);
|
|
if (!csp) {
|
|
callback({});
|
|
return;
|
|
}
|
|
callback({
|
|
responseHeaders: { ...details.responseHeaders, 'Content-Security-Policy': [csp] },
|
|
});
|
|
});
|
|
|
|
// The app window never navigates away from its own origin; external links
|
|
// open in the system browser instead. Deny everything unrecognised outright.
|
|
window.webContents.on('will-navigate', (event, url) => {
|
|
if (cspForUrl(url) === null) {
|
|
event.preventDefault();
|
|
if (/^https?:/i.test(url)) {
|
|
void shell.openExternal(url).catch(() => {});
|
|
}
|
|
}
|
|
});
|
|
window.webContents.setWindowOpenHandler(({ url }) => {
|
|
if (/^https?:/i.test(url)) {
|
|
void shell.openExternal(url).catch(() => {});
|
|
}
|
|
return { action: 'deny' };
|
|
});
|
|
}
|
|
|
|
app.whenReady().then(() => {
|
|
protocol.handle('app', (request) => {
|
|
const { pathname } = new URL(request.url);
|
|
let relative = decodeURIComponent(pathname);
|
|
if (relative.endsWith('/')) {
|
|
relative += 'index.html';
|
|
}
|
|
const safe = path
|
|
.normalize(relative)
|
|
.replace(/^(\.\.[/\\])+/, '')
|
|
.replace(/^[/\\]+/, '');
|
|
const filePath = path.join(app.getAppPath(), 'dist', safe);
|
|
try {
|
|
const data = readFileSync(filePath);
|
|
const type = MIME[path.extname(filePath)] ?? 'application/octet-stream';
|
|
return new Response(data, { headers: { 'content-type': type } });
|
|
} catch {
|
|
return new Response('Not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
}
|
|
});
|
|
|
|
ipcMain.handle(
|
|
'backend:request',
|
|
async (_event, payload: { method: string; params?: Record<string, unknown> }) => {
|
|
if (!isAllowedMethod(payload?.method)) {
|
|
console.warn('[backend] rejected renderer method:', String(payload?.method));
|
|
return {
|
|
status: 'error',
|
|
code: 'unknown_method',
|
|
message: 'That operation is not permitted.',
|
|
details: null,
|
|
};
|
|
}
|
|
const params = payload.params ?? {};
|
|
// Media and network tasks are handled here (Electron) rather than the
|
|
// Rust backend: they need a native file dialog, the hosting upload, and
|
|
// fetching pages for link previews.
|
|
if (payload.method === 'pick_image') {
|
|
return { status: 'ok', data: await pickImage() };
|
|
}
|
|
if (payload.method === 'link_preview') {
|
|
const url = String(params.url ?? '');
|
|
return { status: 'ok', data: url ? await fetchLinkPreview(url) : null };
|
|
}
|
|
if (payload.method === 'upload_image') {
|
|
const token = typeof params.token === 'string' ? params.token : '';
|
|
const file = token ? pickedTokens.get(token) : undefined;
|
|
pickedTokens.delete(token);
|
|
if (!file) {
|
|
return {
|
|
status: 'error',
|
|
code: 'unknown_token',
|
|
message: 'That image selection has expired. Please attach it again.',
|
|
details: null,
|
|
};
|
|
}
|
|
try {
|
|
return { status: 'ok', data: await uploadImage(file) };
|
|
} catch (error) {
|
|
return {
|
|
status: 'error',
|
|
code: 'upload_failed',
|
|
message: error instanceof Error ? error.message : String(error),
|
|
details: null,
|
|
};
|
|
}
|
|
}
|
|
return backendRequest(payload.method, params);
|
|
},
|
|
);
|
|
|
|
ipcMain.handle('clipboard:write', (_event, text: string) => {
|
|
clipboard.writeText(String(text));
|
|
return true;
|
|
});
|
|
|
|
createWindow();
|
|
|
|
app.on('activate', () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
createWindow();
|
|
}
|
|
});
|
|
});
|
|
|
|
app.on('before-quit', () => {
|
|
if (backend) {
|
|
backend.kill();
|
|
}
|
|
});
|
|
|
|
app.on('window-all-closed', () => {
|
|
if (process.platform !== 'darwin') {
|
|
app.quit();
|
|
}
|
|
});
|