From fb7c8ff6cacace220c27c8623d857e4846fca9ff Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Thu, 1 Jan 2026 13:24:44 -0800 Subject: [PATCH 1/4] feat: Add Windows setup script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PowerShell setup script for Windows that: - Checks for Python and Git prerequisites - Clones the Squid repository - Installs Python dependencies via pip - Creates desktop shortcut with icon 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- software/setup_windows.ps1 | 101 +++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 software/setup_windows.ps1 diff --git a/software/setup_windows.ps1 b/software/setup_windows.ps1 new file mode 100644 index 000000000..b790f9776 --- /dev/null +++ b/software/setup_windows.ps1 @@ -0,0 +1,101 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Setup script for Squid on Windows. +.DESCRIPTION + Installs Python dependencies, clones the repository if needed, and creates a desktop shortcut. +.PARAMETER RepoPath + Path where Squid repository should be cloned. Defaults to Desktop\Squid. +#> + +param( + [string]$RepoPath = "$env:USERPROFILE\Desktop\Squid" +) + +$ErrorActionPreference = "Stop" + +Write-Host "Using SQUID_REPO_PATH='$RepoPath'" + +$SQUID_REPO_HTTP = "https://github.com/Cephla-Lab/Squid.git" +$SQUID_SOFTWARE_ROOT = Join-Path $RepoPath "software" +$SQUID_REPO_PATH_PARENT = Split-Path $RepoPath -Parent + +# Check if Python is installed +try { + $pythonVersion = python --version 2>&1 + Write-Host "Found $pythonVersion" +} catch { + Write-Error "Python is not installed or not in PATH. Please install Python 3.10+ from https://python.org" + exit 1 +} + +# Check if git is installed +try { + $gitVersion = git --version 2>&1 + Write-Host "Found $gitVersion" +} catch { + Write-Error "Git is not installed or not in PATH. Please install Git from https://git-scm.com" + exit 1 +} + +# Clone the repo if we don't already have it +if (-not (Test-Path $SQUID_REPO_PATH_PARENT)) { + New-Item -ItemType Directory -Path $SQUID_REPO_PATH_PARENT -Force | Out-Null +} + +if (-not (Test-Path $RepoPath)) { + Write-Host "Cloning Squid repository..." + git clone $SQUID_REPO_HTTP $RepoPath +} else { + $currentHead = git -C $RepoPath rev-parse HEAD + Write-Host "Using existing repo at '$RepoPath' at HEAD=$currentHead" +} + +# Create cache directory +$cacheDir = Join-Path $SQUID_SOFTWARE_ROOT "cache" +if (-not (Test-Path $cacheDir)) { + New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null +} + +# Install Python libraries +Write-Host "Installing Python dependencies..." +python -m pip install --upgrade pip + +python -m pip install qtpy pyserial pandas imageio "crc==1.3.0" lxml numpy tifffile scipy napari pyreadline3 +python -m pip install opencv-python-headless opencv-contrib-python-headless +python -m pip install "napari[all]" scikit-image dask_image ome_zarr aicsimageio basicpy pytest pytest-qt gitpython matplotlib pydantic_xml pyvisa hidapi psutil + +# Camera driver notes +Write-Host "" +Write-Host "========================================" -ForegroundColor Yellow +Write-Host "CAMERA DRIVER INSTALLATION" -ForegroundColor Yellow +Write-Host "========================================" -ForegroundColor Yellow +Write-Host "Please install camera drivers manually:" +Write-Host " - Daheng Camera: Download Galaxy SDK from https://www.dahengimaging.com/" +Write-Host " - ToupCam: DLL is included in the repository" +Write-Host "" + +# Create desktop shortcut +Write-Host "Creating desktop shortcut..." +$desktopPath = [Environment]::GetFolderPath("Desktop") +$shortcutPath = Join-Path $desktopPath "Squid_hcs.lnk" +$iconPath = Join-Path $SQUID_SOFTWARE_ROOT "icon\cephla_logo.ico" +$mainScript = Join-Path $SQUID_SOFTWARE_ROOT "main_hcs.py" + +$WshShell = New-Object -ComObject WScript.Shell +$shortcut = $WshShell.CreateShortcut($shortcutPath) +$shortcut.TargetPath = "python" +$shortcut.Arguments = "`"$mainScript`"" +$shortcut.WorkingDirectory = $SQUID_SOFTWARE_ROOT +if (Test-Path $iconPath) { + $shortcut.IconLocation = $iconPath +} +$shortcut.Save() + +Write-Host "Desktop shortcut created at: $shortcutPath" -ForegroundColor Green + +Write-Host "" +Write-Host "========================================" -ForegroundColor Green +Write-Host "Setup complete!" -ForegroundColor Green +Write-Host "========================================" -ForegroundColor Green +Write-Host "You can launch Squid by double-clicking the desktop shortcut." From 98ad8477ef9ab4786fd4256c0137d73a3c84f915 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 01:58:57 -0700 Subject: [PATCH 2/4] feat(setup): target Python 3.12 and napari 0.7 on Windows Require 64-bit Python 3.12 explicitly instead of accepting whatever `python` resolves to: probe `py -3.12` first, then python3.12/python3/ python, and pin every later pip call and the desktop shortcut to the resolved sys.executable. Install napari as napari[pyqt5]==0.7.1 rather than napari[all]. As of napari 0.7 the all/qt extras resolve to PyQt6, while Squid's GUI is PyQt5 via qtpy, so the old list installed no PyQt5 at all. A post-install check warns if PyQt6 ends up in the environment anyway. Align the dependency list with setup_26.04.sh and with what master actually imports: - drop aicsimageio/basicpy (only importer is control/stitcher.py, which nothing imports) and the numpy<2 cap that existed for them - add pyqtgraph and PyQt5, which the Ubuntu scripts get from apt - add pyyaml, platformdirs, filelock, lxml_html_clean, mcp and ndv, all hard imports that were missing - add tensorstore, required by the ZARR_V3 file_saving_option - install in a single pip call so the resolver sees every constraint Also fix two latent failures: - git clone now recurses submodules; control/ndviewer_light and fluidics_v2 are submodules and the GUI will not start without them - $ErrorActionPreference does not apply to native executables, so a failing pip or git only set $LASTEXITCODE and the script carried on to create a desktop shortcut for a broken install Co-Authored-By: Claude Opus 5 (1M context) --- software/setup_windows.ps1 | 255 ++++++++++++++++++++++++++++++++++--- 1 file changed, 235 insertions(+), 20 deletions(-) diff --git a/software/setup_windows.ps1 b/software/setup_windows.ps1 index b790f9776..7ea798f83 100644 --- a/software/setup_windows.ps1 +++ b/software/setup_windows.ps1 @@ -3,7 +3,35 @@ .SYNOPSIS Setup script for Squid on Windows. .DESCRIPTION - Installs Python dependencies, clones the repository if needed, and creates a desktop shortcut. + Verifies prerequisites, clones the repository if needed, installs Python + dependencies, and creates a desktop shortcut. + + This script targets Python 3.12 and napari 0.7: + + 1. Python 3.12 is required exactly. napari 0.7 supports 3.10-3.14, but + 3.12 is the newest version with wheels for every dependency here + (notably PyQt5 and hidapi), and pinning one interpreter keeps every + Windows install reproducible. + + 2. napari is installed as napari[pyqt5]==0.7.1, NOT napari[all]. As of + napari 0.7 the "all"/"qt" extras resolve to PyQt6, but Squid's GUI is + PyQt5 (control/widgets.py, control/gui_hcs.py via qtpy). Installing + both bindings makes qtpy's choice non-deterministic and crashes the + GUI, so the PyQt5 extra is requested explicitly. + + 3. numpy is not capped at <2. The <2 cap on setup_22.04.sh exists for + aicsimageio, which is not installed here (see 4). + + 4. aicsimageio and basicpy are NOT installed. Their only importer is + control/stitcher.py, which nothing in the codebase imports (the + active stitcher is tools/stitcher.py, an ImageJ/Fiji-based path). + Both are hard to build on Windows, so dropping the dead dependency + de-risks the install. If control/stitcher.py is ever revived, add + them back here. + + 5. Packages the Ubuntu scripts get from apt (pyqtgraph, PyQt5) are + installed from PyPI here, since Windows has no system package + manager providing them. .PARAMETER RepoPath Path where Squid repository should be cloned. Defaults to Desktop\Squid. #> @@ -14,29 +42,134 @@ param( $ErrorActionPreference = "Stop" +# Required Python version. napari 0.7 also runs on 3.10/3.11/3.13/3.14, but we +# pin one version so every Windows machine ends up with the same environment. +$REQUIRED_PYTHON_MAJOR = 3 +$REQUIRED_PYTHON_MINOR = 12 +$REQUIRED_PYTHON = "$REQUIRED_PYTHON_MAJOR.$REQUIRED_PYTHON_MINOR" + Write-Host "Using SQUID_REPO_PATH='$RepoPath'" $SQUID_REPO_HTTP = "https://github.com/Cephla-Lab/Squid.git" $SQUID_SOFTWARE_ROOT = Join-Path $RepoPath "software" $SQUID_REPO_PATH_PARENT = Split-Path $RepoPath -Parent -# Check if Python is installed -try { - $pythonVersion = python --version 2>&1 - Write-Host "Found $pythonVersion" -} catch { - Write-Error "Python is not installed or not in PATH. Please install Python 3.10+ from https://python.org" +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# PowerShell's $ErrorActionPreference does not apply to native executables: +# a failing python/pip/git call only sets $LASTEXITCODE, and the script would +# otherwise sail past a broken install and still create a desktop shortcut. +function Assert-LastExitCode { + param([string]$What) + + if ($LASTEXITCODE -ne 0) { + throw "$What failed with exit code $LASTEXITCODE" + } +} + +# Probe a candidate interpreter. Returns a hashtable with its version, bitness +# and real executable path, or $null if the candidate cannot be run at all. +function Get-PythonInfo { + param( + [string]$Exe, + [string[]]$Prefix = @() + ) + + $probe = "import sys; print(sys.version_info[0]); print(sys.version_info[1]); " + + "print(8 * (sys.maxsize > 2**32)); print(sys.executable)" + + # A wrong-version py launcher, or the Microsoft Store python.exe stub, + # writes to stderr and exits non-zero. Under $ErrorActionPreference = + # "Stop" Windows PowerShell can turn that into a terminating + # NativeCommandError, so probing runs with the preference relaxed and we + # judge the candidate on its exit code instead. + $previousPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = & $Exe @Prefix -c $probe 2>$null + } catch { + return $null + } finally { + $ErrorActionPreference = $previousPreference + } + if ($LASTEXITCODE -ne 0 -or $null -eq $output -or $output.Count -lt 4) { + return $null + } + + return @{ + Major = [int]$output[0] + Minor = [int]$output[1] + Bits = [int]$output[2] + Executable = $output[3] + } +} + +# --------------------------------------------------------------------------- +# Prerequisites +# --------------------------------------------------------------------------- + +# Find a Python 3.12 interpreter. The py launcher is tried first because it can +# select an exact version even when a different Python owns the PATH. +$candidates = @() +if (Get-Command py -ErrorAction SilentlyContinue) { + $candidates += @{ Exe = "py"; Prefix = @("-$REQUIRED_PYTHON") } +} +foreach ($name in @("python$REQUIRED_PYTHON", "python3", "python")) { + if (Get-Command $name -ErrorAction SilentlyContinue) { + $candidates += @{ Exe = $name; Prefix = @() } + } +} + +$PythonExe = $null +$foundVersions = @() +foreach ($candidate in $candidates) { + $info = Get-PythonInfo -Exe $candidate.Exe -Prefix $candidate.Prefix + if ($null -eq $info) { + continue + } + + $version = "$($info.Major).$($info.Minor)" + $foundVersions += "$version ($($info.Executable))" + + if ($info.Major -ne $REQUIRED_PYTHON_MAJOR -or $info.Minor -ne $REQUIRED_PYTHON_MINOR) { + continue + } + if ($info.Bits -ne 64) { + Write-Warning "Ignoring 32-bit Python at $($info.Executable); Squid needs 64-bit Python." + continue + } + + # Use sys.executable rather than the launcher so every later call (pip, + # the desktop shortcut) is pinned to this exact interpreter. + $PythonExe = $info.Executable + break +} + +if ($null -eq $PythonExe) { + $detail = if ($foundVersions.Count -gt 0) { + "Found instead: " + ($foundVersions -join ", ") + "." + } else { + "No Python interpreter was found on PATH." + } + Write-Error ("64-bit Python $REQUIRED_PYTHON is required but was not found. $detail`n" + + "Install it from https://www.python.org/downloads/release/python-3120/ " + + "(check 'Add python.exe to PATH' in the installer), then re-run this script.") exit 1 } +Write-Host "Using Python $REQUIRED_PYTHON at $PythonExe" # Check if git is installed -try { - $gitVersion = git --version 2>&1 - Write-Host "Found $gitVersion" -} catch { +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { Write-Error "Git is not installed or not in PATH. Please install Git from https://git-scm.com" exit 1 } +Write-Host "Found $(git --version)" + +# --------------------------------------------------------------------------- +# Repository +# --------------------------------------------------------------------------- # Clone the repo if we don't already have it if (-not (Test-Path $SQUID_REPO_PATH_PARENT)) { @@ -45,10 +178,17 @@ if (-not (Test-Path $SQUID_REPO_PATH_PARENT)) { if (-not (Test-Path $RepoPath)) { Write-Host "Cloning Squid repository..." - git clone $SQUID_REPO_HTTP $RepoPath + # --recurse-submodules: control/ndviewer_light and fluidics_v2 are + # submodules, and the GUI fails to start without them. + git clone --recurse-submodules $SQUID_REPO_HTTP $RepoPath + Assert-LastExitCode "git clone" } else { $currentHead = git -C $RepoPath rev-parse HEAD + Assert-LastExitCode "git rev-parse" Write-Host "Using existing repo at '$RepoPath' at HEAD=$currentHead" + Write-Host "Updating submodules..." + git -C $RepoPath submodule update --init --recursive + Assert-LastExitCode "git submodule update" } # Create cache directory @@ -57,15 +197,85 @@ if (-not (Test-Path $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null } -# Install Python libraries +# --------------------------------------------------------------------------- +# Python dependencies +# --------------------------------------------------------------------------- + +# Grouped for readability, but installed in a single pip call so the resolver +# sees every constraint at once. Splitting the install lets a later call pull a +# conflicting version of something an earlier call already placed - which is +# exactly how a stray PyQt6 ends up beside PyQt5. +$packages = @( + # Core runtime + "qtpy", + "pyserial", + "pandas", + "imageio", + "crc==1.3.0", + "lxml", + "lxml_html_clean", + "numpy", + "tifffile", + "scipy", + "psutil", + "platformdirs", + "pyyaml", + "filelock", + "gitpython", + "matplotlib", + "pydantic_xml", + # Windows-only readline replacement (control/console.py) + "pyreadline3", + # GUI. The pyqt5 extra is required - see the note at the top of this file. + "napari[pyqt5]==0.7.1", + "pyqtgraph", + "ndv", + # Image processing + "opencv-python-headless", + "opencv-contrib-python-headless", + "scikit-image", + "dask_image", + "ome_zarr", + # Optional at import time (control/core/zarr_writer.py imports it lazily), + # but required for the ZARR_V3 file_saving_option to work at all. + "tensorstore", + # Hardware / instrument I/O + "pyvisa", + "hidapi", + # Claude Code control server (mcp_microscope_server.py) + "mcp", + # Test suite + "pytest", + "pytest-qt" +) + Write-Host "Installing Python dependencies..." -python -m pip install --upgrade pip +& $PythonExe -m pip install --upgrade pip setuptools wheel +Assert-LastExitCode "pip install --upgrade pip" -python -m pip install qtpy pyserial pandas imageio "crc==1.3.0" lxml numpy tifffile scipy napari pyreadline3 -python -m pip install opencv-python-headless opencv-contrib-python-headless -python -m pip install "napari[all]" scikit-image dask_image ome_zarr aicsimageio basicpy pytest pytest-qt gitpython matplotlib pydantic_xml pyvisa hidapi psutil +& $PythonExe -m pip install @packages +Assert-LastExitCode "pip install" + +# Catch a mixed-binding environment early: with both bindings present qtpy +# picks PyQt5 or PyQt6 depending on import order, and the GUI fails in ways +# that look nothing like an install problem. +$pyqt6Probe = "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('PyQt6') is None else 1)" +$previousPreference = $ErrorActionPreference +$ErrorActionPreference = "Continue" +try { + & $PythonExe -c $pyqt6Probe 2>$null +} finally { + $ErrorActionPreference = $previousPreference +} +if ($LASTEXITCODE -ne 0) { + Write-Warning ("PyQt6 is installed alongside PyQt5. Squid requires PyQt5; run " + + "'`"$PythonExe`" -m pip uninstall PyQt6 PyQt6-Qt6 PyQt6-sip' before launching.") +} + +# --------------------------------------------------------------------------- +# Camera drivers +# --------------------------------------------------------------------------- -# Camera driver notes Write-Host "" Write-Host "========================================" -ForegroundColor Yellow Write-Host "CAMERA DRIVER INSTALLATION" -ForegroundColor Yellow @@ -75,7 +285,10 @@ Write-Host " - Daheng Camera: Download Galaxy SDK from https://www.dahengimagin Write-Host " - ToupCam: DLL is included in the repository" Write-Host "" -# Create desktop shortcut +# --------------------------------------------------------------------------- +# Desktop shortcut +# --------------------------------------------------------------------------- + Write-Host "Creating desktop shortcut..." $desktopPath = [Environment]::GetFolderPath("Desktop") $shortcutPath = Join-Path $desktopPath "Squid_hcs.lnk" @@ -84,7 +297,9 @@ $mainScript = Join-Path $SQUID_SOFTWARE_ROOT "main_hcs.py" $WshShell = New-Object -ComObject WScript.Shell $shortcut = $WshShell.CreateShortcut($shortcutPath) -$shortcut.TargetPath = "python" +# Full interpreter path, not "python": the shortcut must use the same 3.12 we +# just installed into, even if PATH later resolves "python" to something else. +$shortcut.TargetPath = $PythonExe $shortcut.Arguments = "`"$mainScript`"" $shortcut.WorkingDirectory = $SQUID_SOFTWARE_ROOT if (Test-Path $iconPath) { From 9d7050a8563a7ca1f07c3d2643af803bdf9c63a3 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 04:10:12 -0700 Subject: [PATCH 3/4] feat(setup): install Python 3.12 automatically on Windows The script previously required the user to have installed Python 3.12 themselves and failed with instructions otherwise. Install it instead: download the official python.org installer, verify its SHA256, and run it silently. The install is per-user (InstallAllUsers=0, InstallLauncherAllUsers=0), so it needs no admin rights and raises no UAC prompt. Since the installer only edits PATH for new processes, the script refreshes $env:Path from the registry afterwards and also probes the default per-user install location directly, then re-runs discovery. Pins 3.12.10 because it is the last 3.12 release with a Windows binary installer - 3.12.11 and later are source-only security releases. Pass -SkipPythonInstall to keep the old fail-with-instructions behaviour on machines where Python is managed centrally. Interpreter discovery moves into Find-Python312 so it can run both before and after the install. Fixes a bitness check that rejected every interpreter: the probe printed 8 * (sys.maxsize > 2**32), which is 8 on a 64-bit Python rather than 64, because Python's True is 1. Compared against 64, no interpreter could ever be accepted. Co-Authored-By: Claude Opus 5 (1M context) --- software/setup_windows.ps1 | 183 +++++++++++++++++++++++++++++-------- 1 file changed, 143 insertions(+), 40 deletions(-) diff --git a/software/setup_windows.ps1 b/software/setup_windows.ps1 index 7ea798f83..c10ecec67 100644 --- a/software/setup_windows.ps1 +++ b/software/setup_windows.ps1 @@ -3,8 +3,8 @@ .SYNOPSIS Setup script for Squid on Windows. .DESCRIPTION - Verifies prerequisites, clones the repository if needed, installs Python - dependencies, and creates a desktop shortcut. + Installs Python 3.12 if it is missing, clones the repository if needed, + installs Python dependencies, and creates a desktop shortcut. This script targets Python 3.12 and napari 0.7: @@ -13,6 +13,13 @@ (notably PyQt5 and hidapi), and pinning one interpreter keeps every Windows install reproducible. + If no 3.12 is present the script downloads the official installer + from python.org and runs it silently, per-user, so no admin rights + or UAC prompt are needed. It installs 3.12.10 specifically: that is + the final 3.12 release with a Windows binary installer (3.12.11 and + later are source-only security releases). Pass -SkipPythonInstall to + require a pre-existing interpreter instead. + 2. napari is installed as napari[pyqt5]==0.7.1, NOT napari[all]. As of napari 0.7 the "all"/"qt" extras resolve to PyQt6, but Squid's GUI is PyQt5 (control/widgets.py, control/gui_hcs.py via qtpy). Installing @@ -34,10 +41,14 @@ manager providing them. .PARAMETER RepoPath Path where Squid repository should be cloned. Defaults to Desktop\Squid. +.PARAMETER SkipPythonInstall + Fail instead of installing Python 3.12 when no suitable interpreter is + found. Useful on machines where Python is managed centrally. #> param( - [string]$RepoPath = "$env:USERPROFILE\Desktop\Squid" + [string]$RepoPath = "$env:USERPROFILE\Desktop\Squid", + [switch]$SkipPythonInstall ) $ErrorActionPreference = "Stop" @@ -48,6 +59,17 @@ $REQUIRED_PYTHON_MAJOR = 3 $REQUIRED_PYTHON_MINOR = 12 $REQUIRED_PYTHON = "$REQUIRED_PYTHON_MAJOR.$REQUIRED_PYTHON_MINOR" +# 3.12.10 is the last 3.12 with a Windows binary installer; 3.12.11+ are +# source-only security releases, so this version does not float. +# +# The hash is of https://www.python.org/ftp/python/3.12.10/python-3.12.10-amd64.exe. +# To re-derive it after a version bump: +# (Get-FileHash .\python-3.12.10-amd64.exe -Algorithm SHA256).Hash +$PYTHON_INSTALLER_VERSION = "3.12.10" +$PYTHON_INSTALLER_SHA256 = "67B5635E80EA51072B87941312D00EC8927C4DB9BA18938F7AD2D27B328B95FB" +$PYTHON_INSTALLER_URL = + "https://www.python.org/ftp/python/$PYTHON_INSTALLER_VERSION/python-$PYTHON_INSTALLER_VERSION-amd64.exe" + Write-Host "Using SQUID_REPO_PATH='$RepoPath'" $SQUID_REPO_HTTP = "https://github.com/Cephla-Lab/Squid.git" @@ -78,7 +100,7 @@ function Get-PythonInfo { ) $probe = "import sys; print(sys.version_info[0]); print(sys.version_info[1]); " + - "print(8 * (sys.maxsize > 2**32)); print(sys.executable)" + "print(64 if sys.maxsize > 2**32 else 32); print(sys.executable)" # A wrong-version py launcher, or the Microsoft Store python.exe stub, # writes to stderr and exits non-zero. Under $ErrorActionPreference = @@ -106,57 +128,138 @@ function Get-PythonInfo { } } -# --------------------------------------------------------------------------- -# Prerequisites -# --------------------------------------------------------------------------- +# Locate a 64-bit Python 3.12, or return $null. Interpreters that were found +# but rejected are recorded in $script:FoundPythonVersions for the error path. +function Find-Python312 { + # The py launcher is tried first because it can select an exact version + # even when a different Python owns the PATH. + $candidates = @() + if (Get-Command py -ErrorAction SilentlyContinue) { + $candidates += @{ Exe = "py"; Prefix = @("-$REQUIRED_PYTHON") } + } + foreach ($name in @("python$REQUIRED_PYTHON", "python3", "python")) { + if (Get-Command $name -ErrorAction SilentlyContinue) { + $candidates += @{ Exe = $name; Prefix = @() } + } + } + # Default per-user install location, checked explicitly because a Python + # this script just installed is not on this process's PATH. + $defaultInstall = Join-Path $env:LOCALAPPDATA "Programs\Python\Python312\python.exe" + if (Test-Path $defaultInstall) { + $candidates += @{ Exe = $defaultInstall; Prefix = @() } + } -# Find a Python 3.12 interpreter. The py launcher is tried first because it can -# select an exact version even when a different Python owns the PATH. -$candidates = @() -if (Get-Command py -ErrorAction SilentlyContinue) { - $candidates += @{ Exe = "py"; Prefix = @("-$REQUIRED_PYTHON") } -} -foreach ($name in @("python$REQUIRED_PYTHON", "python3", "python")) { - if (Get-Command $name -ErrorAction SilentlyContinue) { - $candidates += @{ Exe = $name; Prefix = @() } + $script:FoundPythonVersions = @() + foreach ($candidate in $candidates) { + $info = Get-PythonInfo -Exe $candidate.Exe -Prefix $candidate.Prefix + if ($null -eq $info) { + continue + } + + $version = "$($info.Major).$($info.Minor)" + $script:FoundPythonVersions += "$version ($($info.Executable))" + + if ($info.Major -ne $REQUIRED_PYTHON_MAJOR -or $info.Minor -ne $REQUIRED_PYTHON_MINOR) { + continue + } + if ($info.Bits -ne 64) { + Write-Warning "Ignoring 32-bit Python at $($info.Executable); Squid needs 64-bit Python." + continue + } + + # Return sys.executable rather than the launcher so every later call + # (pip, the desktop shortcut) is pinned to this exact interpreter. + return $info.Executable } + + return $null } -$PythonExe = $null -$foundVersions = @() -foreach ($candidate in $candidates) { - $info = Get-PythonInfo -Exe $candidate.Exe -Prefix $candidate.Prefix - if ($null -eq $info) { - continue - } +# Download and silently install Python from python.org. +function Install-Python312 { + $installer = Join-Path $env:TEMP "python-$PYTHON_INSTALLER_VERSION-amd64.exe" - $version = "$($info.Major).$($info.Minor)" - $foundVersions += "$version ($($info.Executable))" + Write-Host "Downloading Python $PYTHON_INSTALLER_VERSION from $PYTHON_INSTALLER_URL" + # Invoke-WebRequest renders a progress bar per chunk in Windows PowerShell, + # which turns this 27 MB download into a multi-minute one. Older Windows + # also defaults to TLS 1.0, which python.org refuses. + $previousProgress = $ProgressPreference + $ProgressPreference = "SilentlyContinue" + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri $PYTHON_INSTALLER_URL -OutFile $installer -UseBasicParsing + } finally { + $ProgressPreference = $previousProgress + } - if ($info.Major -ne $REQUIRED_PYTHON_MAJOR -or $info.Minor -ne $REQUIRED_PYTHON_MINOR) { - continue + $actualHash = (Get-FileHash -Path $installer -Algorithm SHA256).Hash + if ($actualHash -ne $PYTHON_INSTALLER_SHA256) { + Remove-Item $installer -Force -ErrorAction SilentlyContinue + throw ("Downloaded Python installer failed its integrity check; not running it.`n" + + " expected SHA256: $PYTHON_INSTALLER_SHA256`n" + + " actual SHA256: $actualHash") } - if ($info.Bits -ne 64) { - Write-Warning "Ignoring 32-bit Python at $($info.Executable); Squid needs 64-bit Python." - continue + + Write-Host "Installing Python $PYTHON_INSTALLER_VERSION (per-user, no admin rights needed)..." + # InstallAllUsers=0 and InstallLauncherAllUsers=0 keep this out of Program + # Files, so the install runs without a UAC prompt. Both PrependPath and the + # py launcher are wanted: PrependPath for interactive use, the launcher so + # `py -3.12` finds this interpreter regardless of PATH order. + $installArgs = @( + "/quiet", + "InstallAllUsers=0", + "PrependPath=1", + "Include_launcher=1", + "InstallLauncherAllUsers=0", + "Include_test=0", + "AssociateFiles=0", + "Shortcuts=0" + ) + $process = Start-Process -FilePath $installer -ArgumentList $installArgs -Wait -PassThru + Remove-Item $installer -Force -ErrorAction SilentlyContinue + + # 3010 is "success, reboot required"; the interpreter is usable right now. + if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010) { + throw ("Python installer failed with exit code $($process.ExitCode). " + + "Install Python $REQUIRED_PYTHON manually from https://www.python.org/downloads/ " + + "and re-run this script.") } - # Use sys.executable rather than the launcher so every later call (pip, - # the desktop shortcut) is pinned to this exact interpreter. - $PythonExe = $info.Executable - break + # The installer edits PATH for *new* processes; refresh this one so the + # re-probe below can see the interpreter it just installed. + $machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + $env:Path = (@($machinePath, $userPath) | Where-Object { $_ }) -join ";" } +# --------------------------------------------------------------------------- +# Prerequisites +# --------------------------------------------------------------------------- + +$PythonExe = Find-Python312 + if ($null -eq $PythonExe) { - $detail = if ($foundVersions.Count -gt 0) { - "Found instead: " + ($foundVersions -join ", ") + "." + $detail = if ($script:FoundPythonVersions.Count -gt 0) { + "Found instead: " + ($script:FoundPythonVersions -join ", ") + "." } else { "No Python interpreter was found on PATH." } - Write-Error ("64-bit Python $REQUIRED_PYTHON is required but was not found. $detail`n" + - "Install it from https://www.python.org/downloads/release/python-3120/ " + - "(check 'Add python.exe to PATH' in the installer), then re-run this script.") - exit 1 + + if ($SkipPythonInstall) { + Write-Error ("64-bit Python $REQUIRED_PYTHON is required but was not found. $detail`n" + + "Install it from https://www.python.org/downloads/ (check 'Add python.exe " + + "to PATH'), or re-run without -SkipPythonInstall to install it automatically.") + exit 1 + } + + Write-Host "64-bit Python $REQUIRED_PYTHON not found. $detail" + Install-Python312 + + $PythonExe = Find-Python312 + if ($null -eq $PythonExe) { + throw ("Python $REQUIRED_PYTHON still could not be found after installing it. " + + "Try opening a new terminal and re-running this script.") + } } Write-Host "Using Python $REQUIRED_PYTHON at $PythonExe" From 93a9c6788d9b7a5b676fa5a4881e7c2c7a0a7aa8 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 04:20:02 -0700 Subject: [PATCH 4/4] feat(setup): install Git automatically on Windows Git was the last prerequisite the script still required the user to install by hand. Fetch it the same way as Python: download the pinned Git for Windows installer, verify its SHA256, run it silently. Git for Windows is an Inno Setup installer built with PrivilegesRequired=none, so an unelevated run installs per-user into %LOCALAPPDATA%\Programs\Git and records itself under HKCU rather than HKLM - no UAC prompt, matching the Python install. Run elevated it installs machine-wide instead; Find-Git handles either by checking PATH, then both registry keys, then the two default directories. Every git call site now uses the resolved $GitExe rather than bare `git`, since a Git installed by this script is not on this process's PATH. The download/verify/run/refresh-PATH sequence is now shared by both prerequisites as Invoke-SilentInstaller instead of being duplicated, with the per-installer differences (silent flags, accepted exit codes, manual-install URL) passed in. Git is pinned at 2.55.0.3 for reproducibility rather than compatibility - Squid works with any modern Git. Its SHA256 is the one published in the release notes, which matches the GitHub API's asset digest. Co-Authored-By: Claude Opus 5 (1M context) --- software/setup_windows.ps1 | 193 ++++++++++++++++++++++++++++--------- 1 file changed, 149 insertions(+), 44 deletions(-) diff --git a/software/setup_windows.ps1 b/software/setup_windows.ps1 index c10ecec67..0c15d2f8f 100644 --- a/software/setup_windows.ps1 +++ b/software/setup_windows.ps1 @@ -3,8 +3,13 @@ .SYNOPSIS Setup script for Squid on Windows. .DESCRIPTION - Installs Python 3.12 if it is missing, clones the repository if needed, - installs Python dependencies, and creates a desktop shortcut. + Installs Python 3.12 and Git if they are missing, clones the repository if + needed, installs Python dependencies, and creates a desktop shortcut. + + Both prerequisites are fetched from their official download hosts, checked + against a pinned SHA256 before being run, and installed silently per-user + so no admin rights or UAC prompts are involved. -SkipPythonInstall and + -SkipGitInstall opt out of either. This script targets Python 3.12 and napari 0.7: @@ -44,11 +49,14 @@ .PARAMETER SkipPythonInstall Fail instead of installing Python 3.12 when no suitable interpreter is found. Useful on machines where Python is managed centrally. +.PARAMETER SkipGitInstall + Fail instead of installing Git when it is not found. #> param( [string]$RepoPath = "$env:USERPROFILE\Desktop\Squid", - [switch]$SkipPythonInstall + [switch]$SkipPythonInstall, + [switch]$SkipGitInstall ) $ErrorActionPreference = "Stop" @@ -70,6 +78,16 @@ $PYTHON_INSTALLER_SHA256 = "67B5635E80EA51072B87941312D00EC8927C4DB9BA18938F7AD2 $PYTHON_INSTALLER_URL = "https://www.python.org/ftp/python/$PYTHON_INSTALLER_VERSION/python-$PYTHON_INSTALLER_VERSION-amd64.exe" +# Git is pinned too, for reproducibility rather than compatibility - Squid +# works with any modern Git. To bump: pick a release from +# https://github.com/git-for-windows/git/releases and take the SHA256 that +# the release notes publish next to the .exe. +$GIT_INSTALLER_VERSION = "2.55.0.3" +$GIT_INSTALLER_TAG = "v2.55.0.windows.3" +$GIT_INSTALLER_SHA256 = "AF12577D0FDFF74243A5988197AA49B957D5044EDC17004F6DDF0768996F1DCA" +$GIT_INSTALLER_URL = + "https://github.com/git-for-windows/git/releases/download/$GIT_INSTALLER_TAG/Git-$GIT_INSTALLER_VERSION-64-bit.exe" + Write-Host "Using SQUID_REPO_PATH='$RepoPath'" $SQUID_REPO_HTTP = "https://github.com/Cephla-Lab/Squid.git" @@ -175,63 +193,136 @@ function Find-Python312 { return $null } -# Download and silently install Python from python.org. -function Install-Python312 { - $installer = Join-Path $env:TEMP "python-$PYTHON_INSTALLER_VERSION-amd64.exe" +# Locate git.exe, or return $null. +function Find-Git { + $command = Get-Command git -ErrorAction SilentlyContinue + if ($command) { + return $command.Source + } + + # Not on PATH. A Git this script just installed will not be either, since + # the installer only edits PATH for new processes - but it does record + # where it landed: HKLM for an elevated install, HKCU for a per-user one. + foreach ($key in @("HKCU:\Software\GitForWindows", "HKLM:\Software\GitForWindows")) { + $installPath = (Get-ItemProperty -Path $key -Name InstallPath -ErrorAction SilentlyContinue).InstallPath + if ($installPath) { + $exe = Join-Path $installPath "cmd\git.exe" + if (Test-Path $exe) { + return $exe + } + } + } + + foreach ($dir in @("$env:LOCALAPPDATA\Programs\Git", "$env:ProgramFiles\Git")) { + $exe = Join-Path $dir "cmd\git.exe" + if (Test-Path $exe) { + return $exe + } + } - Write-Host "Downloading Python $PYTHON_INSTALLER_VERSION from $PYTHON_INSTALLER_URL" + return $null +} + +# Download an installer, verify it against a known SHA256, and run it silently. +function Invoke-SilentInstaller { + param( + [string]$DisplayName, + [string]$Url, + [string]$Sha256, + [string]$FileName, + [string[]]$InstallerArgs, + # Inno Setup and the Python installer both use 3010 for "succeeded, + # reboot pending" - the payload is usable immediately either way. + [int[]]$SuccessExitCodes = @(0, 3010), + [string]$ManualUrl + ) + + $installer = Join-Path $env:TEMP $FileName + + Write-Host "Downloading $DisplayName from $Url" # Invoke-WebRequest renders a progress bar per chunk in Windows PowerShell, - # which turns this 27 MB download into a multi-minute one. Older Windows - # also defaults to TLS 1.0, which python.org refuses. + # which turns a 30-70 MB download into a multi-minute one. Older Windows + # also defaults to TLS 1.0, which both download hosts refuse. $previousProgress = $ProgressPreference $ProgressPreference = "SilentlyContinue" try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - Invoke-WebRequest -Uri $PYTHON_INSTALLER_URL -OutFile $installer -UseBasicParsing + Invoke-WebRequest -Uri $Url -OutFile $installer -UseBasicParsing } finally { $ProgressPreference = $previousProgress } $actualHash = (Get-FileHash -Path $installer -Algorithm SHA256).Hash - if ($actualHash -ne $PYTHON_INSTALLER_SHA256) { + if ($actualHash -ne $Sha256) { Remove-Item $installer -Force -ErrorAction SilentlyContinue - throw ("Downloaded Python installer failed its integrity check; not running it.`n" + - " expected SHA256: $PYTHON_INSTALLER_SHA256`n" + + throw ("Downloaded $DisplayName installer failed its integrity check; not running it.`n" + + " expected SHA256: $Sha256`n" + " actual SHA256: $actualHash") } - Write-Host "Installing Python $PYTHON_INSTALLER_VERSION (per-user, no admin rights needed)..." - # InstallAllUsers=0 and InstallLauncherAllUsers=0 keep this out of Program - # Files, so the install runs without a UAC prompt. Both PrependPath and the - # py launcher are wanted: PrependPath for interactive use, the launcher so - # `py -3.12` finds this interpreter regardless of PATH order. - $installArgs = @( - "/quiet", - "InstallAllUsers=0", - "PrependPath=1", - "Include_launcher=1", - "InstallLauncherAllUsers=0", - "Include_test=0", - "AssociateFiles=0", - "Shortcuts=0" - ) - $process = Start-Process -FilePath $installer -ArgumentList $installArgs -Wait -PassThru + Write-Host "Installing $DisplayName (per-user, no admin rights needed)..." + $process = Start-Process -FilePath $installer -ArgumentList $InstallerArgs -Wait -PassThru Remove-Item $installer -Force -ErrorAction SilentlyContinue - # 3010 is "success, reboot required"; the interpreter is usable right now. - if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010) { - throw ("Python installer failed with exit code $($process.ExitCode). " + - "Install Python $REQUIRED_PYTHON manually from https://www.python.org/downloads/ " + - "and re-run this script.") + if ($SuccessExitCodes -notcontains $process.ExitCode) { + throw ("$DisplayName installer failed with exit code $($process.ExitCode). " + + "Install it manually from $ManualUrl and re-run this script.") } - # The installer edits PATH for *new* processes; refresh this one so the - # re-probe below can see the interpreter it just installed. + # The installers edit PATH for *new* processes; refresh this one so the + # re-probe by the caller can see what was just installed. $machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") $userPath = [Environment]::GetEnvironmentVariable("Path", "User") $env:Path = (@($machinePath, $userPath) | Where-Object { $_ }) -join ";" } +function Install-Python312 { + # InstallAllUsers=0 and InstallLauncherAllUsers=0 keep this out of Program + # Files, so the install runs without a UAC prompt. Both PrependPath and the + # py launcher are wanted: PrependPath for interactive use, the launcher so + # `py -3.12` finds this interpreter regardless of PATH order. + Invoke-SilentInstaller ` + -DisplayName "Python $PYTHON_INSTALLER_VERSION" ` + -Url $PYTHON_INSTALLER_URL ` + -Sha256 $PYTHON_INSTALLER_SHA256 ` + -FileName "python-$PYTHON_INSTALLER_VERSION-amd64.exe" ` + -ManualUrl "https://www.python.org/downloads/" ` + -InstallerArgs @( + "/quiet", + "InstallAllUsers=0", + "PrependPath=1", + "Include_launcher=1", + "InstallLauncherAllUsers=0", + "Include_test=0", + "AssociateFiles=0", + "Shortcuts=0" + ) +} + +function Install-Git { + # Git for Windows is an Inno Setup installer built with + # PrivilegesRequired=none, so an unelevated run installs per-user into + # %LOCALAPPDATA%\Programs\Git with no UAC prompt, and records itself under + # HKCU instead of HKLM. Run elevated it installs machine-wide instead; + # both are fine, and Find-Git handles either. + # + # The default PathOption already puts git on PATH, which is what the clone + # below and everyday use both need. + Invoke-SilentInstaller ` + -DisplayName "Git $GIT_INSTALLER_VERSION" ` + -Url $GIT_INSTALLER_URL ` + -Sha256 $GIT_INSTALLER_SHA256 ` + -FileName "Git-$GIT_INSTALLER_VERSION-64-bit.exe" ` + -ManualUrl "https://git-scm.com/download/win" ` + -InstallerArgs @( + "/VERYSILENT", + "/NORESTART", + "/SP-", + "/SUPPRESSMSGBOXES", + "/NOCANCEL" + ) +} + # --------------------------------------------------------------------------- # Prerequisites # --------------------------------------------------------------------------- @@ -263,12 +354,26 @@ if ($null -eq $PythonExe) { } Write-Host "Using Python $REQUIRED_PYTHON at $PythonExe" -# Check if git is installed -if (-not (Get-Command git -ErrorAction SilentlyContinue)) { - Write-Error "Git is not installed or not in PATH. Please install Git from https://git-scm.com" - exit 1 +$GitExe = Find-Git + +if ($null -eq $GitExe) { + if ($SkipGitInstall) { + Write-Error ("Git was not found. Install it from https://git-scm.com/download/win, " + + "or re-run without -SkipGitInstall to install it automatically.") + exit 1 + } + + Write-Host "Git not found." + Install-Git + + $GitExe = Find-Git + if ($null -eq $GitExe) { + throw ("Git still could not be found after installing it. " + + "Try opening a new terminal and re-running this script.") + } } -Write-Host "Found $(git --version)" +Write-Host "Using Git at $GitExe" +Write-Host "Found $(& $GitExe --version)" # --------------------------------------------------------------------------- # Repository @@ -283,14 +388,14 @@ if (-not (Test-Path $RepoPath)) { Write-Host "Cloning Squid repository..." # --recurse-submodules: control/ndviewer_light and fluidics_v2 are # submodules, and the GUI fails to start without them. - git clone --recurse-submodules $SQUID_REPO_HTTP $RepoPath + & $GitExe clone --recurse-submodules $SQUID_REPO_HTTP $RepoPath Assert-LastExitCode "git clone" } else { - $currentHead = git -C $RepoPath rev-parse HEAD + $currentHead = & $GitExe -C $RepoPath rev-parse HEAD Assert-LastExitCode "git rev-parse" Write-Host "Using existing repo at '$RepoPath' at HEAD=$currentHead" Write-Host "Updating submodules..." - git -C $RepoPath submodule update --init --recursive + & $GitExe -C $RepoPath submodule update --init --recursive Assert-LastExitCode "git submodule update" }