Compare commits
14 Commits
66fb01f6b1
...
2744ab9535
| Author | SHA1 | Date | |
|---|---|---|---|
| 2744ab9535 | |||
| 24ad18df42 | |||
| df5466b7f6 | |||
| 2b25c40f84 | |||
| c7ab790218 | |||
| 24cdfc0019 | |||
| db56cda0ca | |||
| 60186b8eba | |||
| dc7dbd5229 | |||
| 8790364ec0 | |||
| 81b66ffcb7 | |||
| 2d39c4f5ec | |||
| b349099357 | |||
| 98d0ac7191 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -16,6 +16,8 @@ dist/
|
||||
# PyInstaller output
|
||||
/packaging/build/
|
||||
/packaging/dist/
|
||||
# Generated at build time from cim_suite.__version__ (see packaging/suite.spec)
|
||||
/packaging/_version_info.generated.txt
|
||||
|
||||
# Packaged portable-build zips — transient test artifacts, regenerated per build
|
||||
/packaging/CIM-Service-Suite_*.zip
|
||||
|
||||
14
CLAUDE.md
14
CLAUDE.md
@@ -95,14 +95,14 @@ py -m venv .venv
|
||||
.venv\Scripts\python -m pytest tests/core/test_codecs.py::test_name # one test
|
||||
.venv\Scripts\python -m ruff check cim_suite tests
|
||||
|
||||
# Build standalone exe (one-folder) + verify it actually booted
|
||||
.venv\Scripts\pyinstaller --noconfirm --distpath packaging\dist --workpath packaging\build packaging\suite.spec
|
||||
$env:SUITE_SELFTEST="1"; .\packaging\dist\CIM-Service-Suite\CIM-Service-Suite.exe --module da12 --simulate; echo "exit=$LASTEXITCODE"; Remove-Item Env:\SUITE_SELFTEST
|
||||
# Build BOTH distribution artifacts (per-user installer + portable zip) in one shot.
|
||||
# Version is single-sourced from cim_suite.__version__. The installer needs Inno Setup 6
|
||||
# (without it, build.ps1 warns and produces the portable zip only). Code signing is gated
|
||||
# on $env:CIM_SIGN_CERT (see docs/RELEASE-PACKAGING.md); skipped if unset.
|
||||
powershell -ExecutionPolicy Bypass -File packaging\build.ps1
|
||||
|
||||
# Build installer (needs Inno Setup 6). Version is single-sourced from pyproject.toml,
|
||||
# so pass it in (else the installer falls back to the 0.0.0-dev sentinel):
|
||||
$v = .venv\Scripts\python -c "import cim_suite; print(cim_suite.__version__)"
|
||||
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DAppVersion=$v packaging\installer.iss
|
||||
# Verify the frozen exe actually booted (GUI app — use Start-Process -Wait for the exit code)
|
||||
$env:SUITE_SELFTEST="1"; $p = Start-Process .\packaging\dist\CIM-Service-Suite\CIM-Service-Suite.exe -ArgumentList "--module","da12","--simulate" -Wait -PassThru; echo "exit=$($p.ExitCode)"; Remove-Item Env:\SUITE_SELFTEST
|
||||
```
|
||||
|
||||
The whole test suite runs headless with no hardware (`SimulatedStation` speaks the
|
||||
|
||||
@@ -29,10 +29,12 @@ def main(argv: list[str] | None = None) -> int:
|
||||
from cim_suite.core.ui.theme import apply_theme
|
||||
from .registry import build_registry
|
||||
from .window import SuiteWindow
|
||||
from .branding import app_icon
|
||||
|
||||
app = QApplication.instance() or QApplication(sys.argv)
|
||||
app.setApplicationName("CIMTechniques Service Suite")
|
||||
app.setOrganizationName("CIMTechniques")
|
||||
app.setWindowIcon(app_icon())
|
||||
apply_theme(app)
|
||||
|
||||
modules = build_registry(args)
|
||||
|
||||
18
cim_suite/shell/branding.py
Normal file
18
cim_suite/shell/branding.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Suite branding assets resolved at runtime.
|
||||
|
||||
The window/taskbar icon. Uses a package-relative filesystem path (the same pattern as
|
||||
theme.fonts) so it works both from source and in the frozen one-folder bundle, where
|
||||
suite.spec copies cim_suite/shell/resources to the same relative location.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtGui import QIcon
|
||||
|
||||
_ICON_PATH = Path(__file__).parent / "resources" / "app_icon.png"
|
||||
|
||||
|
||||
def app_icon() -> QIcon:
|
||||
"""Return the suite application icon (non-null when the asset is present)."""
|
||||
return QIcon(str(_ICON_PATH))
|
||||
BIN
cim_suite/shell/resources/app_icon.png
Normal file
BIN
cim_suite/shell/resources/app_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -118,6 +118,41 @@ no migration.
|
||||
|
||||
---
|
||||
|
||||
## Packaging / distribution
|
||||
|
||||
### BL-P1 — End-user distribution (per-user installer + portable, signing-ready) · DONE
|
||||
*Completed 2026-06-08.*
|
||||
The suite now ships two artifacts a non-admin user can run on a locked-down laptop:
|
||||
a **per-user installer** (`PrivilegesRequired=lowest` → `%LOCALAPPDATA%\Programs`, no
|
||||
UAC) and a **portable zip** (unzip-and-run), both built in one shot by
|
||||
`packaging\build.ps1`. The exe carries the brand icon (`packaging\icon.ico`, generated
|
||||
from `docs/samples/icon.png`) and version metadata. Code signing is wired into the
|
||||
build but **inert** until `CIM_SIGN_CERT`/`CIM_SIGN_PARAMS` are set (OV / Azure Trusted
|
||||
Signing — not EV; see `docs/RELEASE-PACKAGING.md`). A bundled `READ-ME-FIRST.txt` walks
|
||||
users through the SmartScreen warning. Pillow is a build-only dep (`make_icon.py`) and is
|
||||
excluded from the frozen app.
|
||||
- **Spec/plan:** `docs/superpowers/specs/2026-06-08-end-user-distribution-design.md`,
|
||||
`docs/superpowers/plans/2026-06-08-end-user-distribution.md`.
|
||||
|
||||
### BL-P2 — Buy + enable a code-signing certificate · P1 · TODO
|
||||
*Added 2026-06-08.*
|
||||
Until signed, SmartScreen warns on first run and the strictest (AppLocker/WDAC) shops
|
||||
cannot run the app at all. Acquire an OV cert or enrol in Azure Trusted Signing, then
|
||||
follow the turn-on checklist in `docs/RELEASE-PACKAGING.md` (the build hooks already
|
||||
exist). Tracked separately because it needs a purchasing decision, not code.
|
||||
|
||||
### BL-P3 — Auto-update checker · P3 · TODO
|
||||
*Added 2026-06-08.*
|
||||
Self-serve users have no update path today (the launcher only shows a "What's new"
|
||||
dialog). A future spec: check for a newer published version and prompt to download.
|
||||
|
||||
### BL-P4 — Screenshots in the run guide · P3 · TODO
|
||||
*Added 2026-06-08.*
|
||||
`packaging\READ-ME-FIRST.txt` is text-only. A screenshot-illustrated version of the
|
||||
SmartScreen "More info → Run anyway" click-path would help non-technical users.
|
||||
|
||||
---
|
||||
|
||||
## Robustness & cleanup
|
||||
|
||||
### BL-1 — Graceful shutdown on app close · P2 · DONE
|
||||
|
||||
@@ -35,6 +35,13 @@ failure, and both controllers bridge it to `connectionChanged` — the label fli
|
||||
"Disconnected" and the pill greys, same as a deliberate stop. Report-only; auto-reconnect
|
||||
is filed as BL-9. See BL-2/BL-5 (DONE).
|
||||
|
||||
**End-user distribution (BL-P1, 2026-06-08):** the suite now builds two artifacts a
|
||||
non-admin user can run on a locked-down laptop — a **per-user no-admin installer**
|
||||
(`%LOCALAPPDATA%\Programs`) and a **portable zip** — via `packaging\build.ps1`. The exe
|
||||
carries the brand icon + version metadata, and a `READ-ME-FIRST.txt` walks users past the
|
||||
SmartScreen warning. Code signing is wired but **inert** pending a certificate purchase
|
||||
(**BL-P2** — OV / Azure Trusted Signing, not EV). See `docs/RELEASE-PACKAGING.md`.
|
||||
|
||||
App-wide **serial → model recognition + channel grouping** is now implemented (BL-D1
|
||||
DONE). `cim_suite/core/sensor_models.py` maps any sensor serial to a model name and
|
||||
channel type via prefix lookup, and clusters related channels by shared serial body.
|
||||
|
||||
88
docs/RELEASE-PACKAGING.md
Normal file
88
docs/RELEASE-PACKAGING.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Release packaging
|
||||
|
||||
How to build the distributable artifacts and (later) turn on code signing.
|
||||
|
||||
## Build both artifacts
|
||||
|
||||
Prerequisites: the dev env (`pip install -e ".[dev]"`) and — for the installer —
|
||||
**Inno Setup 6** at `C:\Program Files (x86)\Inno Setup 6\ISCC.exe` (or `ISCC.exe` on
|
||||
PATH).
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File packaging\build.ps1
|
||||
```
|
||||
|
||||
Outputs (in `packaging\Output\`), version taken from `cim_suite.__version__`:
|
||||
|
||||
- `CIM-Service-Suite-<ver>-Setup.exe` — **per-user installer**. No admin/UAC; installs
|
||||
to `%LOCALAPPDATA%\Programs`, per-user Start-menu shortcut, HKCU uninstall entry.
|
||||
- `CIM-Service-Suite-<ver>-portable.zip` — **unzip-and-run**. No install, no registry,
|
||||
no admin. App data lives under `%LOCALAPPDATA%\CIMTechniques` either way.
|
||||
|
||||
Both wrap the same PyInstaller one-folder output, so they never drift. The end-user
|
||||
`READ-ME-FIRST.txt` is copied to the bundle root by the build, so it sits beside the
|
||||
exe in the zip and at `{app}` root after install.
|
||||
|
||||
**If Inno Setup is not installed**, `build.ps1` prints a warning, skips the installer,
|
||||
and produces only the portable zip — so the script still works on a plain dev machine.
|
||||
Install Inno Setup 6 to get the installer.
|
||||
|
||||
## The app icon
|
||||
|
||||
The icon everywhere (exe, installer, Start-menu shortcut, runtime window) is generated
|
||||
from `docs/samples/icon.png`. Regenerate the committed assets only when the art changes
|
||||
(needs Pillow, which is in the `dev` extras):
|
||||
|
||||
```powershell
|
||||
.venv\Scripts\python packaging\make_icon.py
|
||||
```
|
||||
|
||||
This writes `packaging\icon.ico` and `cim_suite\shell\resources\app_icon.png`. Pillow is
|
||||
a **build-time-only** dependency — `suite.spec` excludes `PIL` so it never ships in the
|
||||
frozen app.
|
||||
|
||||
## Code signing (not yet enabled)
|
||||
|
||||
The app currently ships **unsigned**, so Windows SmartScreen warns on first run
|
||||
(`packaging\READ-ME-FIRST.txt` tells users how to proceed). The strictest customer
|
||||
environments (AppLocker/WDAC "signed only") cannot run it until it is signed.
|
||||
|
||||
### Which certificate
|
||||
|
||||
Use an **OV** (Organization Validated) code-signing certificate, OR **Azure Trusted
|
||||
Signing** (Microsoft's cloud service — no physical token, CI-friendly; price out
|
||||
first). Do **not** buy EV: as of March 2024 EV no longer gives instant SmartScreen
|
||||
trust, and EV is only required for kernel-mode driver signing, which this app does not
|
||||
do. Even with OV, SmartScreen reputation builds with download volume — keep the **same**
|
||||
certificate across releases so reputation carries forward.
|
||||
|
||||
### Turning it on
|
||||
|
||||
Signing is already wired in `packaging\build.ps1` (it signs both the inner exe and the
|
||||
Setup.exe) and is gated on env vars, so it is a no-op until configured:
|
||||
|
||||
1. Acquire the OV cert / enrol in Azure Trusted Signing; install the token or HSM client.
|
||||
2. Set the build environment:
|
||||
- `CIM_SIGN_CERT=1`
|
||||
- `CIM_SIGN_PARAMS=<signtool cert-selection args>`, **pipe-delimited** so a value may
|
||||
contain spaces. Examples: `/n|CIMTechniques, Inc.` (cert store by subject name),
|
||||
`/sha1|<thumbprint>` (by thumbprint — no spaces), or `/f|cert.pfx|/p|<password>`
|
||||
(file-based). build.ps1 splits on `|`, so do not wrap values in quotes.
|
||||
3. Re-run `packaging\build.ps1`. It signs with SHA-256 + RFC-3161 timestamping
|
||||
(`http://timestamp.digicert.com`) so signatures stay valid after the cert expires.
|
||||
4. Verify: right-click each artifact → Properties → **Digital Signatures** shows
|
||||
"CIMTechniques, Inc."
|
||||
|
||||
## Self-test
|
||||
|
||||
Both the installed copy and the extracted-portable copy can be smoke-tested headlessly.
|
||||
Because the exe is a GUI-subsystem app, use `Start-Process -Wait -PassThru` to capture
|
||||
the exit code (a bare `&` call does not block on GUI apps):
|
||||
|
||||
```powershell
|
||||
$env:SUITE_SELFTEST="1"
|
||||
$p = Start-Process -FilePath "<path>\CIM-Service-Suite.exe" `
|
||||
-ArgumentList "--module","da12","--simulate" -Wait -PassThru
|
||||
"exit=$($p.ExitCode)" # 0 means it booted and self-quit
|
||||
Remove-Item Env:\SUITE_SELFTEST
|
||||
```
|
||||
BIN
docs/samples/icon.png
Normal file
BIN
docs/samples/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
1069
docs/superpowers/plans/2026-06-08-end-user-distribution.md
Normal file
1069
docs/superpowers/plans/2026-06-08-end-user-distribution.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,245 @@
|
||||
# End-user distribution: per-user installer + portable zip, signing-ready
|
||||
|
||||
**Status:** Design (approved 2026-06-08)
|
||||
**Author:** brainstormed with Andy
|
||||
**Spec date:** 2026-06-08
|
||||
|
||||
## Problem
|
||||
|
||||
The CIMTechniques Service Suite builds today (`packaging/suite.spec` →
|
||||
`packaging/installer.iss`), but the output isn't something a typical end user can
|
||||
reliably run. The realistic delivery path is **self-serve web download**: the
|
||||
customer's own (often non-technical) employee fetches a link or email attachment and
|
||||
runs it themselves — frequently on a **company-provided laptop that is locked down to
|
||||
varying degrees and where the user has no admin rights**.
|
||||
|
||||
Two things in the current packaging stand directly in the way of that scenario:
|
||||
|
||||
1. **The installer requires admin.** `installer.iss` installs machine-wide to Program
|
||||
Files (`{autopf}`, `PrivilegesRequired` defaults to `admin`), which triggers a UAC
|
||||
elevation prompt the user can't satisfy.
|
||||
2. **Nothing is signed.** Every downloaded `.exe` carries the "Mark of the Web," so an
|
||||
unsigned one trips **Windows SmartScreen** ("Windows protected your PC — unknown
|
||||
publisher"). In the strictest shops (AppLocker / WDAC), unsigned binaries may be
|
||||
blocked outright.
|
||||
|
||||
## Goals
|
||||
|
||||
- Produce artifacts a non-admin user can run on a locked-down Windows laptop.
|
||||
- Cover the **varying** degrees of lockdown by shipping two complementary forms, so a
|
||||
given machine can run at least one of them.
|
||||
- Make **code signing a drop-in later** (config flip, not rework) once a certificate
|
||||
is purchased — the hook is wired now but inert without a cert.
|
||||
- Make the unsigned-today experience survivable for a non-technical user, and be honest
|
||||
about the one case it can't fix.
|
||||
|
||||
## Non-goals (named, not silently dropped)
|
||||
|
||||
- **Auto-update.** Real value for self-serve, but its own project. The launcher's
|
||||
"What's new" dialog already exists; an update *checker* is a future spec.
|
||||
- **Machine-wide / IT-deployed install** (SCCM/Intune, all-users). One-line Inno
|
||||
re-enable when a customer's IT asks; not built now (delivery path is self-serve).
|
||||
- **macOS / Linux.** Windows-only, as today.
|
||||
- **Buying the certificate and automating CI signing.** This spec wires the *hooks*
|
||||
and documents the turn-on steps; enabling them is a follow-up once a cert exists.
|
||||
|
||||
## Key facts that shape the design
|
||||
|
||||
- **Signing vs. installer-vs-portable are independent axes.** An installer is itself a
|
||||
downloaded `.exe` and gets the *same* SmartScreen treatment as a portable exe.
|
||||
Wrapping the app in an installer does **not** reduce the need for a certificate. The
|
||||
installer is worth doing for UX and to fix the *admin* problem; signing is what fixes
|
||||
*trust*.
|
||||
- **The app is already portable-friendly.** Config, calibration history (`DACal.csv`),
|
||||
and per-sensor logs already live under `%LOCALAPPDATA%` (via the per-module
|
||||
`config.py`), never next to the exe. Nothing depends on a privileged install path.
|
||||
- **OV is the correct certificate tier for this app (not EV).** As of **March 2024**,
|
||||
Microsoft removed EV's instant-SmartScreen-trust advantage; OV and EV now build
|
||||
SmartScreen reputation identically (by download volume). Since **June 2023** *both*
|
||||
tiers require a hardware token or cloud HSM. EV is only genuinely required for
|
||||
**kernel-mode driver signing**, which this app does not do. Therefore: recommend
|
||||
**OV** (or Microsoft's cloud-based **Azure Trusted Signing**, the likely
|
||||
lowest-friction option); EV only if a specific customer's procurement mandates it.
|
||||
- **Even OV will not silence SmartScreen on day one.** Reputation accrues with
|
||||
downloads; early users may still see the warning until volume builds. Keeping the
|
||||
*same* certificate across every release matters so reputation carries forward.
|
||||
|
||||
## Approach (chosen: dual artifact, signing-ready)
|
||||
|
||||
Anchor everything on the **single PyInstaller one-folder output**
|
||||
(`packaging/dist/CIM-Service-Suite/`) that the build already produces. Both delivered
|
||||
artifacts wrap that same folder, so they can never drift apart.
|
||||
|
||||
```
|
||||
suite.spec ──pyinstaller──► dist/CIM-Service-Suite/ (the folder; also IS the portable payload)
|
||||
│
|
||||
┌────────────────┴─────────────────┐
|
||||
installer.iss (ISCC) zip the folder
|
||||
│ │
|
||||
CIM-Service-Suite-<ver>-Setup.exe CIM-Service-Suite-<ver>-portable.zip
|
||||
(per-user, no admin) (unzip & run)
|
||||
```
|
||||
|
||||
Both finished artifacts land in `packaging/Output/`. Version is read from
|
||||
`pyproject.toml` exactly as today and embedded in both filenames so support can tell
|
||||
what a customer is running.
|
||||
|
||||
Alternatives considered and rejected:
|
||||
- **Installer only** — simpler, but no fallback for shops that block downloaded
|
||||
installers; risky given how varied the customers' lockdown is.
|
||||
- **Portable only** — most admin-proof, but worst self-serve UX for non-technical users
|
||||
(no shortcut; hunting for a deep exe inside a folder) and no clean uninstall.
|
||||
|
||||
## Detailed design
|
||||
|
||||
### 1. Build pipeline
|
||||
|
||||
A single build script (`packaging/build.ps1`) runs the three steps in order and emits
|
||||
both artifacts to `packaging/Output/`:
|
||||
|
||||
1. `pyinstaller packaging\suite.spec` → `dist/CIM-Service-Suite/`.
|
||||
2. *(optional, gated)* sign the inner exe — see §4.
|
||||
3. Compile the installer: `ISCC /DAppVersion=$v packaging\installer.iss`; *(gated)*
|
||||
sign the resulting Setup.exe.
|
||||
4. Zip `dist/CIM-Service-Suite/` → `CIM-Service-Suite-<ver>-portable.zip`.
|
||||
|
||||
The script reads the version once:
|
||||
`$v = .venv\Scripts\python -c "import cim_suite; print(cim_suite.__version__)"` and uses
|
||||
it for both the `/DAppVersion` define and the zip filename. CLAUDE.md's Commands block
|
||||
is updated to point at this script.
|
||||
|
||||
Artifact names (chosen for a non-technical downloader):
|
||||
- `CIM-Service-Suite-<ver>-Setup.exe` — "the installer"
|
||||
- `CIM-Service-Suite-<ver>-portable.zip` — "the no-install version"
|
||||
|
||||
### 2. Per-user, no-admin installer (`installer.iss`)
|
||||
|
||||
| Setting | Now | Change to | Effect |
|
||||
|---|---|---|---|
|
||||
| `PrivilegesRequired` | (default `admin`) | `lowest` | No UAC prompt; runs as the logged-in user |
|
||||
| `DefaultDirName` | `{autopf}\CIMTechniques Service Suite` | *(unchanged text)* | Under `lowest`, `{autopf}` resolves to `%LOCALAPPDATA%\Programs\…` — user-writable |
|
||||
| Start-menu icon | `{group}\…` | *(unchanged; now per-user Start menu)* | Shortcut for this user, no admin |
|
||||
| Desktop icon | `{commondesktop}\…` | `{userdesktop}\…` | Per-user desktop (`commondesktop` needs admin) |
|
||||
| Uninstall registry | (HKLM) | automatic HKCU under `lowest` | Appears in the *user's* Apps list; uninstalls without admin |
|
||||
|
||||
Net: double-click `…-Setup.exe` → click through → Start-menu shortcut + clean
|
||||
uninstaller, **zero admin**. Data continues to live in `%LOCALAPPDATA%`, untouched by
|
||||
install/uninstall.
|
||||
|
||||
Machine-wide install is intentionally **not** offered now. Documented one-line future
|
||||
re-enable: set `PrivilegesRequired=admin` (and restore `{commondesktop}`), or expose
|
||||
both via `PrivilegesRequiredOverridesAllowed=dialog`.
|
||||
|
||||
### 3. Portable zip
|
||||
|
||||
The zip is the PyInstaller folder as-is. Because data goes to `%LOCALAPPDATA%`, the
|
||||
extracted app behaves identically to the installed one — extract anywhere (incl. paths
|
||||
with spaces / deep folders), double-click `CIM-Service-Suite.exe`, done. No registry,
|
||||
no admin, no install. The zip ships with a bundled run-instructions file (see §5).
|
||||
|
||||
### 4. Signing hooks — inert now, drop-in later
|
||||
|
||||
Two artifacts must be signed (both are downloaded `.exe`s): the **inner app exe**
|
||||
(after PyInstaller, before zip/installer) and the **installer** (after ISCC). The
|
||||
portable zip itself can't be Authenticode-signed, but the exe inside it is — which is
|
||||
what matters on extraction.
|
||||
|
||||
The hook is **gated on an env var so the build runs clean with no cert**:
|
||||
|
||||
```powershell
|
||||
# build.ps1 — no-op until a cert is configured
|
||||
if ($env:CIM_SIGN_CERT) {
|
||||
& signtool sign /fd SHA256 /tr <RFC3161-timestamp-URL> /td SHA256 `
|
||||
$env:CIM_SIGN_PARAMS.Split(' ') <target.exe>
|
||||
}
|
||||
```
|
||||
|
||||
- **Cert-type-agnostic.** Signing params (cert store / subject name for a token or
|
||||
cloud HSM, or a `.pfx` path) are passed via `CIM_SIGN_PARAMS`, so OV-via-token,
|
||||
OV-via-Azure-Trusted-Signing, or a future EV token all work by changing the variable,
|
||||
not the script.
|
||||
- **Always timestamped** (`/tr … /td SHA256`) so signatures stay valid after the cert
|
||||
expires — important for an app that lives on customer machines for years.
|
||||
- The Inno `SignTool=` directive (already stubbed as a comment in `installer.iss`) is
|
||||
wired to the same tool configuration so ISCC signs the Setup.exe via the same path.
|
||||
|
||||
**Turn-on checklist when a cert arrives** (documented in the spec / a packaging README):
|
||||
1. Acquire OV cert or enrol in Azure Trusted Signing; install the token/HSM client.
|
||||
2. Set `CIM_SIGN_CERT=1` and `CIM_SIGN_PARAMS=…` in the build environment.
|
||||
3. Configure the Inno `SignTool` define to the same `signtool` invocation.
|
||||
4. Rebuild; verify both artifacts show "CIMTechniques, Inc." as a verified publisher
|
||||
(right-click → Properties → Digital Signatures).
|
||||
|
||||
### 5. The unsigned experience (what users hit *today*)
|
||||
|
||||
Until a cert is in place, every download triggers SmartScreen, and the "Run anyway"
|
||||
button is hidden behind a "More info" link that most users never click. Mitigations:
|
||||
|
||||
- **"How to run this" guide** — one page, screenshot-based, literal click path
|
||||
("Click **More info** → **Run anyway**"). Shipped three ways so it's never missing:
|
||||
bundled in the portable zip as `READ-ME-FIRST.txt` (plus an optional PDF), linked
|
||||
from the download page, and included in the delivery email.
|
||||
- **App icon + version metadata on the exe** (see §6) so it *looks* legitimate when an
|
||||
IT person inspects Properties → Details, even unsigned.
|
||||
- **One-folder (not one-file) build** — already the case; faster start and less likely
|
||||
to trip antivirus heuristics than a self-extracting one-file exe.
|
||||
|
||||
**Honest limitation, stated plainly:** in the strictest shops (AppLocker / WDAC
|
||||
"signed binaries only", or "no execution outside Program Files"), **no instructions
|
||||
help** — unsigned won't run and per-user installs land outside Program Files by design.
|
||||
Those customers are blocked until a certificate exists. This is the concrete
|
||||
justification for the cert spend: it's not polish, it's "these specific customers
|
||||
cannot run our software today."
|
||||
|
||||
### 6. Application icon
|
||||
|
||||
Source art: `docs/samples/icon.png` (the azure pinwheel, 1032×967, 32-bit ARGB with
|
||||
transparency — on-brand, high enough resolution). Two prep steps, done once:
|
||||
|
||||
1. **Pad to square** — center on a transparent 1024×1024 canvas (it's not square now;
|
||||
Windows would distort it). No cropping, no art change.
|
||||
2. **Convert to a multi-size `.ico`** — embed 16/24/32/48/64/128/256 so it stays crisp
|
||||
across taskbar, Start menu, and Explorer view sizes.
|
||||
|
||||
The result is **committed as `packaging/icon.ico`** (a binary asset that rarely
|
||||
changes), so the build doesn't depend on an image library being installed. Three
|
||||
consumers point at it:
|
||||
|
||||
- **PyInstaller** — `icon=` in `suite.spec` (currently `None`) → exe / Explorer /
|
||||
taskbar icon.
|
||||
- **Inno Setup** — `SetupIconFile=` + the Start-menu/desktop shortcut icons.
|
||||
- **Qt runtime** — set the app/window icon if not already set, so the running app shows
|
||||
it too.
|
||||
|
||||
The exe also gets **version metadata** (CompanyName "CIMTechniques, Inc.",
|
||||
ProductName, FileVersion/ProductVersion from `pyproject.toml`) via a PyInstaller
|
||||
version resource, so Properties → Details is populated.
|
||||
|
||||
## Testing & verification ("done" criteria)
|
||||
|
||||
Packaging is "works on my machine"-prone, so these are explicit, not assumed:
|
||||
|
||||
- **Build self-test** — extend the existing `SUITE_SELFTEST=1` frozen-boot check to run
|
||||
against *both* the installed copy and the extracted-portable copy, not just the raw
|
||||
dist folder.
|
||||
- **No-admin install proof** (headline requirement) — install `…-Setup.exe` under a
|
||||
**standard (non-admin) Windows account**; confirm: no UAC prompt, Start-menu shortcut
|
||||
created, app launches, uninstall is clean — all without admin.
|
||||
- **MOTW/metadata sanity** — confirm both artifacts show the icon and populated
|
||||
Properties → Details in Explorer (the "looks legit even unsigned" signal).
|
||||
- **Portable sanity** — extract to a path with spaces / a deep folder, double-click the
|
||||
inner exe, confirm it runs and writes data to `%LOCALAPPDATA%` (not next to the exe).
|
||||
- **Signing hook no-op** — with no cert configured, the build completes and logs
|
||||
"signing skipped (no cert configured)".
|
||||
|
||||
## Files touched
|
||||
|
||||
- `packaging/installer.iss` — per-user reconfig (§2), icon (§6), wired `SignTool` (§4).
|
||||
- `packaging/suite.spec` — `icon=`, version resource (§6).
|
||||
- `packaging/build.ps1` — **new**; orchestrates build + (gated) sign + zip (§1, §4).
|
||||
- `packaging/icon.ico` — **new**; committed binary asset (§6).
|
||||
- `packaging/READ-ME-FIRST.txt` (+ optional PDF) — **new**; bundled run guide (§5).
|
||||
- `docs/` — "How to run this" guide source; cert turn-on checklist.
|
||||
- `CLAUDE.md` — Commands block points at `build.ps1`.
|
||||
- `docs/BACKLOG.md` / `docs/REBUILD-STATUS.md` — track this item + the deferred cert
|
||||
purchase / auto-update / machine-wide install follow-ups.
|
||||
34
packaging/READ-ME-FIRST.txt
Normal file
34
packaging/READ-ME-FIRST.txt
Normal file
@@ -0,0 +1,34 @@
|
||||
CIMTechniques Service Suite — How to run
|
||||
========================================
|
||||
|
||||
Thank you for downloading the CIMTechniques Service Suite. This software is not yet
|
||||
digitally signed, so Windows may show a warning the first time you run it. It is safe
|
||||
to run — here is how to get past the warning.
|
||||
|
||||
------------------------------------------------------------
|
||||
If you downloaded the INSTALLER (…-Setup.exe)
|
||||
------------------------------------------------------------
|
||||
1. Double-click the file.
|
||||
2. If you see a blue box: "Windows protected your PC", click the small
|
||||
"More info" link, then click the "Run anyway" button that appears.
|
||||
3. Follow the prompts. It installs just for you — no administrator password needed.
|
||||
4. Launch it from the Start menu: "CIMTechniques Service Suite".
|
||||
|
||||
------------------------------------------------------------
|
||||
If you downloaded the NO-INSTALL version (…-portable.zip)
|
||||
------------------------------------------------------------
|
||||
1. Right-click the .zip file and choose "Extract All…". Pick any folder you like
|
||||
(your Desktop or Documents is fine — no administrator password needed).
|
||||
2. Open the extracted folder and double-click "CIM-Service-Suite.exe".
|
||||
3. If you see "Windows protected your PC", click "More info", then "Run anyway".
|
||||
|
||||
------------------------------------------------------------
|
||||
If it still will not run
|
||||
------------------------------------------------------------
|
||||
Some company laptops are locked down to only allow signed software. If neither the
|
||||
installer nor the no-install version will start, your IT department's security policy
|
||||
is blocking it. Please contact CIMTechniques support and we will help.
|
||||
|
||||
Your settings and logs are stored under your user profile
|
||||
(%LOCALAPPDATA%\CIMTechniques), not in the program folder, so the no-install version
|
||||
keeps your data even if you move or delete the folder.
|
||||
87
packaging/build.ps1
Normal file
87
packaging/build.ps1
Normal file
@@ -0,0 +1,87 @@
|
||||
# One-shot build of the CIMTechniques Service Suite distribution artifacts.
|
||||
#
|
||||
# Run from the repo root:
|
||||
# powershell -ExecutionPolicy Bypass -File packaging\build.ps1
|
||||
#
|
||||
# Produces, in packaging\Output\:
|
||||
# CIM-Service-Suite-<ver>-Setup.exe per-user, no-admin installer (needs Inno Setup 6)
|
||||
# CIM-Service-Suite-<ver>-portable.zip unzip-and-run
|
||||
#
|
||||
# If Inno Setup 6 is not installed, the installer step is skipped with a warning and
|
||||
# only the portable zip is produced (so the script is usable on a plain dev machine).
|
||||
#
|
||||
# Signing is OPTIONAL and gated: set CIM_SIGN_CERT (any value) and CIM_SIGN_PARAMS
|
||||
# (the signtool cert-selection args) to sign the inner exe and the installer. With
|
||||
# CIM_SIGN_CERT unset, signing is skipped. See docs\RELEASE-PACKAGING.md.
|
||||
[CmdletBinding()]
|
||||
param([switch]$NoZip)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$pkg = $PSScriptRoot
|
||||
$root = Split-Path $pkg -Parent
|
||||
$py = Join-Path $root ".venv\Scripts\python.exe"
|
||||
$pyi = Join-Path $root ".venv\Scripts\pyinstaller.exe"
|
||||
|
||||
$ver = (& $py -c "import cim_suite; print(cim_suite.__version__)").Trim()
|
||||
Write-Host "==> Building CIMTechniques Service Suite $ver"
|
||||
|
||||
function Invoke-Sign([string]$target) {
|
||||
if ($env:CIM_SIGN_CERT) {
|
||||
Write-Host "==> Signing $target"
|
||||
# CIM_SIGN_PARAMS is PIPE-delimited (not space) so a value can contain spaces,
|
||||
# e.g. CIM_SIGN_PARAMS='/n|CIMTechniques, Inc.' -> '/n','CIMTechniques, Inc.'.
|
||||
$extra = if ($env:CIM_SIGN_PARAMS) { $env:CIM_SIGN_PARAMS -split '\|' } else { @() }
|
||||
& signtool sign /fd SHA256 /tr "http://timestamp.digicert.com" /td SHA256 @extra $target
|
||||
if ($LASTEXITCODE -ne 0) { throw "signtool failed for $target" }
|
||||
} else {
|
||||
Write-Host " signing skipped (no cert configured): $target"
|
||||
}
|
||||
}
|
||||
|
||||
# Resolve ISCC.exe (Inno Setup 6): default install dir, then PATH.
|
||||
function Find-ISCC {
|
||||
$default = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
|
||||
if (Test-Path $default) { return $default }
|
||||
$cmd = Get-Command ISCC.exe -ErrorAction SilentlyContinue
|
||||
if ($cmd) { return $cmd.Source }
|
||||
return $null
|
||||
}
|
||||
|
||||
# 1. Freeze the app (one folder).
|
||||
& $pyi --noconfirm --distpath (Join-Path $pkg "dist") --workpath (Join-Path $pkg "build") `
|
||||
(Join-Path $pkg "suite.spec")
|
||||
if ($LASTEXITCODE -ne 0) { throw "pyinstaller failed" }
|
||||
|
||||
$distApp = Join-Path $pkg "dist\CIM-Service-Suite"
|
||||
$innerExe = Join-Path $distApp "CIM-Service-Suite.exe"
|
||||
|
||||
# 2. Place the end-user run guide beside the exe (top level), so it is visible when a
|
||||
# user extracts the portable zip and is installed at {app} root by the installer.
|
||||
Copy-Item (Join-Path $pkg "READ-ME-FIRST.txt") $distApp -Force
|
||||
|
||||
# 3. Sign the inner exe (gated) BEFORE it is wrapped/zipped.
|
||||
Invoke-Sign $innerExe
|
||||
|
||||
# 4. Build the per-user installer (if Inno Setup is available).
|
||||
$iscc = Find-ISCC
|
||||
if ($iscc) {
|
||||
& $iscc "/DAppVersion=$ver" (Join-Path $pkg "installer.iss")
|
||||
if ($LASTEXITCODE -ne 0) { throw "ISCC failed" }
|
||||
$setup = Join-Path $pkg "Output\CIM-Service-Suite-$ver-Setup.exe"
|
||||
Invoke-Sign $setup # 5. Sign the installer (gated).
|
||||
Write-Host "==> Wrote $setup"
|
||||
} else {
|
||||
Write-Warning "Inno Setup 6 (ISCC.exe) not found - skipping installer; building portable zip only."
|
||||
Write-Warning "Install Inno Setup 6 to produce the installer. See docs\RELEASE-PACKAGING.md."
|
||||
}
|
||||
|
||||
# 6. Portable zip of the same folder.
|
||||
if (-not $NoZip) {
|
||||
New-Item -ItemType Directory -Force (Join-Path $pkg "Output") | Out-Null
|
||||
$zip = Join-Path $pkg "Output\CIM-Service-Suite-$ver-portable.zip"
|
||||
if (Test-Path $zip) { Remove-Item $zip -Force }
|
||||
Compress-Archive -Path $distApp -DestinationPath $zip
|
||||
Write-Host "==> Wrote $zip"
|
||||
}
|
||||
|
||||
Write-Host "==> Done. Artifacts in $(Join-Path $pkg 'Output')"
|
||||
BIN
packaging/icon.ico
Normal file
BIN
packaging/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -3,7 +3,7 @@
|
||||
; Prerequisite: build the app first with PyInstaller (see packaging\suite.spec), which
|
||||
; produces packaging\dist\CIM-Service-Suite\. Then compile this script with Inno Setup:
|
||||
; "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" packaging\installer.iss
|
||||
; Output: packaging\Output\CIM-Service-Suite-Setup.exe
|
||||
; Output: packaging\Output\CIM-Service-Suite-<version>-Setup.exe
|
||||
;
|
||||
; This installs the self-contained PyInstaller folder to Program Files with a
|
||||
; Start-menu shortcut. No VB6 runtime / OCX registration required.
|
||||
@@ -28,14 +28,20 @@ DefaultDirName={autopf}\CIMTechniques Service Suite
|
||||
DefaultGroupName={#AppName}
|
||||
DisableProgramGroupPage=yes
|
||||
OutputDir=Output
|
||||
OutputBaseFilename=CIM-Service-Suite-Setup
|
||||
OutputBaseFilename=CIM-Service-Suite-{#AppVersion}-Setup
|
||||
Compression=lzma2
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
; Sign the installer in CI for the cleanest customer experience:
|
||||
; SignTool=signtool $f
|
||||
; Per-user install: no admin / UAC. {autopf} resolves to %LOCALAPPDATA%\Programs,
|
||||
; shortcuts go to the per-user Start menu, and the uninstall entry lands under HKCU.
|
||||
PrivilegesRequired=lowest
|
||||
SetupIconFile=icon.ico
|
||||
UninstallDisplayIcon={app}\{#AppExeName}
|
||||
; Signing is done in packaging\build.ps1 (signs the inner exe AND this Setup.exe),
|
||||
; gated on the CIM_SIGN_CERT env var. See docs\RELEASE-PACKAGING.md. Intentionally
|
||||
; NOT using Inno's SignTool directive, to keep all signing in one place.
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
@@ -45,7 +51,7 @@ Source: "dist\CIM-Service-Suite\*"; DestDir: "{app}"; Flags: recursesubdirs crea
|
||||
|
||||
[Icons]
|
||||
Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"
|
||||
Name: "{commondesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon
|
||||
Name: "{userdesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional icons:"
|
||||
|
||||
45
packaging/make_icon.py
Normal file
45
packaging/make_icon.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""One-time icon generator (run by a developer, not by the build).
|
||||
|
||||
Source art: docs/samples/icon.png (azure pinwheel, ~1032x967, transparent).
|
||||
Produces two committed assets:
|
||||
- packaging/icon.ico multi-size, for the exe + installer
|
||||
- cim_suite/shell/resources/app_icon.png 256x256, for the runtime Qt window icon
|
||||
|
||||
Re-run this only when the source art changes:
|
||||
.venv\\Scripts\\python packaging\\make_icon.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from PIL import Image
|
||||
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
_SRC = os.path.join(_ROOT, "docs", "samples", "icon.png")
|
||||
_ICO = os.path.join(_ROOT, "packaging", "icon.ico")
|
||||
_PNG_DIR = os.path.join(_ROOT, "cim_suite", "shell", "resources")
|
||||
_PNG = os.path.join(_PNG_DIR, "app_icon.png")
|
||||
|
||||
_ICO_SIZES = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)]
|
||||
|
||||
|
||||
def _squared(src_path: str, side: int = 1024) -> Image.Image:
|
||||
"""Center the (non-square) source on a transparent square canvas, then scale."""
|
||||
img = Image.open(src_path).convert("RGBA")
|
||||
edge = max(img.size)
|
||||
canvas = Image.new("RGBA", (edge, edge), (0, 0, 0, 0))
|
||||
canvas.paste(img, ((edge - img.width) // 2, (edge - img.height) // 2))
|
||||
return canvas.resize((side, side), Image.LANCZOS)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
square = _squared(_SRC)
|
||||
square.save(_ICO, sizes=_ICO_SIZES)
|
||||
os.makedirs(_PNG_DIR, exist_ok=True)
|
||||
square.resize((256, 256), Image.LANCZOS).save(_PNG)
|
||||
print(f"wrote {_ICO}")
|
||||
print(f"wrote {_PNG}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -9,6 +9,7 @@
|
||||
# Setup (ISCC.exe).
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
block_cipher = None
|
||||
|
||||
@@ -16,10 +17,21 @@ block_cipher = None
|
||||
# its parent and must be on pathex so `import cim_suite` resolves at build time.
|
||||
_repo_root = os.path.dirname(SPECPATH)
|
||||
|
||||
# Make `import cim_suite` and the local `verinfo` helper importable while the spec runs.
|
||||
for _p in (_repo_root, SPECPATH):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
import cim_suite # noqa: E402 (single-sourced version)
|
||||
import verinfo # noqa: E402 (packaging/verinfo.py)
|
||||
|
||||
# Bundled brand fonts (Lato) live in the theme package and must be copied into the
|
||||
# frozen app at the same package-relative path so theme.fonts can find them.
|
||||
_font_src = os.path.join(_repo_root, "cim_suite", "core", "ui", "theme", "fonts")
|
||||
|
||||
# The runtime window icon (cim_suite/shell/branding.py resolves it package-relative).
|
||||
_app_icon_src = os.path.join(_repo_root, "cim_suite", "shell", "resources")
|
||||
|
||||
# IOModbus ships its device catalog (register maps) as a package resource; it must be
|
||||
# copied into the frozen app at the same package-relative path so config.catalog_text
|
||||
# (importlib.resources) can read it.
|
||||
@@ -30,12 +42,24 @@ _iomodbus_res = os.path.join(_repo_root, "cim_suite", "modules", "iomodbus", "re
|
||||
_pyproject = os.path.join(_repo_root, "pyproject.toml")
|
||||
_changelog = os.path.join(_repo_root, "CHANGELOG.md")
|
||||
|
||||
# NOTE: the end-user READ-ME-FIRST.txt is intentionally NOT a PyInstaller data file.
|
||||
# In a one-folder build, datas land under _internal\ (sys._MEIPASS), but the run guide
|
||||
# must sit beside the .exe so a user sees it on extracting the portable zip. build.ps1
|
||||
# copies it to the dist root after the build; the installer then ships it at {app} root.
|
||||
|
||||
# Exe icon + Windows version resource, both generated from single sources.
|
||||
_icon = os.path.join(SPECPATH, "icon.ico")
|
||||
_version_file = os.path.join(SPECPATH, "_version_info.generated.txt")
|
||||
with open(_version_file, "w", encoding="utf-8") as _vf:
|
||||
_vf.write(verinfo.render_version_resource(cim_suite.__version__))
|
||||
|
||||
a = Analysis(
|
||||
[os.path.join(SPECPATH, "suite_launcher.py")],
|
||||
pathex=[_repo_root],
|
||||
binaries=[],
|
||||
datas=[
|
||||
(_font_src, os.path.join("cim_suite", "core", "ui", "theme", "fonts")),
|
||||
(_app_icon_src, os.path.join("cim_suite", "shell", "resources")),
|
||||
(_iomodbus_res, os.path.join("cim_suite", "modules", "iomodbus", "resources")),
|
||||
(_pyproject, "."),
|
||||
(_changelog, "."),
|
||||
@@ -53,6 +77,8 @@ a = Analysis(
|
||||
"PySide6.QtWebEngineWidgets",
|
||||
"PySide6.QtMultimedia",
|
||||
"tkinter",
|
||||
# Pillow is a build-time-only dep (packaging/make_icon.py); never ship it.
|
||||
"PIL",
|
||||
],
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
@@ -71,7 +97,8 @@ exe = EXE(
|
||||
upx=False,
|
||||
console=False,
|
||||
disable_windowed_traceback=False,
|
||||
icon=None,
|
||||
icon=_icon,
|
||||
version=_version_file,
|
||||
)
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
|
||||
56
packaging/verinfo.py
Normal file
56
packaging/verinfo.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Generate a Windows version-resource block for the frozen exe.
|
||||
|
||||
Pure (no I/O) so it is unit-testable and importable from suite.spec. The rendered
|
||||
text is the format PyInstaller's EXE(version=...) reads. Version is single-sourced
|
||||
from cim_suite.__version__; this only formats it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
COMPANY = "CIMTechniques, Inc."
|
||||
PRODUCT = "CIMTechniques Service Suite"
|
||||
EXE_NAME = "CIM-Service-Suite.exe"
|
||||
|
||||
|
||||
def version_tuple(version: str) -> tuple[int, int, int, int]:
|
||||
"""Parse '1.0.2' / '1.2.3.4' / '2.10.0-dev' into a 4-int tuple, padding/truncating."""
|
||||
parts: list[int] = []
|
||||
for chunk in version.split("."):
|
||||
m = re.match(r"\d+", chunk.strip())
|
||||
parts.append(int(m.group()) if m else 0)
|
||||
# Windows VERSIONINFO is exactly four components: pad short, truncate long.
|
||||
parts = (parts + [0, 0, 0, 0])[:4]
|
||||
return parts[0], parts[1], parts[2], parts[3]
|
||||
|
||||
|
||||
def render_version_resource(version: str) -> str:
|
||||
"""Render the VSVersionInfo text block PyInstaller embeds into the exe."""
|
||||
vt = version_tuple(version)
|
||||
return f"""\
|
||||
# UTF-8 — generated by packaging/verinfo.py; do not edit by hand.
|
||||
VSVersionInfo(
|
||||
ffi=FixedFileInfo(
|
||||
filevers={vt},
|
||||
prodvers={vt},
|
||||
mask=0x3f,
|
||||
flags=0x0,
|
||||
OS=0x40004,
|
||||
fileType=0x1,
|
||||
subtype=0x0,
|
||||
date=(0, 0)
|
||||
),
|
||||
kids=[
|
||||
StringFileInfo([
|
||||
StringTable('040904B0', [
|
||||
StringStruct('CompanyName', '{COMPANY}'),
|
||||
StringStruct('FileDescription', '{PRODUCT}'),
|
||||
StringStruct('FileVersion', '{version}'),
|
||||
StringStruct('InternalName', 'CIM-Service-Suite'),
|
||||
StringStruct('OriginalFilename', '{EXE_NAME}'),
|
||||
StringStruct('ProductName', '{PRODUCT}'),
|
||||
StringStruct('ProductVersion', '{version}')])]),
|
||||
VarFileInfo([VarStruct('Translation', [1033, 1200])])
|
||||
]
|
||||
)
|
||||
"""
|
||||
@@ -6,7 +6,7 @@ requires-python = ">=3.11"
|
||||
dependencies = ["PySide6>=6.7", "pyserial>=3.5", "openpyxl>=3.1"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8", "pytest-qt>=4.4", "ruff>=0.6", "pyinstaller>=6.10"]
|
||||
dev = ["pytest>=8", "pytest-qt>=4.4", "ruff>=0.6", "pyinstaller>=6.10", "pillow"]
|
||||
|
||||
[project.scripts]
|
||||
cim-suite = "cim_suite.shell.app:main"
|
||||
|
||||
0
tests/packaging/__init__.py
Normal file
0
tests/packaging/__init__.py
Normal file
22
tests/packaging/test_icon_assets.py
Normal file
22
tests/packaging/test_icon_assets.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""The committed icon assets must exist at the expected square sizes."""
|
||||
import os
|
||||
|
||||
from PIL import Image
|
||||
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
_ICO = os.path.join(_ROOT, "packaging", "icon.ico")
|
||||
_PNG = os.path.join(_ROOT, "cim_suite", "shell", "resources", "app_icon.png")
|
||||
|
||||
|
||||
def test_ico_exists_and_has_256_frame():
|
||||
assert os.path.exists(_ICO), "run: .venv\\Scripts\\python packaging\\make_icon.py"
|
||||
with Image.open(_ICO) as im:
|
||||
# Pillow returns the largest frame; our largest is 256x256.
|
||||
assert im.size == (256, 256)
|
||||
|
||||
|
||||
def test_runtime_png_is_square_256():
|
||||
assert os.path.exists(_PNG), "run: .venv\\Scripts\\python packaging\\make_icon.py"
|
||||
with Image.open(_PNG) as im:
|
||||
assert im.size == (256, 256)
|
||||
assert im.mode == "RGBA"
|
||||
47
tests/packaging/test_verinfo.py
Normal file
47
tests/packaging/test_verinfo.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Unit tests for the PyInstaller version-resource helper."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# packaging/ is not an installed package; put it on the path so we can import verinfo.
|
||||
_PKG = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "packaging")
|
||||
if _PKG not in sys.path:
|
||||
sys.path.insert(0, _PKG)
|
||||
|
||||
import verinfo # noqa: E402
|
||||
|
||||
|
||||
def test_version_tuple_pads_to_four():
|
||||
assert verinfo.version_tuple("1.0.2") == (1, 0, 2, 0)
|
||||
|
||||
|
||||
def test_version_tuple_full():
|
||||
assert verinfo.version_tuple("1.2.3.4") == (1, 2, 3, 4)
|
||||
|
||||
|
||||
def test_version_tuple_strips_prerelease_suffix():
|
||||
assert verinfo.version_tuple("2.10.0-dev") == (2, 10, 0, 0)
|
||||
|
||||
|
||||
def test_version_tuple_single_component():
|
||||
assert verinfo.version_tuple("1") == (1, 0, 0, 0)
|
||||
|
||||
|
||||
def test_version_tuple_truncates_to_four():
|
||||
assert verinfo.version_tuple("1.2.3.4.5") == (1, 2, 3, 4)
|
||||
|
||||
|
||||
def test_render_contains_filevers_and_strings():
|
||||
text = verinfo.render_version_resource("1.0.2")
|
||||
assert "filevers=(1, 0, 2, 0)" in text
|
||||
assert "prodvers=(1, 0, 2, 0)" in text
|
||||
assert "StringStruct('CompanyName', 'CIMTechniques, Inc.')" in text
|
||||
assert "StringStruct('FileVersion', '1.0.2')" in text
|
||||
assert "StringStruct('ProductVersion', '1.0.2')" in text
|
||||
assert "StringStruct('OriginalFilename', 'CIM-Service-Suite.exe')" in text
|
||||
|
||||
|
||||
def test_render_output_is_valid_python():
|
||||
# The block is eval'd by PyInstaller; catch template corruption (unbalanced
|
||||
# brackets, bad nesting) early by compiling it.
|
||||
text = verinfo.render_version_resource("1.0.2")
|
||||
compile(text, "<version_resource>", "eval")
|
||||
9
tests/shell/test_branding.py
Normal file
9
tests/shell/test_branding.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""The suite app icon must load as a non-null QIcon."""
|
||||
|
||||
|
||||
def test_app_icon_loads(qapp):
|
||||
# qapp fixture (pytest-qt) ensures a QApplication exists, which QIcon needs.
|
||||
from cim_suite.shell.branding import app_icon
|
||||
|
||||
icon = app_icon()
|
||||
assert not icon.isNull()
|
||||
Reference in New Issue
Block a user