From 52748d78b50ef4f98f5cf6a779f32112936ab6ae Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:56:19 +0800 Subject: [PATCH] refactor(gateway): migrate from child_process.spawn to utilityProcess.fork (#290) --- electron/gateway/manager.ts | 279 +++++++++++++++--------------------- package.json | 2 +- scripts/installer.nsh | 105 ++++++-------- src/pages/Setup/index.tsx | 8 +- 4 files changed, 162 insertions(+), 232 deletions(-) diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index b1f9a57a6..541d1c9ea 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -2,9 +2,8 @@ * Gateway Process Manager * Manages the OpenClaw Gateway process lifecycle */ -import { app } from 'electron'; +import { app, utilityProcess } from 'electron'; import path from 'path'; -import { spawn, ChildProcess } from 'child_process'; import { EventEmitter } from 'events'; import { existsSync, writeFileSync } from 'fs'; import WebSocket from 'ws'; @@ -12,10 +11,8 @@ import { PORTS } from '../utils/config'; import { getOpenClawDir, getOpenClawEntryPath, - isOpenClawBuilt, isOpenClawPresent, appendNodeRequireToNodeOptions, - quoteForCmd, } from '../utils/paths'; import { getAllSettings, getSetting } from '../utils/store'; import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage'; @@ -83,38 +80,8 @@ const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = { maxDelay: 30000, }; -/** - * Get the Node.js-compatible executable path for spawning child processes. - * - * On macOS in packaged mode, using `process.execPath` directly causes the - * child process to appear as a separate dock icon (named "exec") because the - * binary lives inside a `.app` bundle that macOS treats as a GUI application. - * - * To avoid this, we resolve the Electron Helper binary which has - * `LSUIElement` set in its Info.plist, preventing dock icon creation. - * Falls back to `process.execPath` if the Helper binary is not found. - */ -function getNodeExecutablePath(): string { - if (process.platform === 'darwin' && app.isPackaged) { - // Electron Helper binary lives at: - // .app/Contents/Frameworks/ Helper.app/Contents/MacOS/ Helper - const appName = app.getName(); - const helperName = `${appName} Helper`; - const helperPath = path.join( - path.dirname(process.execPath), // .../Contents/MacOS - '../Frameworks', - `${helperName}.app`, - 'Contents/MacOS', - helperName, - ); - if (existsSync(helperPath)) { - logger.debug(`Using Electron Helper binary to avoid dock icon: ${helperPath}`); - return helperPath; - } - logger.debug(`Electron Helper binary not found at ${helperPath}, falling back to process.execPath`); - } - return process.execPath; -} +// getNodeExecutablePath() removed: utilityProcess.fork() handles process isolation +// natively on all platforms (no dock icon on macOS, no console on Windows). /** * Ensure the gateway fetch-preload script exists in userData and return @@ -158,6 +125,35 @@ const GATEWAY_FETCH_PRELOAD_SOURCE = `'use strict'; } return _f.call(globalThis, input, init); }; + + // Global monkey-patch for child_process to enforce windowsHide: true on Windows + // This prevents OpenClaw's tools (e.g. Terminal, Python) from flashing black + // command boxes during AI conversations, without triggering AVs. + if (process.platform === 'win32') { + try { + var cp = require('child_process'); + if (!cp.__clawxPatched) { + cp.__clawxPatched = true; + ['spawn', 'exec', 'execFile', 'spawnSync', 'execSync', 'execFileSync'].forEach(function(method) { + var original = cp[method]; + if (typeof original === 'function') { + cp[method] = function() { + var args = Array.prototype.slice.call(arguments); + var lastArg = args[args.length - 1]; + if (lastArg && typeof lastArg === 'object' && !Array.isArray(lastArg)) { + lastArg.windowsHide = true; + } else { + args.push({ windowsHide: true }); + } + return original.apply(this, args); + }; + } + }); + } + } catch (e) { + // ignore + } + } })(); `; @@ -179,7 +175,8 @@ class LifecycleSupersededError extends Error { * Handles starting, stopping, and communicating with the OpenClaw Gateway */ export class GatewayManager extends EventEmitter { - private process: ChildProcess | null = null; + private process: Electron.UtilityProcess | null = null; + private processExitCode: number | null = null; // set by exit event, replaces exitCode/signalCode private ownsProcess = false; private ws: WebSocket | null = null; private status: GatewayStatus = { state: 'stopped', port: PORTS.OPENCLAW_GATEWAY }; @@ -461,43 +458,32 @@ export class GatewayManager extends EventEmitter { // Kill process if (this.process && this.ownsProcess) { const child = this.process; + // UtilityProcess doesn't expose exitCode/signalCode — track exit via event. + let exited = false; await new Promise((resolve) => { - // If process already exited, resolve immediately - if (child.exitCode !== null || child.signalCode !== null) { - return resolve(); - } + child.once('exit', () => { + exited = true; + resolve(); + }); - // Kill the entire process group so respawned children are also terminated. - // The gateway entry script may respawn itself; killing only the parent PID - // leaves the child orphaned (PPID=1) and still holding the port. const pid = child.pid; - logger.info(`Sending SIGTERM to Gateway process group (pid=${pid ?? 'unknown'})`); - if (pid) { - try { process.kill(-pid, 'SIGTERM'); } catch { /* group kill failed, fall back */ } - } - child.kill('SIGTERM'); + logger.info(`Sending kill to Gateway process (pid=${pid ?? 'unknown'})`); + try { child.kill(); } catch { /* ignore if already exited */ } - // Force kill after timeout + // Force kill after timeout via OS-level kill on the PID const timeout = setTimeout(() => { - if (child.exitCode === null && child.signalCode === null) { - logger.warn(`Gateway did not exit in time, sending SIGKILL (pid=${pid ?? 'unknown'})`); + if (!exited) { + logger.warn(`Gateway did not exit in time, force-killing (pid=${pid ?? 'unknown'})`); if (pid) { - try { process.kill(-pid, 'SIGKILL'); } catch { /* ignore */ } + try { process.kill(pid, 'SIGKILL'); } catch { /* ignore */ } } - child.kill('SIGKILL'); } resolve(); }, 5000); child.once('exit', () => { clearTimeout(timeout); - resolve(); - }); - - child.once('error', () => { - clearTimeout(timeout); - resolve(); }); }); @@ -721,11 +707,11 @@ export class GatewayManager extends EventEmitter { try { // Platform-specific command to find processes listening on the gateway port. - // On Windows, lsof doesn't exist; use PowerShell's Get-NetTCPConnection instead. - // -WindowStyle Hidden is used to prevent PowerShell from popping up a brief console window - // even when windowsHide: true is passed to cp.exec. + // We use native commands (netstat on Windows) to avoid triggering AV blocks + // that flag "powershell -WindowStyle Hidden" as malware behavior. + // windowsHide: true in cp.exec natively prevents the black command window. const cmd = process.platform === 'win32' - ? `powershell -WindowStyle Hidden -NoProfile -Command "(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue).OwningProcess"` + ? `netstat -ano | findstr :${port}` : `lsof -i :${port} -sTCP:LISTEN -t`; const { stdout } = await new Promise<{ stdout: string }>((resolve, reject) => { @@ -738,9 +724,23 @@ export class GatewayManager extends EventEmitter { }); if (stdout.trim()) { - const pids = stdout.trim().split(/\r?\n/) - .map(s => s.trim()) - .filter(Boolean); + // Parse netstat or lsof output to extract PIDs + let pids: string[] = []; + if (process.platform === 'win32') { + // netstat -ano output format: + // TCP 127.0.0.1:3000 0.0.0.0:0 LISTENING 12345 + const lines = stdout.trim().split(/\r?\n/); + for (const line of lines) { + const parts = line.trim().split(/\s+/); + if (parts.length >= 5 && parts[3] === 'LISTENING') { + pids.push(parts[4]); + } + } + } else { + pids = stdout.trim().split(/\r?\n/).map(s => s.trim()).filter(Boolean); + } + // Remove duplicate PIDs + pids = [...new Set(pids)]; if (pids.length > 0) { if (!this.process || !pids.includes(String(this.process.pid))) { @@ -756,12 +756,11 @@ export class GatewayManager extends EventEmitter { for (const pid of pids) { try { if (process.platform === 'win32') { - // Use PowerShell with -WindowStyle Hidden to kill the process without - // flashing a black console window. taskkill.exe is a console app and - // can flash a window even when windowsHide: true is set. + // Use taskkill with windowsHide: true. This natively hides the console + // flash without needing PowerShell, avoiding AV alerts. import('child_process').then(cp => { cp.exec( - `powershell -WindowStyle Hidden -NoProfile -Command "Stop-Process -Id ${pid} -Force -ErrorAction SilentlyContinue"`, + `taskkill /F /PID ${pid} /T`, { timeout: 5000, windowsHide: true }, () => { } ); @@ -839,36 +838,23 @@ export class GatewayManager extends EventEmitter { : process.env.PATH || ''; const uvEnv = await getUvMirrorEnv(); - const command = app.isPackaged ? getNodeExecutablePath() : 'node'; - const args = [entryScript, 'doctor', '--fix', '--yes', '--non-interactive']; + const doctorArgs = ['doctor', '--fix', '--yes', '--non-interactive']; logger.info( - `Running OpenClaw doctor repair (command="${command}", args="${args.join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'})` + `Running OpenClaw doctor repair (entry="${entryScript}", args="${doctorArgs.join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'})` ); return new Promise((resolve) => { - const spawnEnv: Record = { + const forkEnv: Record = { ...process.env, PATH: finalPath, ...uvEnv, + OPENCLAW_NO_RESPAWN: '1', }; - if (app.isPackaged) { - spawnEnv['ELECTRON_RUN_AS_NODE'] = '1'; - spawnEnv['OPENCLAW_NO_RESPAWN'] = '1'; - const existingNodeOpts = spawnEnv['NODE_OPTIONS'] ?? ''; - if (!existingNodeOpts.includes('--disable-warning=ExperimentalWarning') && - !existingNodeOpts.includes('--no-warnings')) { - spawnEnv['NODE_OPTIONS'] = `${existingNodeOpts} --disable-warning=ExperimentalWarning`.trim(); - } - } - - const child = spawn(command, args, { + const child = utilityProcess.fork(entryScript, doctorArgs, { cwd: openclawDir, - stdio: ['ignore', 'pipe', 'pipe'], - detached: false, - shell: false, - windowsHide: true, - env: spawnEnv, + stdio: 'pipe', + env: forkEnv as NodeJS.ProcessEnv, }); let settled = false; @@ -881,7 +867,7 @@ export class GatewayManager extends EventEmitter { const timeout = setTimeout(() => { logger.error('OpenClaw doctor repair timed out after 120000ms'); try { - child.kill('SIGTERM'); + child.kill(); } catch { // ignore } @@ -912,14 +898,14 @@ export class GatewayManager extends EventEmitter { } }); - child.on('exit', (code, signal) => { + child.on('exit', (code: number) => { clearTimeout(timeout); if (code === 0) { logger.info('OpenClaw doctor repair completed successfully'); finish(true); return; } - logger.warn(`OpenClaw doctor repair exited (${this.formatExit(code, signal)})`); + logger.warn(`OpenClaw doctor repair exited (code=${code})`); finish(false); }); }); @@ -976,40 +962,16 @@ export class GatewayManager extends EventEmitter { logger.warn('Failed to sync browser config to openclaw.json:', err); } - let command: string; - let args: string[]; - let mode: 'packaged' | 'dev-built' | 'dev-pnpm'; - - // Determine the Node.js executable - // In packaged Electron app, use process.execPath with ELECTRON_RUN_AS_NODE=1 - // which makes the Electron binary behave as plain Node.js. - // In development, use system 'node'. - const gatewayArgs = ['gateway', '--port', String(this.status.port), '--token', gatewayToken, '--allow-unconfigured']; - - if (app.isPackaged) { - // Production: use Electron binary as Node.js via ELECTRON_RUN_AS_NODE - // On macOS, use the Electron Helper binary to avoid extra dock icons - if (existsSync(entryScript)) { - command = getNodeExecutablePath(); - args = [entryScript, ...gatewayArgs]; - mode = 'packaged'; - } else { - const errMsg = `OpenClaw entry script not found at: ${entryScript}`; - logger.error(errMsg); - throw new Error(errMsg); - } - } else if (isOpenClawBuilt() && existsSync(entryScript)) { - // Development with built package: use system node - command = 'node'; - args = [entryScript, ...gatewayArgs]; - mode = 'dev-built'; - } else { - // Development without build: use pnpm dev - command = 'pnpm'; - args = ['run', 'dev', ...gatewayArgs]; - mode = 'dev-pnpm'; + // utilityProcess.fork() works for both dev and packaged — no ELECTRON_RUN_AS_NODE needed. + if (!existsSync(entryScript)) { + const errMsg = `OpenClaw entry script not found at: ${entryScript}`; + logger.error(errMsg); + throw new Error(errMsg); } + const gatewayArgs = ['gateway', '--port', String(this.status.port), '--token', gatewayToken, '--allow-unconfigured']; + const mode = app.isPackaged ? 'packaged' : 'dev'; + // Resolve bundled bin path for uv const platform = process.platform; const arch = process.arch; @@ -1067,13 +1029,15 @@ export class GatewayManager extends EventEmitter { const proxyEnv = buildProxyEnv(appSettings); const resolvedProxy = resolveProxySettings(appSettings); logger.info( - `Starting Gateway process (mode=${mode}, port=${this.status.port}, command="${command}", args="${this.sanitizeSpawnArgs(args).join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'}, providerKeys=${loadedProviderKeyCount}, proxy=${appSettings.proxyEnabled ? `http=${resolvedProxy.httpProxy || '-'}, https=${resolvedProxy.httpsProxy || '-'}, all=${resolvedProxy.allProxy || '-'}` : 'disabled'})` + `Starting Gateway process (mode=${mode}, port=${this.status.port}, entry="${entryScript}", args="${this.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'}, providerKeys=${loadedProviderKeyCount}, proxy=${appSettings.proxyEnabled ? `http=${resolvedProxy.httpProxy || '-'}, https=${resolvedProxy.httpsProxy || '-'}, all=${resolvedProxy.allProxy || '-'}` : 'disabled'})` ); - this.lastSpawnSummary = `mode=${mode}, command="${command}", args="${this.sanitizeSpawnArgs(args).join(' ')}", cwd="${openclawDir}"`; + this.lastSpawnSummary = `mode=${mode}, entry="${entryScript}", args="${this.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}"`; return new Promise((resolve, reject) => { + // Reset exit tracking for this new process instance. + this.processExitCode = null; const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env; - const spawnEnv: Record = { + const forkEnv: Record = { ...baseEnv, PATH: finalPath, ...providerEnv, @@ -1082,29 +1046,17 @@ export class GatewayManager extends EventEmitter { OPENCLAW_GATEWAY_TOKEN: gatewayToken, OPENCLAW_SKIP_CHANNELS: '', CLAWDBOT_SKIP_CHANNELS: '', + // Prevent OpenClaw from respawning itself inside the utility process + OPENCLAW_NO_RESPAWN: '1', }; - // Critical: In packaged mode, make Electron binary act as Node.js - if (app.isPackaged) { - spawnEnv['ELECTRON_RUN_AS_NODE'] = '1'; - // Prevent OpenClaw entry.ts from respawning itself (which would create - // another child process and a second "exec" dock icon on macOS) - spawnEnv['OPENCLAW_NO_RESPAWN'] = '1'; - // Pre-set the NODE_OPTIONS that entry.ts would have added via respawn - const existingNodeOpts = spawnEnv['NODE_OPTIONS'] ?? ''; - if (!existingNodeOpts.includes('--disable-warning=ExperimentalWarning') && - !existingNodeOpts.includes('--no-warnings')) { - spawnEnv['NODE_OPTIONS'] = `${existingNodeOpts} --disable-warning=ExperimentalWarning`.trim(); - } - } - // Inject fetch preload so OpenRouter requests carry ClawX headers. // The preload patches globalThis.fetch before any module loads. try { const preloadPath = ensureGatewayFetchPreload(); if (existsSync(preloadPath)) { - spawnEnv['NODE_OPTIONS'] = appendNodeRequireToNodeOptions( - spawnEnv['NODE_OPTIONS'], + forkEnv['NODE_OPTIONS'] = appendNodeRequireToNodeOptions( + forkEnv['NODE_OPTIONS'], preloadPath, ); } @@ -1112,17 +1064,13 @@ export class GatewayManager extends EventEmitter { logger.warn('Failed to set up OpenRouter headers preload:', err); } - const useShell = !app.isPackaged && process.platform === 'win32'; - const spawnCmd = useShell ? quoteForCmd(command) : command; - const spawnArgs = useShell ? args.map(a => quoteForCmd(a)) : args; - - this.process = spawn(spawnCmd, spawnArgs, { + // utilityProcess.fork() runs the .mjs entry directly without spawning a + // shell or visible console window. Works identically in dev and packaged. + this.process = utilityProcess.fork(entryScript, gatewayArgs, { cwd: openclawDir, - stdio: ['ignore', 'pipe', 'pipe'], - detached: false, - shell: useShell, - windowsHide: true, - env: spawnEnv, + stdio: 'pipe', + env: forkEnv as NodeJS.ProcessEnv, + serviceName: 'OpenClaw Gateway', }); const child = this.process; this.ownsProcess = true; @@ -1133,10 +1081,11 @@ export class GatewayManager extends EventEmitter { reject(error); }); - child.on('exit', (code, signal) => { + child.on('exit', (code: number) => { + this.processExitCode = code; const expectedExit = !this.shouldReconnect || this.status.state === 'stopped'; const level = expectedExit ? logger.info : logger.warn; - level(`Gateway process exited (${this.formatExit(code, signal)}, expected=${expectedExit ? 'yes' : 'no'})`); + level(`Gateway process exited (code=${code}, expected=${expectedExit ? 'yes' : 'no'})`); this.ownsProcess = false; if (this.process === child) { this.process = null; @@ -1149,9 +1098,7 @@ export class GatewayManager extends EventEmitter { } }); - child.on('close', (code, signal) => { - logger.debug(`Gateway process stdio closed (${this.formatExit(code, signal)})`); - }); + // UtilityProcess doesn't emit 'close'; stdout/stderr end naturally on exit. // Log stderr child.stderr?.on('data', (data) => { @@ -1186,12 +1133,12 @@ export class GatewayManager extends EventEmitter { private async waitForReady(retries = 2400, interval = 250): Promise { const child = this.process; for (let i = 0; i < retries; i++) { - // Early exit if the gateway process has already exited - if (child && (child.exitCode !== null || child.signalCode !== null)) { - const code = child.exitCode; - const signal = child.signalCode; - logger.error(`Gateway process exited before ready (${this.formatExit(code, signal)})`); - throw new Error(`Gateway process exited before becoming ready (${this.formatExit(code, signal)})`); + // Early exit if the gateway process has already exited. + // UtilityProcess has no synchronous exitCode/signalCode — use our tracked flag. + if (child && this.processExitCode !== null) { + const code = this.processExitCode; + logger.error(`Gateway process exited before ready (code=${code})`); + throw new Error(`Gateway process exited before becoming ready (code=${code})`); } try { diff --git a/package.json b/package.json index 5b0e095d2..157884504 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawx", - "version": "0.1.22-alpha.2", + "version": "0.1.22-alpha.5", "pnpm": { "onlyBuiltDependencies": [ "@whiskeysockets/baileys", diff --git a/scripts/installer.nsh b/scripts/installer.nsh index c437afc66..74ea8c50d 100644 --- a/scripts/installer.nsh +++ b/scripts/installer.nsh @@ -3,83 +3,64 @@ ; Install: enables long paths, adds resources\cli to user PATH for openclaw CLI. ; Uninstall: removes the PATH entry and optionally deletes user data. -; When customCheckAppRunning is defined, electron-builder skips its conditional -; !include for getProcessInfo.nsh and the "Var pid" declaration. We must do -; both ourselves so ${GetProcessInfo} and $pid are available. -!ifndef getProcessInfo_included - !define getProcessInfo_included - !include "getProcessInfo.nsh" - Var pid -!endif -!ifndef nsProcess_included - !define nsProcess_included +!ifndef nsProcess::FindProcess !include "nsProcess.nsh" !endif !macro customCheckAppRunning - ${GetProcessInfo} 0 $pid $1 $2 $3 $4 - ${if} $3 != "${APP_EXECUTABLE_FILENAME}" + ${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0 + + ${if} $R0 == 0 ${if} ${isUpdated} # allow app to exit without explicit kill - Sleep 300 + Sleep 1000 + Goto doStopProcess ${endIf} + MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "$(appRunning)" /SD IDOK IDOK doStopProcess + Quit - # Instead of launching cmd.exe /c tasklist, use the nsProcess plugin directly for all environments. - # This prevents the brief black cmd window from flashing. - ${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0 + doStopProcess: + DetailPrint `Closing running "${PRODUCT_NAME}"...` - ${if} $R0 == 0 - ${if} ${isUpdated} - # allow app to exit without explicit kill + # Silently kill the process using nsProcess instead of taskkill / cmd.exe + ${nsProcess::KillProcess} "${APP_EXECUTABLE_FILENAME}" $R0 + + # to ensure that files are not "in-use" + Sleep 300 + + # Retry counter + StrCpy $R1 0 + + loop: + IntOp $R1 $R1 + 1 + + ${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0 + ${if} $R0 == 0 + # wait to give a chance to exit gracefully Sleep 1000 - Goto doStopProcess - ${endIf} - MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "$(appRunning)" /SD IDOK IDOK doStopProcess - Quit - - doStopProcess: - DetailPrint `Closing running "${PRODUCT_NAME}"...` - - # Silently kill the process using nsProcess instead of taskkill / cmd.exe - ${nsProcess::KillProcess} "${APP_EXECUTABLE_FILENAME}" $R0 - - # to ensure that files are not "in-use" - Sleep 300 - - # Retry counter - StrCpy $R1 0 - - loop: - IntOp $R1 $R1 + 1 - + ${nsProcess::KillProcess} "${APP_EXECUTABLE_FILENAME}" $R0 + ${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0 - ${if} $R0 == 0 - # wait to give a chance to exit gracefully - Sleep 1000 - ${nsProcess::KillProcess} "${APP_EXECUTABLE_FILENAME}" $R0 - - ${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0 - ${If} $R0 == 0 - DetailPrint `Waiting for "${PRODUCT_NAME}" to close.` - Sleep 2000 - ${else} - Goto not_running - ${endIf} + ${If} $R0 == 0 + DetailPrint `Waiting for "${PRODUCT_NAME}" to close.` + Sleep 2000 ${else} Goto not_running ${endIf} + ${else} + Goto not_running + ${endIf} - # App likely running with elevated permissions. - # Ask user to close it manually - ${if} $R1 > 1 - MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDCANCEL IDRETRY loop - Quit - ${else} - Goto loop - ${endIf} - not_running: - nsProcess::Unload - ${endIf} + # App likely running with elevated permissions. + # Ask user to close it manually + ${if} $R1 > 1 + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDCANCEL IDRETRY loop + Quit + ${else} + Goto loop + ${endIf} + not_running: + ${nsProcess::Unload} ${endIf} !macroend diff --git a/src/pages/Setup/index.tsx b/src/pages/Setup/index.tsx index 6e7bb6ae5..f767fe5a5 100644 --- a/src/pages/Setup/index.tsx +++ b/src/pages/Setup/index.tsx @@ -948,7 +948,8 @@ function ProviderContent({ try { // Validate key if the provider requires one and a key was entered - if (requiresKey && apiKey) { + const isApiKeyRequired = requiresKey || (supportsApiKey && authMode === 'apikey'); + if (isApiKeyRequired && apiKey) { const result = await window.electron.ipcRenderer.invoke( 'provider:validateKey', selectedProviderConfigId || selectedProvider, @@ -1024,9 +1025,10 @@ function ProviderContent({ }; // Can the user submit? + const isApiKeyRequired = requiresKey || (supportsApiKey && authMode === 'apikey'); const canSubmit = selectedProvider - && (requiresKey ? apiKey.length > 0 : true) + && (isApiKeyRequired ? apiKey.length > 0 : true) && (showModelIdField ? modelId.trim().length > 0 : true) && !useOAuthFlow; @@ -1197,7 +1199,7 @@ function ProviderContent({ )} {/* API Key field (hidden for ollama) */} - {(!isOAuth || (supportsApiKey && authMode === 'apikey')) && requiresKey && ( + {(!isOAuth || (supportsApiKey && authMode === 'apikey')) && (