2024-06-20 15:35:28 +02:00
import './log.js' ;
2026-02-18 19:52:09 +08:00
import { app , BrowserWindow , Menu , MenuItem , Tray , shell , dialog , nativeTheme , nativeImage , ipcMain , clipboard } from 'electron' ;
2026-02-18 18:51:02 +08:00
import { automateUploadMedia } from './media-upload-automation.js' ;
2024-06-20 15:35:28 +02:00
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' ;
2025-05-22 13:15:40 +02:00
// 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' ));
2023-04-24 19:42:49 +00:00
2025-10-25 18:27:56 +02:00
// 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
}
}
2023-12-16 16:49:26 +00:00
let startIpfsError ;
2023-12-20 16:05:50 +00:00
startIpfs . onError = ( error ) => {
// only show error once or it spams the user
const alreadyShownIpfsError = !! startIpfsError ;
startIpfsError = error ;
2024-10-11 17:24:22 +02:00
if ( ! alreadyShownIpfsError && error . message ) {
2023-12-20 16:05:50 +00:00
dialog . showErrorBox ( 'IPFS warning' , error . message );
2023-12-11 00:49:36 +00:00
}
2023-12-20 16:05:50 +00:00
};
2023-08-25 20:43:40 +00:00
2024-06-20 15:35:28 +02:00
// 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 ));
2023-08-25 20:43:40 +00:00
// 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
2023-12-16 16:49:26 +00:00
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' ;
2025-10-17 23:30:35 +02:00
const realUserAgent = `5chan/ ${ packageJson . version } ` ;
2023-04-24 19:42:49 +00:00
// 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 ,
2023-12-16 16:49:26 +00:00
});
2023-04-24 19:42:49 +00:00
const createMainWindow = () => {
let mainWindow = new BrowserWindow ({
width : 1000 ,
height : 600 ,
show : false ,
2024-06-20 15:35:28 +02:00
backgroundColor : nativeTheme . shouldUseDarkColors ? '#000000' : '#ffffff' ,
2023-04-24 19:42:49 +00:00
webPreferences : {
2023-08-25 20:43:40 +00:00
webSecurity : true , // must be true or iframe embeds like youtube can do remote code execution
2023-04-24 19:42:49 +00:00
nodeIntegration : false ,
contextIsolation : true ,
devTools : true , // TODO: change to isDev when no bugs left
2026-01-30 12:28:41 +08:00
preload : path . join ( dirname , '../build/electron/preload.cjs' ),
2023-04-24 19:42:49 +00:00
},
2023-12-16 16:49:26 +00:00
});
2023-08-25 20:43:40 +00:00
// set fake user agent
2023-12-16 16:49:26 +00:00
mainWindow . webContents . userAgent = fakeUserAgent ;
2023-08-25 20:43:40 +00:00
// set custom user agent and other headers for window.fetch requests to prevent origin errors
2023-12-16 16:49:26 +00:00
mainWindow . webContents . session . webRequest . onBeforeSendHeaders ({ urls : [ '*://*/*' ] }, ( details , callback ) => {
const isIframe = !! details . frame ? . parent ;
2023-08-26 15:13:24 +00:00
// if not a fetch request (or fetch request is from within iframe), do nothing, filtering webRequest by types doesn't seem to work
2023-08-25 23:37:03 +00:00
if ( details . resourceType !== 'xhr' || isIframe ) {
2023-12-16 16:49:26 +00:00
return callback ({ requestHeaders : details . requestHeaders });
2023-08-25 20:43:40 +00:00
}
2023-08-26 15:13:24 +00:00
// add privacy
2023-12-16 16:49:26 +00:00
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 ;
2023-08-26 15:13:24 +00:00
// prevent origin errors
2023-12-16 16:49:26 +00:00
details . requestHeaders [ 'Origin' ] = undefined ;
callback ({ requestHeaders : details . requestHeaders });
});
2023-08-25 20:43:40 +00:00
// fix cors errors for window.fetch. must not be enabled for iframe or can cause remote code execution
2023-12-16 16:49:26 +00:00
mainWindow . webContents . session . webRequest . onHeadersReceived ({ urls : [ '*://*/*' ] }, ( details , callback ) => {
const isIframe = !! details . frame ? . parent ;
2023-08-26 15:13:24 +00:00
// if not a fetch request (or fetch request is from within iframe), do nothing, filtering webRequest by types doesn't seem to work
2023-08-25 23:37:03 +00:00
if ( details . resourceType !== 'xhr' || isIframe ) {
2023-12-16 16:49:26 +00:00
return callback ({ responseHeaders : details . responseHeaders });
2023-08-25 20:43:40 +00:00
}
2023-08-26 15:13:24 +00:00
// must delete lower case headers or both '*, *' could get added
2023-12-16 16:49:26 +00:00
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 });
});
2023-08-25 20:43:40 +00:00
2026-01-30 12:28:41 +08:00
const startURL = isDev ? 'http://localhost:3000' : `file:// ${ path . join ( dirname , '../build/index.html' ) } ` ;
2023-04-24 19:42:49 +00:00
2023-12-16 16:49:26 +00:00
mainWindow . loadURL ( startURL );
2023-04-24 19:42:49 +00:00
mainWindow . once ( 'ready-to-show' , async () => {
// make sure back button is disabled on launch
2023-12-16 16:49:26 +00:00
mainWindow . webContents . clearHistory ();
2023-04-24 19:42:49 +00:00
2023-12-16 16:49:26 +00:00
mainWindow . show ();
2023-04-24 19:42:49 +00:00
if ( isDev ) {
2023-12-16 16:49:26 +00:00
mainWindow . openDevTools ();
2023-04-24 19:42:49 +00:00
}
if ( startIpfsError ) {
2023-12-20 16:05:50 +00:00
dialog . showErrorBox ( 'IPFS warning' , startIpfsError . message );
2023-04-24 19:42:49 +00:00
}
2023-12-16 16:49:26 +00:00
});
2023-04-24 19:42:49 +00:00
mainWindow . on ( 'closed' , () => {
2023-12-16 16:49:26 +00:00
mainWindow = null ;
});
2023-04-24 19:42:49 +00:00
// don't open new windows
mainWindow . webContents . on ( 'new-window' , ( event , url ) => {
2023-12-16 16:49:26 +00:00
event . preventDefault ();
mainWindow . loadURL ( url );
});
2023-04-24 19:42:49 +00:00
// open links in external browser
2025-10-17 23:30:35 +02:00
// do not open links in 5chan or will lead to remote execution
2023-04-24 19:42:49 +00:00
mainWindow . webContents . on ( 'will-navigate' , ( e , originalUrl ) => {
if ( originalUrl != mainWindow . webContents . getURL ()) {
2023-12-16 16:49:26 +00:00
e . preventDefault ();
2023-04-24 19:42:49 +00:00
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
2023-12-16 16:49:26 +00:00
const validatedUrl = new URL ( originalUrl );
let serializedUrl = '' ;
2023-07-04 13:42:19 +02:00
// make an exception for ipfs stats
2024-12-07 17:35:47 +00:00
if ( validatedUrl . toString () === 'http://localhost:50019/webui/' ) {
2023-12-16 16:49:26 +00:00
serializedUrl = validatedUrl . toString ();
2023-07-04 13:42:19 +02:00
} else if ( validatedUrl . protocol === 'https:' ) {
// open serialized url to prevent remote execution
2023-12-16 16:49:26 +00:00
serializedUrl = validatedUrl . toString ();
2023-07-04 13:42:19 +02:00
} else {
2023-12-16 16:49:26 +00:00
throw Error ( `can't open url ' ${ originalUrl } ', it's not https and not the allowed http exception` );
2023-04-24 19:42:49 +00:00
}
2023-12-16 16:49:26 +00:00
shell . openExternal ( serializedUrl );
2023-04-24 19:42:49 +00:00
} catch ( e ) {
2023-12-16 16:49:26 +00:00
console . warn ( e );
2023-04-24 19:42:49 +00:00
}
}
2023-12-16 16:49:26 +00:00
});
2023-04-24 19:42:49 +00:00
// open links (with target="_blank") in external browser
2025-10-17 23:30:35 +02:00
// do not open links in 5chan or will lead to remote execution
2023-12-16 16:49:26 +00:00
mainWindow . webContents . setWindowOpenHandler (({ url }) => {
const originalUrl = url ;
2023-04-24 19:42:49 +00:00
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
2023-12-16 16:49:26 +00:00
const validatedUrl = new URL ( originalUrl );
let serializedUrl = '' ;
2023-07-04 13:42:19 +02:00
// make an exception for ipfs stats
2024-12-07 17:35:47 +00:00
if ( validatedUrl . toString () === 'http://localhost:50019/webui/' ) {
2023-12-16 16:49:26 +00:00
serializedUrl = validatedUrl . toString ();
2023-07-04 13:42:19 +02:00
} else if ( validatedUrl . protocol === 'https:' ) {
// open serialized url to prevent remote execution
2023-12-16 16:49:26 +00:00
serializedUrl = validatedUrl . toString ();
2023-07-04 13:42:19 +02:00
} else {
2023-12-16 16:49:26 +00:00
throw Error ( `can't open url ' ${ originalUrl } ', it's not https and not the allowed http exception` );
2023-04-24 19:42:49 +00:00
}
2023-12-16 16:49:26 +00:00
shell . openExternal ( serializedUrl );
2023-04-24 19:42:49 +00:00
} catch ( e ) {
2023-12-16 16:49:26 +00:00
console . warn ( e );
2023-04-24 19:42:49 +00:00
}
2023-12-16 16:49:26 +00:00
return { action : 'deny' };
});
2023-04-24 19:42:49 +00:00
// 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
2023-12-16 16:49:26 +00:00
return callback ( false );
});
2023-04-24 19:42:49 +00:00
// 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
2023-12-16 16:49:26 +00:00
e . preventDefault ();
});
2023-04-24 19:42:49 +00:00
2023-06-26 09:38:17 +02:00
if ( process . platform !== 'darwin' ) {
// tray
2026-01-30 12:28:41 +08:00
const trayIconPath = path . join ( dirname , '..' , isDev ? 'public' : 'build' , 'electron-tray-icon.png' );
2023-12-16 16:49:26 +00:00
const tray = new Tray ( trayIconPath );
2025-10-17 23:30:35 +02:00
tray . setToolTip ( '5chan' );
2023-06-26 09:38:17 +02:00
const trayMenu = Menu . buildFromTemplate ([
{
2025-10-17 23:30:35 +02:00
label : 'Open 5chan' ,
2023-06-26 09:38:17 +02:00
click : () => {
2023-12-16 16:49:26 +00:00
mainWindow . show ();
2023-06-26 09:38:17 +02:00
},
2023-04-24 19:42:49 +00:00
},
2023-06-26 09:38:17 +02:00
{
2025-10-17 23:30:35 +02:00
label : 'Quit 5chan' ,
2023-06-26 09:38:17 +02:00
click : () => {
2023-12-16 16:49:26 +00:00
mainWindow . destroy ();
app . quit ();
2023-06-26 09:38:17 +02:00
},
2023-04-24 19:42:49 +00:00
},
2023-12-16 16:49:26 +00:00
]);
tray . setContextMenu ( trayMenu );
2023-06-29 21:15:56 +02:00
2023-06-26 09:38:17 +02:00
// show/hide on tray right click
tray . on ( 'right-click' , () => {
2023-12-16 16:49:26 +00:00
mainWindow . isVisible () ? mainWindow . hide () : mainWindow . show ();
});
2023-04-24 19:42:49 +00:00
2023-06-29 21:15:56 +02:00
// close to tray
if ( ! isDev ) {
2023-12-16 16:49:26 +00:00
let isQuiting = false ;
2023-06-29 21:15:56 +02:00
app . on ( 'before-quit' , () => {
2023-12-16 16:49:26 +00:00
isQuiting = true ;
});
2023-06-29 21:15:56 +02:00
mainWindow . on ( 'close' , ( event ) => {
if ( ! isQuiting ) {
2023-12-16 16:49:26 +00:00
event . preventDefault ();
mainWindow . hide ();
event . returnValue = false ;
2023-06-29 21:15:56 +02:00
}
2023-12-16 16:49:26 +00:00
});
2023-06-29 21:15:56 +02:00
}
2023-04-24 19:42:49 +00:00
}
2023-08-10 21:43:32 +02:00
const appMenuBack = new MenuItem ({
label : '←' ,
enabled : mainWindow ? . webContents ? . canGoBack (),
click : () => mainWindow ? . webContents ? . goBack (),
2023-12-16 16:49:26 +00:00
});
2023-08-10 21:43:32 +02:00
const appMenuForward = new MenuItem ({
label : '→' ,
enabled : mainWindow ? . webContents ? . canGoForward (),
click : () => mainWindow ? . webContents ? . goForward (),
2023-12-16 16:49:26 +00:00
});
2023-08-10 21:43:32 +02:00
const appMenuReload = new MenuItem ({
label : '⟳' ,
role : 'reload' ,
click : () => mainWindow ? . webContents ? . reload (),
2023-12-16 16:49:26 +00:00
});
2023-08-10 21:43:32 +02:00
2023-04-24 19:42:49 +00:00
// application menu
// hide useless electron help menu
2023-06-29 21:15:56 +02:00
if ( process . platform === 'darwin' ) {
2023-12-16 16:49:26 +00:00
const appMenu = Menu . getApplicationMenu ();
appMenu . insert ( 1 , appMenuBack );
appMenu . insert ( 2 , appMenuForward );
appMenu . insert ( 3 , appMenuReload );
Menu . setApplicationMenu ( appMenu );
2023-06-29 21:15:56 +02:00
} else {
// Other platforms
2023-12-16 16:49:26 +00:00
const originalAppMenuWithoutHelp = Menu . getApplicationMenu () ? . items . filter (( item ) => item . role !== 'help' );
const appMenu = [ appMenuBack , appMenuForward , appMenuReload , ... originalAppMenuWithoutHelp ];
Menu . setApplicationMenu ( Menu . buildFromTemplate ( appMenu ));
2023-06-29 21:15:56 +02:00
}
2023-12-16 16:49:26 +00:00
};
2023-04-24 19:42:49 +00:00
2026-02-18 19:52:09 +08:00
const setDevDockIcon = () => {
if ( process . platform !== 'darwin' || ! isDev || ! app . dock ) {
return ;
}
const iconCandidates = [ path . join ( dirname , '..' , 'public' , 'icon.icns' ), path . join ( dirname , '..' , 'public' , 'icon.png' )];
for ( const iconPath of iconCandidates ) {
try {
const icon = nativeImage . createFromPath ( iconPath );
if ( icon . isEmpty ()) {
continue ;
}
app . dock . setIcon ( icon );
return ;
} catch ( error ) {
console . warn ( `[Electron Main] Failed to set dock icon from ${ iconPath } ` , error );
}
}
console . warn ( `[Electron Main] Could not load any dock icon ( ${ iconCandidates . join ( ', ' ) } )` );
};
2023-06-29 21:15:56 +02:00
app . whenReady (). then (() => {
2026-01-30 13:50:17 +08:00
// Set app name and dock icon for development mode on macOS
2026-01-30 14:33:45 +08:00
if ( process . platform === 'darwin' && isDev ) {
2026-02-16 16:16:05 +08:00
app . setName ( '5chan' );
2026-02-18 19:52:09 +08:00
setDevDockIcon ();
2026-01-30 13:50:17 +08:00
}
2023-12-16 16:49:26 +00:00
createMainWindow ();
2023-06-28 09:53:42 +02:00
2023-06-29 21:15:56 +02:00
app . on ( 'activate' , () => {
if ( ! BrowserWindow . getAllWindows (). length ) {
2023-12-16 16:49:26 +00:00
createMainWindow ();
2023-04-24 19:42:49 +00:00
}
2023-12-16 16:49:26 +00:00
});
});
2023-06-29 21:15:56 +02:00
app . on ( 'window-all-closed' , () => {
if ( process . platform !== 'darwin' ) {
2023-12-16 16:49:26 +00:00
app . quit ();
2023-06-29 21:15:56 +02:00
}
2023-12-16 16:49:26 +00:00
});
2025-06-03 22:11:17 +02:00
// 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 ,
};
});
2026-02-18 18:51:02 +08:00
// Handle automated media upload (hidden BrowserWindow + CDP). Strict cleanup enforced in automation module.
ipcMain . handle ( 'automate-upload-media' , async ( event , options ) => {
const { provider , filePath } = options || {};
if ( ! provider || typeof filePath !== 'string' ) {
throw new Error ( 'automate-upload-media requires { provider, filePath }' );
}
return automateUploadMedia ({ provider , filePath });
});