refactor(gateway): migrate from child_process.spawn to utilityProcess.fork (#290)

This commit is contained in:
paisley
2026-03-04 20:56:19 +08:00
committed by GitHub
Unverified
parent 76df84e68c
commit 52748d78b5
4 changed files with 162 additions and 232 deletions

View File

@@ -2,9 +2,8 @@
* Gateway Process Manager * Gateway Process Manager
* Manages the OpenClaw Gateway process lifecycle * Manages the OpenClaw Gateway process lifecycle
*/ */
import { app } from 'electron'; import { app, utilityProcess } from 'electron';
import path from 'path'; import path from 'path';
import { spawn, ChildProcess } from 'child_process';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { existsSync, writeFileSync } from 'fs'; import { existsSync, writeFileSync } from 'fs';
import WebSocket from 'ws'; import WebSocket from 'ws';
@@ -12,10 +11,8 @@ import { PORTS } from '../utils/config';
import { import {
getOpenClawDir, getOpenClawDir,
getOpenClawEntryPath, getOpenClawEntryPath,
isOpenClawBuilt,
isOpenClawPresent, isOpenClawPresent,
appendNodeRequireToNodeOptions, appendNodeRequireToNodeOptions,
quoteForCmd,
} from '../utils/paths'; } from '../utils/paths';
import { getAllSettings, getSetting } from '../utils/store'; import { getAllSettings, getSetting } from '../utils/store';
import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage'; import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage';
@@ -83,38 +80,8 @@ const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = {
maxDelay: 30000, maxDelay: 30000,
}; };
/** // getNodeExecutablePath() removed: utilityProcess.fork() handles process isolation
* Get the Node.js-compatible executable path for spawning child processes. // natively on all platforms (no dock icon on macOS, no console on Windows).
*
* 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>.app/Contents/Frameworks/<ProductName> Helper.app/Contents/MacOS/<ProductName> 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;
}
/** /**
* Ensure the gateway fetch-preload script exists in userData and return * 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); 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 * Handles starting, stopping, and communicating with the OpenClaw Gateway
*/ */
export class GatewayManager extends EventEmitter { 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 ownsProcess = false;
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private status: GatewayStatus = { state: 'stopped', port: PORTS.OPENCLAW_GATEWAY }; private status: GatewayStatus = { state: 'stopped', port: PORTS.OPENCLAW_GATEWAY };
@@ -461,43 +458,32 @@ export class GatewayManager extends EventEmitter {
// Kill process // Kill process
if (this.process && this.ownsProcess) { if (this.process && this.ownsProcess) {
const child = this.process; const child = this.process;
// UtilityProcess doesn't expose exitCode/signalCode — track exit via event.
let exited = false;
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
// If process already exited, resolve immediately child.once('exit', () => {
if (child.exitCode !== null || child.signalCode !== null) { exited = true;
return resolve(); 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; const pid = child.pid;
logger.info(`Sending SIGTERM to Gateway process group (pid=${pid ?? 'unknown'})`); logger.info(`Sending kill to Gateway process (pid=${pid ?? 'unknown'})`);
if (pid) { try { child.kill(); } catch { /* ignore if already exited */ }
try { process.kill(-pid, 'SIGTERM'); } catch { /* group kill failed, fall back */ }
}
child.kill('SIGTERM');
// Force kill after timeout // Force kill after timeout via OS-level kill on the PID
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) { if (!exited) {
logger.warn(`Gateway did not exit in time, sending SIGKILL (pid=${pid ?? 'unknown'})`); logger.warn(`Gateway did not exit in time, force-killing (pid=${pid ?? 'unknown'})`);
if (pid) { if (pid) {
try { process.kill(-pid, 'SIGKILL'); } catch { /* ignore */ } try { process.kill(pid, 'SIGKILL'); } catch { /* ignore */ }
} }
child.kill('SIGKILL');
} }
resolve(); resolve();
}, 5000); }, 5000);
child.once('exit', () => { child.once('exit', () => {
clearTimeout(timeout); clearTimeout(timeout);
resolve();
});
child.once('error', () => {
clearTimeout(timeout);
resolve();
}); });
}); });
@@ -721,11 +707,11 @@ export class GatewayManager extends EventEmitter {
try { try {
// Platform-specific command to find processes listening on the gateway port. // Platform-specific command to find processes listening on the gateway port.
// On Windows, lsof doesn't exist; use PowerShell's Get-NetTCPConnection instead. // We use native commands (netstat on Windows) to avoid triggering AV blocks
// -WindowStyle Hidden is used to prevent PowerShell from popping up a brief console window // that flag "powershell -WindowStyle Hidden" as malware behavior.
// even when windowsHide: true is passed to cp.exec. // windowsHide: true in cp.exec natively prevents the black command window.
const cmd = process.platform === 'win32' 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`; : `lsof -i :${port} -sTCP:LISTEN -t`;
const { stdout } = await new Promise<{ stdout: string }>((resolve, reject) => { const { stdout } = await new Promise<{ stdout: string }>((resolve, reject) => {
@@ -738,9 +724,23 @@ export class GatewayManager extends EventEmitter {
}); });
if (stdout.trim()) { if (stdout.trim()) {
const pids = stdout.trim().split(/\r?\n/) // Parse netstat or lsof output to extract PIDs
.map(s => s.trim()) let pids: string[] = [];
.filter(Boolean); 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 (pids.length > 0) {
if (!this.process || !pids.includes(String(this.process.pid))) { if (!this.process || !pids.includes(String(this.process.pid))) {
@@ -756,12 +756,11 @@ export class GatewayManager extends EventEmitter {
for (const pid of pids) { for (const pid of pids) {
try { try {
if (process.platform === 'win32') { if (process.platform === 'win32') {
// Use PowerShell with -WindowStyle Hidden to kill the process without // Use taskkill with windowsHide: true. This natively hides the console
// flashing a black console window. taskkill.exe is a console app and // flash without needing PowerShell, avoiding AV alerts.
// can flash a window even when windowsHide: true is set.
import('child_process').then(cp => { import('child_process').then(cp => {
cp.exec( cp.exec(
`powershell -WindowStyle Hidden -NoProfile -Command "Stop-Process -Id ${pid} -Force -ErrorAction SilentlyContinue"`, `taskkill /F /PID ${pid} /T`,
{ timeout: 5000, windowsHide: true }, { timeout: 5000, windowsHide: true },
() => { } () => { }
); );
@@ -839,36 +838,23 @@ export class GatewayManager extends EventEmitter {
: process.env.PATH || ''; : process.env.PATH || '';
const uvEnv = await getUvMirrorEnv(); const uvEnv = await getUvMirrorEnv();
const command = app.isPackaged ? getNodeExecutablePath() : 'node'; const doctorArgs = ['doctor', '--fix', '--yes', '--non-interactive'];
const args = [entryScript, 'doctor', '--fix', '--yes', '--non-interactive'];
logger.info( 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<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
const spawnEnv: Record<string, string | undefined> = { const forkEnv: Record<string, string | undefined> = {
...process.env, ...process.env,
PATH: finalPath, PATH: finalPath,
...uvEnv, ...uvEnv,
OPENCLAW_NO_RESPAWN: '1',
}; };
if (app.isPackaged) { const child = utilityProcess.fork(entryScript, doctorArgs, {
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, {
cwd: openclawDir, cwd: openclawDir,
stdio: ['ignore', 'pipe', 'pipe'], stdio: 'pipe',
detached: false, env: forkEnv as NodeJS.ProcessEnv,
shell: false,
windowsHide: true,
env: spawnEnv,
}); });
let settled = false; let settled = false;
@@ -881,7 +867,7 @@ export class GatewayManager extends EventEmitter {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
logger.error('OpenClaw doctor repair timed out after 120000ms'); logger.error('OpenClaw doctor repair timed out after 120000ms');
try { try {
child.kill('SIGTERM'); child.kill();
} catch { } catch {
// ignore // ignore
} }
@@ -912,14 +898,14 @@ export class GatewayManager extends EventEmitter {
} }
}); });
child.on('exit', (code, signal) => { child.on('exit', (code: number) => {
clearTimeout(timeout); clearTimeout(timeout);
if (code === 0) { if (code === 0) {
logger.info('OpenClaw doctor repair completed successfully'); logger.info('OpenClaw doctor repair completed successfully');
finish(true); finish(true);
return; return;
} }
logger.warn(`OpenClaw doctor repair exited (${this.formatExit(code, signal)})`); logger.warn(`OpenClaw doctor repair exited (code=${code})`);
finish(false); finish(false);
}); });
}); });
@@ -976,40 +962,16 @@ export class GatewayManager extends EventEmitter {
logger.warn('Failed to sync browser config to openclaw.json:', err); logger.warn('Failed to sync browser config to openclaw.json:', err);
} }
let command: string; // utilityProcess.fork() works for both dev and packaged — no ELECTRON_RUN_AS_NODE needed.
let args: string[]; if (!existsSync(entryScript)) {
let mode: 'packaged' | 'dev-built' | 'dev-pnpm'; const errMsg = `OpenClaw entry script not found at: ${entryScript}`;
logger.error(errMsg);
// Determine the Node.js executable throw new Error(errMsg);
// 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';
} }
const gatewayArgs = ['gateway', '--port', String(this.status.port), '--token', gatewayToken, '--allow-unconfigured'];
const mode = app.isPackaged ? 'packaged' : 'dev';
// Resolve bundled bin path for uv // Resolve bundled bin path for uv
const platform = process.platform; const platform = process.platform;
const arch = process.arch; const arch = process.arch;
@@ -1067,13 +1029,15 @@ export class GatewayManager extends EventEmitter {
const proxyEnv = buildProxyEnv(appSettings); const proxyEnv = buildProxyEnv(appSettings);
const resolvedProxy = resolveProxySettings(appSettings); const resolvedProxy = resolveProxySettings(appSettings);
logger.info( 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) => { return new Promise((resolve, reject) => {
// Reset exit tracking for this new process instance.
this.processExitCode = null;
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env; const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
const spawnEnv: Record<string, string | undefined> = { const forkEnv: Record<string, string | undefined> = {
...baseEnv, ...baseEnv,
PATH: finalPath, PATH: finalPath,
...providerEnv, ...providerEnv,
@@ -1082,29 +1046,17 @@ export class GatewayManager extends EventEmitter {
OPENCLAW_GATEWAY_TOKEN: gatewayToken, OPENCLAW_GATEWAY_TOKEN: gatewayToken,
OPENCLAW_SKIP_CHANNELS: '', OPENCLAW_SKIP_CHANNELS: '',
CLAWDBOT_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. // Inject fetch preload so OpenRouter requests carry ClawX headers.
// The preload patches globalThis.fetch before any module loads. // The preload patches globalThis.fetch before any module loads.
try { try {
const preloadPath = ensureGatewayFetchPreload(); const preloadPath = ensureGatewayFetchPreload();
if (existsSync(preloadPath)) { if (existsSync(preloadPath)) {
spawnEnv['NODE_OPTIONS'] = appendNodeRequireToNodeOptions( forkEnv['NODE_OPTIONS'] = appendNodeRequireToNodeOptions(
spawnEnv['NODE_OPTIONS'], forkEnv['NODE_OPTIONS'],
preloadPath, preloadPath,
); );
} }
@@ -1112,17 +1064,13 @@ export class GatewayManager extends EventEmitter {
logger.warn('Failed to set up OpenRouter headers preload:', err); logger.warn('Failed to set up OpenRouter headers preload:', err);
} }
const useShell = !app.isPackaged && process.platform === 'win32'; // utilityProcess.fork() runs the .mjs entry directly without spawning a
const spawnCmd = useShell ? quoteForCmd(command) : command; // shell or visible console window. Works identically in dev and packaged.
const spawnArgs = useShell ? args.map(a => quoteForCmd(a)) : args; this.process = utilityProcess.fork(entryScript, gatewayArgs, {
this.process = spawn(spawnCmd, spawnArgs, {
cwd: openclawDir, cwd: openclawDir,
stdio: ['ignore', 'pipe', 'pipe'], stdio: 'pipe',
detached: false, env: forkEnv as NodeJS.ProcessEnv,
shell: useShell, serviceName: 'OpenClaw Gateway',
windowsHide: true,
env: spawnEnv,
}); });
const child = this.process; const child = this.process;
this.ownsProcess = true; this.ownsProcess = true;
@@ -1133,10 +1081,11 @@ export class GatewayManager extends EventEmitter {
reject(error); reject(error);
}); });
child.on('exit', (code, signal) => { child.on('exit', (code: number) => {
this.processExitCode = code;
const expectedExit = !this.shouldReconnect || this.status.state === 'stopped'; const expectedExit = !this.shouldReconnect || this.status.state === 'stopped';
const level = expectedExit ? logger.info : logger.warn; 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; this.ownsProcess = false;
if (this.process === child) { if (this.process === child) {
this.process = null; this.process = null;
@@ -1149,9 +1098,7 @@ export class GatewayManager extends EventEmitter {
} }
}); });
child.on('close', (code, signal) => { // UtilityProcess doesn't emit 'close'; stdout/stderr end naturally on exit.
logger.debug(`Gateway process stdio closed (${this.formatExit(code, signal)})`);
});
// Log stderr // Log stderr
child.stderr?.on('data', (data) => { child.stderr?.on('data', (data) => {
@@ -1186,12 +1133,12 @@ export class GatewayManager extends EventEmitter {
private async waitForReady(retries = 2400, interval = 250): Promise<void> { private async waitForReady(retries = 2400, interval = 250): Promise<void> {
const child = this.process; const child = this.process;
for (let i = 0; i < retries; i++) { for (let i = 0; i < retries; i++) {
// Early exit if the gateway process has already exited // Early exit if the gateway process has already exited.
if (child && (child.exitCode !== null || child.signalCode !== null)) { // UtilityProcess has no synchronous exitCode/signalCode — use our tracked flag.
const code = child.exitCode; if (child && this.processExitCode !== null) {
const signal = child.signalCode; const code = this.processExitCode;
logger.error(`Gateway process exited before ready (${this.formatExit(code, signal)})`); logger.error(`Gateway process exited before ready (code=${code})`);
throw new Error(`Gateway process exited before becoming ready (${this.formatExit(code, signal)})`); throw new Error(`Gateway process exited before becoming ready (code=${code})`);
} }
try { try {

View File

@@ -1,6 +1,6 @@
{ {
"name": "clawx", "name": "clawx",
"version": "0.1.22-alpha.2", "version": "0.1.22-alpha.5",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@whiskeysockets/baileys", "@whiskeysockets/baileys",

View File

@@ -3,83 +3,64 @@
; Install: enables long paths, adds resources\cli to user PATH for openclaw CLI. ; Install: enables long paths, adds resources\cli to user PATH for openclaw CLI.
; Uninstall: removes the PATH entry and optionally deletes user data. ; Uninstall: removes the PATH entry and optionally deletes user data.
; When customCheckAppRunning is defined, electron-builder skips its conditional !ifndef nsProcess::FindProcess
; !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
!include "nsProcess.nsh" !include "nsProcess.nsh"
!endif !endif
!macro customCheckAppRunning !macro customCheckAppRunning
${GetProcessInfo} 0 $pid $1 $2 $3 $4 ${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0
${if} $3 != "${APP_EXECUTABLE_FILENAME}"
${if} $R0 == 0
${if} ${isUpdated} ${if} ${isUpdated}
# allow app to exit without explicit kill # allow app to exit without explicit kill
Sleep 300 Sleep 1000
Goto doStopProcess
${endIf} ${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. doStopProcess:
# This prevents the brief black cmd window from flashing. DetailPrint `Closing running "${PRODUCT_NAME}"...`
${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0
${if} $R0 == 0 # Silently kill the process using nsProcess instead of taskkill / cmd.exe
${if} ${isUpdated} ${nsProcess::KillProcess} "${APP_EXECUTABLE_FILENAME}" $R0
# allow app to exit without explicit kill
# 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 Sleep 1000
Goto doStopProcess ${nsProcess::KillProcess} "${APP_EXECUTABLE_FILENAME}" $R0
${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::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0 ${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0
${if} $R0 == 0 ${If} $R0 == 0
# wait to give a chance to exit gracefully DetailPrint `Waiting for "${PRODUCT_NAME}" to close.`
Sleep 1000 Sleep 2000
${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}
${else} ${else}
Goto not_running Goto not_running
${endIf} ${endIf}
${else}
Goto not_running
${endIf}
# App likely running with elevated permissions. # App likely running with elevated permissions.
# Ask user to close it manually # Ask user to close it manually
${if} $R1 > 1 ${if} $R1 > 1
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDCANCEL IDRETRY loop MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDCANCEL IDRETRY loop
Quit Quit
${else} ${else}
Goto loop Goto loop
${endIf} ${endIf}
not_running: not_running:
nsProcess::Unload ${nsProcess::Unload}
${endIf}
${endIf} ${endIf}
!macroend !macroend

View File

@@ -948,7 +948,8 @@ function ProviderContent({
try { try {
// Validate key if the provider requires one and a key was entered // 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( const result = await window.electron.ipcRenderer.invoke(
'provider:validateKey', 'provider:validateKey',
selectedProviderConfigId || selectedProvider, selectedProviderConfigId || selectedProvider,
@@ -1024,9 +1025,10 @@ function ProviderContent({
}; };
// Can the user submit? // Can the user submit?
const isApiKeyRequired = requiresKey || (supportsApiKey && authMode === 'apikey');
const canSubmit = const canSubmit =
selectedProvider selectedProvider
&& (requiresKey ? apiKey.length > 0 : true) && (isApiKeyRequired ? apiKey.length > 0 : true)
&& (showModelIdField ? modelId.trim().length > 0 : true) && (showModelIdField ? modelId.trim().length > 0 : true)
&& !useOAuthFlow; && !useOAuthFlow;
@@ -1197,7 +1199,7 @@ function ProviderContent({
)} )}
{/* API Key field (hidden for ollama) */} {/* API Key field (hidden for ollama) */}
{(!isOAuth || (supportsApiKey && authMode === 'apikey')) && requiresKey && ( {(!isOAuth || (supportsApiKey && authMode === 'apikey')) && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="apiKey">{t('provider.apiKey')}</Label> <Label htmlFor="apiKey">{t('provider.apiKey')}</Label>
<div className="relative"> <div className="relative">