diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..35daf01 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,78 @@ +name: build & release + +# Builds the standalone exe (and the Inno Setup installer) on Windows. On a +# version tag (v*) it publishes a GitHub Release with both attached, so cutting +# a release is just: git tag v1.0.1 && git push --tags +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build tooling (PyInstaller + pybind11) + run: pip install -e ".[build]" + + - name: Build native C++ extension in place + # Reuses the env's pybind11; compile failures degrade to the pure-Python + # walker (see setup.py), so this never blocks the build. + run: pip install -e . --no-build-isolation + + - name: Build standalone exe + run: pyinstaller storageanalyzer.spec --noconfirm + + - name: Smoke-test the exe + shell: pwsh + run: | + $exe = "dist\storageanalyzer.exe" + & $exe --version + $vi = (Get-Item $exe).VersionInfo + if ($vi.ProductName -ne "StorageAnalyzer") { throw "missing version resource" } + $t = Join-Path $env:RUNNER_TEMP "satest" + New-Item -ItemType Directory -Force -Path $t | Out-Null + Set-Content -Path (Join-Path $t "f.txt") -Value ("x" * 4096) + & $exe $t --no-open -o (Join-Path $env:RUNNER_TEMP "report.html") + if (-not (Test-Path (Join-Path $env:RUNNER_TEMP "report.html"))) { throw "no report produced" } + + - name: Build installer (Inno Setup) + shell: pwsh + run: | + choco install innosetup --no-progress -y + $version = (Select-String -Path src\storageanalyzer\__init__.py ` + -Pattern '__version__\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value + & "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" ` + "/DMyAppVersion=$version" installer\storageanalyzer.iss + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: storageanalyzer-windows + path: | + dist/storageanalyzer.exe + dist/StorageAnalyzer-Setup-*.exe + if-no-files-found: error + + - name: Publish GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$env:GITHUB_REF_NAME" ` + dist\storageanalyzer.exe ` + (Get-ChildItem dist\StorageAnalyzer-Setup-*.exe).FullName ` + --title "StorageAnalyzer $env:GITHUB_REF_NAME" ` + --generate-notes diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bd36a9a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Cameron Crow + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 2e73522..5ede415 100644 --- a/README.md +++ b/README.md @@ -62,13 +62,54 @@ pip install -e ".[build]" `build-exe.ps1` compiles the native C++ walker, then runs PyInstaller against `storageanalyzer.spec` to produce a one-file exe (~8.5 MB) with the native -walker and the HTML template bundled in. PyInstaller caches its analysis in -`build\`, so re-runs are quick — pure-Python source edits need no recompile, -only `walker.cpp` changes do. Pass `-Clean` to force a full rebuild. +walker and the HTML template bundled in. The exe is a proper Windows artifact: +it carries the application **icon** and a **version resource** (right-click → +Properties → Details shows ProductName, version, company, and copyright), both +derived from `storageanalyzer.__version__` so they never drift. PyInstaller +caches its analysis in `build\`, so re-runs are quick — pure-Python source edits +need no recompile, only `walker.cpp` changes do. Pass `-Clean` to force a full +rebuild. The exe is fully portable: copy `dist\storageanalyzer.exe` anywhere and run it. It takes the same arguments as the `storageanalyzer` command below. +### Build a Windows installer + +For a friendlier distribution — a per-user installer that adds `storageanalyzer` +to your PATH and registers an uninstaller — build the [Inno Setup](https://jrsoftware.org/isinfo.php) +package: + +```powershell +# one-time: install Inno Setup +winget install JRSoftware.InnoSetup + +# build -- produces dist\StorageAnalyzer-Setup-.exe +.\build-installer.ps1 +``` + +`build-installer.ps1` builds the exe first, then compiles +`installer\storageanalyzer.iss`. The installer needs no admin rights (installs +under `%LOCALAPPDATA%\Programs`), offers an opt-in "add to PATH" task, and shows +up in *Apps & features*. If Inno Setup isn't installed the script still builds +the exe and exits with a note — the one-file exe is shippable on its own. + +### Automated releases + +`.github/workflows/release.yml` builds the exe and the installer on Windows for +every push and PR, and **publishes a GitHub Release with both attached whenever +a version tag is pushed**: + +```powershell +git tag v1.0.1 +git push --tags +``` + +The icon and version metadata are regenerated from `__version__` on every build, +so the only thing to bump for a release is `__version__` in +`src/storageanalyzer/__init__.py` (and the matching `version` in +`pyproject.toml`). The application icon lives at `packaging/storageanalyzer.ico`; +regenerate or tweak it with `python packaging/make_icon.py` (requires Pillow). + ## Usage ```powershell diff --git a/build-installer.ps1 b/build-installer.ps1 new file mode 100644 index 0000000..c3a4092 --- /dev/null +++ b/build-installer.ps1 @@ -0,0 +1,66 @@ +<# + build-installer.ps1 -- build dist\StorageAnalyzer-Setup-.exe + + Steps: + 1. Build the standalone exe (delegates to build-exe.ps1). + 2. Compile installer\storageanalyzer.iss with Inno Setup's ISCC, stamping + the version read from storageanalyzer.__version__. + + Inno Setup is required for step 2. If ISCC.exe is not found the script builds + the exe and then exits with guidance instead of failing hard -- the one-file + exe is already a complete, shippable artifact on its own. + + Install Inno Setup: winget install JRSoftware.InnoSetup + or: choco install innosetup + + Usage: + .\build-installer.ps1 # build exe + installer + .\build-installer.ps1 -Clean # force a full exe rebuild first +#> +param([switch]$Clean) + +$ErrorActionPreference = "Stop" +Set-Location $PSScriptRoot + +# --- 1. build the exe ------------------------------------------------------ +& "$PSScriptRoot\build-exe.ps1" @(if ($Clean) { "-Clean" }) +if ($LASTEXITCODE -ne 0) { Write-Host "exe build failed" -ForegroundColor Red; exit 1 } + +# --- version (single source of truth: __init__.py __version__) ------------- +$initPy = Join-Path $PSScriptRoot "src\storageanalyzer\__init__.py" +$m = Select-String -Path $initPy -Pattern '__version__\s*=\s*["'']([^"'']+)["'']' +$version = $m.Matches[0].Groups[1].Value +if (-not $version) { Write-Host "could not read __version__" -ForegroundColor Red; exit 1 } + +# --- 2. locate ISCC -------------------------------------------------------- +$iscc = (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source +if (-not $iscc) { + foreach ($p in @( + "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", + "$env:ProgramFiles\Inno Setup 6\ISCC.exe")) { + if (Test-Path $p) { $iscc = $p; break } + } +} +if (-not $iscc) { + Write-Host "" + Write-Host "Inno Setup (ISCC.exe) not found -- skipping the installer." -ForegroundColor Yellow + Write-Host "The standalone exe is ready at dist\storageanalyzer.exe." -ForegroundColor Yellow + Write-Host "To build the installer, install Inno Setup and re-run:" -ForegroundColor Yellow + Write-Host " winget install JRSoftware.InnoSetup" -ForegroundColor Yellow + exit 0 +} + +# --- 3. compile the installer ---------------------------------------------- +Write-Host "Compiling installer (version $version) with $iscc ..." -ForegroundColor Cyan +& $iscc "/DMyAppVersion=$version" "$PSScriptRoot\installer\storageanalyzer.iss" +if ($LASTEXITCODE -ne 0) { Write-Host "ISCC failed" -ForegroundColor Red; exit 1 } + +$setup = Join-Path $PSScriptRoot "dist\StorageAnalyzer-Setup-$version.exe" +if (Test-Path $setup) { + $size = "{0:N1} MB" -f ((Get-Item $setup).Length / 1MB) + Write-Host "" + Write-Host "Built: $setup ($size)" -ForegroundColor Green +} else { + Write-Host "ISCC reported success but $setup was not found." -ForegroundColor Red + exit 1 +} diff --git a/installer/storageanalyzer.iss b/installer/storageanalyzer.iss new file mode 100644 index 0000000..e7f4bf3 --- /dev/null +++ b/installer/storageanalyzer.iss @@ -0,0 +1,123 @@ +; Inno Setup script for StorageAnalyzer. +; +; Wraps the standalone dist\storageanalyzer.exe in a per-user installer that +; * installs to %LOCALAPPDATA%\Programs\StorageAnalyzer (no admin needed), +; * optionally adds that folder to the user PATH so `storageanalyzer` works +; from any terminal, +; * registers a proper uninstaller (Apps & features), +; * stamps publisher / version metadata. +; +; The version is injected by build-installer.ps1 from storageanalyzer.__version__; +; the default below is only used if you run ISCC by hand. +; +; Build: ISCC.exe /DMyAppVersion=1.0.0 installer\storageanalyzer.iss +; Or just: .\build-installer.ps1 (builds the exe first, then this) + +#ifndef MyAppVersion + #define MyAppVersion "1.0.0" +#endif + +#define MyAppName "StorageAnalyzer" +#define MyAppPublisher "Cameron Crow" +#define MyAppURL "https://github.com/CameronCrow/StorageAnalyzer" +#define MyAppExeName "storageanalyzer.exe" + +[Setup] +; A stable AppId keeps upgrades/uninstall coherent across versions -- do not change. +AppId={{75FD88CC-FE15-46C9-894F-5A5CABD9E1A5} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL}/releases +DefaultDirName={autopf}\{#MyAppName} +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes +DisableDirPage=auto +; Per-user install -- no UAC prompt, installs under %LOCALAPPDATA%\Programs. +PrivilegesRequired=lowest +ChangesEnvironment=yes +LicenseFile=..\LICENSE +OutputDir=..\dist +OutputBaseFilename=StorageAnalyzer-Setup-{#MyAppVersion} +SetupIconFile=..\packaging\storageanalyzer.ico +UninstallDisplayIcon={app}\{#MyAppExeName} +Compression=lzma2 +SolidCompression=yes +WizardStyle=modern +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "addtopath"; Description: "Add StorageAnalyzer to my PATH (run ""storageanalyzer"" from any terminal)"; Flags: checkedonce + +[Files] +Source: "..\dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{group}\{#MyAppName} on GitHub"; Filename: "{#MyAppURL}" +Name: "{group}\Uninstall {#MyAppName}"; Filename: "{uninstallexe}" + +[Code] +const + EnvironmentKey = 'Environment'; + +procedure EnvAddPath(Path: string); +var + Paths: string; +begin + { Read the existing user PATH (empty if unset). } + if not RegQueryStringValue(HKEY_CURRENT_USER, EnvironmentKey, 'Path', Paths) then + Paths := ''; + + { Skip if already present (delimited, case-insensitive). } + if Pos(';' + Uppercase(Path) + ';', ';' + Uppercase(Paths) + ';') > 0 then + exit; + + { Append, normalising the trailing delimiter. } + if Paths = '' then + Paths := Path + else if Copy(Paths, Length(Paths), 1) = ';' then + Paths := Paths + Path + else + Paths := Paths + ';' + Path; + + if RegWriteStringValue(HKEY_CURRENT_USER, EnvironmentKey, 'Path', Paths) then + Log('PATH: added ' + Path) + else + Log('PATH: FAILED to add ' + Path); +end; + +procedure EnvRemovePath(Path: string); +var + Paths: string; + P: Integer; +begin + if not RegQueryStringValue(HKEY_CURRENT_USER, EnvironmentKey, 'Path', Paths) then + exit; + + P := Pos(';' + Uppercase(Path) + ';', ';' + Uppercase(Paths) + ';'); + if P = 0 then + exit; + + Delete(Paths, P - 1, Length(Path) + 1); + RegWriteStringValue(HKEY_CURRENT_USER, EnvironmentKey, 'Path', Paths); +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + if (CurStep = ssPostInstall) and WizardIsTaskSelected('addtopath') then + EnvAddPath(ExpandConstant('{app}')); +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep = usPostUninstall then + EnvRemovePath(ExpandConstant('{app}')); +end; diff --git a/packaging/make_icon.py b/packaging/make_icon.py new file mode 100644 index 0000000..7494e1b --- /dev/null +++ b/packaging/make_icon.py @@ -0,0 +1,68 @@ +"""Generate ``packaging/storageanalyzer.ico`` -- the application icon. + +The icon is a stylised squarified treemap (the same visual the HTML report +draws): a rounded tile partitioned into a few nested rectangles. It is checked +in as a binary so the exe build never depends on Pillow; this script exists only +to regenerate / tweak it. + + python packaging/make_icon.py # rewrites packaging/storageanalyzer.ico + +Requires Pillow (``pip install pillow``). +""" + +from __future__ import annotations + +from pathlib import Path + +from PIL import Image, ImageDraw + +# Palette -- a cool blue/teal treemap on a dark rounded tile. +_BG = (24, 28, 38, 255) +_BORDER = (15, 18, 26, 255) +_TILES = [ + # (x0, y0, x1, y1, fill) -- in a 0..1 unit square, drawn largest-first + (0.06, 0.06, 0.60, 0.62, (56, 132, 222, 255)), # big blue block + (0.62, 0.06, 0.94, 0.40, (88, 196, 214, 255)), # teal + (0.62, 0.42, 0.94, 0.62, (122, 162, 247, 255)), # periwinkle + (0.06, 0.64, 0.36, 0.94, (94, 214, 168, 255)), # green + (0.38, 0.64, 0.60, 0.94, (240, 190, 92, 255)), # amber + (0.62, 0.64, 0.94, 0.94, (233, 116, 122, 255)), # red +] + + +def _render(size: int) -> Image.Image: + """Render the icon at ``size`` x ``size`` px with a rounded background.""" + # Supersample for crisp edges, then downscale. + scale = 4 + s = size * scale + img = Image.new("RGBA", (s, s), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + + radius = int(s * 0.18) + d.rounded_rectangle([0, 0, s - 1, s - 1], radius=radius, fill=_BG, + outline=_BORDER, width=max(1, int(s * 0.012))) + + gap = s * 0.012 + tile_r = max(1, int(s * 0.04)) + for x0, y0, x1, y1, fill in _TILES: + box = [ + x0 * s + gap, y0 * s + gap, + x1 * s - gap, y1 * s - gap, + ] + d.rounded_rectangle(box, radius=tile_r, fill=fill) + + return img.resize((size, size), Image.LANCZOS) + + +def main() -> None: + out = Path(__file__).with_name("storageanalyzer.ico") + sizes = [16, 24, 32, 48, 64, 128, 256] + frames = [_render(n) for n in sizes] + # Pillow writes a proper multi-resolution .ico from the largest frame + + # the explicit sizes list. + frames[-1].save(out, format="ICO", sizes=[(n, n) for n in sizes]) + print(f"wrote {out} ({out.stat().st_size:,} bytes, sizes={sizes})") + + +if __name__ == "__main__": + main() diff --git a/packaging/storageanalyzer.ico b/packaging/storageanalyzer.ico new file mode 100644 index 0000000..92957f4 Binary files /dev/null and b/packaging/storageanalyzer.ico differ diff --git a/storageanalyzer.spec b/storageanalyzer.spec index adb2443..ccd6484 100644 --- a/storageanalyzer.spec +++ b/storageanalyzer.spec @@ -1,15 +1,91 @@ -# PyInstaller build spec -- produces a one-file Windows console exe. +# PyInstaller build spec -- produces a polished one-file Windows console exe. # # Build: pyinstaller storageanalyzer.spec --noconfirm # Or just run build-exe.ps1, which (re)builds the native extension first. +# +# Beyond bundling the code, this spec gives the exe a real Windows identity: +# * an application icon (packaging/storageanalyzer.ico), and +# * a version resource (Properties -> Details: ProductName, version, company, +# copyright, ...) generated from storageanalyzer.__version__ so it can never +# drift from the package version. import glob +import re +from pathlib import Path + +# --- single source of truth: the package __version__ ----------------------- +# Parsed (not imported) so building the version resource never has to import the +# package or its native extension. +_init = Path("src/storageanalyzer/__init__.py").read_text(encoding="utf-8") +_m = re.search(r"""__version__\s*=\s*["']([^"']+)["']""", _init) +_version = _m.group(1) if _m else "0.0.0" +# Windows file/product version wants a 4-int tuple; pad/truncate from the parts. +_nums = (re.findall(r"\d+", _version) + ["0", "0", "0", "0"])[:4] +_vtuple = tuple(int(n) for n in _nums) + +# --- Windows version resource ---------------------------------------------- +# Written to build/ (git-ignored) each build and handed to EXE(version=...). +from PyInstaller.utils.win32.versioninfo import ( # noqa: E402 + FixedFileInfo, + StringFileInfo, + StringStruct, + StringTable, + VarFileInfo, + VarStruct, + VSVersionInfo, +) + +_version_info = VSVersionInfo( + ffi=FixedFileInfo( + filevers=_vtuple, + prodvers=_vtuple, + mask=0x3F, + flags=0x0, + OS=0x40004, # VOS_NT_WINDOWS32 + fileType=0x1, # VFT_APP + subtype=0x0, + date=(0, 0), + ), + kids=[ + StringFileInfo([ + StringTable( + "040904B0", # U.S. English, Unicode + [ + StringStruct("CompanyName", "Cameron Crow"), + StringStruct( + "FileDescription", + "Fast parallel Windows storage analyzer " + "with an interactive HTML report", + ), + StringStruct("FileVersion", _version), + StringStruct("InternalName", "storageanalyzer"), + StringStruct( + "LegalCopyright", "Cameron Crow. MIT License." + ), + StringStruct("OriginalFilename", "storageanalyzer.exe"), + StringStruct("ProductName", "StorageAnalyzer"), + StringStruct("ProductVersion", _version), + ], + ) + ]), + VarFileInfo([VarStruct("Translation", [0x0409, 1200])]), + ], +) + +Path("build").mkdir(exist_ok=True) +_version_file = Path("build/version_info.txt") +_version_file.write_text(str(_version_info), encoding="utf-8") + +# --- application icon (optional) ------------------------------------------- +_icon = "packaging/storageanalyzer.ico" +_icon = _icon if Path(_icon).is_file() else None -# The native C++ walker is optional. If it was built in place (by -# `pip install -e .` or build-exe.ps1) bundle the .pyd and register it as a -# hidden import -- scan.py imports it lazily inside a try/except, so -# PyInstaller's static analysis would otherwise miss it. If it was never -# built, the exe still works on the pure-Python walker. +# --- native walker (optional) ---------------------------------------------- +# If the native C++ walker was built in place (by `pip install -e .` or +# build-exe.ps1) bundle the .pyd and register it as a hidden import -- scan.py +# imports it lazily inside a try/except, so PyInstaller's static analysis would +# otherwise miss it. If it was never built, the exe still works on the +# pure-Python walker. _pyd = glob.glob("src/storageanalyzer/_native_walker*.pyd") binaries = [(p, "storageanalyzer") for p in _pyd] hiddenimports = ["storageanalyzer._native_walker"] if _pyd else [] @@ -33,6 +109,8 @@ exe = EXE( a.datas, [], name="storageanalyzer", + icon=_icon, + version=str(_version_file), debug=False, bootloader_ignore_signals=False, strip=False,