diff --git a/BUILD.md b/BUILD.md index ce382cb..c8c506d 100644 --- a/BUILD.md +++ b/BUILD.md @@ -5,16 +5,25 @@ OpenSpan ships as source, but it also packages into ONE standalone ## Build +Use whatever local Python 3.x with PyInstaller you have — it is never +hardcoded to a version or path. Two equivalent forms: + ``` -C:\Python313\python.exe D:\OpenSpan\build_exe.py +:: from the repo folder: any local 3.x works, e.g. +py -3 build_exe.py +:: or +python build_exe.py ``` -Needs PyInstaller (`pip install --user pyinstaller`; already present here). -Produces `D:\OpenSpan\OpenSpan.exe` (~64 MB). It runs **in place** — the app -anchors every data file on the exe's own folder, and `D:\OpenSpan\` already -holds them, so there's nothing to assemble. `OpenSpan.bat` and the Start-Menu -shortcut auto-prefer the exe when it exists and fall back to the Python -entry point otherwise. +Needs PyInstaller in the active interpreter +(`py -3 -m pip install --user pyinstaller`; already present on the author +machine). Produces `D:\OpenSpan\OpenSpan.exe` (~64 MB). It runs **in place** +— the app anchors every data file on the exe's own folder, and `D:\OpenSpan\` +already holds them, so there's nothing to assemble. `OpenSpan.bat` and the +Start-Menu shortcut auto-prefer the exe when it exists and fall back to the +Python entry point otherwise (the .bat resolves the interpreter +dynamically: pyw.exe → pythonw.exe → python.exe → registered installs, +validating tkinter per candidate — no hardcoded paths). ## How the one file does three processes @@ -49,5 +58,8 @@ is rebuilt on demand, not stored in the repo. Built `--noconsole` (GUI, no console flash) with PyInstaller's `runw` bootloader, and unsigned-but-unflagged, so it launches unelevated — dodging -the same shell-reputation gate that made the `openspanw.exe` interpreter copy -necessary for the raw `.py` path. +the same shell-reputation gate that a RUNASADMIN-flagged `pythonw.exe` copy +would trip (the raw `.py` path is covered by `OpenSpan.bat`'s dynamic +interpreter resolution: pyw.exe → pythonw.exe → python.exe → registered +installs, each validated with `import tkinter`; no renamed `openspanw.exe` +needed anymore). diff --git a/OpenSpan.bat b/OpenSpan.bat index f384349..0543a6b 100644 --- a/OpenSpan.bat +++ b/OpenSpan.bat @@ -1,8 +1,102 @@ @echo off -rem Prefer the packaged single-file exe if it's been built; otherwise fall -rem back to the Python entry point via the unflagged interpreter copy. -if exist "%~dp0OpenSpan.exe" ( - start "" "%~dp0OpenSpan.exe" +rem --------------------------------------------------------------------------- +rem OpenSpan launcher +rem +rem 1. A packaged OpenSpan.exe next to this file wins, always. +rem 2. Otherwise pick the BEST locally available Python GUI interpreter -- +rem NOTHING hardcoded: no install paths, no usernames, no renamed-copy +rem tricks. Candidate order: +rem a) pyw.exe (Windows Python Launcher, console-less) +rem b) pythonw.exe (on PATH, console-less) +rem c) python.exe (on PATH, console fallback) +rem d) registered PythonCore installs (newest 3.x, pythonw preferred) +rem Every candidate is validated with "import tkinter"; a broken +rem candidate is skipped, never trusted. +rem 3. Clear error surfacing: "no Python found" vs "Python found but +rem tkinter missing" -- never a raw "missing interpreter" error. +rem --------------------------------------------------------------------------- +setlocal EnableExtensions EnableDelayedExpansion +set "ROOT=%~dp0" + +rem -- packaged exe wins ------------------------------------------------------ +if exist "%ROOT%OpenSpan.exe" ( + start "" "%ROOT%OpenSpan.exe" + exit /b 0 +) + +set "FOUND_WITHOUT_TK=" + +rem -- 1) Windows Python Launcher (pyw.exe = console-less variant) ------------ +for /f "delims=" %%W in ('where pyw.exe 2^>nul') do ( + if not defined PYW_CMD set "PYW_CMD=%%W" +) +if defined PYW_CMD ( + "%PYW_CMD%" -3 -c "import tkinter" >nul 2>&1 + if not errorlevel 1 ( + start "" "%PYW_CMD%" -3 "%ROOT%win\openspan.py" + exit /b 0 + ) + set "FOUND_WITHOUT_TK=%PYW_CMD% (-3, tkinter import failed)" +) + +rem -- 2) pythonw.exe on PATH (console-less GUI interpreter) ------------------ +set "PYWW_OK=" +for /f "delims=" %%W in ('where pythonw.exe 2^>nul') do ( + if not defined PYWW_OK ( + "%%W" -c "import tkinter" >nul 2>&1 + if not errorlevel 1 ( + set "PYWW_OK=%%W" + ) else ( + if not defined FOUND_WITHOUT_TK set "FOUND_WITHOUT_TK=%%W" + ) + ) +) +if defined PYWW_OK ( + start "" "%PYWW_OK%" "%ROOT%win\openspan.py" + exit /b 0 +) + +rem -- 3) python.exe on PATH (console fallback; skip the Store stub) ---------- +set "PYEXE_OK=" +for /f "delims=" %%W in ('where python.exe 2^>nul') do ( + if not defined PYEXE_OK ( + echo %%W | findstr /i "WindowsApps" >nul 2>&1 + if errorlevel 1 ( + "%%W" -c "import tkinter" >nul 2>&1 + if not errorlevel 1 ( + set "PYEXE_OK=%%W" + ) else ( + if not defined FOUND_WITHOUT_TK set "FOUND_WITHOUT_TK=%%W" + ) + ) + ) +) +if defined PYEXE_OK ( + start "" "%PYEXE_OK%" "%ROOT%win\openspan.py" + exit /b 0 +) + +rem -- 4) registered PythonCore installs (newest 3.x; pythonw.exe, tkinter-ok) - +set "REGPW=" +for /f "usebackq delims=" %%P in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "$vs=@(); foreach ($root in 'HKCU:\SOFTWARE\Python\PythonCore','HKLM:\SOFTWARE\Python\PythonCore') { Get-ChildItem $root -ErrorAction SilentlyContinue | ForEach-Object { $ip=(Get-ItemProperty ($_.PSPath + '\InstallPath') -ErrorAction SilentlyContinue).'(default)'; if ($ip -and (Test-Path ($ip + 'pythonw.exe'))) { $parts=$_.PSChildName -split '\.'; $vs += [pscustomobject]@{ V=([int]$parts[0])*100+[int]$parts[1]; P=$ip } } } }; $best=$vs | Sort-Object V -Descending | Select-Object -First 1; if ($best) { $best.P }"`) do ( + if not defined REGPW set "REGPW=%%P" +) +if defined REGPW ( + if exist "%REGPW%pythonw.exe" ( + "%REGPW%pythonw.exe" -c "import tkinter" >nul 2>&1 + if not errorlevel 1 ( + start "" "%REGPW%pythonw.exe" "%ROOT%win\openspan.py" + exit /b 0 + ) + set "FOUND_WITHOUT_TK=%REGPW%pythonw.exe" + ) +) + +rem -- nothing usable: say so, clearly ---------------------------------------- +if defined FOUND_WITHOUT_TK ( + set "ERRMSG=Python was found (%FOUND_WITHOUT_TK%) but it cannot import tkinter. Use a standard python.org 3.x (tkinter included), no PATH changes needed." ) else ( - start "" "C:\Python313\openspanw.exe" "%~dp0win\openspan.py" + set "ERRMSG=No usable Python interpreter was found. Install a standard python.org 3.x from https://www.python.org/downloads/ and run this file again -- no PATH changes needed." ) +powershell -NoProfile -ExecutionPolicy Bypass -Command "Add-Type -AssemblyName PresentationFramework; [System.Windows.MessageBox]::Show('%ERRMSG%', 'OpenSpan - Python not usable', [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error)" +exit /b 1 diff --git a/TECHNICAL_NOTES.md b/TECHNICAL_NOTES.md index 30ff7a5..130f195 100644 --- a/TECHNICAL_NOTES.md +++ b/TECHNICAL_NOTES.md @@ -166,8 +166,11 @@ Windows input portal (openspan_portal.py, captures at the screen edge) - **On-demand only.** Launches from a Start Menu shortcut *or* `D:\OpenSpan\OpenSpan.bat`. The launcher prefers the packaged **`OpenSpan.exe`** (single file, built via `build_exe.py` — see BUILD.md) - when present, and falls back to **`C:\Python313\openspanw.exe - win\openspan.py`** otherwise. + when present, and otherwise resolves a real local Python interpreter + **dynamically** (pyw.exe launcher → PATH pythonw.exe → PATH python.exe → + newest registered PythonCore install; every candidate is validated with + `import tkinter`; broken candidates are skipped and clear errors are + surfaced instead of a stale hardcoded path). - **Single-file packaging (2026-07-12).** `build_exe.py` → PyInstaller → one ~64 MB `OpenSpan.exe` that runs in place (data files anchor on `sys.executable`'s folder, which is `D:\OpenSpan`). The GUI's separate @@ -176,13 +179,21 @@ Windows input portal (openspan_portal.py, captures at the screen edge) entry script); every module switches its path anchor from `__file__` to `sys.executable` under `sys.frozen`. Built `--noconsole` + unflagged → unelevated, no console flash. The exe / `build/` / `dist/` are gitignored. -- **Why a renamed interpreter (`openspanw.exe`):** `C:\Python313\pythonw.exe` - carries a `RUNASADMIN` AppCompatFlags layer that forces elevation of the - unsigned interpreter, which trips Windows' shell reputation gate ("an - administrator has blocked this app" — independent of the UAC prompt setting). - A same-directory unflagged copy (`Copy-Item pythonw.exe openspanw.exe`) runs - unelevated → no block. OpenSpan needs no admin (VBoxManage / ssh / shutdown / - subprocess all work unelevated). +- **Why the launcher resolves interpreters dynamically (was `openspanw.exe`):** + a renamed-interpreter copy (`openspanw.exe`) was once used because + `pythonw.exe` under some installs carries a `RUNASADMIN` AppCompatFlags + layer that forces elevation of the unsigned interpreter and trips Windows' + shell reputation gate ("an administrator has blocked this app"). That trick + hardcodes an install path (`C:\Python313\...`), so `OpenSpan.bat` now picks + the first *working* GUI interpreter at launch instead: pyw.exe (Python + Launcher) → pythonw.exe on PATH → python.exe on PATH (Store stub skipped) → + newest registered PythonCore install (pythonw preferred), each validated + with `import tkinter`. No renamed binaries, no path assumptions, no PATH + edits. OpenSpan itself needs no admin (VBoxManage / ssh / shutdown / + subprocess all work unelevated); for UIPI compatibility with elevated + apps on the desktop run the launcher *as administrator* (right-click → + Run as administrator, or `Start-Process -Verb RunAs`), which the GUI's + NOT ADMIN indicator covers. - **Do not put the launcher in a OneDrive-synced folder** — that was a false lead, but OneDrive placeholders add their own friction; keep launchers local (Start Menu / D: drive). diff --git a/build_exe.py b/build_exe.py index 2fbc1c5..5e6e6ce 100644 --- a/build_exe.py +++ b/build_exe.py @@ -1,13 +1,16 @@ #!/usr/bin/env python3 """Build OpenSpan into a single-file OpenSpan.exe (PyInstaller). - C:\\Python313\\python.exe D:\\OpenSpan\\build_exe.py + py -3 build_exe.py # any local Python 3.x works, from the repo + :: or: python build_exe.py Produces D:\\OpenSpan\\OpenSpan.exe. Because the app anchors all its data on the executable's folder (sys.executable when frozen), the exe is dropped straight into D:\\OpenSpan\\ next to the configs/keys/scripts it already uses --- nothing else to assemble. No console window; unelevated (so it dodges the -same shell-reputation gate the openspanw.exe trick was created for). +-- nothing else to assemble. No console window; unelevated, so it dodges +the same shell-reputation gate that a RUNASADMIN-flagged pythonw.exe would +trip (OpenSpan.bat resolves the raw .py interpreter dynamically today: no +renamed openspanw.exe needed). """ import os import shutil