Driver rename and icons: `Driver.display_name` plus a `DriverIcon` table, both global/shared like the `Driver` row they hang off, so a rename or an icon is what every Owner sees. The rename/icon dialog keeps its forms as siblings (nested forms are invalid HTML) and the icon routes return an `hx-swap-oob` thumbnail refresh rather than re-rendering the table, which would tear the open `<dialog>` out of the DOM. Web image picker: `GET /web/images` renders a pickable grid for a printer or a driver icon, with the search term prefilled from the entity name and editable. Picking one downloads it server-side and normalizes it. Driver download search: `GET /web/drivers` searches for a vendor-wide driver (the term is rewritten into the vendor's real product name for 15 brands) or for the exact model as typed. Links only — nothing is downloaded, and the fragment says the results are unvetted. Icon uploads no longer reject off-size or non-PNG files: `normalize_icon()` letterboxes any decodable raster into a 256x256 PNG. An already-exact 256x256 PNG is returned byte-identical, because icon storage is content-addressed and re-encoding would move the file on every save. `fetch_image()` makes the request from the server, so `assert_fetchable()` refuses any URL resolving to a private, loopback, or link-local address, and re-runs on every redirect. ImpTune sits on the same LAN as the printers it configures; an unguarded fetcher would be a port scanner for anyone who can reach the UI. DuckDuckGo is scraped, not called through an API — no key needed, but fragile, so both search functions swallow parse failures and return [] instead of 500ing a page. `WEB_SEARCH=false` disables every outbound request and hides the controls, for air-gapped installs. Also: one shared `Jinja2Templates` in `templating.py` instead of five per-router instances, so a template global is declared once; `_add_missing_columns()` in `database.py` adds new nullable columns to a pre-existing table, which `create_tables(safe=True)` skips; `db_env` in test_db.py now closes its connection on teardown, or the next test's ORM writes land in the previous test's DB file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
12 KiB
CLAUDE.md
Guide for Claude Code (claude.ai/code) work in repo.
Commands
Run tests:
pytest tests/ # whole suite (no env vars needed)
pytest tests/test_inf_parser.py # single test file (no tests/unit/ dir)
pytest tests/ -k "test_name" # single test by name
Both tests/conftest.py (tmp_data_dir) and tests/e2e/conftest.py force
cfg.COOKIE_SECURE = False, because TestClient talks plain HTTP to
http://testserver and a Secure cookie would be dropped — every request would
land on a new Owner and ~41 tests would 404. Tests asserting the Secure
branch (test_secure_mode_* in tests/test_session.py) monkeypatch it back to
True.
Run dev server:
export DATA_DIR=/tmp/imptune_data
export COOKIE_SECURE=false # plain HTTP — omit if serving behind TLS
uvicorn imptune.main:app --reload --port 8000
Docker:
docker-compose up
docker build -t imptune .
Install deps:
pip install -r requirements.txt -r requirements-dev.txt
Architecture
ImpTune make printer deploy packages (.intunewin for Intune, .zip for NinjaRMM) from Windows driver ZIPs + web UI. No external services — single FastAPI + SQLite + Docker volume.
Request flow:
- Driver upload →
api/drivers.py→services/inf_parser.pyparse INF →storage/driver_store.pystore by SHA256 → PeeweeDriverrecord (shared/global — visible to every Owner) - Printer config →
api/printers.py→db/models.pyPrinterrecord (links Driver FK, scoped torequest.state.owner) - Icon upload →
api/icons.py→services/image_utils.normalize_icon()resize any raster to 256×256 PNG → SHA256 storage →Iconrecord - Package export →
api/packages.py→generators/script_generator.pyrender Jinja2 PS1 templates →generators/intunewin_builder.pyencrypt ZIP (AES-256-CBC + HMAC-SHA256)
Key modules:
imptune/config.py—DATA_DIR,DB_PATH,DRIVERS_DIR,ICONS_DIR,COOKIE_SECURE,WEB_SEARCHfrom envimptune/templating.py— the onlyJinja2Templatesinstance; every router importstemplatesfrom it. Template globals (web_search_enabled) are declared once there, as callables so a monkeypatchedcfgtakes effectimptune/services/image_utils.py—normalize_icon(): any Pillow-decodable raster → 256×256 PNG, letterboxed (aspect kept, transparent padding). An already-exact 256×256 PNG is returned byte-identical, because icon storage is content-addressed and re-encoding would move the file on every saveimptune/services/icons.py— shared icon storage forIcon(printer) andDriverIcon; 750 KB cap on the source bytesimptune/services/websearch.py— the only code that leaves the boximptune/db/database.py— SQLite WAL mode +foreign_keys=1; all models inheritBaseModel;init_db()also backfillsowner_idon pre-per-owner-scoping DBs into a synthetic legacyOwner(key written to{DATA_DIR}/legacy_owner_key.txt)imptune/services/session.py—OwnerSessionMiddlewareresolvesrequest.state.ownerfrom theimptune_owner_keycookie, creating one on first visit (skips/health)imptune/services/inf_parser.py— auto-detect encoding (UTF-16/UTF-8/cp1252), resolve%TOKEN%from[Strings], handle multi-model INFsimptune/generators/intunewin_builder.py— Python-native.intunewin(ZIP-in-ZIP); IV 16 bytes (not 32); match reference tool1.8.6.0outputimptune/templates/scripts/— Jinja2 templates forinstall.ps1,uninstall.ps1,detect.ps1
Per-owner storage: Printer/Client (groups) are scoped to an Owner identified by an opaque bearer key in a cookie — no accounts. Driver stays global/shared. Every route taking a printer_id/client_id must filter/check .owner == request.state.owner (404, not 403, on mismatch) — printer IDs are small sequential ints, so a list-only filter isn't enough. Onboarding modal (templates/base.html, gated on request.state.is_new_owner) offers "download backup key" (GET /session/key/download, marks Owner.is_permanent) vs. temporary; /session/restore re-attaches a browser to a previously downloaded key. In tests, use the owner fixture (tests/conftest.py) when creating Printer/Client rows directly via the ORM so the client fixture's cookie-scoped requests can see them.
Web lookups (services/websearch.py, api/web.py): DuckDuckGo is scraped,
not called through an API — no key, but fragile by nature, so search_images()
and search_pages() swallow parse/transport failures and return [] instead of
500ing a page. Image search needs a per-query vqd token scraped from the HTML
first, and the _XHR_HEADERS set (Accept, X-Requested-With, Sec-Fetch-*)
on the i.js call — with a valid token but no fetch metadata it answers 403. GET /web/images?q&target=printer|driver&id= and GET /web/drivers?q&mode=generic|exact
return HTML fragments (never JSON), and ownership is checked before a search is
spent on the id. fetch_image() downloads server-side, so assert_fetchable()
refuses any URL resolving to a private/loopback/link-local address — ImpTune sits
on the same LAN as the printers, and an unguarded fetcher is a port scanner for
anyone who can reach the UI. Redirects re-run the guard via
_GuardedRedirectHandler. Driver search returns links only — nothing is
downloaded, and partials/driver_search_results.html must keep saying so.
generic_driver_query() maps a detected brand to that vendor's real universal-driver
product name (GENERIC_DRIVER_TERMS); an unknown brand falls back to
"<typed> universal print driver download".
Driver rename + driver icons: Driver.display_name (nullable) and the
DriverIcon table. Both are global/shared like Driver itself — a rename is
visible to every Owner, and GET /drivers/{id}/icon is deliberately not
owner-scoped. PATCH /drivers/{id} swaps the whole #driver-list; the icon
routes return a small status fragment plus an hx-swap-oob refresh of
#driver-thumb-{id}, because the dialog stays open after picking an icon and
re-rendering the table would tear the open <dialog> out of the DOM.
partials/driver_edit_modal.html keeps the rename form and the icon forms as
siblings (nested forms are invalid HTML) — the footer's Save reaches the rename
form through form="driver-rename-{id}".
Schema changes on an existing DB: create_tables(safe=True) skips a table
that already exists, so a new field on an old model needs an entry in
database._add_missing_columns() — that is what puts display_name on a
pre-rename driver table. Tests: test_db.py::test_init_db_adds_display_name_*.
UI stack: Pico CSS + HTMX 2 + Alpine.js 3 + Jinja2 server-side templates.
Design layer (static/app.css): a token + component layer over Pico. Tokens
(--im-*) are declared three times — :root:not([data-theme=dark]), the
prefers-color-scheme: dark block, and [data-theme=dark] — mirroring Pico's
own selectors so equal specificity + later source order wins; a new color must be
added to all three. Pico vars are remapped from those tokens, so use --im-* in
components. Prose is set in the system UI face, machine values (IPs, ports, INF
names, PS commands) in --im-mono. Components: .card, .rail (the
driver → printer → package pipeline on the dashboard), .data-table, .badge,
.pill, .kv, .cmd, .empty, .form-section, .toolbar. Because the edit
dialog renders inside a table cell, dialog resets inherited text-align /
white-space — keep that.
Shell: base.html owns the sidebar + topbar; pages fill the crumb,
page_title, page_actions, and content blocks and must not render their own
<h1>. Icons come from {% import "partials/icons.html" as ico %} →
{{ ico.i('printer') }} — inline SVG with no text nodes, because E2E tests read
textContent of nav links to assert the translated label. Nav links are
{{ ico.i(...) }}<span x-text="...">: never add count badges or other text
inside them.
i18n: every user-facing string goes through $store.i18n.t('key') with the
English text as the element's fallback body, and keys must be added to both
fr and en in base.html. Server-rendered HTMX fragments (icon-upload
confirmation, _error_response) are English-only.
The store's default language follows navigator.language, so E2E specs must
never locate a control by its visible label — button:has-text('Edit')
matched only on English-locale machines and timed out everywhere else. Target a
structural hook instead (button[onclick*='showModal']), except in
test_i18n_toggle.py, which asserts the labels on purpose and pins
locale= per context.
Client-side filter: Alpine.store('filter') holds the printer search text.
Rows and group cards carry data-search (lowercased) and x-show off that
store, so HTMX-swapped rows keep filtering. A group's data-search must be a
superset of its rows' — otherwise a matching row hides inside a hidden group.
.col-defaults / .col-arch / .col-used / .col-added mark columns dropped
on narrow screens or in the add-printer sidebar (.form-aside).
HTMX pattern: Forms hx-post, swap #driver-list / #printer-list / #client-list targets. Errors return inline HTML fragments (HTTP 400/409) via _error_response(). Success return partials from templates/partials/.
PowerShell install script notes:
- WOW64 64-bit relaunch guard (Intune run 32-bit,
pnputilneed 64-bit) - UAC self-elevation for user context (SYSTEM context skip)
- Two-step:
pnputil /add-driverthenAdd-PrinterDriver+Add-PrinterPort+Add-Printer - All idempotent (
-ErrorAction SilentlyContinue)
Test Setup
conftest.py monkeypatch config.DATA_DIR + config.DB_PATH to temp dir per test. client fixture yield TestClient(app) with isolated SQLite. E2E in tests/e2e/ use Playwright.
Environment Variables
| Var | Default | Purpose |
|---|---|---|
DATA_DIR |
/data |
Storage root (DB + drivers + icons) |
PORT |
8000 |
Server port |
WEB_SEARCH |
true |
false disables every outbound request (image search, driver-page search, image download) and hides the search controls — templating.py exposes it to templates as web_search_enabled() |
COOKIE_SECURE |
true |
Three-way session mode, parsed by config.parse_cookie_mode() into (COOKIE_SECURE, SINGLE_USER): true = Secure + 10-year cookie; false = plain-HTTP serving (browser drops a Secure cookie → new Owner per request), cookie becomes memory-only (no Max-Age); single_user (or single-user/single) = no cookie at all, one shared Owner — see below. |
COOKIE_SECURE=false degrades the session instead of weakening the credential:
services/session.cookie_kwargs() drops max_age, so the browser holds the
owner key in memory and the session ends when the window closes. Everything
still persists server-side; only the browser's link to it is temporary. Both
cookie-setting call sites (the middleware and POST /session/restore) must go
through cookie_kwargs(). request.state.ephemeral_session mirrors the flag,
and base.html renders the #ephemeral-session-warning banner plus an extra
paragraph in the onboarding modal off it. Changing this touches
tests/test_session.py::test_insecure_mode_* / test_secure_mode_*.
COOKIE_SECURE=single_user (cfg.SINGLE_USER) removes sessions for test boxes
and single-person local prod: the middleware never reads or sets a cookie and
returns services/session.single_user_owner() — the oldest Owner row,
created on demand — so a deployment switched over from cookie mode keeps the
printers it already had and no second row is ever minted. request.state.owner
is still what every route filters on, so per-owner query code is unchanged.
request.state.single_user gates the sidebar "This session" menu and the
#single-user-notice banner in base.html; is_new_owner/ephemeral_session
are forced False (no onboarding modal, no memory-only warning). All three
/session/* routes 404 via api/session._require_cookie_sessions() — a key
can't re-point a cookie that isn't read, and downloading one would leak the
shared owner's bearer credential for a later switch back to cookie mode. Tests:
test_session.py::test_single_user_* + test_parse_cookie_mode_*.