yum/FastTextPager
Compressed text paging over OSC.
git clone https://git.yummers.dev/yum/FastTextPager
e1730a6
master
1const { app, BrowserWindow, ipcMain} = require ( 'electron' ); 2const path = require ( 'node:path' ); 3const fs = require ( 'node:fs' ). promises ; 4const yaml = require ( 'js-yaml' ); 5const { spawn} = require ( 'child_process' ); 6const https = require ( 'https' ); 7const { CONFIG_SCHEMA , getDefaultConfig} = require ( './config-schema.js' ); 8 9// Detect if we're running in development or production 10const isDev = ! app . isPackaged ; 11const APP_ROOT = isDev 12 ?path . join ( __dirname , '..' ) // Development: go up from ui/ to project root 13 :process . resourcesPath ; // Production: use Electron's resource path 14 15const CONFIG_PATH = path . join ( APP_ROOT , 'config.yaml' ); 16 17let mainWindow ; 18let runningProcess = null ; // Track the running Python process 19 20// Required DLL files for CUDA/cuDNN support 21const REQUIRED_DLLS = [ 22'cublas64_12.dll' , 23'cublasLt64_12.dll' , 24'cudnn64_9.dll' , 25'cudnn_adv64_9.dll' , 26'cudnn_cnn64_9.dll' , 27'cudnn_engines_precompiled64_9.dll' , 28'cudnn_engines_runtime_compiled64_9.dll' , 29'cudnn_graph64_9.dll' , 30'cudnn_heuristic64_9.dll' , 31'cudnn_ops64_9.dll' 32]; 33 34// Helper function to get the correct Python executable from venv 35function getVenvPython () { 36const venvPath = path . join ( APP_ROOT , 'venv' ); 37const pythonPath = path . join ( venvPath , 'Scripts' , 'python.exe' ); 38return pythonPath ; 39} 40 41// Helper function to send Python output to renderer 42function sendPythonOutput ( message , type = 'stdout' ) { 43if ( mainWindow && ! mainWindow . isDestroyed ()) { 44mainWindow . webContents . send ( 'python-output' , { message, type}); 45} 46} 47 48// Helper function to create environment with DLL path 49function createPythonEnvironment () { 50const dllPath = path . join ( APP_ROOT , 'dll' ); 51const binPath = path . join ( APP_ROOT , 'bin' ); 52const env = { ...process . env }; 53env . PATH = ` ${ dllPath } ; ${ binPath } ; ${ env . PATH } ` ; 54env . HF_HUB_DISABLE_SYMLINKS_WARNING = '1' ; 55return env ; 56} 57 58// Helper function to download a file from URL with progress 59function downloadFile ( url , outputPath ) { 60return new Promise (( resolve , reject ) => { 61const file = require ( 'fs' ). createWriteStream ( outputPath ); 62const fileName = path . basename ( outputPath ); 63 64const request = https . get ( url , ( response ) => { 65if ( response . statusCode === 200 ) { 66const totalSize = parseInt ( response . headers [ 'content-length' ], 10 ); 67let downloadedSize = 0 ; 68let lastProgressTime = Date . now (); 69 70response . on ( 'data' , ( chunk ) => { 71downloadedSize += chunk . length ; 72 73// Log progress every 5 seconds 74const now = Date . now (); 75if ( totalSize && ( now - lastProgressTime >= 5000 )) { 76const progress = Math . round (( downloadedSize / totalSize ) * 100 ); 77const mb = ( downloadedSize / 1024 / 1024 ). toFixed ( 1 ); 78const totalMb = ( totalSize / 1024 / 1024 ). toFixed ( 1 ); 79sendPythonOutput ( `Downloading ${ fileName } : ${ mb } / ${ totalMb } MB ( ${ progress } %)` , 'info' ); 80lastProgressTime = now ; 81} 82}); 83 84response . pipe ( file ); 85 86file . on ( 'finish' , () => { 87file . close (); 88resolve (); 89}); 90 91file . on ( 'error' , ( err ) => { 92fs . unlink ( outputPath ). catch (() => {}); // Clean up on error 93reject ( err ); 94}); 95} else { 96file . close (); 97fs . unlink ( outputPath ). catch (() => {}); // Clean up on error 98reject ( new Error ( `Failed to download: HTTP ${ response . statusCode } ` )); 99} 100}); 101 102request . on ( 'error' , ( err ) => { 103file . close (); 104fs . unlink ( outputPath ). catch (() => {}); // Clean up on error 105reject ( err ); 106}); 107}); 108} 109 110function shouldFilterMessage ( message ) { 111// Filter out pydub ffmpeg/avconv warning. It does not actually matter. 112if ( message . includes ( "Couldn't find ffmpeg or avconv - defaulting to ffmpeg, but may not work" )) { 113return true ; 114} 115return false ; 116} 117 118// Helper function to setup process event handlers 119function setupProcessHandlers ( process ) { 120process . stdout . on ( 'data' , ( data ) => { 121const text = data . toString (); 122sendPythonOutput ( text . trimEnd (), 'stdout' ); 123}); 124 125process . stderr . on ( 'data' , ( data ) => { 126const text = data . toString (); 127if ( ! shouldFilterMessage ( text )) { 128sendPythonOutput ( text . trimEnd (), 'stderr' ); 129} 130}); 131 132process . on ( 'error' , ( error ) => { 133sendPythonOutput ( `Process error: ${ error . message } ` , 'stderr' ); 134runningProcess = null ; 135if ( mainWindow && ! mainWindow . isDestroyed ()) { 136mainWindow . webContents . send ( 'process-stopped' ); 137} 138}); 139 140process . on ( 'close' , ( code ) => { 141sendPythonOutput ( `Process exited with code ${ code } ` , 'info' ); 142runningProcess = null ; 143if ( mainWindow && ! mainWindow . isDestroyed ()) { 144mainWindow . webContents . send ( 'process-stopped' ); 145} 146}); 147} 148 149// Helper function to execute Python commands using venv 150function executePythonCommand ( args , options = {}) { 151return new Promise (( resolve , reject ) => { 152const pythonPath = getVenvPython (); 153const commandStr = ` ${ path . basename ( pythonPath )} ${ args . join ( ' ' )} ` ; 154sendPythonOutput ( `> ${ commandStr } ` , 'info' ); 155 156const spawnOptions = { 157 ...options , 158env :createPythonEnvironment () 159}; 160 161const pythonProcess = spawn ( pythonPath , args , spawnOptions ); 162 163let stdout = '' ; 164let stderr = '' ; 165 166pythonProcess . stdout . on ( 'data' , ( data ) => { 167const text = data . toString (); 168stdout += text ; 169sendPythonOutput ( text . trimEnd (), 'stdout' ); 170}); 171 172pythonProcess . stderr . on ( 'data' , ( data ) => { 173const text = data . toString (); 174stderr += text ; 175// Filter out specific warning messages 176if ( ! shouldFilterMessage ( text )) { 177sendPythonOutput ( text . trimEnd (), 'stderr' ); 178} 179}); 180 181pythonProcess . on ( 'error' , ( error ) => { 182sendPythonOutput ( `Failed to start Python process: ${ error . message } ` , 'stderr' ); 183reject ({ error :error . message , stdout, stderr}); 184}); 185 186pythonProcess . on ( 'close' , ( code ) => { 187if ( code !== 0 ) { 188sendPythonOutput ( `Process exited with code ${ code } ` , 'stderr' ); 189reject ({ code, stdout, stderr}); 190} else { 191resolve ({ stdout, stderr}); 192} 193}); 194}); 195} 196 197function createWindow () { 198mainWindow = new BrowserWindow ({ 199width :1000 , 200height :800 , 201icon :path . join ( APP_ROOT , 'Images' , 'favicon.ico' ), 202webPreferences :{ 203preload :path . join ( __dirname , 'preload.js' ), 204contextIsolation :true , 205nodeIntegration :false 206} 207}); 208 209mainWindow . loadFile ( 'index.html' ); 210} 211 212// Replace the DEFAULT_CONFIG constant with: 213const DEFAULT_CONFIG = getDefaultConfig (); 214 215// IPC handlers 216ipcMain . handle ( 'load-config' , async () => { 217try { 218const fileContent = await fs . readFile ( CONFIG_PATH , 'utf8' ); 219return yaml . load ( fileContent ); 220} catch ( error ) { 221if ( error . code === 'ENOENT' ) { 222// Config file doesn't exist, create it with defaults 223console . error ( 'Config file not found, creating with defaults...' ); 224try { 225const yamlContent = yaml . dump ( DEFAULT_CONFIG , { lineWidth :- 1 }); 226await fs . writeFile ( CONFIG_PATH , yamlContent , 'utf8' ); 227console . error ( 'Created config.yaml with default values' ); 228return DEFAULT_CONFIG ; 229} catch ( writeError ) { 230console . error ( 'Error creating default config:' , writeError ); 231// Return defaults even if we can't write the file 232return DEFAULT_CONFIG ; 233} 234} 235console . error ( 'Error loading config:' , error ); 236throw error ; 237} 238}); 239 240ipcMain . handle ( 'save-config' , async ( event , config ) => { 241try { 242const yamlContent = yaml . dump ( config , { lineWidth :- 1 }); 243await fs . writeFile ( CONFIG_PATH , yamlContent , 'utf8' ); 244return { success :true }; 245} catch ( error ) { 246console . error ( 'Error saving config:' , error ); 247throw error ; 248} 249}); 250 251ipcMain . handle ( 'reset-config' , async () => { 252try { 253// Check if the file exists first 254try { 255await fs . access ( CONFIG_PATH ); 256// File exists, delete it 257await fs . unlink ( CONFIG_PATH ); 258console . error ( 'Config file deleted successfully' ); 259return { success :true , message :'Configuration reset to defaults' }; 260} catch ( error ) { 261if ( error . code === 'ENOENT' ) { 262// Config file doesn't exist, that's fine 263return { success :true , message :'Configuration already at defaults' }; 264} 265throw error ; 266} 267} catch ( error ) { 268console . error ( 'Error resetting config:' , error ); 269throw new Error ( `Failed to reset configuration: ${ error . message } ` ); 270} 271}); 272 273ipcMain . handle ( 'deleteVenvIndicatorFile' , async () => { 274const venvMarkerPath = path . join ( APP_ROOT , '.venv_is_set_up' ); 275try { 276await fs . unlink ( venvMarkerPath ); 277return { success :true , message :'.venv_is_set_up deleted successfully.' }; 278} catch ( error ) { 279if ( error . code === 'ENOENT' ) { 280return { success :true , message :'.venv_is_set_up not found.' }; 281} 282console . error ( 'Error deleting .venv_is_set_up file:' , error ); 283sendPythonOutput ( `Error deleting .venv_is_set_up: ${ error . message } ` , 'stderr' ); 284throw error ; 285} 286}); 287 288// Generic function to ensure required files are present 289async function ensureRequiredFiles ( config ) { 290const { 291 directoryName, 292 requiredFiles, 293 downloadBaseUrl, 294 resourceType 295} = config ; 296 297const targetPath = path . join ( APP_ROOT , directoryName ); 298 299try { 300// Check if target directory exists, create it if not 301try { 302await fs . access ( targetPath ); 303sendPythonOutput ( ` ${ resourceType } directory exists` , 'info' ); 304} catch ( error ) { 305if ( error . code === 'ENOENT' ) { 306sendPythonOutput ( `Creating ${ resourceType } directory...` , 'info' ); 307await fs . mkdir ( targetPath , { recursive :true }); 308sendPythonOutput ( ` ${ resourceType } directory created` , 'info' ); 309} else { 310throw error ; 311} 312} 313 314// Check each required file 315const missingFiles = []; 316for ( const fileName of requiredFiles ) { 317const filePath = path . join ( targetPath , fileName ); 318try { 319await fs . access ( filePath ); 320sendPythonOutput ( `✓ ${ fileName } exists` , 'info' ); 321} catch ( error ) { 322if ( error . code === 'ENOENT' ) { 323missingFiles . push ( fileName ); 324sendPythonOutput ( `✗ ${ fileName } missing` , 'info' ); 325} else { 326throw error ; 327} 328} 329} 330 331// Download missing files 332if ( missingFiles . length > 0 ) { 333sendPythonOutput ( `Downloading ${ missingFiles . length } missing ${ resourceType } file ${ missingFiles . length > 1 ? 's' : '' } ...` , 'info' ); 334 335for ( const fileName of missingFiles ) { 336const filePath = path . join ( targetPath , fileName ); 337const downloadUrl = ` ${ downloadBaseUrl } / ${ fileName } ` ; 338 339try { 340sendPythonOutput ( `Downloading ${ fileName } ...` , 'info' ); 341await downloadFile ( downloadUrl , filePath ); 342sendPythonOutput ( `✓ Downloaded ${ fileName } ` , 'info' ); 343} catch ( downloadError ) { 344sendPythonOutput ( `✗ Failed to download ${ fileName } : ${ downloadError . message } ` , 'stderr' ); 345throw new Error ( `Failed to download ${ fileName } : ${ downloadError . message } ` ); 346} 347} 348 349sendPythonOutput ( `All missing ${ resourceType } files downloaded successfully` , 'info' ); 350} else { 351sendPythonOutput ( `All required ${ resourceType } files are present` , 'info' ); 352} 353 354return { 355success :true , 356message :` ${ resourceType } setup complete. ${ missingFiles . length } file ${ missingFiles . length > 1 ? 's' : '' } downloaded.` , 357downloadedFiles :missingFiles 358}; 359} catch ( error ) { 360console . error ( `Error setting up ${ resourceType } files:` , error ); 361throw new Error ( ` ${ resourceType } setup failed: ${ error . message } ` ); 362} 363} 364 365// Update the install-requirements handler 366ipcMain . handle ( 'install-requirements' , async () => { 367const requirementsPath = path . join ( APP_ROOT , 'app' , 'requirements.txt' ); 368const venvMarkerPath = path . join ( APP_ROOT , '.venv_is_set_up' ); 369 370try { 371// Check if venv is already set up 372try { 373await fs . access ( venvMarkerPath ); 374return { success :true , message :'Virtual environment already set up' }; 375} catch ( error ) { 376// Marker doesn't exist, proceed with setup 377} 378 379// Check if requirements.txt exists 380await fs . access ( requirementsPath ); 381 382await executePythonCommand ([ '-m' , 'pip' , 'install' , '-r' , requirementsPath ]); 383 384await ensureRequiredFiles ({ 385directoryName :'dll' , 386requiredFiles :REQUIRED_DLLS , 387downloadBaseUrl :'https://yummers.dev/tastt/dll' , 388resourceType :'DLL' 389}); 390 391await fs . mkdir ( path . join ( APP_ROOT , 'Models' ), { recursive :true }); 392 393await fs . writeFile ( venvMarkerPath , new Date (). toISOString (), 'utf8' ); 394sendPythonOutput ( 'Created .venv_is_set_up marker file' , 'info' ); 395 396return { success :true , message :'Requirements and dependencies installed successfully' }; 397} catch ( error ) { 398console . error ( 'Error installing requirements:' , error ); 399if ( error . code === 'ENOENT' ) { 400throw new Error ( 'requirements.txt not found' ); 401} 402throw new Error ( `Installation failed: ${ error . stderr || error . error || 'Unknown error' } ` ); 403} 404}); 405 406ipcMain . handle ( 'get-microphones' , async () => { 407const scriptPath = path . join ( APP_ROOT , 'app' , 'list_microphones.py' ); 408 409try { 410const result = await executePythonCommand ([ scriptPath ]); 411const microphones = JSON . parse ( result . stdout . trim ()); 412return microphones ; 413} catch ( error ) { 414console . error ( 'Failed to get microphones:' , error ); 415throw new Error ( `Failed to get microphones: ${ error . stderr || error . error || 'Unknown error' } ` ); 416} 417}); 418 419// Helper function to safely delete directory contents 420async function clearDirectory ( dirPath , dirName ) { 421try { 422await fs . access ( dirPath ); 423sendPythonOutput ( `Clearing ${ dirName } directory...` , 'info' ); 424 425const files = await fs . readdir ( dirPath ); 426let deletedCount = 0 ; 427 428for ( const file of files ) { 429const filePath = path . join ( dirPath , file ); 430 431try { 432await fs . rm ( filePath , { recursive :true , force :true }); 433sendPythonOutput ( `✗ Deleted file ${ file } ` , 'info' ); 434 435deletedCount ++ ; 436} catch ( deleteError ) { 437sendPythonOutput ( `Warning: Could not delete ${ file } : ${ deleteError . message } ` , 'stderr' ); 438// Continue with other files even if one fails 439} 440} 441 442sendPythonOutput ( ` ${ dirName } directory cleared` , 'info' ); 443return deletedCount ; 444} catch ( error ) { 445if ( error . code === 'ENOENT' ) { 446sendPythonOutput ( ` ${ dirName } directory doesn't exist, skipping` , 'info' ); 447return 0 ; 448} else { 449sendPythonOutput ( `Error clearing ${ dirName } directory: ${ error . message } ` , 'stderr' ); 450throw error ; 451} 452} 453} 454 455ipcMain . handle ( 'reset-venv' , async () => { 456const venvMarkerPath = path . join ( APP_ROOT , '.venv_is_set_up' ); 457 458try { 459sendPythonOutput ( 'Starting virtual environment reset...' , 'info' ); 460 461// Delete the venv marker file first 462try { 463await fs . unlink ( venvMarkerPath ); 464sendPythonOutput ( 'Deleted .venv_is_set_up marker file' , 'info' ); 465} catch ( error ) { 466if ( error . code !== 'ENOENT' ) { 467sendPythonOutput ( `Warning: Could not delete marker file: ${ error . message } ` , 'stderr' ); 468} 469} 470 471// Get list of installed packages 472sendPythonOutput ( 'Getting list of installed packages...' , 'info' ); 473const freezeResult = await executePythonCommand ([ '-m' , 'pip' , 'freeze' ]); 474const installedPackages = freezeResult . stdout . trim (); 475 476let uninstalledPackages = []; 477 478if ( ! installedPackages ) { 479sendPythonOutput ( 'No packages found to uninstall' , 'info' ); 480} else { 481// Parse package names and filter out core packages 482const packageLines = installedPackages . split ( '\n' ). filter ( line => line . trim ()); 483const packageNames = packageLines 484. map ( line => line . split ( '==' )[ 0 ]. trim ()) 485. filter ( name => name && ! name . startsWith ( '#' )); 486 487const corePackages = [ 'pip' , 'setuptools' , 'wheel' ]; 488const packagesToUninstall = packageNames . filter ( name => ! corePackages . includes ( name . toLowerCase ())); 489 490if ( packagesToUninstall . length === 0 ) { 491sendPythonOutput ( 'Only core packages found, nothing to uninstall' , 'info' ); 492} else { 493sendPythonOutput ( `Uninstalling ${ packagesToUninstall . length } packages...` , 'info' ); 494 495const uninstallArgs = [ '-m' , 'pip' , 'uninstall' , '-y' , ...packagesToUninstall ]; 496await executePythonCommand ( uninstallArgs ); 497uninstalledPackages = packagesToUninstall ; 498} 499} 500 501// Clear downloaded files 502sendPythonOutput ( 'Clearing downloaded files...' , 'info' ); 503 504const dllPath = path . join ( APP_ROOT , 'dll' ); 505const modelsPath = path . join ( APP_ROOT , 'Models' ); 506const binPath = path . join ( APP_ROOT , 'bin' ); 507 508const deletedDlls = await clearDirectory ( dllPath , 'DLL' ); 509const deletedModels = await clearDirectory ( modelsPath , 'Models' ); 510const deletedBins = await clearDirectory ( binPath , 'Binary' ); 511 512const totalDeletedFiles = deletedDlls + deletedModels + deletedBins ; 513 514sendPythonOutput ( 'Virtual environment reset successfully!' , 'info' ); 515 516return { 517success :true , 518message :`Virtual environment reset complete. Uninstalled ${ uninstalledPackages . length } packages and deleted ${ totalDeletedFiles } downloaded files.` , 519 uninstalledPackages, 520deletedFiles :{ 521dlls :deletedDlls , 522models :deletedModels , 523binaries :deletedBins , 524total :totalDeletedFiles 525} 526}; 527} catch ( error ) { 528console . error ( 'Error resetting virtual environment:' , error ); 529throw new Error ( `Virtual environment reset failed: ${ error . message } ` ); 530} 531}); 532 533// Add handlers for starting and stopping the process 534ipcMain . handle ( 'start-process' , async () => { 535if ( runningProcess ) { 536throw new Error ( 'Process is already running' ); 537} 538 539const scriptPath = path . join ( APP_ROOT , 'app' , 'hi.py' ); 540const args = [ scriptPath , '--config' , CONFIG_PATH ]; 541 542try { 543const pythonPath = getVenvPython (); 544sendPythonOutput ( `Starting process: ${ path . basename ( pythonPath )} ${ args . join ( ' ' )} ` , 'info' ); 545 546runningProcess = spawn ( pythonPath , args , { env :createPythonEnvironment () }); 547setupProcessHandlers ( runningProcess ); 548 549return { success :true }; 550} catch ( error ) { 551runningProcess = null ; 552throw error ; 553} 554}); 555 556ipcMain . handle ( 'stop-process' , async () => { 557if ( ! runningProcess ) { 558sendPythonOutput ( 'No process to stop' , 'info' ); 559return { success :true , forcefullyKilled :false }; 560} 561 562return new Promise (( resolve ) => { 563let forcefullyKilled = false ; 564 565// Set up a timeout to force kill after 10 seconds 566const killTimeout = setTimeout (() => { 567if ( runningProcess ) { 568sendPythonOutput ( 'Process did not stop gracefully, forcing termination...' , 'stderr' ); 569forcefullyKilled = true ; 570runningProcess . kill ( 'SIGKILL' ); 571} 572}, 10000 ); 573 574// Listen for the process to exit 575runningProcess . once ( 'exit' , ( code , signal ) => { 576clearTimeout ( killTimeout ); 577runningProcess = null ; 578 579if ( forcefullyKilled ) { 580sendPythonOutput ( 'Process forcefully terminated' , 'info' ); 581} else { 582sendPythonOutput ( 'Process stopped gracefully' , 'info' ); 583} 584 585resolve ({ success :true , forcefullyKilled}); 586}); 587 588// Send termination signal 589sendPythonOutput ( 'Stopping process gracefully...' , 'info' ); 590runningProcess . kill ( 'SIGTERM' ); 591}); 592}); 593 594ipcMain . handle ( 'get-process-state' , () => { 595return { isRunning :runningProcess !== null }; 596}); 597 598// Clean up on app quit 599app . on ( 'before-quit' , () => { 600if ( runningProcess ) { 601runningProcess . kill (); 602} 603}); 604 605app . whenReady (). then (() => { 606createWindow (); 607 608app . on ( 'activate' , function () { 609if ( BrowserWindow . getAllWindows (). length === 0 ) createWindow (); 610}); 611}); 612 613app . on ( 'window-all-closed' , function () { 614app . quit (); 615}); 616