Skip to content

Commit 7233794

Browse files
ozgesolidkeyclaude
andcommitted
Fix: Windows and Linux compatibility for dialogs, terminal, and CLI detection
- Linux: add re-entrancy guard on file dialogs + XDG portal pre-warming - Windows: replace lsof with netstat for port conflict recovery - Windows: terminal fallback spawns cmd.exe directly (no Unix script/dev/null) - Windows: CLI detection uses 'where' instead of 'which', adds Windows paths - Cross-platform: replace curl with Node http.get for LLM service detection - Cross-platform: fix path.split('/') → split(/[\\/]/) in 6 renderer locations Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e5a5901 commit 7233794

3 files changed

Lines changed: 87 additions & 35 deletions

File tree

src/main/api-server.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -774,10 +774,17 @@ export function startApiServer(ctx: ApiContext): void {
774774
}
775775
);
776776
shutdownReq.on('error', () => {
777-
// Old instance not responding — force kill via lsof
777+
// Old instance not responding — force kill the process holding the port
778778
try {
779779
const { execSync } = require('child_process');
780-
const pid = execSync(`lsof -ti:${API_PORT} 2>/dev/null`, { encoding: 'utf-8' }).trim();
780+
let pid = '';
781+
if (process.platform === 'win32') {
782+
const out = execSync(`netstat -ano | findstr :${API_PORT} | findstr LISTENING`, { encoding: 'utf-8' }).trim();
783+
const match = out.split('\n')[0]?.trim().split(/\s+/).pop();
784+
if (match) pid = match;
785+
} else {
786+
pid = execSync(`lsof -ti:${API_PORT} 2>/dev/null`, { encoding: 'utf-8' }).trim();
787+
}
781788
if (pid) {
782789
console.log(`Killing old process ${pid} on port ${API_PORT}`);
783790
process.kill(parseInt(pid), 'SIGTERM');

src/main/index.ts

Lines changed: 72 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
19231943
function 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

19291952
function 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

19351961
ipcMain.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

55125541
function 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

59455990
ipcMain.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'] },

src/renderer/renderer.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4369,7 +4369,7 @@ async function toggleRecentFilesPopup(): Promise<void> {
43694369
} else {
43704370
html += `<div class="recent-files-list">`;
43714371
for (const f of files) {
4372-
const name = f.path.split('/').pop() || f.path;
4372+
const name = f.path.split(/[\\/]/).pop() || f.path;
43734373
const dir = f.path.substring(0, f.path.length - name.length - 1);
43744374
const ageMs = Date.now() - f.lastOpened;
43754375
let ageText: string;
@@ -4519,7 +4519,7 @@ async function openFolder(): Promise<void> {
45194519

45204520
const result = await window.api.readFolder(folderPath);
45214521
if (result.success && result.files) {
4522-
const folderName = folderPath.split('/').pop() || folderPath;
4522+
const folderName = folderPath.split(/[\\/]/).pop() || folderPath;
45234523
state.folders.push({
45244524
path: folderPath,
45254525
name: folderName,
@@ -8407,7 +8407,7 @@ function loadVideoFromPath(videoPath: string): void {
84078407
state.videoFilePath = videoPath;
84088408
elements.videoElement.src = 'file://' + videoPath;
84098409
elements.videoContainer.classList.add('has-video');
8410-
const fileName = videoPath.split('/').pop() || videoPath;
8410+
const fileName = videoPath.split(/[\\/]/).pop() || videoPath;
84118411
elements.videoFileName.textContent = fileName;
84128412
saveVideoState();
84138413
}
@@ -13551,7 +13551,7 @@ function openImageInPanel(filePath: string): void {
1355113551
imageViewerImg.style.display = 'none';
1355213552
if (imageDropZone) imageDropZone.style.display = 'none';
1355313553

13554-
const fileName = filePath.split('/').pop() || filePath;
13554+
const fileName = filePath.split(/[\\/]/).pop() || filePath;
1355513555
if (imageFileNameEl) imageFileNameEl.textContent = fileName;
1355613556

1355713557
imageViewerImg.onload = () => {
@@ -13602,7 +13602,7 @@ let currentDiagramPath = '';
1360213602
function openDiagramInPanel(filePath: string): void {
1360313603
currentDiagramPath = filePath;
1360413604
const ext = filePath.toLowerCase().split('.').pop() || '';
13605-
const fileName = filePath.split('/').pop() || filePath;
13605+
const fileName = filePath.split(/[\\/]/).pop() || filePath;
1360613606

1360713607
const contentEl = document.getElementById('diagram-content') as HTMLDivElement;
1360813608
const loadingEl = document.getElementById('diagram-loading') as HTMLDivElement;
@@ -14138,7 +14138,7 @@ function formatActivityDetails(entry: ActivityEntry): string {
1413814138
case 'highlight_cleared':
1413914139
return `${d.count} removed`;
1414014140
case 'diff_compared':
14141-
return `${(d.leftFile as string || '').split('/').pop()} vs ${(d.rightFile as string || '').split('/').pop()}`;
14141+
return `${(d.leftFile as string || '').split(/[\\/]/).pop()} vs ${(d.rightFile as string || '').split(/[\\/]/).pop()}`;
1414214142
case 'time_gap_analysis':
1414314143
return `${d.gapsFound} gaps (>${d.threshold}s)`;
1414414144
case 'analysis_run':

0 commit comments

Comments
 (0)