@@ -745,6 +745,20 @@ app.whenReady().then(() => {
745745 ensureConfigDir ( ) ;
746746 createWindow ( ) ;
747747
748+ // On Linux, pre-warm the XDG desktop portal so the first file-open dialog
749+ // is responsive immediately. Without this, the portal's D-Bus service may
750+ // not be activated yet, causing the file chooser widget to appear but not
751+ // accept clicks until the service finishes initializing.
752+ if ( process . platform === 'linux' ) {
753+ try {
754+ spawn ( 'gdbus' , [
755+ 'introspect' , '--session' ,
756+ '--dest' , 'org.freedesktop.portal.Desktop' ,
757+ '--object-path' , '/org/freedesktop/portal/desktop' ,
758+ ] , { stdio : 'ignore' } ) . unref ( ) ;
759+ } catch { /* non-critical — dialog still works on retry */ }
760+ }
761+
748762 // Check if launched with a file path argument (e.g. `logan myfile.log`)
749763 const cliFilePath = extractFilePathFromArgv ( process . argv ) ;
750764 if ( cliFilePath && mainWindow ) {
@@ -1920,16 +1934,28 @@ ipcMain.handle(IPC.SSH_DOWNLOAD_FILE, async (_, remotePath: string) => {
19201934// On Linux, passing a parent BrowserWindow to dialog.show*Dialog causes the
19211935// dialog to attach modally via XDG portal / GTK, which can deadlock and leave
19221936// the window unresponsive. Calling the parentless overload avoids this entirely.
1937+ // A re-entrancy guard prevents stacking multiple native dialogs (which on Linux
1938+ // can leave a dialog visible but non-interactive until the earlier one resolves).
1939+ let _dialogOpen = false ;
1940+ const _cancelledResult : Electron . OpenDialogReturnValue = { canceled : true , filePaths : [ ] } ;
1941+ const _cancelledSaveResult : Electron . SaveDialogReturnValue = { canceled : true , filePath : '' } ;
1942+
19231943function showOpenDialog ( options : Electron . OpenDialogOptions ) : Promise < Electron . OpenDialogReturnValue > {
1924- return process . platform === 'linux' || ! mainWindow
1944+ if ( _dialogOpen ) return Promise . resolve ( _cancelledResult ) ;
1945+ _dialogOpen = true ;
1946+ const p = process . platform === 'linux' || ! mainWindow
19251947 ? dialog . showOpenDialog ( options )
19261948 : dialog . showOpenDialog ( mainWindow , options ) ;
1949+ return p . finally ( ( ) => { _dialogOpen = false ; } ) ;
19271950}
19281951
19291952function showSaveDialog ( options : Electron . SaveDialogOptions ) : Promise < Electron . SaveDialogReturnValue > {
1930- return process . platform === 'linux' || ! mainWindow
1953+ if ( _dialogOpen ) return Promise . resolve ( _cancelledSaveResult ) ;
1954+ _dialogOpen = true ;
1955+ const p = process . platform === 'linux' || ! mainWindow
19311956 ? dialog . showSaveDialog ( options )
19321957 : dialog . showSaveDialog ( mainWindow , options ) ;
1958+ return p . finally ( ( ) => { _dialogOpen = false ; } ) ;
19331959}
19341960
19351961ipcMain . handle ( IPC . OPEN_FILE_DIALOG , async ( ) => {
@@ -5115,21 +5141,24 @@ ipcMain.handle(IPC.TERMINAL_CREATE_LOCAL, async (_, sessionId: string, options?:
51155141 return { success : true , label : 'Local' } ;
51165142 }
51175143
5118- // Fallback for Linux (no node-pty): use `script` to wrap the shell
5119- // in a real PTY. `script -qfc <cmd> /dev/null` is portable across
5120- // util-linux and BSD `script` implementations .
5144+ // Fallback (no node-pty): spawn the shell directly as a child process.
5145+ // On Linux, wrap with `script` for PTY emulation when available.
5146+ // On Windows, cmd.exe / powershell work fine without PTY wrapping .
51215147 const env = { ...process . env , TERM : 'xterm-256color' , COLUMNS : String ( cols ) , LINES : String ( rows ) } ;
51225148 let scriptCmd : string ;
51235149 let scriptArgs : string [ ] ;
5124- try {
5125- execSync ( 'which script' , { timeout : 1000 } ) ;
5126- // util-linux script: -q quiet, -f flush, -c command, /dev/null = no typescript file
5127- scriptCmd = 'script' ;
5128- scriptArgs = [ '-qfc' , shellPath , '/dev/null' ] ;
5129- } catch {
5130- // No `script` available — fall back to direct bash (will have echo issues but works)
5150+ if ( process . platform === 'win32' ) {
51315151 scriptCmd = shellPath ;
5132- scriptArgs = [ '-i' ] ;
5152+ scriptArgs = [ ] ;
5153+ } else {
5154+ try {
5155+ execSync ( 'which script' , { timeout : 1000 } ) ;
5156+ scriptCmd = 'script' ;
5157+ scriptArgs = [ '-qfc' , shellPath , '/dev/null' ] ;
5158+ } catch {
5159+ scriptCmd = shellPath ;
5160+ scriptArgs = [ '-i' ] ;
5161+ }
51335162 }
51345163 const child = spawn ( scriptCmd , scriptArgs , { cwd, env, stdio : [ 'pipe' , 'pipe' , 'pipe' ] } ) ;
51355164
@@ -5510,19 +5539,23 @@ function getBuiltinScriptPath(): string {
55105539}
55115540
55125541function findClaudeCli ( ) : string | null {
5513- // Try PATH first
5542+ const whichCmd = process . platform === 'win32' ? 'where claude' : 'which claude' ;
55145543 try {
5515- const result = execSync ( 'which claude' , { timeout : 3000 , encoding : 'utf-8' } ) . trim ( ) ;
5544+ const result = execSync ( whichCmd , { timeout : 3000 , encoding : 'utf-8' } ) . trim ( ) . split ( '\n' ) [ 0 ] ;
55165545 if ( result ) return result ;
55175546 } catch { /* not in PATH */ }
5518- // Common install locations
5519- const candidates = [
5520- path . join ( os . homedir ( ) , '.claude' , 'bin' , 'claude' ) ,
5521- '/usr/local/bin/claude' ,
5522- '/opt/homebrew/bin/claude' ,
5523- ] ;
5547+ const candidates = process . platform === 'win32'
5548+ ? [
5549+ path . join ( os . homedir ( ) , '.claude' , 'bin' , 'claude.exe' ) ,
5550+ path . join ( process . env . LOCALAPPDATA || '' , 'Programs' , 'claude' , 'claude.exe' ) ,
5551+ ]
5552+ : [
5553+ path . join ( os . homedir ( ) , '.claude' , 'bin' , 'claude' ) ,
5554+ '/usr/local/bin/claude' ,
5555+ '/opt/homebrew/bin/claude' ,
5556+ ] ;
55245557 for ( const p of candidates ) {
5525- if ( fs . existsSync ( p ) ) return p ;
5558+ if ( p && fs . existsSync ( p ) ) return p ;
55265559 }
55275560 return null ;
55285561}
@@ -5723,7 +5756,9 @@ ipcMain.handle('agent-detect-environment', async () => {
57235756 '/opt/homebrew/bin/claude' ,
57245757 path . join ( os . homedir ( ) , '.nvm' , 'versions' , 'node' , 'current' , 'bin' , 'claude' ) ,
57255758 ] ,
5726- aider : [ '/usr/local/bin/aider' , path . join ( os . homedir ( ) , '.local' , 'bin' , 'aider' ) ] ,
5759+ aider : process . platform === 'win32'
5760+ ? [ path . join ( os . homedir ( ) , '.local' , 'bin' , 'aider.exe' ) ]
5761+ : [ '/usr/local/bin/aider' , path . join ( os . homedir ( ) , '.local' , 'bin' , 'aider' ) ] ,
57275762 } [ bin ] ?? [ ] ;
57285763 for ( const p of extra ) {
57295764 if ( ! fs . existsSync ( p ) ) continue ;
@@ -5744,14 +5779,24 @@ ipcMain.handle('agent-detect-environment', async () => {
57445779 const builtinPath = path . join ( app . getAppPath ( ) , 'examples' , 'agent-node.mjs' ) ;
57455780 const hasBuiltin = fs . existsSync ( builtinPath ) ;
57465781
5747- // Detect local LLM services
5782+ // Detect local LLM services (use Node http instead of curl for cross-platform)
57485783 let hasOllama = false ;
57495784 let ollamaModels : string [ ] = [ ] ;
57505785 let hasLmStudio = false ;
57515786
5787+ const httpGet = ( url : string , timeoutMs : number ) : Promise < string > => new Promise ( ( resolve , reject ) => {
5788+ const req = require ( 'http' ) . get ( url , { timeout : timeoutMs } , ( res : any ) => {
5789+ let body = '' ;
5790+ res . on ( 'data' , ( chunk : string ) => { body += chunk ; } ) ;
5791+ res . on ( 'end' , ( ) => resolve ( body ) ) ;
5792+ } ) ;
5793+ req . on ( 'error' , reject ) ;
5794+ req . on ( 'timeout' , ( ) => { req . destroy ( ) ; reject ( new Error ( 'timeout' ) ) ; } ) ;
5795+ } ) ;
5796+
57525797 // Check Ollama (port 11434)
57535798 try {
5754- const ollamaResp = execSync ( 'curl -sf http://localhost:11434/api/tags 2>/dev/null ', { timeout : 3000 , encoding : 'utf-8' } ) ;
5799+ const ollamaResp = await httpGet ( ' http://localhost:11434/api/tags', 3000 ) ;
57555800 const data = JSON . parse ( ollamaResp ) ;
57565801 if ( data . models ?. length > 0 ) {
57575802 hasOllama = true ;
@@ -5761,7 +5806,7 @@ ipcMain.handle('agent-detect-environment', async () => {
57615806
57625807 // Check LM Studio (port 1234)
57635808 try {
5764- const lmsResp = execSync ( 'curl -sf http://localhost:1234/v1/models 2>/dev/null ', { timeout : 3000 , encoding : 'utf-8' } ) ;
5809+ const lmsResp = await httpGet ( ' http://localhost:1234/v1/models', 3000 ) ;
57655810 const data = JSON . parse ( lmsResp ) ;
57665811 if ( data . data ?. length > 0 ) {
57675812 hasLmStudio = true ;
@@ -5943,7 +5988,7 @@ ipcMain.handle(IPC.READ_FILE_TEXT, async (_event, filePath: string) => {
59435988} ) ;
59445989
59455990ipcMain . handle ( 'agent-browse-script' , async ( ) => {
5946- const result = await dialog . showOpenDialog ( {
5991+ const result = await showOpenDialog ( {
59475992 title : 'Select Agent Script' ,
59485993 filters : [
59495994 { name : 'Scripts' , extensions : [ 'mjs' , 'js' , 'ts' , 'sh' , 'py' ] } ,
0 commit comments