Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions BUILD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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).
104 changes: 99 additions & 5 deletions OpenSpan.bat
Original file line number Diff line number Diff line change
@@ -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
29 changes: 20 additions & 9 deletions TECHNICAL_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand Down
9 changes: 6 additions & 3 deletions build_exe.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down