Files
5chan/electron/main.js
T
plebeius 50ee10cbc9 fix: resolve PR 877 issues - route params, artifact paths, and build configs
Fix route parameter name in use-initial-theme hook (communityAddress -> boardIdentifier), correct release workflow artifact paths (dist -> out/make), add BUILD_ARCH env var for Linux arm64 builds, fix macOS app name dev check, and update capacitor webDir to match vite output.
2026-01-30 14:33:45 +08:00

364 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import './log.js';
import { app, BrowserWindow, Menu, MenuItem, Tray, shell, dialog, nativeTheme, ipcMain, clipboard } from 'electron';
import isDev from 'electron-is-dev';
import fs from 'fs';
import path from 'path';
import EnvPaths from 'env-paths';
import startIpfs from './start-ipfs.js';
import './start-plebbit-rpc.js';
import { URL, fileURLToPath } from 'node:url';
import contextMenu from 'electron-context-menu';
// Determine __filename and dirname for ESM
const __filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(__filename);
// Load package.json dynamically
const packageJson = JSON.parse(fs.readFileSync(path.join(dirname, '../package.json'), 'utf-8'));
// Enforce GTK 3 on Linux to avoid mixing GTK 4 and GTK 3 in the same process
// which can happen on some distros/desktops and crashes AppImage with:
// "GTK 2/3 symbols detected. Using GTK 2/3 and GTK 4 in the same process is not supported"
if (process.platform === 'linux') {
try {
app.commandLine.appendSwitch('gtk-version', '3');
} catch (e) {
// ignore if unsupported, Electron will simply ignore this switch
}
}
let startIpfsError;
startIpfs.onError = (error) => {
// only show error once or it spams the user
const alreadyShownIpfsError = !!startIpfsError;
startIpfsError = error;
if (!alreadyShownIpfsError && error.message) {
dialog.showErrorBox('IPFS warning', error.message);
}
};
// send plebbit rpc auth key to renderer
const plebbitDataPath = !isDev ? EnvPaths('plebbit', { suffix: false }).data : path.join(dirname, '..', '.plebbit');
const plebbitRpcAuthKey = fs.readFileSync(path.join(plebbitDataPath, 'auth-key'), 'utf8');
ipcMain.on('get-plebbit-rpc-auth-key', (event) => event.reply('plebbit-rpc-auth-key', plebbitRpcAuthKey));
// use common user agent instead of electron so img, video, audio, iframe elements don't get blocked
// https://www.whatismybrowser.com/guides/the-latest-version/chrome
// https://www.whatismybrowser.com/guides/the-latest-user-agent/chrome
// NOTE: eventually should probably fake sec-ch-ua header as well
let fakeUserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36';
if (process.platform === 'darwin') fakeUserAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36';
if (process.platform === 'linux') fakeUserAgent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36';
const realUserAgent = `5chan/${packageJson.version}`;
// add right click menu
contextMenu({
// prepend custom buttons to top
prepend: (defaultActions, parameters, browserWindow) => [
{
label: 'Back',
visible: parameters.mediaType === 'none',
enabled: browserWindow?.webContents?.canGoBack(),
click: () => browserWindow?.webContents?.goBack(),
},
{
label: 'Forward',
visible: parameters.mediaType === 'none',
enabled: browserWindow?.webContents?.canGoForward(),
click: () => browserWindow?.webContents?.goForward(),
},
{
label: 'Reload',
visible: parameters.mediaType === 'none',
click: () => browserWindow?.webContents?.reload(),
},
],
showLookUpSelection: false,
showCopyImage: true,
showCopyImageAddress: true,
showSaveImageAs: true,
showSaveLinkAs: true,
showInspectElement: true,
showServices: false,
showSearchWithGoogle: false,
});
const createMainWindow = () => {
let mainWindow = new BrowserWindow({
width: 1000,
height: 600,
show: false,
backgroundColor: nativeTheme.shouldUseDarkColors ? '#000000' : '#ffffff',
webPreferences: {
webSecurity: true, // must be true or iframe embeds like youtube can do remote code execution
nodeIntegration: false,
contextIsolation: true,
devTools: true, // TODO: change to isDev when no bugs left
preload: path.join(dirname, '../build/electron/preload.cjs'),
},
});
// set fake user agent
mainWindow.webContents.userAgent = fakeUserAgent;
// set custom user agent and other headers for window.fetch requests to prevent origin errors
mainWindow.webContents.session.webRequest.onBeforeSendHeaders({ urls: ['*://*/*'] }, (details, callback) => {
const isIframe = !!details.frame?.parent;
// if not a fetch request (or fetch request is from within iframe), do nothing, filtering webRequest by types doesn't seem to work
if (details.resourceType !== 'xhr' || isIframe) {
return callback({ requestHeaders: details.requestHeaders });
}
// add privacy
details.requestHeaders['User-Agent'] = realUserAgent;
details.requestHeaders['sec-ch-ua'] = undefined;
details.requestHeaders['sec-ch-ua-platform'] = undefined;
details.requestHeaders['sec-ch-ua-mobile'] = undefined;
details.requestHeaders['Sec-Fetch-Dest'] = undefined;
details.requestHeaders['Sec-Fetch-Mode'] = undefined;
details.requestHeaders['Sec-Fetch-Site'] = undefined;
// prevent origin errors
details.requestHeaders['Origin'] = undefined;
callback({ requestHeaders: details.requestHeaders });
});
// fix cors errors for window.fetch. must not be enabled for iframe or can cause remote code execution
mainWindow.webContents.session.webRequest.onHeadersReceived({ urls: ['*://*/*'] }, (details, callback) => {
const isIframe = !!details.frame?.parent;
// if not a fetch request (or fetch request is from within iframe), do nothing, filtering webRequest by types doesn't seem to work
if (details.resourceType !== 'xhr' || isIframe) {
return callback({ responseHeaders: details.responseHeaders });
}
// must delete lower case headers or both '*, *' could get added
delete details.responseHeaders['access-control-allow-origin'];
delete details.responseHeaders['access-control-allow-headers'];
delete details.responseHeaders['access-control-allow-methods'];
delete details.responseHeaders['access-control-expose-headers'];
details.responseHeaders['Access-Control-Allow-Origin'] = '*';
details.responseHeaders['Access-Control-Allow-Headers'] = '*';
details.responseHeaders['Access-Control-Allow-Methods'] = '*';
details.responseHeaders['Access-Control-Expose-Headers'] = '*';
callback({ responseHeaders: details.responseHeaders });
});
const startURL = isDev ? 'http://localhost:3000' : `file://${path.join(dirname, '../build/index.html')}`;
mainWindow.loadURL(startURL);
mainWindow.once('ready-to-show', async () => {
// make sure back button is disabled on launch
mainWindow.webContents.clearHistory();
mainWindow.show();
if (isDev) {
mainWindow.openDevTools();
}
if (startIpfsError) {
dialog.showErrorBox('IPFS warning', startIpfsError.message);
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
// don't open new windows
mainWindow.webContents.on('new-window', (event, url) => {
event.preventDefault();
mainWindow.loadURL(url);
});
// open links in external browser
// do not open links in 5chan or will lead to remote execution
mainWindow.webContents.on('will-navigate', (e, originalUrl) => {
if (originalUrl != mainWindow.webContents.getURL()) {
e.preventDefault();
try {
// do not let the user open any url with shell.openExternal
// or it will lead to remote execution https://benjamin-altpeter.de/shell-openexternal-dangers/
// only open valid https urls to prevent remote execution
// will throw if url isn't valid
const validatedUrl = new URL(originalUrl);
let serializedUrl = '';
// make an exception for ipfs stats
if (validatedUrl.toString() === 'http://localhost:50019/webui/') {
serializedUrl = validatedUrl.toString();
} else if (validatedUrl.protocol === 'https:') {
// open serialized url to prevent remote execution
serializedUrl = validatedUrl.toString();
} else {
throw Error(`can't open url '${originalUrl}', it's not https and not the allowed http exception`);
}
shell.openExternal(serializedUrl);
} catch (e) {
console.warn(e);
}
}
});
// open links (with target="_blank") in external browser
// do not open links in 5chan or will lead to remote execution
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
const originalUrl = url;
try {
// do not let the user open any url with shell.openExternal
// or it will lead to remote execution https://benjamin-altpeter.de/shell-openexternal-dangers/
// only open valid https urls to prevent remote execution
// will throw if url isn't valid
const validatedUrl = new URL(originalUrl);
let serializedUrl = '';
// make an exception for ipfs stats
if (validatedUrl.toString() === 'http://localhost:50019/webui/') {
serializedUrl = validatedUrl.toString();
} else if (validatedUrl.protocol === 'https:') {
// open serialized url to prevent remote execution
serializedUrl = validatedUrl.toString();
} else {
throw Error(`can't open url '${originalUrl}', it's not https and not the allowed http exception`);
}
shell.openExternal(serializedUrl);
} catch (e) {
console.warn(e);
}
return { action: 'deny' };
});
// deny permissions like location, notifications, etc https://www.electronjs.org/docs/latest/tutorial/security#5-handle-session-permission-requests-from-remote-content
mainWindow.webContents.session.setPermissionRequestHandler((webContents, permission, callback) => {
// deny all permissions
return callback(false);
});
// deny attaching webview https://www.electronjs.org/docs/latest/tutorial/security#12-verify-webview-options-before-creation
mainWindow.webContents.on('will-attach-webview', (e, webPreferences, params) => {
// deny all
e.preventDefault();
});
if (process.platform !== 'darwin') {
// tray
const trayIconPath = path.join(dirname, '..', isDev ? 'public' : 'build', 'electron-tray-icon.png');
const tray = new Tray(trayIconPath);
tray.setToolTip('5chan');
const trayMenu = Menu.buildFromTemplate([
{
label: 'Open 5chan',
click: () => {
mainWindow.show();
},
},
{
label: 'Quit 5chan',
click: () => {
mainWindow.destroy();
app.quit();
},
},
]);
tray.setContextMenu(trayMenu);
// show/hide on tray right click
tray.on('right-click', () => {
mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show();
});
// close to tray
if (!isDev) {
let isQuiting = false;
app.on('before-quit', () => {
isQuiting = true;
});
mainWindow.on('close', (event) => {
if (!isQuiting) {
event.preventDefault();
mainWindow.hide();
event.returnValue = false;
}
});
}
}
const appMenuBack = new MenuItem({
label: '←',
enabled: mainWindow?.webContents?.canGoBack(),
click: () => mainWindow?.webContents?.goBack(),
});
const appMenuForward = new MenuItem({
label: '→',
enabled: mainWindow?.webContents?.canGoForward(),
click: () => mainWindow?.webContents?.goForward(),
});
const appMenuReload = new MenuItem({
label: '⟳',
role: 'reload',
click: () => mainWindow?.webContents?.reload(),
});
// application menu
// hide useless electron help menu
if (process.platform === 'darwin') {
const appMenu = Menu.getApplicationMenu();
appMenu.insert(1, appMenuBack);
appMenu.insert(2, appMenuForward);
appMenu.insert(3, appMenuReload);
Menu.setApplicationMenu(appMenu);
} else {
// Other platforms
const originalAppMenuWithoutHelp = Menu.getApplicationMenu()?.items.filter((item) => item.role !== 'help');
const appMenu = [appMenuBack, appMenuForward, appMenuReload, ...originalAppMenuWithoutHelp];
Menu.setApplicationMenu(Menu.buildFromTemplate(appMenu));
}
};
app.whenReady().then(() => {
// Set app name and dock icon for development mode on macOS
if (process.platform === 'darwin' && isDev) {
app.setName('seedit');
if (app.dock) {
const iconPath = path.join(dirname, '..', isDev ? 'public' : 'build', 'icon.png');
app.dock.setIcon(iconPath);
}
}
createMainWindow();
app.on('activate', () => {
if (!BrowserWindow.getAllWindows().length) {
createMainWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
// Handle request to copy text to clipboard
ipcMain.handle('copy-to-clipboard', async (event, text) => {
try {
clipboard.writeText(text);
return { success: true };
} catch (error) {
console.error('[Electron Main] Error copying to clipboard:', error);
return { success: false, error: error.message };
}
});
// Handle request for platform info
ipcMain.handle('get-platform', async () => {
return {
platform: process.platform,
arch: process.arch,
version: process.version,
};
});