diff --git a/application/i18n/locales/en.ts b/application/i18n/locales/en.ts index 0eb6bc8f3..c4f5ea729 100644 --- a/application/i18n/locales/en.ts +++ b/application/i18n/locales/en.ts @@ -383,6 +383,7 @@ const en: Messages = { 'settings.terminal.mosh.client.placeholder': 'Auto-detect', 'settings.terminal.mosh.client.notFound': 'File not found at that path.', 'settings.terminal.mosh.client.isDirectory': 'Path points to a directory, not an executable.', + 'settings.terminal.mosh.client.notExecutable': 'File exists but is not executable. Run `chmod +x` on it or pick another binary.', 'settings.terminal.mosh.detect': 'Detect', 'settings.terminal.mosh.browse': 'Browse…', 'settings.terminal.mosh.detected': 'Detected at', diff --git a/application/i18n/locales/zh-CN.ts b/application/i18n/locales/zh-CN.ts index caf55b49a..e91a14553 100644 --- a/application/i18n/locales/zh-CN.ts +++ b/application/i18n/locales/zh-CN.ts @@ -1467,6 +1467,7 @@ const zhCN: Messages = { 'settings.terminal.mosh.client.placeholder': '自动探测', 'settings.terminal.mosh.client.notFound': '该路径下未找到文件。', 'settings.terminal.mosh.client.isDirectory': '该路径指向目录而非可执行文件。', + 'settings.terminal.mosh.client.notExecutable': '文件存在但不可执行。请对其执行 `chmod +x`,或选择其它二进制文件。', 'settings.terminal.mosh.detect': '探测', 'settings.terminal.mosh.browse': '浏览…', 'settings.terminal.mosh.detected': '已找到', diff --git a/components/settings/tabs/SettingsTerminalTab.tsx b/components/settings/tabs/SettingsTerminalTab.tsx index 11ec8c4e1..da87760b2 100644 --- a/components/settings/tabs/SettingsTerminalTab.tsx +++ b/components/settings/tabs/SettingsTerminalTab.tsx @@ -478,7 +478,11 @@ export default function SettingsTerminalTab(props: { } const timeoutId = setTimeout(() => { bridge.validatePath(moshPath, "file").then((result) => { - if (result.exists && result.isFile) { + if (result.exists && result.isFile && !result.isExecutable) { + // Stays consistent with startMoshSession's isExecutableFile check — + // a regular file without the execute bit can't actually launch. + setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.notExecutable") }); + } else if (result.exists && result.isFile) { setMoshValidation({ valid: true }); } else if (result.exists && result.isDirectory) { setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.isDirectory") }); diff --git a/electron/bridges/terminalBridge.cjs b/electron/bridges/terminalBridge.cjs index 9a58b03c2..3c1a8a00a 100644 --- a/electron/bridges/terminalBridge.cjs +++ b/electron/bridges/terminalBridge.cjs @@ -1287,29 +1287,42 @@ function getDefaultShell() { * Validate a path - check if it exists and whether it's a file or directory * @param {object} event - IPC event * @param {object} payload - Contains { path: string, type?: 'file' | 'directory' | 'any' } - * @returns {{ exists: boolean, isFile: boolean, isDirectory: boolean }} + * @returns {{ exists: boolean, isFile: boolean, isDirectory: boolean, isExecutable: boolean }} + * + * `isExecutable` mirrors isExecutableFile(): POSIX requires the file mode + * to have an execute bit; Win32 treats any regular file as executable + * (NTFS lacks POSIX bits — extension/PATHEXT decides at spawn time). + * Existing callers ignore the new field; consumers that need exec + * semantics (e.g. Mosh client path) read it explicitly. */ +function statIsExecutable(stat) { + if (!stat || !stat.isFile()) return false; + if (process.platform === "win32") return true; + return (stat.mode & 0o111) !== 0; +} + function validatePath(event, payload) { const targetPath = payload?.path; const type = payload?.type || 'any'; if (!targetPath) { - return { exists: false, isFile: false, isDirectory: false }; + return { exists: false, isFile: false, isDirectory: false, isExecutable: false }; } - + try { // Resolve path (handle ~, etc.) let resolvedPath = expandHomePath(targetPath); resolvedPath = path.resolve(resolvedPath); - + if (fs.existsSync(resolvedPath)) { const stat = fs.statSync(resolvedPath); return { exists: true, isFile: stat.isFile(), isDirectory: stat.isDirectory(), + isExecutable: statIsExecutable(stat), }; } - + // If type is 'file' and path doesn't exist, try to resolve via PATH (for executables like cmd.exe, powershell.exe) if (type === 'file') { const resolvedExecutable = findExecutable(targetPath); @@ -1320,6 +1333,7 @@ function validatePath(event, payload) { exists: true, isFile: stat.isFile(), isDirectory: stat.isDirectory(), + isExecutable: statIsExecutable(stat), }; } // Also try with .exe extension on Windows if not already present @@ -1331,15 +1345,16 @@ function validatePath(event, payload) { exists: true, isFile: stat.isFile(), isDirectory: stat.isDirectory(), + isExecutable: statIsExecutable(stat), }; } } } - - return { exists: false, isFile: false, isDirectory: false }; + + return { exists: false, isFile: false, isDirectory: false, isExecutable: false }; } catch (err) { console.warn(`[Terminal] Error validating path "${targetPath}":`, err.message); - return { exists: false, isFile: false, isDirectory: false }; + return { exists: false, isFile: false, isDirectory: false, isExecutable: false }; } } diff --git a/global.d.ts b/global.d.ts index cd18c915b..065b784f9 100644 --- a/global.d.ts +++ b/global.d.ts @@ -216,7 +216,7 @@ declare global { }>>; getDefaultShell?(): Promise; discoverShells?(): Promise; - validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean }>; + validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean; isExecutable: boolean }>; generateKeyPair?(options: { type: 'RSA' | 'ECDSA' | 'ED25519'; bits?: number;