diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 74fc4636dd28..a4be33a332c1 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -19,7 +19,7 @@ const https = require('node:https') const net = require('node:net') const path = require('node:path') const { fileURLToPath, pathToFileURL } = require('node:url') -const { spawn } = require('node:child_process') +const { execFileSync, spawn } = require('node:child_process') const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs') const { DATA_URL_READ_MAX_BYTES, @@ -438,13 +438,120 @@ function findPythonForRoot(root) { } function findSystemPython() { - const commands = IS_WINDOWS ? ['python.exe', 'py.exe', 'python'] : ['python3', 'python'] - - for (const command of commands) { - const candidate = findOnPath(command) - if (candidate) return candidate + if (!IS_WINDOWS) { + // POSIX systems: PATH lookup is safe. + for (const command of ['python3', 'python']) { + const candidate = findOnPath(command) + if (candidate) return candidate + } + return null } + // Windows: PATH-based detection has TWO landmines we have to dodge. + // + // (1) The Microsoft Store "Python stub" lives at + // %LOCALAPPDATA%\Microsoft\WindowsApps\python.exe and is on PATH + // by default on modern Windows. It's a redirector that opens the + // Store window if no Store Python is installed. Running it for + // `-m venv` would either succeed (real Store install — fine) or + // pop the Store dialog (bad UX during boot). + // (2) `py.exe` (Python launcher) is missing from per-user installs + // that didn't check the launcher option, so PATH-only checks + // miss real Python 3.13 installs (user-reported case). + // + // We also restrict ourselves to Python 3.11–3.13. 3.14 is the latest + // CPython but several Hermes deps (notably pywinpty's Rust-built + // windows_x86_64_msvc crate) don't yet publish 3.14 wheels, and + // `pip install -e .` falls back to source-build, which fails without + // a Rust toolchain. install.ps1 sidesteps this by pinning to 3.11 + // via uv; until we add the same uv-managed Python pathway here, the + // simplest fix is to refuse 3.14 detection and let the NSIS prereq + // page offer to install 3.11 alongside. + // + // Strategy: probe in three passes, in order from most-precise to + // least-precise, and ONLY use PATH lookup as a last resort after + // confirming the candidate isn't the WindowsApps redirector. + // + // Pass 1: PEP 514 registry — every standards-compliant Python + // installer registers itself at SOFTWARE\Python\PythonCore. + // The MS Store stub does NOT register here, so a hit means + // a real Python install. Versions are explicit so we + // inherently filter 3.14 out. + // Pass 2: Filesystem probe of standard install locations + // (Program Files, LocalAppData\Programs\Python). Same + // version filtering by directory name. + // Pass 3: PATH lookup of `py.exe` (the launcher itself never + // triggers the Store) — but call it with a version flag so + // we resolve to a SPECIFIC supported version, not whatever + // py.exe's default is (which on a 3.14-only box would be + // 3.14). + + const SUPPORTED_VERSIONS = ['3.11', '3.12', '3.13'] + const SUPPORTED_VERSIONS_NO_DOT = ['311', '312', '313'] + + // Pass 1: registry. Use `reg query` since main process doesn't have + // a reliable in-process registry API across all electron versions. + for (const hive of ['HKLM', 'HKCU']) { + for (const version of SUPPORTED_VERSIONS) { + try { + const out = execFileSync( + 'reg', + ['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] } + ) + // Output format: " (Default) REG_SZ C:\Path\To\Python\" + const match = out.match(/REG_SZ\s+(.+?)\s*$/m) + if (match) { + const installPath = match[1].trim() + const pythonExe = path.join(installPath, 'python.exe') + if (fileExists(pythonExe)) return pythonExe + } + } catch { + // Key not present — try next. + } + } + } + + // Pass 2: filesystem probe of standard locations. + const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files' + const localAppData = process.env.LOCALAPPDATA || '' + for (const versionDir of SUPPORTED_VERSIONS_NO_DOT) { + const systemWide = path.join(programFiles, `Python${versionDir}`, 'python.exe') + if (fileExists(systemWide)) return systemWide + if (localAppData) { + const perUser = path.join(localAppData, 'Programs', 'Python', `Python${versionDir}`, 'python.exe') + if (fileExists(perUser)) return perUser + } + } + + // Pass 3: py.exe with explicit version flag. The launcher itself is + // safe to invoke (no Store popup) and `py -3.13 -c "import sys; + // print(sys.executable)"` resolves to the actual python.exe path of + // the requested version. We try in version-priority order so the + // first hit wins. + const pyExe = findOnPath('py.exe') + if (pyExe) { + for (const version of SUPPORTED_VERSIONS) { + try { + const out = execFileSync( + pyExe, + [`-${version}`, '-c', 'import sys; print(sys.executable)'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] } + ) + const candidate = out.trim() + if (candidate && fileExists(candidate)) return candidate + } catch { + // py couldn't find that version — try next. + } + } + } + + // We deliberately do NOT fall back to plain `python.exe` on PATH. + // Without a way to verify the version safely (running `python -V` + // risks the Microsoft Store popup), accepting whatever's there + // could land us on 3.14 and trigger the Rust-build-from-source + // failure. Better to return null and let the NSIS prereq page + // offer to install a known-good 3.11 via winget. return null } diff --git a/apps/desktop/installer/prereq-check.nsh b/apps/desktop/installer/prereq-check.nsh index c21b6dfd330a..84960d714f02 100644 --- a/apps/desktop/installer/prereq-check.nsh +++ b/apps/desktop/installer/prereq-check.nsh @@ -71,6 +71,103 @@ Var HermesInstallPython Var HermesInstallGit Var HermesInstallRipgrep +; ---------------------------------------------------------------------------- +; HermesDetectPythonViaRegistry — sets $HermesHasPython="1" if a PEP 514 +; entry exists for any of the supported Python versions. Reads HKLM +; (system-wide installs) then HKCU (per-user installs). Vendor "PythonCore" +; covers official python.org distributions; "ContinuumAnalytics" covers +; Anaconda/Miniconda. We don't enumerate other vendors because they're +; rare in our user base and we'd rather miss them and let winget add a +; second Python than misclassify something else as a working Python. +; ---------------------------------------------------------------------------- +Function HermesDetectPythonViaRegistry + Push $1 + Push $2 + + ; Set view to 64-bit on x64 systems so we read the right hive — the + ; default 32-bit view would miss a 64-bit Python install on 64-bit + ; Windows. SetRegView 32 restored at function exit. + SetRegView 64 + + ; Iterate the supported versions. Each is its own ReadRegStr — NSIS + ; doesn't have loops over arrays inside functions easily, and four + ; copies is clearer than gymnastics with $R0-$R9. + ReadRegStr $1 HKLM "SOFTWARE\Python\PythonCore\3.11\InstallPath" "" + ${If} $1 != "" + StrCpy $HermesHasPython "1" + Goto hermes_py_reg_done + ${EndIf} + ReadRegStr $1 HKLM "SOFTWARE\Python\PythonCore\3.12\InstallPath" "" + ${If} $1 != "" + StrCpy $HermesHasPython "1" + Goto hermes_py_reg_done + ${EndIf} + ReadRegStr $1 HKLM "SOFTWARE\Python\PythonCore\3.13\InstallPath" "" + ${If} $1 != "" + StrCpy $HermesHasPython "1" + Goto hermes_py_reg_done + ${EndIf} + + ReadRegStr $1 HKCU "SOFTWARE\Python\PythonCore\3.11\InstallPath" "" + ${If} $1 != "" + StrCpy $HermesHasPython "1" + Goto hermes_py_reg_done + ${EndIf} + ReadRegStr $1 HKCU "SOFTWARE\Python\PythonCore\3.12\InstallPath" "" + ${If} $1 != "" + StrCpy $HermesHasPython "1" + Goto hermes_py_reg_done + ${EndIf} + ReadRegStr $1 HKCU "SOFTWARE\Python\PythonCore\3.13\InstallPath" "" + ${If} $1 != "" + StrCpy $HermesHasPython "1" + Goto hermes_py_reg_done + ${EndIf} + +hermes_py_reg_done: + SetRegView 32 + Pop $2 + Pop $1 +FunctionEnd + +; ---------------------------------------------------------------------------- +; HermesDetectPythonViaFilesystem — sets $HermesHasPython="1" if a Python +; install exists at one of the standard locations. FileExists never runs +; the binary so this is safe even if the user has the MS Store stub on +; their PATH. We probe both system-wide (Program Files) and per-user +; (LocalAppData\Programs) install locations for versions 3.11–3.14. +; ---------------------------------------------------------------------------- +Function HermesDetectPythonViaFilesystem + ; System-wide installs (default location for python.org with admin) + ${If} ${FileExists} "$PROGRAMFILES64\Python311\python.exe" + StrCpy $HermesHasPython "1" + Return + ${EndIf} + ${If} ${FileExists} "$PROGRAMFILES64\Python312\python.exe" + StrCpy $HermesHasPython "1" + Return + ${EndIf} + ${If} ${FileExists} "$PROGRAMFILES64\Python313\python.exe" + StrCpy $HermesHasPython "1" + Return + ${EndIf} + + ; Per-user installs (default location for python.org without admin + ; or with "Install for me only"). Covers the user-reported case. + ${If} ${FileExists} "$LOCALAPPDATA\Programs\Python\Python311\python.exe" + StrCpy $HermesHasPython "1" + Return + ${EndIf} + ${If} ${FileExists} "$LOCALAPPDATA\Programs\Python\Python312\python.exe" + StrCpy $HermesHasPython "1" + Return + ${EndIf} + ${If} ${FileExists} "$LOCALAPPDATA\Programs\Python\Python313\python.exe" + StrCpy $HermesHasPython "1" + Return + ${EndIf} +FunctionEnd + ; ---------------------------------------------------------------------------- ; HermesDetectPrereqs — populates $HermesHasWinget / $HermesHasPython / ; $HermesHasGit / $HermesHasRipgrep with "0" or "1". Called from the @@ -86,10 +183,32 @@ Function HermesDetectPrereqs StrCpy $HermesHasWinget "0" ${EndIf} - ; --- Python 3.11+ --- - ; The py launcher returns exit 0 only when that specific version is - ; installed. We probe each version Hermes' pyproject.toml accepts. + ; --- Python 3.11 / 3.12 / 3.13 --- + ; We deliberately accept 3.11–3.13 only and NOT 3.14, because some of + ; Hermes' transitive deps (notably pywinpty, which carries Rust crates + ; like windows_x86_64_msvc) don't yet publish 3.14 wheels. Without + ; wheels, `pip install -e .` falls back to building from sdist, which + ; needs a Rust toolchain. Users without one see a confusing "could + ; not compile windows_x86_64_msvc build script" error. install.ps1 + ; sidesteps this by pinning to 3.11 via uv; the desktop installer + ; can't easily install uv in the same flow yet, so we just refuse to + ; accept 3.14 as "good" and offer 3.11 via winget instead. Revisit + ; when 3.14 wheels are widely available across our dep tree. + ; + ; Detection strategy, in order from most-precise to least-precise. + ; Each step uses ONLY operations that don't execute `python.exe` + ; directly off PATH — running `python` on Windows can open the + ; Microsoft Store if only the "Python stub" is installed, which is + ; terrible UX during an installer. We avoid that by: + ; (a) launcher checks (py.exe runs no python until -V), + ; (b) registry reads (PEP 514, no execution at all), + ; (c) filesystem probes via FileExists. StrCpy $HermesHasPython "0" + + ; (1) The py launcher. Ships with python.org installer when + ; "Install launcher for all users" is checked (default for some + ; paths, not for per-user installs without elevation). When + ; present, py -3.X --version returns 0 iff that version exists. nsExec::Exec 'cmd.exe /c py -3.11 --version >nul 2>&1' Pop $0 ${If} $0 == 0 @@ -104,16 +223,31 @@ Function HermesDetectPrereqs Pop $0 ${If} $0 == 0 StrCpy $HermesHasPython "1" - ${Else} - nsExec::Exec 'cmd.exe /c py -3.14 --version >nul 2>&1' - Pop $0 - ${If} $0 == 0 - StrCpy $HermesHasPython "1" - ${EndIf} ${EndIf} ${EndIf} ${EndIf} + ; (2) PEP 514 registry probe. Every standards-compliant Python + ; installer registers itself under HKLM or HKCU at + ; SOFTWARE\Python\PythonCore\\InstallPath. The MS Store + ; stub does NOT register here — so we get a clean signal for + ; "real Python is installed" without ever risking the Store + ; popup. Covers the case the user reported: per-user Python.org + ; install without launcher checkbox, plus Anaconda which writes + ; similar keys under a different vendor name. + ${If} $HermesHasPython == "0" + Call HermesDetectPythonViaRegistry + ${EndIf} + + ; (3) Filesystem probe of common install locations. Catches edge + ; cases where the installer didn't update the registry (rare + ; but possible with hand-extracted Python or some third-party + ; installers). We only check standard paths — running anything + ; would risk spawning the Store stub. + ${If} $HermesHasPython == "0" + Call HermesDetectPythonViaFilesystem + ${EndIf} + ; --- Git --- nsExec::Exec 'cmd.exe /c where git >nul 2>&1' Pop $0