fix(desktop): detect Python via registry/filesystem; pin to 3.11–3.13

Two related fixes for Python detection on Windows:

1. py.exe (Python launcher) is missing from per-user installs that
   didn't check the launcher option, so 'py -3.X --version' alone
   misses real Python installs. User-reported case: clean Win11 +
   official Python.org 3.14 install -> 'where py' returned nothing,
   our installer offered to install Python again. Both NSIS prereq
   page and main.cjs now probe in this order:
     1. py.exe launcher (when present)
     2. PEP 514 registry: HKLM/HKCU\SOFTWARE\Python\PythonCore\<v>\InstallPath
     3. Filesystem: %ProgramFiles%\Python<v>, %LocalAppData%\Programs\Python\Python<v>
   Crucially, we never fall back to running 'python.exe' from PATH
   on Windows — the WindowsApps stub at %LOCALAPPDATA%\Microsoft\
   WindowsApps\python.exe is a redirector that opens the Microsoft
   Store window if no Store Python is installed. Triggering that
   during boot would be terrible UX. Registry/filesystem probes
   never execute the binary.

2. Drop 3.14 from the supported version set. Several Hermes deps
   (notably pywinpty, which carries Rust crates like
   windows_x86_64_msvc) don't yet publish 3.14 wheels. With wheels
   missing, 'pip install -e .' falls back to building from sdist,
   which needs a Rust toolchain — users see 'could not compile
   windows_x86_64_msvc build script' on first run. install.ps1
   sidesteps this by pinning to 3.11 via uv; the desktop installer
   doesn't yet have the same uv-managed-Python pathway, so for now
   we accept 3.11/3.12/3.13 and tell winget to install 3.11 if
   none of those are present. Revisit when the wheel ecosystem
   catches up to 3.14 (~early 2026).
This commit is contained in:
emozilla 2026-05-12 22:14:08 -04:00
parent 708d2a0c33
commit 49de1adc49
2 changed files with 256 additions and 15 deletions

View file

@ -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.113.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
}

View file

@ -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.113.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.113.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\<version>\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