2025-11-05 10:36:16 -06:00
const {
app ,
BrowserWindow ,
shell ,
ipcMain ,
dialog ,
Menu ,
2026-05-06 15:12:07 -05:00
session ,
safeStorage ,
2026-03-08 18:02:14 -05:00
Tray ,
2026-05-06 15:12:07 -05:00
clipboard ,
2026-06-29 13:28:26 -05:00
nativeImage ,
2025-11-05 10:36:16 -06:00
} = require ( "electron" );
2025-09-12 14:42:00 -05:00
const path = require ( "path" );
const fs = require ( "fs" );
2025-10-01 15:40:10 -05:00
const os = require ( "os" );
2026-03-08 18:02:14 -05:00
const https = require ( "https" );
const http = require ( "http" );
2026-05-06 15:12:07 -05:00
const net = require ( "net" );
2026-03-08 18:02:14 -05:00
const { URL } = require ( "url" );
2026-06-29 13:28:26 -05:00
const { fork , spawn } = require ( "child_process" );
2026-05-06 15:12:07 -05:00
const WebSocket = require ( "ws" );
2026-03-08 18:02:14 -05:00
2026-06-21 15:55:41 -05:00
// Portable mode: if a `.portable` marker exists next to the executable,
// store all data in a `data` folder beside the exe instead of %APPDATA%.
( function setupPortableDataDir () {
const exeDir = path . dirname ( process . execPath );
const markerPath = path . join ( exeDir , ".portable" );
const envOverride = process . env . TERMIX_DATA_DIR ;
let portableDataDir = null ;
if ( envOverride ) {
portableDataDir = envOverride ;
} else if ( app . isPackaged && fs . existsSync ( markerPath )) {
portableDataDir = path . join ( exeDir , "data" );
}
if ( portableDataDir ) {
try {
if ( ! fs . existsSync ( portableDataDir )) {
fs . mkdirSync ( portableDataDir , { recursive : true });
}
app . setPath ( "userData" , portableDataDir );
} catch {
// Fall back to default userData if we can't create the directory.
}
}
})();
2026-03-08 18:02:14 -05:00
const logFile = path . join ( app . getPath ( "userData" ), "termix-main.log" );
2026-05-06 15:12:07 -05:00
const electronAuthCookiesPath = path . join (
app . getPath ( "userData" ),
"electron-auth-cookies.json" ,
);
const electronAuthCookies = new Map ();
2026-03-08 18:02:14 -05:00
function logToFile (... args ) {
const timestamp = new Date (). toISOString ();
const msg = args
. map (( a ) => ( typeof a === "object" ? JSON . stringify ( a ) : String ( a )))
. join ( " " );
const line = `[ ${ timestamp } ] ${ msg } \n` ;
try {
fs . appendFileSync ( logFile , line );
} catch {
// ignore
}
console . log (... args );
}
2026-05-06 15:12:07 -05:00
function getCookieOrigin ( url ) {
try {
const parsedUrl = new URL ( url );
const protocol =
parsedUrl . protocol === "ws:"
? "http:"
: parsedUrl . protocol === "wss:"
? "https:"
: parsedUrl . protocol ;
return ` ${ protocol } // ${ parsedUrl . host } ` ;
} catch {
return null ;
}
}
function parseCookieTarget ( url ) {
try {
const parsedUrl = new URL ( url );
if ( parsedUrl . protocol === "ws:" ) {
parsedUrl . protocol = "http:" ;
} else if ( parsedUrl . protocol === "wss:" ) {
parsedUrl . protocol = "https:" ;
}
return parsedUrl ;
} catch {
return null ;
}
}
function getElectronAuthCookieKey ( name , origin ) {
return origin ? ` ${ origin } | ${ name } ` : null ;
}
function getSafeStorageAvailable () {
try {
return safeStorage . isEncryptionAvailable ();
} catch {
return false ;
}
}
function encodeElectronAuthCookieValue ( value ) {
return {
encrypted : true ,
value : safeStorage . encryptString ( value ). toString ( "base64" ),
};
}
function decodeElectronAuthCookieValue ( record ) {
if ( ! record . encrypted || ! getSafeStorageAvailable ()) {
return null ;
}
try {
return safeStorage . decryptString ( Buffer . from ( record . value , "base64" ));
} catch ( error ) {
logToFile (
"Failed to decrypt persisted Electron auth cookie:" ,
error . message ,
);
return null ;
}
}
function isElectronAuthCookieExpired ( cookie ) {
return Number . isFinite ( cookie . expiresAt ) && cookie . expiresAt <= Date . now ();
}
function saveElectronAuthCookiesToDisk () {
try {
if ( ! getSafeStorageAvailable ()) {
if ( fs . existsSync ( electronAuthCookiesPath )) {
fs . rmSync ( electronAuthCookiesPath , { force : true });
}
return ;
}
const records = [];
for ( const [ key , cookie ] of electronAuthCookies . entries ()) {
if ( isElectronAuthCookieExpired ( cookie )) {
electronAuthCookies . delete ( key );
continue ;
}
records . push ({
key ,
name : cookie . name ,
origin : cookie . origin ,
path : cookie . path ,
expiresAt : cookie . expiresAt ,
... encodeElectronAuthCookieValue ( cookie . value ),
});
}
fs . writeFileSync (
electronAuthCookiesPath ,
JSON . stringify ({ version : 1 , records }, null , 2 ),
);
} catch ( error ) {
logToFile ( "Failed to persist Electron auth cookies:" , error . message );
}
}
function loadElectronAuthCookiesFromDisk () {
electronAuthCookies . clear ();
try {
if ( ! getSafeStorageAvailable ()) {
if ( fs . existsSync ( electronAuthCookiesPath )) {
fs . rmSync ( electronAuthCookiesPath , { force : true });
}
return ;
}
if ( ! fs . existsSync ( electronAuthCookiesPath )) {
return ;
}
const data = JSON . parse ( fs . readFileSync ( electronAuthCookiesPath , "utf8" ));
const records = Array . isArray ( data . records ) ? data . records : [];
for ( const record of records ) {
if (
! record ||
typeof record . key !== "string" ||
typeof record . name !== "string" ||
typeof record . origin !== "string"
) {
continue ;
}
const value = decodeElectronAuthCookieValue ( record );
if ( ! value ) {
continue ;
}
const cookie = {
name : record . name ,
value ,
origin : record . origin ,
path : typeof record . path === "string" ? record . path : "/" ,
expiresAt : Number . isFinite ( record . expiresAt ) ? record . expiresAt : null ,
};
if ( ! isElectronAuthCookieExpired ( cookie )) {
electronAuthCookies . set ( record . key , cookie );
}
}
saveElectronAuthCookiesToDisk ();
} catch ( error ) {
logToFile ( "Failed to load persisted Electron auth cookies:" , error . message );
}
}
function clearPersistedElectronAuthCookies () {
electronAuthCookies . clear ();
try {
if ( fs . existsSync ( electronAuthCookiesPath )) {
fs . rmSync ( electronAuthCookiesPath , { force : true });
}
} catch ( error ) {
logToFile (
"Failed to clear persisted Electron auth cookies:" ,
error . message ,
);
}
}
function parseSetCookieHeader ( header ) {
const [ cookiePair , ... attributes ] = String ( header || "" ). split ( ";" );
const separatorIndex = cookiePair . indexOf ( "=" );
if ( separatorIndex <= 0 ) return null ;
const parsed = {
name : cookiePair . slice ( 0 , separatorIndex ). trim (),
value : cookiePair . slice ( separatorIndex + 1 ). trim (),
path : "/" ,
maxAge : null ,
expires : null ,
};
for ( const attribute of attributes ) {
const [ rawName , ... rawValueParts ] = attribute . trim (). split ( "=" );
const attrName = rawName . toLowerCase ();
const attrValue = rawValueParts . join ( "=" );
if ( attrName === "path" && attrValue ) {
parsed . path = attrValue ;
} else if ( attrName === "max-age" && attrValue ) {
const maxAge = Number ( attrValue );
parsed . maxAge = Number . isFinite ( maxAge ) ? maxAge : null ;
} else if ( attrName === "expires" && attrValue ) {
const expires = Date . parse ( attrValue );
parsed . expires = Number . isFinite ( expires ) ? expires : null ;
}
}
return parsed ;
}
function rememberElectronAuthCookieFromHeader ( url , header ) {
const origin = getCookieOrigin ( url );
if ( ! origin ) return ;
const cookie = parseSetCookieHeader ( header );
if ( ! cookie || cookie . name !== "jwt" ) return ;
const key = getElectronAuthCookieKey ( cookie . name , origin );
if ( ! key ) return ;
const expired =
cookie . maxAge === 0 ||
( cookie . expires !== null && cookie . expires <= Date . now ());
if ( expired || ! cookie . value ) {
electronAuthCookies . delete ( key );
saveElectronAuthCookiesToDisk ();
return ;
}
const expiresAt =
cookie . maxAge !== null ? Date . now () + cookie . maxAge * 1000 : cookie . expires ;
electronAuthCookies . set ( key , {
name : cookie . name ,
value : cookie . value ,
origin ,
path : cookie . path ,
expiresAt ,
});
saveElectronAuthCookiesToDisk ();
}
function getRememberedElectronAuthCookie ( name , targetUrl ) {
const target = parseCookieTarget ( targetUrl );
if ( ! target ) return null ;
const exactKey = getElectronAuthCookieKey ( name , target . origin );
const exactCookie = exactKey ? electronAuthCookies . get ( exactKey ) : null ;
if ( exactCookie && ! isElectronAuthCookieExpired ( exactCookie )) {
return exactCookie ;
}
if ( target . protocol !== "https:" ) {
return null ;
}
const httpOrigin = `http:// ${ target . host } ` ;
const httpKey = getElectronAuthCookieKey ( name , httpOrigin );
const httpCookie = httpKey ? electronAuthCookies . get ( httpKey ) : null ;
return httpCookie && ! isElectronAuthCookieExpired ( httpCookie )
? httpCookie
: null ;
}
function getHeaderName ( headers , name ) {
const lowerName = name . toLowerCase ();
return Object . keys ( headers || {}). find (
( key ) => key . toLowerCase () === lowerName ,
);
}
function setCookieHeaderValue ( requestHeaders , name , value ) {
const headerName = getHeaderName ( requestHeaders , "Cookie" ) || "Cookie" ;
const existing = requestHeaders [ headerName ];
const existingValue = Array . isArray ( existing )
? existing . join ( "; " )
: existing ;
const nextCookie = ` ${ name } = ${ value } ` ;
const otherCookies = String ( existingValue || "" )
. split ( ";" )
. map (( cookie ) => cookie . trim ())
. filter (( cookie ) => cookie && ! cookie . startsWith ( ` ${ name } =` ));
requestHeaders [ headerName ] =
otherCookies . length > 0
? ` ${ otherCookies . join ( "; " ) } ; ${ nextCookie } `
: nextCookie ;
}
function parseSemver ( version ) {
const match = String ( version || "" ). match ( /(\d+)\.(\d+)(?:\.(\d+))?/ );
if ( ! match ) return null ;
return [ Number ( match [ 1 ]), Number ( match [ 2 ]), Number ( match [ 3 ] || 0 )];
}
function compareSemver ( a , b ) {
const parsedA = parseSemver ( a );
const parsedB = parseSemver ( b );
if ( ! parsedA || ! parsedB ) return null ;
for ( let i = 0 ; i < 3 ; i += 1 ) {
if ( parsedA [ i ] > parsedB [ i ]) return 1 ;
if ( parsedA [ i ] < parsedB [ i ]) return - 1 ;
}
return 0 ;
}
const INSECURE_MODE_VALUES = new Set ([ "true" , "1" , "yes" ]);
function isInsecureModeEnabled () {
return INSECURE_MODE_VALUES . has (
String ( process . env . ENABLE_INSECURE_MODE || "" )
. trim ()
. toLowerCase (),
);
}
2026-06-04 15:16:53 -04:00
function getServerConfigPath () {
return path . join ( app . getPath ( "userData" ), "server-config.json" );
}
function getServerConfigSync () {
try {
const configPath = getServerConfigPath ();
if ( ! fs . existsSync ( configPath )) return null ;
return JSON . parse ( fs . readFileSync ( configPath , "utf8" ));
} catch {
return null ;
}
}
function getOrigin ( url ) {
try {
return new URL ( url ). origin ;
} catch {
return null ;
}
}
function isPrivateNetworkHost ( hostname ) {
if (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1"
) {
return true ;
}
const ipv4Match = hostname . match (
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/ ,
);
if ( ipv4Match ) {
const [, a , b ] = ipv4Match . map ( Number );
return (
a === 10 ||
( a === 172 && b >= 16 && b <= 31 ) ||
( a === 192 && b === 168 ) ||
( a === 169 && b === 254 )
);
}
if ( hostname . startsWith ( "fc" ) || hostname . startsWith ( "fd" )) {
return true ;
}
return false ;
}
function isInvalidCertificateAllowedForUrl ( url ) {
if ( isInsecureModeEnabled ()) return true ;
try {
const { hostname } = new URL ( url );
if ( isPrivateNetworkHost ( hostname )) return true ;
} catch {
// fall through
}
const config = getServerConfigSync ();
if ( ! config ? . allowInvalidCertificate || ! config ? . serverUrl ) return false ;
return getOrigin ( url ) === getOrigin ( config . serverUrl );
}
function getTlsVerificationOptions ( url ) {
2026-05-06 15:12:07 -05:00
return {
2026-06-04 15:16:53 -04:00
rejectUnauthorized : ! isInvalidCertificateAllowedForUrl ( url ),
2026-05-06 15:12:07 -05:00
};
}
function getWebSocketOptions ( url , options = {}) {
return {
... options ,
2026-06-04 15:16:53 -04:00
...( String ( url ). startsWith ( "wss:" ) ? getTlsVerificationOptions ( url ) : {}),
2026-05-06 15:12:07 -05:00
};
}
2026-03-08 18:02:14 -05:00
function httpFetch ( url , options = {}) {
return new Promise (( resolve , reject ) => {
const urlObj = new URL ( url );
const isHttps = urlObj . protocol === "https:" ;
const client = isHttps ? https : http ;
const requestOptions = {
method : options . method || "GET" ,
headers : options . headers || {},
timeout : options . timeout || 10000 ,
2026-06-04 15:16:53 -04:00
...( isHttps ? getTlsVerificationOptions ( url ) : {}),
2026-03-08 18:02:14 -05:00
};
const req = client . request ( url , requestOptions , ( res ) => {
let data = "" ;
res . on ( "data" , ( chunk ) => ( data += chunk ));
res . on ( "end" , () => {
resolve ({
ok : res . statusCode >= 200 && res . statusCode < 300 ,
status : res . statusCode ,
text : () => Promise . resolve ( data ),
json : () => Promise . resolve ( JSON . parse ( data )),
});
});
});
req . on ( "error" , reject );
req . on ( "timeout" , () => {
req . destroy ();
reject ( new Error ( "Request timeout" ));
});
if ( options . body ) {
req . write ( options . body );
}
req . end ();
});
}
2025-10-01 15:40:10 -05:00
2025-11-05 10:36:16 -06:00
if ( process . platform === "linux" ) {
2025-12-31 22:20:12 -06:00
app . commandLine . appendSwitch ( "--ozone-platform-hint=auto" );
2025-11-05 10:36:16 -06:00
2025-12-31 22:20:12 -06:00
app . commandLine . appendSwitch ( "--enable-features=VaapiVideoDecoder" );
2026-06-21 15:55:41 -05:00
// Fix click-coordinate misalignment with fractional display scaling on KDE/Wayland.
// Chromium's hit-testing uses unscaled coords while the compositor scales visually,
// so forcing scale factor 1 keeps them in sync. See: https://github.com/brave/brave-browser/issues/50028
app . commandLine . appendSwitch ( "--force-device-scale-factor" , "1" );
2025-11-05 10:36:16 -06:00
}
2026-06-16 15:59:53 -05:00
if ( process . platform === "win32" ) {
app . disableHardwareAcceleration ();
}
2026-05-06 15:12:07 -05:00
if ( isInsecureModeEnabled ()) {
logToFile (
"[security] ENABLE_INSECURE_MODE is enabled; TLS certificate validation is disabled." ,
);
app . commandLine . appendSwitch ( "--ignore-certificate-errors" );
app . commandLine . appendSwitch ( "--ignore-ssl-errors" );
app . commandLine . appendSwitch ( "--ignore-certificate-errors-spki-list" );
}
2025-10-01 15:40:10 -05:00
app . commandLine . appendSwitch ( "--enable-features=NetworkService" );
2025-09-12 14:42:00 -05:00
let mainWindow = null ;
2026-03-08 18:02:14 -05:00
let backendProcess = null ;
2026-05-28 22:29:20 -05:00
let backendStartFailed = false ;
2026-03-08 18:02:14 -05:00
let tray = null ;
let isQuitting = false ;
2026-06-29 13:28:26 -05:00
const tempFiles = new Map ();
const externalEditorSessions = new Map ();
2025-09-12 14:42:00 -05:00
const isDev = process . env . NODE_ENV === "development" || ! app . isPackaged ;
2025-11-05 10:36:16 -06:00
const appRoot = isDev ? process . cwd () : path . join ( __dirname , ".." );
2026-06-29 13:28:26 -05:00
const windowsAppUserModelId = "com.karmaa.termix" ;
2026-05-06 15:12:07 -05:00
const electronCacheBuildPath = path . join (
app . getPath ( "userData" ),
"client-cache-build.json" ,
);
const termixSessionPartition = "persist:termix" ;
2026-06-29 13:28:26 -05:00
function getTempRoot () {
const tempRoot = path . join ( app . getPath ( "temp" ), "termix" );
fs . mkdirSync ( tempRoot , { recursive : true });
return tempRoot ;
}
function sanitizeFileName ( fileName ) {
const baseName = path . basename ( String ( fileName || "file" ));
return baseName . replace ( /[<>:"/\\|?*\x00-\x1f]/g , "_" ) || "file" ;
}
function decodeFileContent ( content , encoding ) {
if ( encoding === "base64" ) {
return Buffer . from ( String ( content || "" ), "base64" );
}
return Buffer . from ( String ( content || "" ), "utf8" );
}
function createManagedTempFile ( fileName , content , encoding = "utf8" ) {
const tempId = ` ${ Date . now () } - ${ Math . random (). toString ( 36 ). slice ( 2 ) } ` ;
const tempDir = fs . mkdtempSync ( path . join ( getTempRoot (), ` ${ tempId } -` ));
const filePath = path . join ( tempDir , sanitizeFileName ( fileName ));
fs . writeFileSync ( filePath , decodeFileContent ( content , encoding ));
tempFiles . set ( tempId , { path : filePath , dir : tempDir });
return { tempId , path : filePath };
}
function cleanupManagedTempFile ( tempId ) {
const temp = tempFiles . get ( tempId );
if ( ! temp ) return ;
try {
fs . rmSync ( temp . dir , { recursive : true , force : true });
} catch ( error ) {
logToFile ( "Failed to clean up temporary file:" , error . message );
}
tempFiles . delete ( tempId );
}
function closeExternalEditorSession ( editId ) {
const session = externalEditorSessions . get ( editId );
if ( ! session ) return ;
if ( session . timer ) clearTimeout ( session . timer );
try {
session . watcher . close ();
} catch {
// watcher may already be closed
}
externalEditorSessions . delete ( editId );
cleanupManagedTempFile ( editId );
}
function notifyExternalEditorSaved ( editId ) {
const session = externalEditorSessions . get ( editId );
if ( ! session || ! mainWindow || mainWindow . isDestroyed ()) return ;
try {
const stat = fs . statSync ( session . path );
if ( stat . mtimeMs === session . lastMtimeMs ) return ;
session . lastMtimeMs = stat . mtimeMs ;
const content = fs . readFileSync ( session . path , "utf8" );
mainWindow . webContents . send ( "external-editor-saved" , {
editId ,
content ,
encoding : "utf8" ,
path : session . path ,
});
} catch ( error ) {
logToFile ( "Failed to read external editor file:" , error . message );
}
}
function openPathWithEditor ( filePath , editorPath ) {
if ( ! editorPath ) {
return shell . openPath ( filePath );
}
return new Promise (( resolve ) => {
let settled = false ;
const child = spawn ( editorPath , [ filePath ], {
detached : true ,
stdio : "ignore" ,
});
child . once ( "error" , ( error ) => {
if ( settled ) return ;
settled = true ;
resolve ( error . message );
});
setTimeout (() => {
if ( settled ) return ;
settled = true ;
child . unref ();
resolve ( "" );
}, 500 );
});
}
2026-06-04 15:16:53 -04:00
app . on (
"certificate-error" ,
( event , _webContents , url , error , certificate , callback ) => {
if ( isInvalidCertificateAllowedForUrl ( url )) {
event . preventDefault ();
logToFile ( "Allowed invalid certificate for configured server" , {
url ,
error ,
issuer : certificate ? . issuerName ,
subject : certificate ? . subjectName ,
});
callback ( true );
return ;
}
callback ( false );
},
);
2026-05-06 15:12:07 -05:00
function getElectronBuildTimestamp () {
try {
const buildInfo = require ( "./build-info.cjs" );
if ( Number . isInteger ( buildInfo . buildTimestamp )) {
return buildInfo . buildTimestamp ;
}
} catch {
// Development runs may not have generated build metadata yet.
}
return 0 ;
}
async function clearElectronClientCacheIfBuildChanged () {
const buildTimestamp = getElectronBuildTimestamp ();
let cacheTimestamp = 0 ;
try {
if ( fs . existsSync ( electronCacheBuildPath )) {
const data = JSON . parse ( fs . readFileSync ( electronCacheBuildPath , "utf8" ));
cacheTimestamp = Number . isInteger ( data . buildTimestamp )
? data . buildTimestamp
: 0 ;
}
} catch ( error ) {
logToFile (
"Failed to read Electron client cache build info:" ,
error . message ,
);
}
if ( cacheTimestamp === buildTimestamp ) {
return ;
}
const clearStep = async ( label , action ) => {
try {
await action ();
} catch ( error ) {
logToFile ( `Failed to clear Electron ${ label } :` , error . message );
}
};
try {
const defaultSession = session . defaultSession ;
await clearStep ( "HTTP cache" , () => defaultSession . clearCache ());
await clearStep ( "code cache" , () =>
defaultSession . clearCodeCaches ({ urls : [] }),
);
await clearStep ( "auth cache" , () => defaultSession . clearAuthCache ());
await clearStep ( "storage data" , () =>
defaultSession . clearStorageData ({
storages : [
"appcache" ,
"cookies" ,
"filesystem" ,
"shadercache" ,
"websql" ,
"serviceworkers" ,
"cachestorage" ,
],
}),
);
fs . writeFileSync (
electronCacheBuildPath ,
JSON . stringify (
{
buildTimestamp ,
appVersion : app . getVersion (),
updatedAt : new Date (). toISOString (),
},
null ,
2 ,
),
);
logToFile ( "Electron client cache cleared for build change" , {
from : cacheTimestamp ,
to : buildTimestamp ,
appVersion : app . getVersion (),
});
} catch ( error ) {
logToFile ( "Failed to clear Electron client cache:" , error . message );
}
}
function getCookieRemovalUrl ( cookie ) {
const scheme = cookie . secure ? "https" : "http" ;
const domain = cookie . domain ? . startsWith ( "." )
? cookie . domain . slice ( 1 )
: cookie . domain || "localhost" ;
return ` ${ scheme } :// ${ domain }${ cookie . path || "/" } ` ;
}
async function clearElectronJwtCookiesAtStartup () {
loadElectronAuthCookiesFromDisk ();
const targetSessions = new Set ([
session . defaultSession ,
session . fromPartition ( termixSessionPartition ),
]);
for ( const targetSession of targetSessions ) {
try {
const cookies = await targetSession . cookies . get ({ name : "jwt" });
await Promise . all (
cookies . map (( cookie ) =>
targetSession . cookies . remove (
getCookieRemovalUrl ( cookie ),
cookie . name ,
),
),
);
if ( cookies . length > 0 ) {
logToFile ( "Cleared Electron JWT cookies from cookie store" , {
count : cookies . length ,
});
}
} catch ( error ) {
logToFile ( "Failed to clear Electron JWT cookies:" , error . message );
}
}
}
2025-09-12 14:42:00 -05:00
2026-05-12 23:50:25 -05:00
function getBackendPaths () {
2026-03-08 18:02:14 -05:00
if ( isDev ) {
2026-05-12 23:50:25 -05:00
const backendDir = path . join ( appRoot , "dist" , "backend" , "backend" );
2026-05-28 22:29:20 -05:00
return {
entryPath : path . join ( backendDir , "starter.js" ),
backendCwd : backendDir ,
};
2026-03-08 18:02:14 -05:00
}
2026-06-29 13:28:26 -05:00
// fork() does not go through Electron's asar redirector — use the unpacked path.
// On macOS multi-arch builds (mergeASARs: false), electron-builder names the ASAR
// app-arm64.asar / app-x64.asar instead of app.asar, so match all variants.
2026-05-28 22:29:20 -05:00
const unpackedRoot = appRoot . replace (
2026-06-29 13:28:26 -05:00
/app(-[a-z0-9]+)?\.asar(?!\.unpacked)/ ,
2026-05-28 22:29:20 -05:00
"app.asar.unpacked" ,
);
2026-05-12 23:50:25 -05:00
const backendDir = path . join ( unpackedRoot , "dist" , "backend" , "backend" );
2026-05-28 22:29:20 -05:00
return {
entryPath : path . join ( backendDir , "starter.js" ),
backendCwd : backendDir ,
};
2026-03-08 18:02:14 -05:00
}
function getBackendDataDir () {
const userDataPath = app . getPath ( "userData" );
const dataDir = path . join ( userDataPath , "server-data" );
if ( ! fs . existsSync ( dataDir )) {
fs . mkdirSync ( dataDir , { recursive : true });
}
return dataDir ;
}
function startBackendServer () {
return new Promise (( resolve ) => {
2026-05-12 23:50:25 -05:00
const { entryPath , backendCwd } = getBackendPaths ();
2026-03-08 18:02:14 -05:00
logToFile ( "isDev:" , isDev , "appRoot:" , appRoot );
logToFile ( "app.isPackaged:" , app . isPackaged );
logToFile ( "process.env.NODE_ENV:" , process . env . NODE_ENV );
if ( ! fs . existsSync ( entryPath )) {
logToFile ( "Backend entry not found:" , entryPath );
resolve ( false );
return ;
}
const dataDir = getBackendDataDir ();
logToFile ( "Starting embedded backend server..." );
logToFile ( "Backend entry:" , entryPath );
logToFile ( "Data directory:" , dataDir );
2026-05-12 23:50:25 -05:00
logToFile ( "Backend cwd:" , backendCwd );
2026-03-08 18:02:14 -05:00
logToFile ( "Checking paths..." );
logToFile ( " entryPath exists:" , fs . existsSync ( entryPath ));
logToFile ( " dataDir exists:" , fs . existsSync ( dataDir ));
2026-05-12 23:50:25 -05:00
logToFile ( " backendCwd exists:" , fs . existsSync ( backendCwd ));
2026-03-08 18:02:14 -05:00
backendProcess = fork ( entryPath , [], {
2026-05-28 22:29:20 -05:00
cwd :
fs . existsSync ( backendCwd ) && fs . statSync ( backendCwd ). isDirectory ()
? backendCwd
: dataDir ,
2026-03-08 18:02:14 -05:00
env : {
... process . env ,
DATA_DIR : dataDir ,
NODE_ENV : "production" ,
ELECTRON_EMBEDDED : "true" ,
PORT : "30001" ,
},
stdio : [ "pipe" , "pipe" , "pipe" , "ipc" ],
});
logToFile ( "Backend process spawned, pid:" , backendProcess . pid );
let resolved = false ;
const readyTimeout = setTimeout (() => {
if ( ! resolved ) {
resolved = true ;
logToFile ( "Backend ready timeout (15s), proceeding anyway..." );
resolve ( true );
}
}, 15000 );
backendProcess . stdout . on ( "data" , ( data ) => {
const msg = data . toString (). trim ();
logToFile ( "[backend]" , msg );
if ( ! resolved && msg . includes ( "started successfully" )) {
resolved = true ;
clearTimeout ( readyTimeout );
logToFile ( "Backend ready signal received" );
resolve ( true );
}
});
backendProcess . stderr . on ( "data" , ( data ) => {
logToFile ( "[backend:stderr]" , data . toString (). trim ());
});
backendProcess . on ( "exit" , ( code , signal ) => {
logToFile ( `Backend process exited with code ${ code } , signal ${ signal } ` );
2026-05-28 22:29:20 -05:00
if ( ! resolved && code !== 0 ) {
backendStartFailed = true ;
}
2026-03-08 18:02:14 -05:00
backendProcess = null ;
if ( ! resolved ) {
resolved = true ;
clearTimeout ( readyTimeout );
resolve ( false );
}
});
backendProcess . on ( "error" , ( err ) => {
logToFile ( "Failed to start backend process:" , err . message );
backendProcess = null ;
if ( ! resolved ) {
resolved = true ;
clearTimeout ( readyTimeout );
resolve ( false );
}
});
});
}
function stopBackendServer () {
if ( ! backendProcess ) return ;
console . log ( "Stopping embedded backend server..." );
try {
backendProcess . send ({ type : "shutdown" });
} catch {
// IPC channel may already be closed
}
const forceKillTimeout = setTimeout (() => {
if ( backendProcess ) {
console . log ( "Force killing backend process..." );
backendProcess . kill ( "SIGKILL" );
backendProcess = null ;
}
}, 5000 );
backendProcess . on ( "exit" , () => {
clearTimeout ( forceKillTimeout );
backendProcess = null ;
});
}
2025-09-12 14:42:00 -05:00
const gotTheLock = app . requestSingleInstanceLock ();
if ( ! gotTheLock ) {
console . log ( "Another instance is already running, quitting..." );
app . quit ();
process . exit ( 0 );
} else {
app . on ( "second-instance" , ( event , commandLine , workingDirectory ) => {
if ( mainWindow ) {
if ( mainWindow . isMinimized ()) mainWindow . restore ();
mainWindow . focus ();
mainWindow . show ();
}
});
}
2026-03-08 18:02:14 -05:00
function createTray () {
try {
const { nativeImage } = require ( "electron" );
2026-05-12 23:54:51 -05:00
// Native APIs (Tray, nativeImage) can't load files from inside app.asar —
// use the unpacked path so the OS sees a real file.
const publicRoot = isDev
? path . join ( appRoot , "public" )
2026-06-29 13:28:26 -05:00
: path . join (
appRoot . replace (
/app(-[a-z0-9]+)?\.asar(?!\.unpacked)/ ,
"app.asar.unpacked" ,
),
"public" ,
);
2026-05-12 23:54:51 -05:00
2026-03-08 18:02:14 -05:00
let trayIcon ;
if ( process . platform === "darwin" ) {
2026-05-12 23:54:51 -05:00
const iconPath = path . join ( publicRoot , "icons" , "16x16.png" );
2026-03-08 18:02:14 -05:00
trayIcon = nativeImage . createFromPath ( iconPath );
trayIcon . setTemplateImage ( true );
} else if ( process . platform === "win32" ) {
2026-05-12 23:54:51 -05:00
trayIcon = path . join ( publicRoot , "icon.ico" );
2026-03-08 18:02:14 -05:00
} else {
2026-05-12 23:54:51 -05:00
trayIcon = path . join ( publicRoot , "icons" , "32x32.png" );
2026-03-08 18:02:14 -05:00
}
tray = new Tray ( trayIcon );
tray . setToolTip ( "Termix" );
const contextMenu = Menu . buildFromTemplate ([
{
label : "Show Window" ,
click : () => {
if ( mainWindow ) {
mainWindow . show ();
mainWindow . focus ();
}
},
},
{
label : "Quit" ,
click : () => {
isQuitting = true ;
app . quit ();
},
},
]);
tray . setContextMenu ( contextMenu );
tray . on ( "click" , () => {
if ( mainWindow ) {
if ( mainWindow . isVisible ()) {
mainWindow . hide ();
} else {
mainWindow . show ();
mainWindow . focus ();
}
}
});
console . log ( "System tray created successfully" );
} catch ( err ) {
console . error ( "Failed to create system tray:" , err );
}
}
2025-09-12 14:42:00 -05:00
function createWindow () {
2025-11-05 10:36:16 -06:00
const appVersion = app . getVersion ();
const electronVersion = process . versions . electron ;
const platform =
process . platform === "win32"
? "Windows"
: process . platform === "darwin"
? "macOS"
: "Linux" ;
2025-09-12 14:42:00 -05:00
mainWindow = new BrowserWindow ({
width : 1200 ,
height : 800 ,
minWidth : 800 ,
minHeight : 600 ,
title : "Termix" ,
2026-06-29 13:28:26 -05:00
icon : path . join (
appRoot ,
"public" ,
process . platform === "win32" ? "icon.ico" : "icon.png" ,
),
2025-09-12 14:42:00 -05:00
webPreferences : {
nodeIntegration : false ,
contextIsolation : true ,
2025-11-05 10:36:16 -06:00
webSecurity : false ,
2025-09-12 14:42:00 -05:00
preload : path . join ( __dirname , "preload.js" ),
2026-05-06 15:12:07 -05:00
partition : termixSessionPartition ,
2025-11-05 10:36:16 -06:00
allowRunningInsecureContent : true ,
webviewTag : true ,
offscreen : false ,
2025-09-12 14:42:00 -05:00
},
2025-11-05 10:36:16 -06:00
show : true ,
2025-09-12 14:42:00 -05:00
});
2026-03-08 18:02:14 -05:00
mainWindow . webContents . session . setPermissionRequestHandler (
( webContents , permission , callback ) => {
if (
permission === "clipboard-read" ||
2026-05-06 15:12:07 -05:00
permission === "clipboard-write" ||
2026-03-08 18:02:14 -05:00
permission === "clipboard-sanitized-write"
) {
callback ( true );
return ;
}
2026-05-06 15:12:07 -05:00
callback ( false );
2026-03-08 18:02:14 -05:00
},
);
2025-09-12 14:42:00 -05:00
if ( process . platform !== "darwin" ) {
mainWindow . setMenuBarVisibility ( false );
}
2025-11-05 10:36:16 -06:00
const customUserAgent = `Termix-Desktop/ ${ appVersion } ( ${ platform } ; Electron/ ${ electronVersion } )` ;
mainWindow . webContents . setUserAgent ( customUserAgent );
mainWindow . webContents . session . webRequest . onBeforeSendHeaders (
( details , callback ) => {
details . requestHeaders [ "X-Electron-App" ] = "true" ;
details . requestHeaders [ "User-Agent" ] = customUserAgent ;
2026-05-06 15:12:07 -05:00
const rememberedJwt = getRememberedElectronAuthCookie ( "jwt" , details . url );
if ( rememberedJwt ) {
setCookieHeaderValue (
details . requestHeaders ,
rememberedJwt . name ,
rememberedJwt . value ,
);
}
2025-11-05 10:36:16 -06:00
callback ({ requestHeaders : details . requestHeaders });
},
);
2025-09-12 14:42:00 -05:00
if ( isDev ) {
mainWindow . loadURL ( "http://localhost:5173" );
mainWindow . webContents . openDevTools ();
} else {
2025-11-05 10:36:16 -06:00
const indexPath = path . join ( appRoot , "dist" , "index.html" );
mainWindow . loadFile ( indexPath ). catch (( err ) => {
console . error ( "Failed to load file:" , err );
});
2025-09-12 14:42:00 -05:00
}
2025-11-05 10:36:16 -06:00
mainWindow . webContents . session . webRequest . onHeadersReceived (
( details , callback ) => {
const headers = details . responseHeaders ;
if ( headers ) {
delete headers [ "x-frame-options" ];
delete headers [ "X-Frame-Options" ];
if ( headers [ "content-security-policy" ]) {
headers [ "content-security-policy" ] = headers [
"content-security-policy"
]
. map (( value ) => value . replace ( /frame-ancestors[^;]*/gi , "" ))
. filter (( value ) => value . trim (). length > 0 );
if ( headers [ "content-security-policy" ]. length === 0 ) {
delete headers [ "content-security-policy" ];
}
}
if ( headers [ "Content-Security-Policy" ]) {
headers [ "Content-Security-Policy" ] = headers [
"Content-Security-Policy"
]
. map (( value ) => value . replace ( /frame-ancestors[^;]*/gi , "" ))
. filter (( value ) => value . trim (). length > 0 );
if ( headers [ "Content-Security-Policy" ]. length === 0 ) {
delete headers [ "Content-Security-Policy" ];
}
}
2026-05-06 15:12:07 -05:00
const setCookieHeaderName = getHeaderName ( headers , "Set-Cookie" );
if ( setCookieHeaderName ) {
const setCookieHeaders = Array . isArray ( headers [ setCookieHeaderName ])
? headers [ setCookieHeaderName ]
: [ headers [ setCookieHeaderName ]];
setCookieHeaders . forEach (( cookie ) => {
rememberElectronAuthCookieFromHeader ( details . url , cookie );
});
headers [ setCookieHeaderName ] = setCookieHeaders . map (( cookie ) => {
2025-11-05 10:36:16 -06:00
let modified = cookie . replace (
/;\s*SameSite=Strict/gi ,
"; SameSite=None" ,
);
modified = modified . replace (
/;\s*SameSite=Lax/gi ,
"; SameSite=None" ,
);
if ( ! modified . includes ( "SameSite=" )) {
modified += "; SameSite=None" ;
}
if (
! modified . includes ( "Secure" ) &&
details . url . startsWith ( "https" )
) {
modified += "; Secure" ;
}
return modified ;
});
}
}
callback ({ responseHeaders : headers });
},
);
2025-09-12 14:42:00 -05:00
mainWindow . once ( "ready-to-show" , () => {
mainWindow . show ();
2026-05-28 22:05:25 -04:00
mainWindow . focus ();
2025-09-12 14:42:00 -05:00
});
2025-11-05 10:36:16 -06:00
setTimeout (() => {
if ( mainWindow && ! mainWindow . isVisible ()) {
mainWindow . show ();
}
}, 3000 );
2025-09-12 14:42:00 -05:00
mainWindow . webContents . on (
"did-fail-load" ,
( event , errorCode , errorDescription , validatedURL ) => {
console . error (
"Failed to load:" ,
errorCode ,
errorDescription ,
validatedURL ,
);
},
);
mainWindow . webContents . on ( "did-finish-load" , () => {
console . log ( "Frontend loaded successfully" );
});
2026-03-08 18:02:14 -05:00
mainWindow . on ( "close" , ( event ) => {
if ( ! isQuitting && tray && ! tray . isDestroyed ()) {
event . preventDefault ();
mainWindow . hide ();
}
});
2025-09-12 14:42:00 -05:00
mainWindow . on ( "closed" , () => {
mainWindow = null ;
});
mainWindow . webContents . setWindowOpenHandler (({ url }) => {
2026-05-28 22:29:20 -05:00
try {
const parsed = new URL ( url );
if ( parsed . protocol === "http:" || parsed . protocol === "https:" ) {
shell . openExternal ( url );
}
} catch {
// invalid URL, ignore
}
2025-09-12 14:42:00 -05:00
return { action : "deny" };
});
}
ipcMain . handle ( "get-app-version" , () => {
return app . getVersion ();
});
2025-10-01 15:40:10 -05:00
const GITHUB_API_BASE = "https://api.github.com" ;
2025-10-12 01:33:30 -05:00
const REPO_OWNER = "Termix-SSH" ;
2025-10-01 15:40:10 -05:00
const REPO_NAME = "Termix" ;
const githubCache = new Map ();
2025-10-12 01:33:30 -05:00
const CACHE_DURATION = 30 * 60 * 1000 ;
2025-10-01 15:40:10 -05:00
async function fetchGitHubAPI ( endpoint , cacheKey ) {
const cached = githubCache . get ( cacheKey );
if ( cached && Date . now () - cached . timestamp < CACHE_DURATION ) {
return {
data : cached . data ,
cached : true ,
cache_age : Date . now () - cached . timestamp ,
};
}
try {
2026-03-08 18:02:14 -05:00
const response = await httpFetch ( ` ${ GITHUB_API_BASE }${ endpoint } ` , {
2025-10-01 15:40:10 -05:00
headers : {
Accept : "application/vnd.github+json" ,
"User-Agent" : "TermixElectronUpdateChecker/1.0" ,
"X-GitHub-Api-Version" : "2022-11-28" ,
},
timeout : 10000 ,
});
if ( ! response . ok ) {
throw new Error (
`GitHub API error: ${ response . status } ${ response . statusText } ` ,
);
}
const data = await response . json ();
githubCache . set ( cacheKey , {
data ,
timestamp : Date . now (),
});
return {
data : data ,
cached : false ,
};
} catch ( error ) {
console . error ( "Failed to fetch from GitHub API:" , error );
throw error ;
}
}
ipcMain . handle ( "check-electron-update" , async () => {
try {
const localVersion = app . getVersion ();
const releaseData = await fetchGitHubAPI (
`/repos/ ${ REPO_OWNER } / ${ REPO_NAME } /releases/latest` ,
"latest_release_electron" ,
);
const rawTag = releaseData . data . tag_name || releaseData . data . name || "" ;
const remoteVersionMatch = rawTag . match ( /(\d+\.\d+(\.\d+)?)/ );
const remoteVersion = remoteVersionMatch ? remoteVersionMatch [ 1 ] : null ;
if ( ! remoteVersion ) {
return {
success : false ,
error : "Remote version not found" ,
localVersion ,
};
}
2026-05-06 15:12:07 -05:00
const versionComparison = compareSemver ( localVersion , remoteVersion );
const status =
versionComparison === null || versionComparison === 0
? "up_to_date"
: versionComparison > 0
? "beta"
: "requires_update" ;
2025-10-01 15:40:10 -05:00
const result = {
success : true ,
2026-05-06 15:12:07 -05:00
status ,
2025-10-01 15:40:10 -05:00
localVersion : localVersion ,
remoteVersion : remoteVersion ,
latest_release : {
tag_name : releaseData . data . tag_name ,
name : releaseData . data . name ,
published_at : releaseData . data . published_at ,
html_url : releaseData . data . html_url ,
body : releaseData . data . body ,
},
cached : releaseData . cached ,
cache_age : releaseData . cache_age ,
};
return result ;
} catch ( error ) {
return {
success : false ,
error : error . message ,
localVersion : app . getVersion (),
};
}
});
2025-09-12 14:42:00 -05:00
ipcMain . handle ( "get-platform" , () => {
return process . platform ;
});
2026-03-08 18:02:14 -05:00
ipcMain . handle ( "get-embedded-server-status" , () => {
return {
2026-05-28 22:29:20 -05:00
running :
backendProcess !== null && ! backendProcess . killed && ! backendStartFailed ,
2026-03-08 18:02:14 -05:00
embedded : ! isDev ,
dataDir : isDev ? null : getBackendDataDir (),
};
});
2026-05-28 22:29:20 -05:00
// OIDC System Browser Authentication (RFC 8252)
ipcMain . handle (
"oidc-system-browser-auth" ,
async ( _event , authUrl , callbackPort ) => {
const http = require ( "http" );
2026-06-29 13:28:26 -05:00
return new Promise (( resolve ) => {
let timeout ;
let settled = false ;
2026-05-28 22:29:20 -05:00
const server = http . createServer (( req , res ) => {
2026-06-29 13:28:26 -05:00
const url = new URL ( req . url || "/" , `http://localhost: ${ callbackPort } ` );
2026-05-28 22:29:20 -05:00
if ( url . pathname === "/oidc-callback" ) {
const success = url . searchParams . get ( "success" );
const error = url . searchParams . get ( "error" );
const token = url . searchParams . get ( "token" );
res . writeHead ( 200 , { "Content-Type" : "text/html" });
res . end (
`<html><body><h2> ${ success === "true" ? "Authentication successful!" : "Authentication failed." } </h2><p>You can close this tab and return to Termix.</p><script>window.close()</script></body></html>` ,
);
if ( success === "true" ) {
2026-06-29 13:28:26 -05:00
finish ({ success : true , token });
2026-05-28 22:29:20 -05:00
} else {
2026-06-29 13:28:26 -05:00
finish ({
2026-05-28 22:29:20 -05:00
success : false ,
error : error || "Authentication failed" ,
});
}
2026-06-29 13:28:26 -05:00
return ;
}
res . writeHead ( 404 , { "Content-Type" : "text/plain" });
res . end ( "Not found" );
});
const finish = ( result ) => {
if ( settled ) return ;
settled = true ;
if ( timeout ) clearTimeout ( timeout );
try {
server . close ();
} catch {
// Server may not have started yet.
}
resolve ( result );
};
const fail = ( error ) => {
finish ({
success : false ,
error : error instanceof Error ? error . message : String ( error ),
});
};
server . once ( "error" , fail );
server . listen ( callbackPort , "127.0.0.1" , async () => {
try {
await shell . openExternal ( authUrl );
} catch ( error ) {
fail ( error );
2026-05-28 22:29:20 -05:00
}
});
// Timeout after 5 minutes
2026-06-29 13:28:26 -05:00
timeout = setTimeout (
2026-05-28 22:29:20 -05:00
() => {
2026-06-29 13:28:26 -05:00
fail ( new Error ( "OIDC authentication timed out" ));
2026-05-28 22:29:20 -05:00
},
5 * 60 * 1000 ,
);
});
},
);
2025-09-12 14:42:00 -05:00
ipcMain . handle ( "get-server-config" , () => {
try {
2026-06-04 15:16:53 -04:00
return getServerConfigSync ();
2025-09-12 14:42:00 -05:00
} catch ( error ) {
console . error ( "Error reading server config:" , error );
return null ;
}
});
ipcMain . handle ( "save-server-config" , ( event , config ) => {
try {
const userDataPath = app . getPath ( "userData" );
2026-06-04 15:16:53 -04:00
const configPath = getServerConfigPath ();
2025-09-12 14:42:00 -05:00
if ( ! fs . existsSync ( userDataPath )) {
fs . mkdirSync ( userDataPath , { recursive : true });
}
fs . writeFileSync ( configPath , JSON . stringify ( config , null , 2 ));
return { success : true };
} catch ( error ) {
console . error ( "Error saving server config:" , error );
return { success : false , error : error . message };
}
});
2026-05-06 15:12:07 -05:00
function getC2STunnelConfigPath () {
return path . join ( app . getPath ( "userData" ), "c2s-tunnels.json" );
}
ipcMain . handle ( "get-c2s-tunnel-config" , () => {
try {
const configPath = getC2STunnelConfigPath ();
if ( ! fs . existsSync ( configPath )) {
return [];
}
const configData = fs . readFileSync ( configPath , "utf8" );
const parsed = JSON . parse ( configData );
return Array . isArray ( parsed ) ? parsed : [];
} catch ( error ) {
console . error ( "Error reading C2S tunnel config:" , error );
return [];
}
});
ipcMain . handle ( "save-c2s-tunnel-config" , async ( _event , config ) => {
try {
if ( ! Array . isArray ( config )) {
return { success : false , error : "C2S tunnel config must be an array" };
}
const autoStartListeners = new Set ();
const autoStartRemoteListeners = new Set ();
for ( const tunnel of config ) {
if ( ! tunnel ? . autoStart ) continue ;
const mode = tunnel . mode || tunnel . tunnelType || "local" ;
if ( mode === "remote" ) {
const sourceHostId = Number ( tunnel . sourceHostId );
const sourcePort = Number ( tunnel . sourcePort );
if (
! Number . isInteger ( sourceHostId ) ||
sourceHostId < 1 ||
! Number . isInteger ( sourcePort ) ||
sourcePort < 1 ||
sourcePort > 65535
) {
return {
success : false ,
error : "Invalid remote client tunnel endpoint or port" ,
};
}
const listenerKey = ` ${ sourceHostId } : ${ sourcePort } ` ;
if ( autoStartRemoteListeners . has ( listenerKey )) {
return {
success : false ,
error : `Another auto-start client tunnel already uses remote ${ listenerKey } ` ,
};
}
autoStartRemoteListeners . add ( listenerKey );
continue ;
}
const bindHost = tunnel . bindHost || "127.0.0.1" ;
const sourcePort = Number ( tunnel . sourcePort );
const listenerKey = ` ${ bindHost } : ${ sourcePort } ` ;
if ( autoStartListeners . has ( listenerKey )) {
return {
success : false ,
error : `Another auto-start client tunnel already uses ${ listenerKey } ` ,
};
}
autoStartListeners . add ( listenerKey );
}
for ( const listenerKey of autoStartListeners ) {
const [ bindHost , sourcePort ] = listenerKey . split ( ":" );
const result = await checkLocalPortAvailable (
bindHost ,
Number ( sourcePort ),
);
const ownedByClientTunnel = Array . from ( c2sTunnelRuntimes . values ()). some (
( runtime ) =>
runtime . bindHost === bindHost &&
runtime . sourcePort === Number ( sourcePort ),
);
if ( ! result . available && ! ownedByClientTunnel ) {
return {
success : false ,
error : `Cannot auto-start client tunnel on ${ listenerKey } : ${ result . error || "port is already in use" } ` ,
};
}
}
const userDataPath = app . getPath ( "userData" );
if ( ! fs . existsSync ( userDataPath )) {
fs . mkdirSync ( userDataPath , { recursive : true });
}
fs . writeFileSync ( getC2STunnelConfigPath (), JSON . stringify ( config , null , 2 ));
return { success : true };
} catch ( error ) {
console . error ( "Error saving C2S tunnel config:" , error );
return { success : false , error : error . message };
}
});
function checkLocalPortAvailable ( host , port ) {
return new Promise (( resolve ) => {
const server = net . createServer ();
server . once ( "error" , ( error ) => {
resolve ({ available : false , error : error . message });
});
server . once ( "listening" , () => {
server . close (() => resolve ({ available : true }));
});
server . listen ({ host , port });
});
}
function checkTcpConnection ( host , port ) {
return new Promise (( resolve ) => {
const socket = net . createConnection ({ host , port });
const timer = setTimeout (() => {
socket . destroy ();
resolve ({ success : false , error : "Connection timed out" });
}, 5000 );
socket . once ( "connect" , () => {
clearTimeout ( timer );
socket . destroy ();
resolve ({ success : true });
});
socket . once ( "error" , ( error ) => {
clearTimeout ( timer );
socket . destroy ();
resolve ({ success : false , error : error . message });
});
});
}
const c2sTunnelRuntimes = new Map ();
const C2S_WS_HIGH_WATERMARK = 1024 * 1024 ;
const C2S_WS_LOW_WATERMARK = 256 * 1024 ;
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024 ;
function getC2SRelayUrl () {
const config = getServerConfigSync ();
const serverUrl =
config ? . serverUrl || ( ! isDev ? "http://127.0.0.1:30003" : null );
if ( ! serverUrl ) {
throw new Error ( "No Termix server configured" );
}
const base = serverUrl . replace ( /\/$/ , "" );
const relayHttpUrl = base . endsWith ( ":30003" )
? ` ${ base } /ssh/tunnel/c2s/stream`
: ` ${ base } /ssh/tunnel/c2s/stream` ;
return relayHttpUrl . replace ( /^http:/ , "ws:" ). replace ( /^https:/ , "wss:" );
}
async function getC2SRelayHeaders ( relayUrl ) {
if ( ! mainWindow ? . webContents ? . session ) return {};
const cookieUrl = relayUrl
. replace ( /^ws:/ , "http:" )
. replace ( /^wss:/ , "https:" );
const cookies = await mainWindow . webContents . session . cookies . get ({
url : cookieUrl ,
name : "jwt" ,
});
const jwt = cookies [ 0 ] ? . value ;
if ( ! jwt ) return {};
return {
Cookie : `jwt= ${ encodeURIComponent ( jwt ) } ` ,
};
}
function getC2STunnelName ( tunnel , index = 0 ) {
if ( tunnel . name ) return tunnel . name ;
return [
"c2s" ,
index ,
tunnel . sourceHostId || 0 ,
tunnel . mode || tunnel . tunnelType || "local" ,
tunnel . bindHost || "127.0.0.1" ,
tunnel . sourcePort ,
tunnel . endpointPort || 0 ,
]. join ( "::" );
}
function getC2STunnelStatus ( tunnelName ) {
return (
c2sTunnelRuntimes . get ( tunnelName ) ? . status || {
connected : false ,
status : "DISCONNECTED" ,
}
);
}
function getAllC2STunnelStatuses () {
const statuses = {};
for ( const [ tunnelName ] of c2sTunnelRuntimes . entries ()) {
statuses [ tunnelName ] = getC2STunnelStatus ( tunnelName );
}
return statuses ;
}
function emitC2STunnelStatuses () {
if ( ! mainWindow || mainWindow . isDestroyed ()) return ;
mainWindow . webContents . send ( "c2s-tunnel-statuses" , getAllC2STunnelStatuses ());
}
function setC2STunnelStatus ( tunnelName , status ) {
const runtime = c2sTunnelRuntimes . get ( tunnelName );
if ( runtime ) {
runtime . status = status ;
emitC2STunnelStatuses ();
}
}
function setC2STunnelError ( tunnelName , message ) {
logToFile ( `[c2s] ${ tunnelName } failed:` , message );
setC2STunnelStatus ( tunnelName , {
connected : false ,
status : "ERROR" ,
reason : message ,
});
}
function parseSocks5Target ( buffer ) {
if ( buffer . length < 7 || buffer [ 0 ] !== 0x05 || buffer [ 1 ] !== 0x01 ) {
return null ;
}
const addressType = buffer [ 3 ];
let offset = 4 ;
let host ;
if ( addressType === 0x01 ) {
if ( buffer . length < offset + 4 + 2 ) return null ;
host = Array . from ( buffer . subarray ( offset , offset + 4 )). join ( "." );
offset += 4 ;
} else if ( addressType === 0x03 ) {
const length = buffer [ offset ];
offset += 1 ;
if ( buffer . length < offset + length + 2 ) return null ;
host = buffer . subarray ( offset , offset + length ). toString ( "utf8" );
offset += length ;
} else if ( addressType === 0x04 ) {
if ( buffer . length < offset + 16 + 2 ) return null ;
const parts = [];
for ( let i = 0 ; i < 16 ; i += 2 ) {
parts . push ( buffer . readUInt16BE ( offset + i ). toString ( 16 ));
}
host = parts . join ( ":" );
offset += 16 ;
} else {
throw new Error ( "Unsupported SOCKS5 address type" );
}
const port = buffer . readUInt16BE ( offset );
return { host , port , bytesRead : offset + 2 };
}
async function openC2SRelay (
tunnel ,
targetHost ,
targetPort ,
socket ,
initialData ,
) {
const tunnelName = tunnel . name || getC2STunnelName ( tunnel );
const relayUrl = getC2SRelayUrl ();
const headers = await getC2SRelayHeaders ( relayUrl );
logToFile ( `[c2s] opening relay for ${ tunnelName } ` , {
relayUrl ,
targetHost ,
targetPort ,
});
setC2STunnelStatus ( tunnelName , {
connected : false ,
status : "CONNECTING" ,
reason : `Opening relay to ${ targetHost } : ${ targetPort } ` ,
});
const ws = new WebSocket (
relayUrl ,
getWebSocketOptions ( relayUrl , { headers }),
);
const pendingChunks = [];
let ready = false ;
let closed = false ;
const cleanup = () => {
if ( closed ) return ;
closed = true ;
try {
socket . destroy ();
} catch {
// expected during shutdown
}
try {
ws . close ();
} catch {
// expected during shutdown
}
};
const sendChunk = ( chunk ) => {
if ( ready && ws . readyState === WebSocket . OPEN ) {
ws . send ( chunk );
} else {
pendingChunks . push ( chunk );
}
};
socket . on ( "data" , sendChunk );
socket . on ( "close" , cleanup );
socket . on ( "error" , ( error ) => {
setC2STunnelError ( tunnelName , error . message || "Local socket error" );
cleanup ();
});
ws . on ( "close" , cleanup );
ws . on ( "error" , ( error ) => {
setC2STunnelError ( tunnelName , error . message || "Relay connection failed" );
cleanup ();
});
ws . on ( "open" , () => {
logToFile ( `[c2s] relay connected for ${ tunnelName } ` );
ws . send (
JSON . stringify ({
type : "open" ,
tunnelConfig : tunnel ,
targetHost ,
targetPort ,
}),
);
});
ws . on ( "message" , ( data , isBinary ) => {
if ( isBinary ) {
socket . write ( Buffer . isBuffer ( data ) ? data : Buffer . from ( data ));
return ;
}
try {
const message = JSON . parse ( data . toString ());
if ( message . type === "ready" ) {
ready = true ;
logToFile ( `[c2s] relay ready for ${ tunnelName } ` );
setC2STunnelStatus ( tunnelName , {
connected : true ,
status : "CONNECTED" ,
});
if ( initialData ? . length ) {
ws . send ( initialData );
}
while ( pendingChunks . length > 0 ) {
ws . send ( pendingChunks . shift ());
}
} else if ( message . type === "error" ) {
logToFile ( "[c2s] relay error:" , message . error );
setC2STunnelError (
tunnelName ,
message . error || "Relay rejected the client tunnel" ,
);
cleanup ();
}
} catch ( error ) {
logToFile ( "[c2s] invalid relay message:" , error . message );
setC2STunnelError ( tunnelName , error . message || "Invalid relay response" );
cleanup ();
}
});
}
async function testC2SRelay ( tunnel , targetHost , targetPort ) {
const relayUrl = getC2SRelayUrl ();
const headers = await getC2SRelayHeaders ( relayUrl );
const ws = new WebSocket (
relayUrl ,
getWebSocketOptions ( relayUrl , { headers }),
);
return new Promise (( resolve ) => {
let settled = false ;
const settle = ( result ) => {
if ( settled ) return ;
settled = true ;
try {
ws . close ();
} catch {
// expected during shutdown
}
resolve ( result );
};
const timer = setTimeout (() => {
settle ({ success : false , error : "Tunnel test timed out" });
}, 15000 );
ws . on ( "open" , () => {
ws . send (
JSON . stringify ({
type : "test" ,
tunnelConfig : tunnel ,
targetHost ,
targetPort ,
}),
);
});
ws . on ( "message" , ( data , isBinary ) => {
if ( isBinary ) return ;
try {
const message = JSON . parse ( data . toString ());
if ( message . type === "ready" ) {
clearTimeout ( timer );
settle ({ success : true });
} else if ( message . type === "error" ) {
clearTimeout ( timer );
settle ({
success : false ,
error : message . error || "Tunnel test failed" ,
});
}
} catch ( error ) {
clearTimeout ( timer );
settle ({ success : false , error : error . message });
}
});
ws . on ( "error" , ( error ) => {
clearTimeout ( timer );
settle ({ success : false , error : error . message });
});
ws . on ( "close" , () => {
clearTimeout ( timer );
settle ({ success : false , error : "Tunnel test connection closed" });
});
});
}
async function testC2STunnel ( tunnel , index = 0 ) {
const mode = tunnel . mode || tunnel . tunnelType || "local" ;
const testTunnel = {
... tunnel ,
name : ` ${ getC2STunnelName ( tunnel , index ) } ::test` ,
mode ,
};
const bindHost = tunnel . bindHost || "127.0.0.1" ;
const sourcePort = Number ( tunnel . sourcePort );
const endpointPort = Number ( tunnel . endpointPort );
if ( ! tunnel . sourceHostId ) {
return { success : false , error : "Endpoint SSH host is required" };
}
if ( mode === "remote" ) {
const localTarget = await checkTcpConnection ( bindHost , endpointPort );
if ( ! localTarget . success ) {
return {
success : false ,
error : `Local target ${ bindHost } : ${ endpointPort } is not reachable: ${ localTarget . error } ` ,
};
}
return testC2SRelay ( testTunnel , undefined , undefined );
}
if ( ! Number . isInteger ( sourcePort ) || sourcePort < 1 || sourcePort > 65535 ) {
return { success : false , error : "Invalid local port" };
}
const runtime = c2sTunnelRuntimes . get ( getC2STunnelName ( tunnel , index ));
if ( ! runtime ) {
const availability = await checkLocalPortAvailable ( bindHost , sourcePort );
if ( ! availability . available ) {
return {
success : false ,
error : `Local listener ${ bindHost } : ${ sourcePort } is not available: ${ availability . error } ` ,
};
}
}
if ( mode === "dynamic" ) {
return testC2SRelay ( testTunnel , undefined , undefined );
}
if ( ! Number . isInteger ( endpointPort ) || endpointPort < 1 ) {
return { success : false , error : "Invalid remote port" };
}
return testC2SRelay (
testTunnel ,
tunnel . targetHost || "127.0.0.1" ,
endpointPort ,
);
}
function handleC2SDynamicConnection ( tunnel , socket ) {
const tunnelName = tunnel . name || getC2STunnelName ( tunnel );
let buffer = Buffer . alloc ( 0 );
let stage = "greeting" ;
const fail = ( code = 0x01 , message = "SOCKS5 request failed" ) => {
setC2STunnelError ( tunnelName , message );
if ( ! socket . destroyed ) {
socket . write ( Buffer . from ([ 0x05 , code , 0x00 , 0x01 , 0 , 0 , 0 , 0 , 0 , 0 ]));
socket . destroy ();
}
};
const onData = ( chunk ) => {
buffer = Buffer . concat ([ buffer , chunk ]);
try {
if ( stage === "greeting" ) {
if ( buffer . length < 2 ) return ;
if ( buffer [ 0 ] !== 0x05 ) {
fail ( 0x01 , "Invalid SOCKS5 greeting" );
return ;
}
const methodsLength = buffer [ 1 ];
if ( buffer . length < 2 + methodsLength ) return ;
socket . write ( Buffer . from ([ 0x05 , 0x00 ]));
buffer = buffer . subarray ( 2 + methodsLength );
stage = "connect" ;
}
if ( stage === "connect" ) {
const target = parseSocks5Target ( buffer );
if ( ! target ) return ;
stage = "piping" ;
socket . off ( "data" , onData );
const remainder = buffer . subarray ( target . bytesRead );
socket . write ( Buffer . from ([ 0x05 , 0x00 , 0x00 , 0x01 , 0 , 0 , 0 , 0 , 0 , 0 ]));
openC2SRelay ( tunnel , target . host , target . port , socket , remainder ). catch (
( error ) => {
logToFile ( "[c2s] dynamic relay failed:" , error . message );
fail ( 0x05 , error . message || "Dynamic relay failed" );
},
);
}
} catch ( error ) {
logToFile ( "[c2s] SOCKS5 parse failed:" , error . message );
fail ( 0x01 , error . message || "SOCKS5 parse failed" );
}
};
socket . on ( "data" , onData );
socket . on ( "error" , () => socket . destroy ());
}
function handleC2SLocalConnection ( tunnel , socket ) {
const tunnelName = tunnel . name || getC2STunnelName ( tunnel );
const targetHost = tunnel . targetHost || "127.0.0.1" ;
const targetPort = Number ( tunnel . endpointPort );
openC2SRelay ( tunnel , targetHost , targetPort , socket ). catch (( error ) => {
logToFile ( "[c2s] local relay failed:" , error . message );
setC2STunnelError ( tunnelName , error . message || "Local relay failed" );
socket . destroy ();
});
}
function pauseSourceForC2SWebSocket ( ws , source ) {
if ( ! source ? . pause || ! source ? . resume ) return ;
if ( ws . bufferedAmount <= C2S_WS_HIGH_WATERMARK ) return ;
source . pause ();
const resumeTimer = setInterval (() => {
if (
ws . readyState !== WebSocket . OPEN ||
source . destroyed ||
ws . bufferedAmount <= C2S_WS_LOW_WATERMARK
) {
clearInterval ( resumeTimer );
if ( ws . readyState === WebSocket . OPEN && ! source . destroyed ) {
source . resume ();
}
}
}, 25 );
}
function sendC2SRemoteMessage ( ws , message , source ) {
if ( ws . readyState === WebSocket . OPEN ) {
ws . send ( JSON . stringify ( message ), ( error ) => {
if ( error && source ? . destroy ) {
source . destroy ( error );
}
});
pauseSourceForC2SWebSocket ( ws , source );
}
}
function writeC2SRemoteChunk ( target , chunk , ws , closeTarget ) {
if ( ! target || target . destroyed ) return ;
if ( target . writableLength > C2S_STREAM_WRITE_LIMIT ) {
closeTarget ();
return ;
}
const canContinue = target . write ( chunk );
if ( ! canContinue && typeof ws . pause === "function" ) {
ws . pause ();
target . once ( "drain" , () => {
if ( ws . readyState === WebSocket . OPEN && typeof ws . resume === "function" ) {
ws . resume ();
}
});
}
}
async function startC2SRemoteTunnel ( tunnel , index = 0 ) {
const tunnelName = getC2STunnelName ( tunnel , index );
const localHost = tunnel . bindHost || "127.0.0.1" ;
const localPort = Number ( tunnel . endpointPort );
const remotePort = Number ( tunnel . sourcePort );
if ( ! tunnel . sourceHostId ) {
return { success : false , error : "Endpoint SSH host is required" };
}
if ( ! Number . isInteger ( remotePort ) || remotePort < 1 || remotePort > 65535 ) {
return { success : false , error : "Invalid remote port" };
}
if ( ! Number . isInteger ( localPort ) || localPort < 1 || localPort > 65535 ) {
return { success : false , error : "Invalid local port" };
}
const localTarget = await checkTcpConnection ( localHost , localPort );
if ( ! localTarget . success ) {
return {
success : false ,
error : `Local target ${ localHost } : ${ localPort } is not reachable: ${ localTarget . error } ` ,
};
}
const existing = c2sTunnelRuntimes . get ( tunnelName );
if ( existing ) {
return { success : true , tunnelName };
}
for ( const runtime of c2sTunnelRuntimes . values ()) {
if (
runtime . mode === "remote" &&
runtime . sourceHostId === Number ( tunnel . sourceHostId ) &&
runtime . sourcePort === remotePort
) {
return {
success : false ,
error : `Another client remote tunnel already uses ${ remotePort } on this endpoint` ,
};
}
}
const relayUrl = getC2SRelayUrl ();
const headers = await getC2SRelayHeaders ( relayUrl );
const ws = new WebSocket (
relayUrl ,
getWebSocketOptions ( relayUrl , { headers }),
);
const sockets = new Map ();
let closed = false ;
const cleanup = () => {
if ( closed ) return ;
closed = true ;
for ( const socket of sockets . values ()) {
socket . destroy ();
}
sockets . clear ();
try {
ws . close ();
} catch {
// expected during shutdown
}
};
c2sTunnelRuntimes . set ( tunnelName , {
ws ,
sockets ,
mode : "remote" ,
sourceHostId : Number ( tunnel . sourceHostId ),
sourcePort : remotePort ,
bindHost : localHost ,
status : { connected : false , status : "CONNECTING" },
close : cleanup ,
});
emitC2STunnelStatuses ();
return new Promise (( resolve ) => {
let settled = false ;
const settle = ( result ) => {
if ( settled ) return ;
settled = true ;
resolve ( result );
};
ws . on ( "open" , () => {
logToFile ( `[c2s] opening remote tunnel ${ tunnelName } ` , {
relayUrl ,
remotePort ,
localHost ,
localPort ,
});
ws . send (
JSON . stringify ({
type : "open" ,
tunnelConfig : { ... tunnel , name : tunnelName , mode : "remote" },
}),
);
});
ws . on ( "message" , ( data , isBinary ) => {
if ( isBinary ) return ;
let message ;
try {
message = JSON . parse ( data . toString ());
} catch ( error ) {
setC2STunnelError ( tunnelName , error . message || "Invalid relay message" );
cleanup ();
settle ({ success : false , error : error . message });
return ;
}
if ( message . type === "ready" ) {
setC2STunnelStatus ( tunnelName , {
connected : true ,
status : "CONNECTED" ,
});
settle ({ success : true , tunnelName });
return ;
}
if ( message . type === "error" ) {
const error = message . error || "Relay rejected the client tunnel" ;
setC2STunnelError ( tunnelName , error );
cleanup ();
c2sTunnelRuntimes . delete ( tunnelName );
emitC2STunnelStatuses ();
settle ({ success : false , error });
return ;
}
if ( message . type === "connection" && message . streamId ) {
const socket = net . createConnection (
{ host : localHost , port : localPort },
() => {
logToFile ( `[c2s] remote stream ${ message . streamId } connected` , {
tunnelName ,
localHost ,
localPort ,
});
},
);
sockets . set ( message . streamId , socket );
socket . on ( "data" , ( chunk ) => {
sendC2SRemoteMessage (
ws ,
{
type : "data" ,
streamId : message . streamId ,
data : chunk . toString ( "base64" ),
},
socket ,
);
});
socket . on ( "close" , () => {
sockets . delete ( message . streamId );
sendC2SRemoteMessage ( ws , {
type : "close" ,
streamId : message . streamId ,
});
});
socket . on ( "error" , ( error ) => {
logToFile ( `[c2s] remote stream ${ message . streamId } failed:` , {
tunnelName ,
error : error . message ,
});
sockets . delete ( message . streamId );
sendC2SRemoteMessage ( ws , {
type : "close" ,
streamId : message . streamId ,
error : error . message ,
});
});
return ;
}
if ( message . type === "data" && message . streamId && message . data ) {
const socket = sockets . get ( message . streamId );
writeC2SRemoteChunk (
socket ,
Buffer . from ( message . data , "base64" ),
ws ,
() => {
if ( socket ) {
sockets . delete ( message . streamId );
socket . destroy ();
}
},
);
return ;
}
if ( message . type === "close" && message . streamId ) {
const socket = sockets . get ( message . streamId );
if ( socket ) {
sockets . delete ( message . streamId );
socket . destroy ();
}
}
});
ws . on ( "close" , () => {
cleanup ();
c2sTunnelRuntimes . delete ( tunnelName );
emitC2STunnelStatuses ();
settle ({ success : false , error : "Remote tunnel relay closed" });
});
ws . on ( "error" , ( error ) => {
setC2STunnelError ( tunnelName , error . message || "Relay connection failed" );
cleanup ();
c2sTunnelRuntimes . delete ( tunnelName );
emitC2STunnelStatuses ();
settle ({ success : false , error : error . message });
});
});
}
async function startC2STunnel ( tunnel , index = 0 ) {
const mode = tunnel . mode || tunnel . tunnelType || "local" ;
const tunnelName = getC2STunnelName ( tunnel , index );
const bindHost = tunnel . bindHost || "127.0.0.1" ;
const sourcePort = Number ( tunnel . sourcePort );
logToFile ( `[c2s] starting tunnel ${ tunnelName } ` , {
mode ,
bindHost ,
sourcePort ,
sourceHostId : tunnel . sourceHostId ,
endpointPort : tunnel . endpointPort ,
});
if ( mode === "remote" ) {
return startC2SRemoteTunnel ( tunnel , index );
}
if ( ! tunnel . sourceHostId ) {
return { success : false , error : "Endpoint SSH host is required" };
}
if ( ! Number . isInteger ( sourcePort ) || sourcePort < 1 || sourcePort > 65535 ) {
return { success : false , error : "Invalid local port" };
}
const existing = c2sTunnelRuntimes . get ( tunnelName );
if ( existing ) {
return { success : true , tunnelName };
}
for ( const runtime of c2sTunnelRuntimes . values ()) {
if (
runtime . mode !== "remote" &&
runtime . bindHost === bindHost &&
runtime . sourcePort === sourcePort
) {
return {
success : false ,
error : `Another client tunnel already uses ${ bindHost } : ${ sourcePort } ` ,
};
}
}
const availability = await checkLocalPortAvailable ( bindHost , sourcePort );
if ( ! availability . available ) {
return {
success : false ,
error : availability . error || "Port is already in use" ,
};
}
const sockets = new Set ();
const server = net . createServer (( socket ) => {
sockets . add ( socket );
socket . on ( "close" , () => sockets . delete ( socket ));
if ( mode === "dynamic" ) {
handleC2SDynamicConnection ({ ... tunnel , name : tunnelName , mode }, socket );
} else {
handleC2SLocalConnection ({ ... tunnel , name : tunnelName , mode }, socket );
}
});
c2sTunnelRuntimes . set ( tunnelName , {
server ,
sockets ,
bindHost ,
sourcePort ,
status : { connected : false , status : "CONNECTING" },
});
return new Promise (( resolve ) => {
server . once ( "error" , ( error ) => {
c2sTunnelRuntimes . delete ( tunnelName );
logToFile ( `[c2s] failed to listen for ${ tunnelName } :` , error . message );
emitC2STunnelStatuses ();
resolve ({ success : false , error : error . message });
});
server . listen ({ host : bindHost , port : sourcePort }, () => {
logToFile (
`[c2s] listening for ${ tunnelName } on ${ bindHost } : ${ sourcePort } ` ,
);
setC2STunnelStatus ( tunnelName , {
connected : true ,
status : "CONNECTED" ,
});
resolve ({ success : true , tunnelName });
});
});
}
async function stopC2STunnel ( tunnelName ) {
const runtime = c2sTunnelRuntimes . get ( tunnelName );
if ( ! runtime ) {
return { success : true };
}
setC2STunnelStatus ( tunnelName , {
connected : false ,
status : "DISCONNECTING" ,
});
return new Promise (( resolve ) => {
if ( typeof runtime . close === "function" ) {
runtime . close ();
c2sTunnelRuntimes . delete ( tunnelName );
emitC2STunnelStatuses ();
resolve ({ success : true });
return ;
}
for ( const socket of runtime . sockets || []) {
socket . destroy ();
}
runtime . server ? . close (() => {
c2sTunnelRuntimes . delete ( tunnelName );
emitC2STunnelStatuses ();
resolve ({ success : true });
});
});
}
function stopAllC2STunnels () {
for ( const [ tunnelName , runtime ] of c2sTunnelRuntimes . entries ()) {
try {
if ( typeof runtime . close === "function" ) {
runtime . close ();
} else {
for ( const socket of runtime . sockets || []) {
socket . destroy ();
}
runtime . server ? . close ();
}
} catch ( error ) {
logToFile ( `[c2s] failed to stop tunnel ${ tunnelName } :` , error . message );
}
c2sTunnelRuntimes . delete ( tunnelName );
}
emitC2STunnelStatuses ();
}
async function startC2SAutoStartTunnels () {
const configPath = getC2STunnelConfigPath ();
if ( ! fs . existsSync ( configPath )) {
return { success : true , started : 0 , errors : [] };
}
const config = JSON . parse ( fs . readFileSync ( configPath , "utf8" ));
const tunnels = Array . isArray ( config ) ? config : [];
const errors = [];
let started = 0 ;
for ( let index = 0 ; index < tunnels . length ; index += 1 ) {
const tunnel = tunnels [ index ];
if ( ! tunnel ? . autoStart ) continue ;
const result = await startC2STunnel ( tunnel , index );
if ( result . success ) {
started += 1 ;
} else {
errors . push ( result . error || "Failed to start client tunnel" );
}
}
return { success : errors . length === 0 , started , errors };
}
ipcMain . handle ( "check-local-port-available" , async ( _event , host , port ) => {
const sourcePort = Number ( port );
if (
! host ||
! Number . isInteger ( sourcePort ) ||
sourcePort < 1 ||
sourcePort > 65535
) {
return { available : false , error : "Invalid local bind address or port" };
}
return checkLocalPortAvailable ( host , sourcePort );
});
ipcMain . handle ( "start-c2s-tunnel" , async ( _event , tunnel , index ) => {
try {
return await startC2STunnel ( tunnel , Number ( index ) || 0 );
} catch ( error ) {
return { success : false , error : error . message };
}
});
ipcMain . handle ( "test-c2s-tunnel" , async ( _event , tunnel , index ) => {
try {
return await testC2STunnel ( tunnel , Number ( index ) || 0 );
} catch ( error ) {
return { success : false , error : error . message };
}
});
ipcMain . handle ( "stop-c2s-tunnel" , async ( _event , tunnelName ) => {
try {
return await stopC2STunnel ( tunnelName );
} catch ( error ) {
return { success : false , error : error . message };
}
});
ipcMain . handle ( "get-c2s-tunnel-statuses" , () => {
return getAllC2STunnelStatuses ();
});
ipcMain . handle ( "start-c2s-autostart-tunnels" , async () => {
try {
return await startC2SAutoStartTunnels ();
} catch ( error ) {
return { success : false , started : 0 , errors : [ error . message ] };
}
});
ipcMain . handle ( "get-c2s-tunnel-preset-default-name" , () => {
const now = new Date ();
const date = now . toISOString (). slice ( 0 , 10 );
const platform =
process . platform === "darwin"
? "macOS"
: process . platform === "win32"
? "Windows"
: "Linux" ;
const release = os . release ();
const computerName = os . hostname ();
return `[ ${ date } ] ${ computerName } ( ${ platform } ${ release } )` ;
});
2025-11-17 09:46:05 -06:00
ipcMain . handle ( "get-setting" , ( event , key ) => {
try {
const userDataPath = app . getPath ( "userData" );
const settingsPath = path . join ( userDataPath , "settings.json" );
if ( ! fs . existsSync ( settingsPath )) {
return null ;
}
const settingsData = fs . readFileSync ( settingsPath , "utf8" );
const settings = JSON . parse ( settingsData );
return settings [ key ] !== undefined ? settings [ key ] : null ;
} catch ( error ) {
console . error ( "Error reading setting:" , error );
return null ;
}
});
ipcMain . handle ( "set-setting" , ( event , key , value ) => {
try {
const userDataPath = app . getPath ( "userData" );
const settingsPath = path . join ( userDataPath , "settings.json" );
if ( ! fs . existsSync ( userDataPath )) {
fs . mkdirSync ( userDataPath , { recursive : true });
}
let settings = {};
if ( fs . existsSync ( settingsPath )) {
const settingsData = fs . readFileSync ( settingsPath , "utf8" );
settings = JSON . parse ( settingsData );
}
settings [ key ] = value ;
fs . writeFileSync ( settingsPath , JSON . stringify ( settings , null , 2 ));
return { success : true };
} catch ( error ) {
console . error ( "Error saving setting:" , error );
return { success : false , error : error . message };
}
});
2026-05-06 15:12:07 -05:00
ipcMain . handle ( "get-session-cookie" , async ( _event , name , targetUrl ) => {
2026-04-22 16:55:23 -05:00
try {
const ses = mainWindow ? . webContents ? . session ;
if ( ! ses ) return null ;
2026-05-06 15:12:07 -05:00
const cookies = await ses . cookies . get ({
name ,
...( targetUrl ? { url : targetUrl } : {}),
});
const cookie = cookies . find (( candidate ) =>
cookieMatchesUrl ( candidate , targetUrl ),
);
return (
cookie ? . value ||
getRememberedElectronAuthCookie ( name , targetUrl ) ? . value ||
null
);
2026-04-22 16:55:23 -05:00
} catch ( error ) {
console . error ( "Failed to get session cookie:" , error );
2026-05-06 15:12:07 -05:00
return getRememberedElectronAuthCookie ( name , targetUrl ) ? . value || null ;
2026-04-22 16:55:23 -05:00
}
});
2026-05-06 15:12:07 -05:00
function cookieMatchesUrl ( cookie , targetUrl ) {
if ( ! targetUrl ) return true ;
try {
const targetHost = new URL ( targetUrl ). hostname ;
const cookieDomain = ( cookie . domain || "" ). replace ( /^\./ , "" );
return (
cookieDomain === targetHost ||
targetHost . endsWith ( `. ${ cookieDomain } ` ) ||
( ! cookieDomain && targetHost === "localhost" )
);
} catch {
return true ;
}
}
ipcMain . handle (
"wait-session-cookie" ,
async ( _event , name , targetUrl , previousValue , timeoutMs = 5000 ) => {
const ses = mainWindow ? . webContents ? . session ;
if ( ! ses ) return { success : false , error : "No Electron session" };
const existingCookies = await ses . cookies . get ({
name ,
...( targetUrl ? { url : targetUrl } : {}),
});
const existingCookie = existingCookies . find (( cookie ) =>
cookieMatchesUrl ( cookie , targetUrl ),
);
if ( existingCookie ? . value && existingCookie . value !== previousValue ) {
return { success : true , value : existingCookie . value };
}
const rememberedCookie = getRememberedElectronAuthCookie ( name , targetUrl );
if ( rememberedCookie ? . value && rememberedCookie . value !== previousValue ) {
return { success : true , value : rememberedCookie . value };
}
return new Promise (( resolve ) => {
const timeout = setTimeout (() => {
ses . cookies . off ( "changed" , onCookieChanged );
resolve ({ success : false , error : "Timed out waiting for cookie" });
}, timeoutMs );
function onCookieChanged ( _event , cookie , _cause , removed ) {
if (
removed ||
cookie . name !== name ||
! cookie . value ||
cookie . value === previousValue ||
! cookieMatchesUrl ( cookie , targetUrl )
) {
return ;
}
clearTimeout ( timeout );
ses . cookies . off ( "changed" , onCookieChanged );
resolve ({ success : true , value : cookie . value });
}
ses . cookies . on ( "changed" , onCookieChanged );
});
},
);
2026-03-08 18:02:14 -05:00
ipcMain . handle ( "clear-session-cookies" , async () => {
try {
2026-05-06 15:12:07 -05:00
clearPersistedElectronAuthCookies ();
2026-03-08 18:02:14 -05:00
const ses = mainWindow ? . webContents ? . session ;
if ( ses ) {
const cookies = await ses . cookies . get ({});
for ( const cookie of cookies ) {
2026-05-06 15:12:07 -05:00
await ses . cookies . remove ( getCookieRemovalUrl ( cookie ), cookie . name );
2026-03-08 18:02:14 -05:00
}
}
} catch ( error ) {
console . error ( "Failed to clear session cookies:" , error );
}
});
2026-05-06 15:12:07 -05:00
ipcMain . handle ( "clipboard-write-text" , ( _event , text ) => {
clipboard . writeText ( typeof text === "string" ? text : String ( text ?? "" ));
return true ;
});
ipcMain . handle ( "clipboard-read-text" , () => clipboard . readText ());
2026-06-29 13:28:26 -05:00
ipcMain . handle ( "show-save-dialog" , async ( _event , options ) => {
return dialog . showSaveDialog ( mainWindow , options || {});
});
ipcMain . handle ( "show-open-dialog" , async ( _event , options ) => {
return dialog . showOpenDialog ( mainWindow , options || {});
});
ipcMain . handle ( "create-temp-file" , async ( _event , fileData ) => {
try {
const result = createManagedTempFile (
fileData ? . fileName ,
fileData ? . content ,
fileData ? . encoding ,
);
return { success : true , ... result };
} catch ( error ) {
return { success : false , error : error . message };
}
});
ipcMain . handle ( "create-temp-folder" , async ( _event , folderData ) => {
try {
const tempId = ` ${ Date . now () } - ${ Math . random (). toString ( 36 ). slice ( 2 ) } ` ;
const tempDir = fs . mkdtempSync ( path . join ( getTempRoot (), ` ${ tempId } -` ));
const folderPath = path . join (
tempDir ,
sanitizeFileName ( folderData ? . folderName || "files" ),
);
fs . mkdirSync ( folderPath , { recursive : true });
for ( const file of folderData ? . files || []) {
const relativePath = String ( file . relativePath || "" )
. split ( /[\\/]+/ )
. map ( sanitizeFileName )
. filter ( Boolean )
. join ( path . sep );
if ( ! relativePath ) continue ;
const targetPath = path . join ( folderPath , relativePath );
fs . mkdirSync ( path . dirname ( targetPath ), { recursive : true });
fs . writeFileSync (
targetPath ,
decodeFileContent ( file . content , file . encoding ),
);
}
tempFiles . set ( tempId , { path : folderPath , dir : tempDir });
return { success : true , tempId , path : folderPath };
} catch ( error ) {
return { success : false , error : error . message };
}
});
ipcMain . handle ( "start-drag-to-desktop" , ( event , dragData ) => {
try {
const temp = tempFiles . get ( dragData ? . tempId );
if ( ! temp ) return { success : false , error : "Temporary file not found" };
event . sender . startDrag ({
file : temp . path ,
icon : nativeImage . createEmpty (),
});
return { success : true };
} catch ( error ) {
return { success : false , error : error . message };
}
});
ipcMain . handle ( "cleanup-temp-file" , ( _event , tempId ) => {
try {
cleanupManagedTempFile ( tempId );
return { success : true };
} catch ( error ) {
return { success : false , error : error . message };
}
});
ipcMain . handle ( "open-external-editor" , async ( _event , fileData ) => {
try {
const result = createManagedTempFile (
fileData ? . fileName ,
fileData ? . content ,
fileData ? . encoding ,
);
const editId = result . tempId ;
const stat = fs . statSync ( result . path );
const watcher = fs . watch ( result . path , { persistent : false }, () => {
const session = externalEditorSessions . get ( editId );
if ( ! session ) return ;
if ( session . timer ) clearTimeout ( session . timer );
session . timer = setTimeout (() => notifyExternalEditorSaved ( editId ), 500 );
});
externalEditorSessions . set ( editId , {
path : result . path ,
watcher ,
timer : null ,
lastMtimeMs : stat . mtimeMs ,
});
const editorPath =
typeof fileData ? . editorPath === "string" && fileData . editorPath . trim ()
? fileData . editorPath . trim ()
: null ;
const openError = await openPathWithEditor ( result . path , editorPath );
if ( openError ) {
closeExternalEditorSession ( editId );
return { success : false , error : openError };
}
return { success : true , editId , path : result . path };
} catch ( error ) {
return { success : false , error : error . message };
}
});
ipcMain . handle ( "close-external-editor" , ( _event , editId ) => {
try {
closeExternalEditorSession ( editId );
return { success : true };
} catch ( error ) {
return { success : false , error : error . message };
}
});
2025-09-12 14:42:00 -05:00
ipcMain . handle ( "test-server-connection" , async ( event , serverUrl ) => {
try {
const normalizedServerUrl = serverUrl . replace ( /\/$/ , "" );
const healthUrl = ` ${ normalizedServerUrl } /health` ;
try {
2026-03-08 18:02:14 -05:00
const response = await httpFetch ( healthUrl , {
2025-09-12 14:42:00 -05:00
method : "GET" ,
2025-10-01 15:40:10 -05:00
timeout : 10000 ,
2025-09-12 14:42:00 -05:00
});
if ( response . ok ) {
const data = await response . text ();
if (
data . includes ( "<html" ) ||
data . includes ( "<!DOCTYPE" ) ||
data . includes ( "<head>" ) ||
data . includes ( "<body>" )
) {
return {
success : false ,
error :
"Server returned HTML instead of JSON. This does not appear to be a Termix server." ,
};
}
try {
const healthData = JSON . parse ( data );
if (
healthData &&
( healthData . status === "ok" ||
healthData . status === "healthy" ||
healthData . healthy === true ||
healthData . database === "connected" )
) {
return {
success : true ,
status : response . status ,
testedUrl : healthUrl ,
};
}
} catch ( parseError ) {
console . log ( "Health endpoint did not return valid JSON" );
}
}
} catch ( urlError ) {
console . error ( "Health check failed:" , urlError );
}
try {
const versionUrl = ` ${ normalizedServerUrl } /version` ;
2026-03-08 18:02:14 -05:00
const response = await httpFetch ( versionUrl , {
2025-09-12 14:42:00 -05:00
method : "GET" ,
2025-10-01 15:40:10 -05:00
timeout : 10000 ,
2025-09-12 14:42:00 -05:00
});
if ( response . ok ) {
const data = await response . text ();
if (
data . includes ( "<html" ) ||
data . includes ( "<!DOCTYPE" ) ||
data . includes ( "<head>" ) ||
data . includes ( "<body>" )
) {
return {
success : false ,
error :
"Server returned HTML instead of JSON. This does not appear to be a Termix server." ,
};
}
try {
const versionData = JSON . parse ( data );
if (
versionData &&
( versionData . status === "up_to_date" ||
versionData . status === "requires_update" ||
( versionData . localVersion &&
versionData . version &&
versionData . latest_release ))
) {
return {
success : true ,
status : response . status ,
testedUrl : versionUrl ,
warning :
"Health endpoint not available, but server appears to be running" ,
};
}
} catch ( parseError ) {
console . log ( "Version endpoint did not return valid JSON" );
}
}
} catch ( versionError ) {
console . error ( "Version check failed:" , versionError );
}
return {
success : false ,
error :
"Server is not responding or does not appear to be a valid Termix server. Please ensure the server is running and accessible." ,
};
} catch ( error ) {
return { success : false , error : error . message };
}
});
2025-11-05 10:36:16 -06:00
function createMenu () {
if ( process . platform === "darwin" ) {
const template = [
{
label : app . name ,
submenu : [
{ role : "about" },
{ type : "separator" },
{ role : "services" },
{ type : "separator" },
{ role : "hide" },
{ role : "hideOthers" },
{ role : "unhide" },
{ type : "separator" },
{ role : "quit" },
],
},
{
label : "Edit" ,
submenu : [
{ role : "undo" },
{ role : "redo" },
{ type : "separator" },
{ role : "cut" },
{ role : "copy" },
{ role : "paste" },
{ role : "selectAll" },
],
},
{
label : "View" ,
submenu : [
{ role : "reload" },
{ role : "forceReload" },
{ role : "toggleDevTools" },
{ type : "separator" },
{ role : "resetZoom" },
{ role : "zoomIn" },
{ role : "zoomOut" },
{ type : "separator" },
{ role : "togglefullscreen" },
],
},
{
label : "Window" ,
submenu : [
{ role : "minimize" },
{ role : "zoom" },
{ type : "separator" },
{ role : "front" },
{ type : "separator" },
{ role : "window" },
],
},
];
const menu = Menu . buildFromTemplate ( template );
Menu . setApplicationMenu ( menu );
}
}
2026-03-08 18:02:14 -05:00
app . whenReady (). then ( async () => {
logToFile ( "=== App ready ===" );
logToFile (
"isDev:" ,
isDev ,
"platform:" ,
process . platform ,
"arch:" ,
process . arch ,
);
2026-06-29 13:28:26 -05:00
if ( process . platform === "win32" ) {
app . setAppUserModelId ( windowsAppUserModelId );
}
2025-11-05 10:36:16 -06:00
createMenu ();
2026-05-06 15:12:07 -05:00
await clearElectronClientCacheIfBuildChanged ();
await clearElectronJwtCookiesAtStartup ();
2026-03-08 18:02:14 -05:00
if ( ! isDev ) {
const result = await startBackendServer ();
logToFile ( "startBackendServer result:" , result );
} else {
logToFile (
"Skipping embedded backend (isDev=true) - expecting separate dev:backend process" ,
);
}
createTray ();
2025-09-12 14:42:00 -05:00
createWindow ();
2026-03-08 18:02:14 -05:00
logToFile ( "=== Startup complete ===" );
2025-09-12 14:42:00 -05:00
});
app . on ( "window-all-closed" , () => {
2026-03-08 18:02:14 -05:00
if ( ! tray || tray . isDestroyed ()) {
app . quit ();
}
2025-09-12 14:42:00 -05:00
});
app . on ( "activate" , () => {
if ( BrowserWindow . getAllWindows (). length === 0 ) {
createWindow ();
2026-05-28 22:05:25 -04:00
} else if ( mainWindow ) {
mainWindow . show ();
mainWindow . focus ();
2025-09-12 14:42:00 -05:00
}
});
2026-03-08 18:02:14 -05:00
app . on ( "before-quit" , () => {
isQuitting = true ;
});
2025-09-12 14:42:00 -05:00
app . on ( "will-quit" , () => {
console . log ( "App will quit..." );
2026-06-29 13:28:26 -05:00
for ( const editId of externalEditorSessions . keys ()) {
closeExternalEditorSession ( editId );
}
for ( const tempId of tempFiles . keys ()) {
cleanupManagedTempFile ( tempId );
}
2026-05-06 15:12:07 -05:00
stopAllC2STunnels ();
2026-03-08 18:02:14 -05:00
stopBackendServer ();
2025-09-12 14:42:00 -05:00
});
process . on ( "uncaughtException" , ( error ) => {
console . error ( "Uncaught Exception:" , error );
});
process . on ( "unhandledRejection" , ( reason , promise ) => {
console . error ( "Unhandled Rejection at:" , promise , "reason:" , reason );
});