diff --git a/CLAUDE.md b/CLAUDE.md
index 6c6df3d..917a57d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,11 +6,18 @@ Guide for Claude Code (claude.ai/code) work in repo.
**Run tests:**
```bash
-pytest tests/
-pytest tests/unit/test_inf_parser.py # single test file
+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:**
```bash
export DATA_DIR=/tmp/imptune_data
@@ -51,6 +58,45 @@ ImpTune make printer deploy packages (`.intunewin` for Intune, `.zip` for NinjaR
**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
+`
`. 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(...) }}`: 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:**
@@ -69,4 +115,14 @@ ImpTune make printer deploy packages (`.intunewin` for Intune, `.zip` for NinjaR
|-----|---------|---------|
| `DATA_DIR` | `/data` | Storage root (DB + drivers + icons) |
| `PORT` | `8000` | Server port |
-| `COOKIE_SECURE` | `true` | Owner-session cookie `Secure` flag. Set `false` for local plain-HTTP dev (`uvicorn --reload`) or the browser drops the cookie and a new Owner is created on every request. |
\ No newline at end of file
+| `COOKIE_SECURE` | `true` | Owner-session cookie `Secure` flag. Set `false` for plain-HTTP serving or the browser drops the cookie and a new Owner is created on every request. `false` also makes the cookie **memory-only** (no `Max-Age`) — 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_*`.
\ No newline at end of file
diff --git a/README.md b/README.md
index d38d103..15c53c5 100644
--- a/README.md
+++ b/README.md
@@ -48,7 +48,46 @@ parameters (`-Registry`, `-Owner`, `-Image`, `-NoBuild`, `-SkipLogin`, …).
Set these under `environment:` in `docker-compose.yml`.
-| Variable | Default | Purpose |
-|------------|---------|--------------------------------------------------|
-| `DATA_DIR` | `/data` | Storage root for the SQLite DB, drivers and icons. Should map to the `imptune_data` volume. |
-| `PORT` | `8000` | Port the server listens on inside the container. |
+| Variable | Default | Purpose |
+|------------------|---------|--------------------------------------------------|
+| `DATA_DIR` | `/data` | Storage root for the SQLite DB, drivers and icons. Should map to the `imptune_data` volume. |
+| `PORT` | `8000` | Port the server listens on inside the container. |
+| `COOKIE_SECURE` | `true` | `Secure` flag on the session cookie. Set to `false` when the app is reached over plain HTTP — see below. |
+
+### `COOKIE_SECURE` and HTTPS
+
+Printers, print defaults and clients belong to a session identified by an opaque
+key in a cookie (there are no accounts). That cookie is `Secure` by default, so
+it only travels over HTTPS.
+
+**Behind a TLS-terminating proxy** (nginx, Traefik, Caddy — the normal setup):
+leave the default. The session cookie lasts ten years, so a browser keeps its
+printers indefinitely.
+
+**Reached directly over plain HTTP** (`http://host:8000`): set
+`COOKIE_SECURE=false`, otherwise the browser refuses the cookie and every
+request starts a brand-new empty session — no printer you save is ever visible
+again.
+
+In that mode the app is fully usable and keeps remembering everything, but the
+cookie becomes **memory-only**: the session ends when the browser closes, and
+every page shows a warning saying so. This is deliberate — over plain HTTP the
+key is readable on the wire, so it is not written to disk for ten years. Use
+**Download my backup key** to save the key to a file; `/session/restore` takes
+it back on the next browser start, or on another machine.
+
+```yaml
+services:
+ imptune:
+ environment:
+ - DATA_DIR=/data
+ - COOKIE_SECURE=false # only when serving plain HTTP
+```
+
+For local development the same applies:
+
+```bash
+export DATA_DIR=/tmp/imptune_data
+export COOKIE_SECURE=false
+uvicorn imptune.main:app --reload --port 8000
+```
diff --git a/docker-compose.yml b/docker-compose.yml
index a5f2316..1a33875 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,6 +12,11 @@ services:
restart: unless-stopped
environment:
- DATA_DIR=/data
+ # Uncomment when this port is reached over plain HTTP (no TLS proxy in
+ # front): the owner-session cookie is Secure by default, so the browser
+ # drops it and every request mints a new empty Owner. With `false` the
+ # session works but lives only until the browser closes, and the UI warns.
+ # - COOKIE_SECURE=false
volumes:
imptune_data:
\ No newline at end of file
diff --git a/imptune/api/clients.py b/imptune/api/clients.py
index 785985d..5a77a7c 100644
--- a/imptune/api/clients.py
+++ b/imptune/api/clients.py
@@ -27,11 +27,15 @@ def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
def _render_client_list(request: Request) -> HTMLResponse:
"""Render the client list partial for HTMX swap."""
- clients = list(Client.select().where(Client.owner == request.state.owner).order_by(Client.name))
+ from imptune.api.pages import printer_counts_by_client
+
+ owner = request.state.owner
+ clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
return templates.TemplateResponse(
request=request,
name="partials/client_list.html",
- context={"clients": clients},
+ # Counts must come along, or the swapped-in rows all read "0 printers".
+ context={"clients": clients, "counts": printer_counts_by_client(owner)},
)
diff --git a/imptune/api/icons.py b/imptune/api/icons.py
index 8c25c13..7decd0b 100644
--- a/imptune/api/icons.py
+++ b/imptune/api/icons.py
@@ -6,7 +6,7 @@ import io
from pathlib import Path
from fastapi import APIRouter, Request, UploadFile
-from fastapi.responses import HTMLResponse
+from fastapi.responses import FileResponse, HTMLResponse, Response
from PIL import Image
import imptune.config as cfg
@@ -68,9 +68,11 @@ def upload_icon(request: Request, printer_id: int, file: UploadFile) -> HTMLResp
status_code=422,
)
- # Store SHA256-addressed on disk
+ # Store SHA256-addressed on disk. cfg.ICONS_DIR is the same path the
+ # .intunewin export reads from — deriving it a second time from DATA_DIR
+ # would silently diverge if the layout ever changes.
sha256 = hashlib.sha256(data).hexdigest()
- icons_dir = Path(cfg.DATA_DIR) / "icons"
+ icons_dir = Path(cfg.ICONS_DIR)
icons_dir.mkdir(parents=True, exist_ok=True)
icon_path = icons_dir / sha256
icon_path.write_bytes(data)
@@ -85,6 +87,37 @@ def upload_icon(request: Request, printer_id: int, file: UploadFile) -> HTMLResp
)
return HTMLResponse(
- content="Icon uploaded successfully
",
+ content=(
+ 'Icon uploaded successfully
'
+ f'
{img.size[0]}×{img.size[1]} PNG '
+ ),
status_code=200,
)
+
+
+@router.get("/{printer_id}/icon")
+def get_icon(request: Request, printer_id: int) -> Response:
+ """Serve the stored 256x256 PNG so the UI can show what was uploaded.
+
+ Owner-scoped: a printer belonging to another owner reads as missing.
+ """
+ printer = Printer.get_or_none(
+ (Printer.id == printer_id) & (Printer.owner == request.state.owner)
+ )
+ if printer is None:
+ return Response(status_code=404)
+
+ icon = Icon.get_or_none(Icon.printer == printer_id)
+ if icon is None:
+ return Response(status_code=404)
+
+ icon_path = Path(cfg.ICONS_DIR) / icon.sha256
+ if not icon_path.exists():
+ return Response(status_code=404)
+
+ return FileResponse(
+ icon_path,
+ media_type="image/png",
+ headers={"Cache-Control": "private, max-age=300"},
+ )
diff --git a/imptune/api/packages.py b/imptune/api/packages.py
index ec7b6e1..1caaa61 100644
--- a/imptune/api/packages.py
+++ b/imptune/api/packages.py
@@ -1,5 +1,4 @@
"""Package export endpoints — serves deployment packages for NinjaRMM and Microsoft Intune."""
-import io
import json
import os
import shutil
@@ -7,7 +6,8 @@ import tempfile
import zipfile
from fastapi import APIRouter, Request
-from fastapi.responses import PlainTextResponse, Response
+from fastapi.responses import FileResponse, PlainTextResponse
+from starlette.background import BackgroundTask
import imptune.config as cfg
from imptune.db.models import Icon, Owner, Printer
@@ -17,6 +17,11 @@ from imptune.storage.driver_store import DriverStore
router = APIRouter(prefix="/printers")
+# Driver payloads run to ~100 MB. Packages are assembled in a temp dir and
+# streamed from disk instead of being held in memory, so N concurrent
+# downloads cost N file handles rather than N × package size of RAM.
+_CHUNK = 1024 * 1024
+
def _get_printer_and_driver(printer_id: int, owner: Owner):
"""Fetch printer (scoped to owner) and validate driver — returns (printer, driver, driver_name) or PlainTextResponse error."""
@@ -47,6 +52,16 @@ def _get_driver_zip_path(driver) -> str:
return str(DriverStore(cfg.DRIVERS_DIR).get_path(driver.sha256))
+def _streamed_download(path: str, tmpdir: str, media_type: str, filename: str) -> FileResponse:
+ """Serve a built package off disk, deleting its temp dir once sent."""
+ return FileResponse(
+ path,
+ media_type=media_type,
+ filename=filename,
+ background=BackgroundTask(shutil.rmtree, tmpdir, True),
+ )
+
+
@router.get("/{printer_id}/packages/ninja")
def get_ninja_package(request: Request, printer_id: int):
"""Download a NinjaRMM-ready ZIP containing install.ps1 and the driver files."""
@@ -76,22 +91,30 @@ def get_ninja_package(request: Request, printer_id: int):
collate=printer.collate,
)
- # Build ZIP in-memory
- buf = io.BytesIO()
- with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
- # Add install script
- zf.writestr(f"{safe_name}/install.ps1", install_script)
+ # Build the ZIP on disk, copying driver members through in chunks so a
+ # 100 MB driver never lands in memory whole.
+ tmpdir = tempfile.mkdtemp(prefix="imptune_ninja_")
+ try:
+ # Fixed on-disk name — the printer name only shapes the download name,
+ # so a "/" or ":" in it can't break the temp path.
+ out_path = os.path.join(tmpdir, "package.zip")
+ with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
+ zf.writestr(f"{safe_name}/install.ps1", install_script)
- # Extract and re-add driver files from driver ZIP
- with zipfile.ZipFile(driver_zip_path, "r") as driver_zf:
- for member in driver_zf.namelist():
- member_data = driver_zf.read(member)
- zf.writestr(f"{safe_name}/drivers/{member}", member_data)
+ with zipfile.ZipFile(driver_zip_path, "r") as driver_zf:
+ for member in driver_zf.infolist():
+ target = f"{safe_name}/drivers/{member.filename}"
+ if member.is_dir():
+ zf.writestr(target, b"")
+ continue
+ with driver_zf.open(member) as src, zf.open(target, "w") as dst:
+ shutil.copyfileobj(src, dst, _CHUNK)
+ except BaseException:
+ shutil.rmtree(tmpdir, ignore_errors=True)
+ raise
- return Response(
- content=buf.getvalue(),
- media_type="application/zip",
- headers={"Content-Disposition": f'attachment; filename="{safe_name}_ninja.zip"'},
+ return _streamed_download(
+ out_path, tmpdir, "application/zip", f"{safe_name}_ninja.zip"
)
@@ -130,17 +153,24 @@ def get_intunewin_package(request: Request, printer_id: int):
)
detect_script = render_detect(printer_name=printer.name)
- with tempfile.TemporaryDirectory(prefix="imptune_") as tmpdir:
- # Write scripts
- with open(os.path.join(tmpdir, "install.ps1"), "w", encoding="utf-8") as f:
- f.write(install_script)
- with open(os.path.join(tmpdir, "uninstall.ps1"), "w", encoding="utf-8") as f:
- f.write(uninstall_script)
- with open(os.path.join(tmpdir, "detect.ps1"), "w", encoding="utf-8") as f:
- f.write(detect_script)
+ # The build output is streamed straight off disk, so the temp tree has to
+ # outlive this handler — the response's background task removes it.
+ tmpdir = tempfile.mkdtemp(prefix="imptune_")
+ try:
+ staging = os.path.join(tmpdir, "staging")
+ os.makedirs(staging, exist_ok=True)
- # Extract driver ZIP contents into tmpdir/drivers/
- drivers_subdir = os.path.join(tmpdir, "drivers")
+ # Write scripts
+ for filename, script in (
+ ("install.ps1", install_script),
+ ("uninstall.ps1", uninstall_script),
+ ("detect.ps1", detect_script),
+ ):
+ with open(os.path.join(staging, filename), "w", encoding="utf-8") as f:
+ f.write(script)
+
+ # Extract driver ZIP contents into staging/drivers/
+ drivers_subdir = os.path.join(staging, "drivers")
os.makedirs(drivers_subdir, exist_ok=True)
with zipfile.ZipFile(driver_zip_path, "r") as driver_zf:
driver_zf.extractall(drivers_subdir)
@@ -150,17 +180,16 @@ def get_intunewin_package(request: Request, printer_id: int):
if icon_record is not None:
icon_src = os.path.join(cfg.ICONS_DIR, icon_record.sha256)
if os.path.isfile(icon_src):
- shutil.copy2(icon_src, os.path.join(tmpdir, "icon.png"))
+ shutil.copy2(icon_src, os.path.join(staging, "icon.png"))
- # Build .intunewin
- output_path = os.path.join(tmpdir, "out.intunewin")
- build_intunewin(tmpdir, "install.ps1", output_path)
+ # Build .intunewin outside the staging dir — an output file written into
+ # the tree being packaged would end up inside its own package.
+ output_path = os.path.join(tmpdir, "package.intunewin")
+ build_intunewin(staging, "install.ps1", output_path)
+ except BaseException:
+ shutil.rmtree(tmpdir, ignore_errors=True)
+ raise
- with open(output_path, "rb") as f:
- content = f.read()
-
- return Response(
- content=content,
- media_type="application/octet-stream",
- headers={"Content-Disposition": f'attachment; filename="{safe_name}.intunewin"'},
+ return _streamed_download(
+ output_path, tmpdir, "application/octet-stream", f"{safe_name}.intunewin"
)
diff --git a/imptune/api/pages.py b/imptune/api/pages.py
index 593ab04..9426af3 100644
--- a/imptune/api/pages.py
+++ b/imptune/api/pages.py
@@ -5,37 +5,102 @@ from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
-from peewee import JOIN
+from peewee import JOIN, fn
router = APIRouter()
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
+# Group label for printers with no client — the template translates it.
+UNASSIGNED_GROUP = "Unassigned"
+
+
+def printer_counts_by_driver(owner) -> dict[int, int]:
+ """How many of this owner's printers use each (globally shared) driver."""
+ from imptune.db.models import Printer
+
+ rows = (
+ Printer.select(Printer.driver, fn.COUNT(Printer.id).alias("n"))
+ .where((Printer.owner == owner) & Printer.driver.is_null(False))
+ .group_by(Printer.driver)
+ )
+ return {row.driver_id: row.n for row in rows}
+
+
+def printer_counts_by_client(owner) -> dict[int, int]:
+ """How many printers each client groups."""
+ from imptune.db.models import Printer
+
+ rows = (
+ Printer.select(Printer.client, fn.COUNT(Printer.id).alias("n"))
+ .where((Printer.owner == owner) & Printer.client.is_null(False))
+ .group_by(Printer.client)
+ )
+ return {row.client_id: row.n for row in rows}
+
+
+def group_printers_by_client(printers) -> dict[str, list]:
+ """Group printers under their client name, unassigned ones last.
+
+ The template renders groups in insertion order, and "a printer nobody has
+ filed yet" belongs at the bottom of the page, not the top.
+ """
+ grouped: dict[str, list] = defaultdict(list)
+ for p in printers:
+ grouped[p.client.name if p.client_id else UNASSIGNED_GROUP].append(p)
+
+ unassigned = grouped.pop(UNASSIGNED_GROUP, None)
+ ordered = {name: grouped[name] for name in sorted(grouped)}
+ if unassigned:
+ ordered[UNASSIGNED_GROUP] = unassigned
+ return ordered
+
+
+def _driver_data(drivers) -> list[dict]:
+ """Attach the parsed driver names to each Driver row for template use."""
+ return [
+ {"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
+ for d in drivers
+ ]
+
@router.get("/", response_class=HTMLResponse)
def dashboard(request: Request):
- from imptune.db.models import Printer
+ from imptune.db.models import Client, Driver, Printer
owner = request.state.owner
recent_printers = list(
- Printer.select()
+ Printer.select(Printer, Client)
+ .join(Client, JOIN.LEFT_OUTER)
.where(Printer.owner == owner)
.order_by(Printer.created_at.desc())
.limit(5)
)
recent_packages = list(
- Printer.select()
+ Printer.select(Printer, Client)
+ .join(Client, JOIN.LEFT_OUTER)
+ .switch(Printer)
.where((Printer.owner == owner) & Printer.driver.is_null(False))
.order_by(Printer.created_at.desc())
.limit(5)
)
+ printer_count = Printer.select().where(Printer.owner == owner).count()
+ ready_count = (
+ Printer.select()
+ .where((Printer.owner == owner) & Printer.driver.is_null(False))
+ .count()
+ )
return templates.TemplateResponse(
request=request,
name="dashboard.html",
context={
"recent_printers": recent_printers,
"recent_packages": recent_packages,
+ "driver_count": Driver.select().count(),
+ "printer_count": printer_count,
+ "client_count": Client.select().where(Client.owner == owner).count(),
+ "ready_count": ready_count,
},
)
@@ -45,14 +110,13 @@ def drivers_page(request: Request):
from imptune.db.models import Driver
drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
- driver_data = []
- for d in drivers:
- names = json.loads(d.driver_desc) if d.driver_desc else []
- driver_data.append({"driver": d, "names": names})
return templates.TemplateResponse(
request=request,
name="drivers.html",
- context={"driver_data": driver_data},
+ context={
+ "driver_data": _driver_data(drivers),
+ "usage": printer_counts_by_driver(request.state.owner),
+ },
)
@@ -67,18 +131,13 @@ def printers_page(request: Request):
.where(Printer.owner == owner)
.order_by(Client.name, Printer.name)
)
- grouped: dict[str, list] = defaultdict(list)
- for p in query:
- client_name = p.client.name if p.client_id else "Unassigned"
- grouped[client_name].append(p)
+ grouped = group_printers_by_client(query)
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
-
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
- driver_data = []
- for d in all_drivers:
- names = json.loads(d.driver_desc) if d.driver_desc else []
- driver_data.append({"driver": d, "names": names})
+
+ printer_count = sum(len(v) for v in grouped.values())
+ ready_count = sum(1 for v in grouped.values() for p in v if p.driver_id)
return templates.TemplateResponse(
request=request,
@@ -86,7 +145,9 @@ def printers_page(request: Request):
context={
"grouped": grouped,
"clients": clients,
- "driver_data": driver_data,
+ "driver_data": _driver_data(all_drivers),
+ "printer_count": printer_count,
+ "ready_count": ready_count,
},
)
@@ -97,14 +158,10 @@ def printers_new_page(request: Request):
clients = list(Client.select().where(Client.owner == request.state.owner).order_by(Client.name))
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
- driver_data = [
- {"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
- for d in all_drivers
- ]
return templates.TemplateResponse(
request=request,
name="printers_new.html",
- context={"clients": clients, "driver_data": driver_data},
+ context={"clients": clients, "driver_data": _driver_data(all_drivers)},
)
@@ -154,11 +211,12 @@ def printer_detail(request: Request, printer_id: int):
def clients_page(request: Request):
from imptune.db.models import Client
- clients = list(Client.select().where(Client.owner == request.state.owner).order_by(Client.name))
+ owner = request.state.owner
+ clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
return templates.TemplateResponse(
request=request,
name="clients.html",
- context={"clients": clients},
+ context={"clients": clients, "counts": printer_counts_by_client(owner)},
)
@@ -180,14 +238,11 @@ def client_detail(request: Request, client_id: int):
.where((Printer.client == client_id) & (Printer.owner == owner))
.order_by(Printer.name)
)
- grouped = {client.name: list(query)}
+ printers = list(query)
+ grouped = {client.name: printers}
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
- driver_data = [
- {"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
- for d in all_drivers
- ]
return templates.TemplateResponse(
request=request,
@@ -196,25 +251,43 @@ def client_detail(request: Request, client_id: int):
"client": client,
"grouped": grouped,
"clients": clients,
- "driver_data": driver_data,
+ "driver_data": _driver_data(all_drivers),
+ "printer_count": len(printers),
+ "ready_count": sum(1 for p in printers if p.driver_id),
},
)
@router.get("/packages", response_class=HTMLResponse)
def packages_page(request: Request):
- from imptune.db.models import Client, Driver, Printer
+ from imptune.db.models import Client, Driver, Icon, Printer
+ owner = request.state.owner
printers = list(
Printer.select(Printer, Client, Driver)
.join(Client, JOIN.LEFT_OUTER)
.switch(Printer)
.join(Driver, JOIN.LEFT_OUTER)
- .where((Printer.owner == request.state.owner) & Printer.driver.is_null(False))
+ .where((Printer.owner == owner) & Printer.driver.is_null(False))
.order_by(Printer.name)
)
+ pending_count = (
+ Printer.select()
+ .where((Printer.owner == owner) & Printer.driver.is_null(True))
+ .count()
+ )
+ icon_printer_ids = {
+ row.printer_id
+ for row in Icon.select(Icon.printer).where(
+ Icon.printer.in_([p.id for p in printers] or [0])
+ )
+ }
return templates.TemplateResponse(
request=request,
name="packages.html",
- context={"printers": printers},
+ context={
+ "printers": printers,
+ "pending_count": pending_count,
+ "icon_printer_ids": icon_printer_ids,
+ },
)
diff --git a/imptune/api/printers.py b/imptune/api/printers.py
index 0af007e..a0b0350 100644
--- a/imptune/api/printers.py
+++ b/imptune/api/printers.py
@@ -1,14 +1,14 @@
"""Printer CRUD API — POST /printers, DELETE /printers/{id}, PATCH /printers/{id}."""
from __future__ import annotations
-from collections import defaultdict
from pathlib import Path
from fastapi import APIRouter, Form, Request
-from fastapi.responses import HTMLResponse, RedirectResponse
+from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from peewee import JOIN
+from imptune.api.pages import group_printers_by_client
from imptune.db.models import Client, Driver, Printer
router = APIRouter(prefix="/printers")
@@ -29,6 +29,59 @@ def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
)
+def _validate_fields(
+ name: str, ip_address: str, port_name: str, duplex_mode: str, paper_size: str
+) -> HTMLResponse | None:
+ """Shared field validation for create and update — None when everything is valid."""
+ if not name:
+ return _error_response("Printer name is required.")
+ if not ip_address:
+ return _error_response("IP address is required.")
+ if not port_name:
+ return _error_response("Port name is required.")
+ if duplex_mode not in _VALID_DUPLEX:
+ return _error_response(f"Invalid duplex mode: {duplex_mode}.")
+ if paper_size not in _VALID_PAPER:
+ return _error_response(f"Invalid paper size: {paper_size}.")
+ return None
+
+
+def _resolve_client(raw: str, owner) -> tuple[int | None, HTMLResponse | None]:
+ """Resolve the optional client_id form field to an owned Client id.
+
+ The form value is attacker-controlled text: a non-numeric value used to
+ raise ValueError (HTTP 500) instead of the 400 the HTMX form can render.
+ """
+ raw = raw.strip()
+ if not raw:
+ return None, None
+ try:
+ client_fk = int(raw)
+ except ValueError:
+ return None, _error_response(f"Invalid client id: {raw}.")
+ if Client.get_or_none((Client.id == client_fk) & (Client.owner == owner)) is None:
+ return None, _error_response(f"Client {client_fk} not found.", status_code=404)
+ return client_fk, None
+
+
+def _resolve_driver(raw: str) -> tuple[int | None, HTMLResponse | None]:
+ """Resolve the optional driver_id form field. Drivers are global/shared.
+
+ Existence is checked here because an unknown id otherwise reaches SQLite as
+ a FOREIGN KEY violation — an IntegrityError (HTTP 500) rather than a 404.
+ """
+ raw = raw.strip()
+ if not raw:
+ return None, None
+ try:
+ driver_fk = int(raw)
+ except ValueError:
+ return None, _error_response(f"Invalid driver id: {raw}.")
+ if Driver.get_or_none(Driver.id == driver_fk) is None:
+ return None, _error_response(f"Driver {driver_fk} not found.", status_code=404)
+ return driver_fk, None
+
+
def _render_printer_list(request: Request) -> HTMLResponse:
"""Query printers with LEFT JOIN on client and render grouped partial."""
import json
@@ -40,10 +93,7 @@ def _render_printer_list(request: Request) -> HTMLResponse:
.where(Printer.owner == owner)
.order_by(Client.name, Printer.name)
)
- grouped: dict[str, list[Printer]] = defaultdict(list)
- for p in query:
- client_name = p.client.name if p.client_id else "Unassigned"
- grouped[client_name].append(p)
+ grouped = group_printers_by_client(query)
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
@@ -71,48 +121,39 @@ def create_printer(
collate: str = Form(""),
client_id: str = Form(""),
driver_id: str = Form(""),
-) -> HTMLResponse:
+) -> Response:
"""Create a new printer configuration.
- Boolean fields (color_mode, collate) use HTML checkbox convention:
- "on" = True, absent/empty = False.
+ Redirects to /printers on success; returns an inline HTMX error fragment
+ (400/404) on validation failure.
"""
name = name.strip()
ip_address = ip_address.strip()
port_name = port_name.strip()
- if not name:
- return _error_response("Printer name is required.")
- if not ip_address:
- return _error_response("IP address is required.")
- if not port_name:
- return _error_response("Port name is required.")
- if duplex_mode not in _VALID_DUPLEX:
- return _error_response(f"Invalid duplex mode: {duplex_mode}.")
- if paper_size not in _VALID_PAPER:
- return _error_response(f"Invalid paper size: {paper_size}.")
-
- # Convert checkbox values
- color_mode_bool = color_mode == "on"
- collate_bool = collate == "on"
+ invalid = _validate_fields(name, ip_address, port_name, duplex_mode, paper_size)
+ if invalid is not None:
+ return invalid
owner = request.state.owner
# Resolve optional FK IDs — client must belong to this owner
- client_fk = int(client_id) if client_id.strip() else None
- if client_fk is not None:
- if Client.get_or_none((Client.id == client_fk) & (Client.owner == owner)) is None:
- return _error_response(f"Client {client_fk} not found.", status_code=404)
- driver_fk = int(driver_id) if driver_id.strip() else None
+ client_fk, error = _resolve_client(client_id, owner)
+ if error is not None:
+ return error
+ driver_fk, error = _resolve_driver(driver_id)
+ if error is not None:
+ return error
Printer.create(
name=name,
ip_address=ip_address,
port_name=port_name,
duplex_mode=duplex_mode,
- color_mode=color_mode_bool,
+ # HTML checkbox convention: "on" = True, absent/empty = False
+ color_mode=color_mode == "on",
paper_size=paper_size,
- collate=collate_bool,
+ collate=collate == "on",
owner=owner,
client=client_fk,
driver=driver_fk,
@@ -161,21 +202,16 @@ def update_printer(
ip_address = ip_address.strip() or printer.ip_address
port_name = port_name.strip() or printer.port_name
- if not name:
- return _error_response("Printer name is required.")
- if not ip_address:
- return _error_response("IP address is required.")
- if not port_name:
- return _error_response("Port name is required.")
- if duplex_mode not in _VALID_DUPLEX:
- return _error_response(f"Invalid duplex mode: {duplex_mode}.")
- if paper_size not in _VALID_PAPER:
- return _error_response(f"Invalid paper size: {paper_size}.")
+ invalid = _validate_fields(name, ip_address, port_name, duplex_mode, paper_size)
+ if invalid is not None:
+ return invalid
- client_fk = int(client_id) if client_id.strip() else None
- if client_fk is not None:
- if Client.get_or_none((Client.id == client_fk) & (Client.owner == owner)) is None:
- return _error_response(f"Client {client_fk} not found.", status_code=404)
+ client_fk, error = _resolve_client(client_id, owner)
+ if error is not None:
+ return error
+ driver_fk, error = _resolve_driver(driver_id)
+ if error is not None:
+ return error
printer.name = name
printer.ip_address = ip_address
@@ -185,7 +221,7 @@ def update_printer(
printer.paper_size = paper_size
printer.collate = collate == "on"
printer.client = client_fk
- printer.driver = int(driver_id) if driver_id.strip() else None
+ printer.driver = driver_fk
printer.updated_at = datetime.now(UTC).replace(tzinfo=None)
printer.save()
diff --git a/imptune/api/session.py b/imptune/api/session.py
index 18e7682..39c7d0a 100644
--- a/imptune/api/session.py
+++ b/imptune/api/session.py
@@ -7,9 +7,8 @@ from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
-import imptune.config as cfg
from imptune.db.models import Owner
-from imptune.services.session import COOKIE_MAX_AGE, COOKIE_NAME, is_same_origin
+from imptune.services.session import COOKIE_NAME, cookie_kwargs, is_same_origin
router = APIRouter(prefix="/session")
@@ -62,12 +61,5 @@ def restore_session(request: Request, key: str = Form(...)):
)
response = RedirectResponse(url="/", status_code=303)
- response.set_cookie(
- COOKIE_NAME,
- owner.key,
- max_age=COOKIE_MAX_AGE,
- httponly=True,
- samesite="lax",
- secure=cfg.COOKIE_SECURE,
- )
+ response.set_cookie(COOKIE_NAME, owner.key, **cookie_kwargs())
return response
diff --git a/imptune/generators/intunewin_builder.py b/imptune/generators/intunewin_builder.py
index b991667..d8d91fb 100644
--- a/imptune/generators/intunewin_builder.py
+++ b/imptune/generators/intunewin_builder.py
@@ -27,8 +27,9 @@ Outer ZIP structure:
import base64
import hashlib
import hmac
-import io
import os
+import shutil
+import tempfile
import zipfile
from xml.etree.ElementTree import Element, SubElement, indent, tostring
@@ -41,6 +42,10 @@ from Crypto.Util.Padding import pad
# was produced by a compatible tool version.
_TOOL_VERSION = "1.8.6.0"
+# Streaming chunk size. Must be a multiple of AES.block_size (16) so every
+# chunk but the last is a whole number of CBC blocks.
+_CHUNK = 1024 * 1024
+
def build_intunewin(source_dir: str, setup_file: str, output_path: str) -> None:
"""Build a .intunewin file from source_dir, with setup_file as entry point.
@@ -54,43 +59,93 @@ def build_intunewin(source_dir: str, setup_file: str, output_path: str) -> None:
Raises:
FileNotFoundError: If source_dir does not exist.
ValueError: If setup_file is empty.
+
+ Driver payloads run to ~100 MB, so every step streams through 1 MB chunks
+ against temp files: buffering the inner ZIP, its ciphertext and the outer
+ ZIP in memory held 3-4 full copies of the package per concurrent download.
"""
- # --- Step 1: Create inner ZIP (DEFLATE-compressed content) ---
- inner_zip_buf = io.BytesIO()
- with zipfile.ZipFile(inner_zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
- for root, dirs, files in os.walk(source_dir):
- dirs.sort() # deterministic ordering
- for filename in sorted(files):
- abs_path = os.path.join(root, filename)
- arc_name = os.path.relpath(abs_path, source_dir)
- # Normalise to forward slashes for cross-platform consistency
- arc_name = arc_name.replace("\\", "/")
- zf.write(abs_path, arc_name)
- plaintext = inner_zip_buf.getvalue()
+ if not setup_file:
+ raise ValueError("setup_file must not be empty")
+ if not os.path.isdir(source_dir):
+ raise FileNotFoundError(f"source_dir does not exist: {source_dir}")
- # --- Step 2: Generate random keys and IV ---
- aes_key = os.urandom(32) # 256-bit AES key
- mac_key = os.urandom(32) # 256-bit HMAC key (same size as AES key)
- iv = os.urandom(16) # 128-bit IV — standard AES-CBC block size (NOT 32 bytes)
+ with tempfile.TemporaryDirectory(prefix="intunewin_") as staging:
+ inner_zip_path = os.path.join(staging, "inner.zip")
+ ciphertext_path = os.path.join(staging, "inner.enc")
- # --- Step 3: Encrypt with AES-256-CBC (PKCS7 padding) ---
- cipher = AES.new(aes_key, AES.MODE_CBC, iv)
- ciphertext = cipher.encrypt(pad(plaintext, AES.block_size))
+ # --- Step 1: Create inner ZIP (DEFLATE-compressed content) ---
+ with zipfile.ZipFile(inner_zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
+ for root, dirs, files in os.walk(source_dir):
+ dirs.sort() # deterministic ordering
+ for filename in sorted(files):
+ abs_path = os.path.join(root, filename)
+ arc_name = os.path.relpath(abs_path, source_dir)
+ # Normalise to forward slashes for cross-platform consistency
+ arc_name = arc_name.replace("\\", "/")
+ zf.write(abs_path, arc_name)
+ plaintext_size = os.path.getsize(inner_zip_path)
- # --- Step 4: Compute HMAC-SHA256 over (IV + ciphertext) using mac_key ---
- # The reference (svrooij/ContentPrep Zipper.cs DecryptFileAsync) reads the first
- # 32 bytes as the stored HMAC, then computes the hash of the *remaining* bytes
- # (= IV || ciphertext) to verify integrity. Authenticated-encryption best
- # practice (Encrypt-then-MAC) also requires the IV to be covered by the MAC so
- # that a forged IV cannot redirect decryption.
- mac_digest = hmac.new(mac_key, iv + ciphertext, hashlib.sha256).digest()
+ # --- Step 2: Generate random keys and IV ---
+ aes_key = os.urandom(32) # 256-bit AES key
+ mac_key = os.urandom(32) # 256-bit HMAC key (same size as AES key)
+ iv = os.urandom(16) # 128-bit IV — standard AES-CBC block size (NOT 32 bytes)
- # --- Step 5: Assemble encrypted blob: [HMAC(32)] + [IV(16)] + [ciphertext] ---
- encrypted_blob = mac_digest + iv + ciphertext
+ # --- Steps 3-4: Encrypt (AES-256-CBC, PKCS7) while MAC-ing and digesting ---
+ # The reference (svrooij/ContentPrep Zipper.cs DecryptFileAsync) reads the first
+ # 32 bytes as the stored HMAC, then computes the hash of the *remaining* bytes
+ # (= IV || ciphertext) to verify integrity. Authenticated-encryption best
+ # practice (Encrypt-then-MAC) also requires the IV to be covered by the MAC so
+ # that a forged IV cannot redirect decryption.
+ cipher = AES.new(aes_key, AES.MODE_CBC, iv)
+ mac = hmac.new(mac_key, iv, hashlib.sha256)
+ plaintext_hash = hashlib.sha256()
- # --- Step 6: Compute plaintext (inner ZIP) SHA256 digest for Detection.xml ---
- file_digest = hashlib.sha256(plaintext).digest()
+ with open(inner_zip_path, "rb") as src, open(ciphertext_path, "wb") as dst:
+ while True:
+ # BufferedReader.read(n) returns n bytes unless EOF, so a short
+ # read means "last chunk" — the only one that carries padding.
+ chunk = src.read(_CHUNK)
+ plaintext_hash.update(chunk)
+ if len(chunk) < _CHUNK:
+ block = cipher.encrypt(pad(chunk, AES.block_size))
+ mac.update(block)
+ dst.write(block)
+ break
+ block = cipher.encrypt(chunk)
+ mac.update(block)
+ dst.write(block)
+ mac_digest = mac.digest()
+
+ # --- Step 6: Plaintext (inner ZIP) SHA256 digest for Detection.xml ---
+ file_digest = plaintext_hash.digest()
+
+ return _finalize(
+ output_path=output_path,
+ ciphertext_path=ciphertext_path,
+ setup_file=setup_file,
+ plaintext_size=plaintext_size,
+ aes_key=aes_key,
+ mac_key=mac_key,
+ iv=iv,
+ mac_digest=mac_digest,
+ file_digest=file_digest,
+ )
+
+
+def _finalize(
+ *,
+ output_path: str,
+ ciphertext_path: str,
+ setup_file: str,
+ plaintext_size: int,
+ aes_key: bytes,
+ mac_key: bytes,
+ iv: bytes,
+ mac_digest: bytes,
+ file_digest: bytes,
+) -> None:
+ """Write Detection.xml plus the [HMAC(32)][IV(16)][ciphertext] blob into the outer ZIP."""
# --- Step 7: Build Detection.xml ---
# Format MUST match IntuneWinAppUtil.exe reference output exactly:
# - ToolVersion is an XML attribute on ApplicationInfo (not a child element)
@@ -102,7 +157,7 @@ def build_intunewin(source_dir: str, setup_file: str, output_path: str) -> None:
attrib={"ToolVersion": _TOOL_VERSION},
)
SubElement(app_info, "Name").text = setup_file
- SubElement(app_info, "UnencryptedContentSize").text = str(len(plaintext))
+ SubElement(app_info, "UnencryptedContentSize").text = str(plaintext_size)
SubElement(app_info, "FileName").text = "IntunePackage.intunewin"
SubElement(app_info, "SetupFile").text = setup_file
@@ -121,11 +176,14 @@ def build_intunewin(source_dir: str, setup_file: str, output_path: str) -> None:
detection_xml = tostring(app_info, encoding="unicode", xml_declaration=False)
# --- Step 8: Build outer ZIP (STORED — no extra compression on encrypted content) ---
+ # The blob is streamed in as [HMAC(32)] + [IV(16)] + [ciphertext] so the
+ # encrypted payload is never materialised as a second in-memory copy.
with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_STORED) as outer:
- outer.writestr(
- "IntuneWinPackage/Contents/IntunePackage.intunewin",
- encrypted_blob,
- )
+ with outer.open("IntuneWinPackage/Contents/IntunePackage.intunewin", "w") as dest:
+ dest.write(mac_digest)
+ dest.write(iv)
+ with open(ciphertext_path, "rb") as ct:
+ shutil.copyfileobj(ct, dest, _CHUNK)
outer.writestr(
"IntuneWinPackage/Metadata/Detection.xml",
detection_xml.encode("utf-8"),
diff --git a/imptune/services/inf_parser.py b/imptune/services/inf_parser.py
index 545d6c6..0d21bb9 100644
--- a/imptune/services/inf_parser.py
+++ b/imptune/services/inf_parser.py
@@ -173,7 +173,11 @@ def parse_inf(inf_text: str, inf_filename: str, zip_names: list[str]) -> ParsedI
for key, _val in parser.items(section):
if key.startswith("__bare_"):
continue
- resolved = _resolve_tokens(key, strings)
+ # DriverDesc keys are sometimes quoted directly in the INF
+ # (e.g. `"Canon Generic PCL6" = SectionName, HardwareID`)
+ # instead of via %TOKEN%; configparser keeps those quotes as
+ # part of the key, so strip them same as [Strings] values.
+ resolved = _resolve_tokens(key, strings).strip('"')
# Skip empty, purely numeric, or clearly non-driver-name entries
if resolved and not resolved.isdigit():
driver_names.add(resolved)
diff --git a/imptune/services/session.py b/imptune/services/session.py
index b1ffe5c..784c913 100644
--- a/imptune/services/session.py
+++ b/imptune/services/session.py
@@ -12,11 +12,50 @@ from imptune.db.models import Owner
COOKIE_NAME = "imptune_owner_key"
COOKIE_MAX_AGE = 10 * 365 * 24 * 60 * 60 # 10 years
+# Paths that never need an Owner. Minting one for them means a DB write per
+# request for any client that doesn't carry the cookie — asset fetches racing
+# the first page load, health probes, crawlers, favicon hunts — and every row
+# is dead weight, since only a browser holding the cookie can ever use it.
+_UNSCOPED_PATHS = frozenset({"/health", "/favicon.ico", "/robots.txt"})
+_UNSCOPED_PREFIXES = ("/static/",)
+
def generate_key() -> str:
return secrets.token_urlsafe(32)
+def cookie_kwargs() -> dict:
+ """`set_cookie` attributes for the owner key — persistent only when Secure.
+
+ With `COOKIE_SECURE=false` the key travels over plain HTTP, so persisting it
+ for ten years would leave a long-lived bearer credential on disk and in
+ cleartext traffic. Instead `max_age` is dropped: the browser holds the cookie
+ in memory only. The app stays fully usable and keeps remembering printers,
+ configs and clients for as long as the window is open — the session just
+ ends when the browser does, and the UI says so (see `ephemeral_session`).
+ The backup key remains the way to carry a session across browsers.
+ """
+ kwargs: dict = {
+ "httponly": True,
+ "samesite": "lax",
+ "secure": cfg.COOKIE_SECURE,
+ }
+ if cfg.COOKIE_SECURE:
+ kwargs["max_age"] = COOKIE_MAX_AGE
+ return kwargs
+
+
+def _request_hosts(request: Request) -> set[str]:
+ """Every host spelling that legitimately identifies this deployment."""
+ hosts = {request.url.netloc}
+ for header in ("host", "x-forwarded-host"):
+ value = request.headers.get(header)
+ if value:
+ # X-Forwarded-Host may be a proxy chain: the client-facing host is first.
+ hosts.add(value.split(",")[0].strip())
+ return hosts
+
+
def is_same_origin(request: Request) -> bool:
"""Origin/Referer check — the CSRF guard for /session/restore.
@@ -28,19 +67,25 @@ def is_same_origin(request: Request) -> bool:
Origin (and usually Referer) on form POSTs, same-site or not, so
requiring a match — and rejecting when both are absent — blocks a plain
auto-submitting HTML form without needing a token.
+
+ Only the *host* is compared, not the scheme: behind a TLS-terminating
+ proxy the browser sends `Origin: https://host` while uvicorn sees
+ `http` (it only trusts X-Forwarded-Proto from `forwarded_allow_ips`,
+ which excludes a proxy in a sibling container), so a scheme comparison
+ rejected every legitimate restore in production. A same-host attacker
+ origin is not a capability the scheme check was buying.
"""
- expected = f"{request.url.scheme}://{request.url.netloc}"
+ from urllib.parse import urlparse
+
+ hosts = _request_hosts(request)
origin = request.headers.get("origin")
if origin is not None:
- return origin == expected
+ return urlparse(origin).netloc in hosts
referer = request.headers.get("referer")
if referer:
- from urllib.parse import urlparse
-
- parsed = urlparse(referer)
- return f"{parsed.scheme}://{parsed.netloc}" == expected
+ return urlparse(referer).netloc in hosts
return False
@@ -49,7 +94,8 @@ class OwnerSessionMiddleware(BaseHTTPMiddleware):
"""Resolves request.state.owner from a cookie, creating one on first visit."""
async def dispatch(self, request: Request, call_next):
- if request.url.path == "/health":
+ path = request.url.path
+ if path in _UNSCOPED_PATHS or path.startswith(_UNSCOPED_PREFIXES):
return await call_next(request)
key = request.cookies.get(COOKIE_NAME)
@@ -60,6 +106,8 @@ class OwnerSessionMiddleware(BaseHTTPMiddleware):
request.state.owner = owner
request.state.is_new_owner = is_new
+ # Templates warn about it; see cookie_kwargs().
+ request.state.ephemeral_session = not cfg.COOKIE_SECURE
response = await call_next(request)
@@ -72,13 +120,6 @@ class OwnerSessionMiddleware(BaseHTTPMiddleware):
)
if is_new and not route_already_set_cookie:
- response.set_cookie(
- COOKIE_NAME,
- owner.key,
- max_age=COOKIE_MAX_AGE,
- httponly=True,
- samesite="lax",
- secure=cfg.COOKIE_SECURE,
- )
+ response.set_cookie(COOKIE_NAME, owner.key, **cookie_kwargs())
return response
diff --git a/imptune/static/app.css b/imptune/static/app.css
index 5b3cebc..b7f807c 100644
--- a/imptune/static/app.css
+++ b/imptune/static/app.css
@@ -1,4 +1,270 @@
-/* ImpTune layout — supplements Pico CSS */
+/* ==========================================================================
+ ImpTune — application shell + component layer on top of Pico CSS.
+
+ Design direction: a workbench for a build pipeline (driver -> printer ->
+ package), not a dashboard. Prose is set in the system UI face; every value
+ the machine cares about (IPs, port names, INF driver names, PowerShell
+ commands) is set in monospace. That split is the visual identity.
+
+ Theme selectors mirror Pico's own so equal specificity + later source order
+ wins: light = :root:not([data-theme=dark]) ; dark = the two dark selectors.
+ `data-theme="auto"` is not `light`, so it follows the OS preference.
+ ========================================================================== */
+
+/* --- Tokens: light (paper) ----------------------------------------------- */
+:root:not([data-theme="dark"]) {
+ --im-bg: #f2f2ef;
+ --im-surface: #ffffff;
+ --im-surface-2: #f8f8f5;
+ --im-surface-3: #eeeeea;
+ --im-line: #e2e3de;
+ --im-line-strong: #cdcfc8;
+ --im-text: #191f26;
+ --im-text-dim: #59626c;
+ --im-text-faint: #868f99;
+ --im-accent: #0d6f79;
+ --im-accent-hover: #0a575f;
+ --im-accent-soft: #dff0f1;
+ --im-accent-line: #a9d5d9;
+ --im-ok: #2b7048;
+ --im-ok-soft: #e2f0e7;
+ --im-warn: #8c5a0c;
+ --im-warn-soft: #fbeed7;
+ --im-danger: #a32f2f;
+ --im-danger-soft: #f8e4e2;
+ --im-shadow: 0 1px 2px rgba(20, 26, 32, .05), 0 8px 24px -12px rgba(20, 26, 32, .18);
+ --im-ring: rgba(13, 111, 121, .35);
+
+ /* Pico remap */
+ --pico-background-color: var(--im-bg);
+ --pico-color: var(--im-text);
+ --pico-muted-color: var(--im-text-dim);
+ --pico-muted-border-color: var(--im-line);
+ --pico-border-color: var(--im-line);
+ --pico-h1-color: var(--im-text);
+ --pico-h2-color: var(--im-text);
+ --pico-h3-color: var(--im-text);
+ --pico-h4-color: var(--im-text);
+ --pico-h5-color: var(--im-text);
+ --pico-h6-color: var(--im-text-dim);
+ --pico-primary: var(--im-accent);
+ --pico-primary-background: var(--im-accent);
+ --pico-primary-border: var(--im-accent);
+ --pico-primary-hover: var(--im-accent-hover);
+ --pico-primary-hover-background: var(--im-accent-hover);
+ --pico-primary-hover-border: var(--im-accent-hover);
+ --pico-primary-underline: var(--im-accent-line);
+ --pico-primary-focus: var(--im-ring);
+ --pico-primary-inverse: #ffffff;
+ --pico-secondary: var(--im-text-dim);
+ --pico-secondary-background: var(--im-surface-3);
+ --pico-secondary-border: var(--im-line-strong);
+ --pico-secondary-hover: var(--im-text);
+ --pico-secondary-hover-background: var(--im-line);
+ --pico-secondary-hover-border: var(--im-line-strong);
+ --pico-secondary-inverse: var(--im-text);
+ --pico-card-background-color: var(--im-surface);
+ --pico-card-border-color: var(--im-line);
+ --pico-card-sectioning-background-color: var(--im-surface-2);
+ --pico-card-box-shadow: none;
+ --pico-form-element-background-color: var(--im-surface);
+ --pico-form-element-border-color: var(--im-line-strong);
+ --pico-form-element-color: var(--im-text);
+ --pico-form-element-placeholder-color: var(--im-text-faint);
+ --pico-form-element-active-border-color: var(--im-accent);
+ --pico-form-element-focus-color: var(--im-accent);
+ --pico-code-background-color: var(--im-surface-3);
+ --pico-code-color: var(--im-text);
+ --pico-modal-overlay-background-color: rgba(24, 30, 36, .42);
+}
+
+/* --- Tokens: dark (ink) -------------------------------------------------- */
+@media only screen and (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ --im-bg: #0f151b;
+ --im-surface: #161d24;
+ --im-surface-2: #1b232b;
+ --im-surface-3: #222c35;
+ --im-line: #26313a;
+ --im-line-strong: #35434e;
+ --im-text: #e5eaee;
+ --im-text-dim: #98a5b0;
+ --im-text-faint: #6c7a86;
+ --im-accent: #33bec6;
+ --im-accent-hover: #5ad3d9;
+ --im-accent-soft: #10333a;
+ --im-accent-line: #1f5d66;
+ --im-ok: #58c185;
+ --im-ok-soft: #14301f;
+ --im-warn: #dfa757;
+ --im-warn-soft: #33260f;
+ --im-danger: #e8746c;
+ --im-danger-soft: #35191a;
+ --im-shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 12px 32px -14px rgba(0, 0, 0, .7);
+ --im-ring: rgba(51, 190, 198, .4);
+
+ --pico-background-color: var(--im-bg);
+ --pico-color: var(--im-text);
+ --pico-muted-color: var(--im-text-dim);
+ --pico-muted-border-color: var(--im-line);
+ --pico-border-color: var(--im-line);
+ --pico-h1-color: var(--im-text);
+ --pico-h2-color: var(--im-text);
+ --pico-h3-color: var(--im-text);
+ --pico-h4-color: var(--im-text);
+ --pico-h5-color: var(--im-text);
+ --pico-h6-color: var(--im-text-dim);
+ --pico-primary: var(--im-accent);
+ --pico-primary-background: var(--im-accent);
+ --pico-primary-border: var(--im-accent);
+ --pico-primary-hover: var(--im-accent-hover);
+ --pico-primary-hover-background: var(--im-accent-hover);
+ --pico-primary-hover-border: var(--im-accent-hover);
+ --pico-primary-underline: var(--im-accent-line);
+ --pico-primary-focus: var(--im-ring);
+ --pico-primary-inverse: #06232a;
+ --pico-secondary: var(--im-text-dim);
+ --pico-secondary-background: var(--im-surface-3);
+ --pico-secondary-border: var(--im-line-strong);
+ --pico-secondary-hover: var(--im-text);
+ --pico-secondary-hover-background: var(--im-line-strong);
+ --pico-secondary-hover-border: var(--im-line-strong);
+ --pico-secondary-inverse: var(--im-text);
+ --pico-card-background-color: var(--im-surface);
+ --pico-card-border-color: var(--im-line);
+ --pico-card-sectioning-background-color: var(--im-surface-2);
+ --pico-card-box-shadow: none;
+ --pico-form-element-background-color: var(--im-surface-2);
+ --pico-form-element-border-color: var(--im-line-strong);
+ --pico-form-element-color: var(--im-text);
+ --pico-form-element-placeholder-color: var(--im-text-faint);
+ --pico-form-element-active-border-color: var(--im-accent);
+ --pico-form-element-focus-color: var(--im-accent);
+ --pico-code-background-color: var(--im-surface-3);
+ --pico-code-color: var(--im-text);
+ --pico-modal-overlay-background-color: rgba(4, 8, 11, .62);
+ }
+}
+
+[data-theme="dark"] {
+ --im-bg: #0f151b;
+ --im-surface: #161d24;
+ --im-surface-2: #1b232b;
+ --im-surface-3: #222c35;
+ --im-line: #26313a;
+ --im-line-strong: #35434e;
+ --im-text: #e5eaee;
+ --im-text-dim: #98a5b0;
+ --im-text-faint: #6c7a86;
+ --im-accent: #33bec6;
+ --im-accent-hover: #5ad3d9;
+ --im-accent-soft: #10333a;
+ --im-accent-line: #1f5d66;
+ --im-ok: #58c185;
+ --im-ok-soft: #14301f;
+ --im-warn: #dfa757;
+ --im-warn-soft: #33260f;
+ --im-danger: #e8746c;
+ --im-danger-soft: #35191a;
+ --im-shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 12px 32px -14px rgba(0, 0, 0, .7);
+ --im-ring: rgba(51, 190, 198, .4);
+
+ --pico-background-color: var(--im-bg);
+ --pico-color: var(--im-text);
+ --pico-muted-color: var(--im-text-dim);
+ --pico-muted-border-color: var(--im-line);
+ --pico-border-color: var(--im-line);
+ --pico-h1-color: var(--im-text);
+ --pico-h2-color: var(--im-text);
+ --pico-h3-color: var(--im-text);
+ --pico-h4-color: var(--im-text);
+ --pico-h5-color: var(--im-text);
+ --pico-h6-color: var(--im-text-dim);
+ --pico-primary: var(--im-accent);
+ --pico-primary-background: var(--im-accent);
+ --pico-primary-border: var(--im-accent);
+ --pico-primary-hover: var(--im-accent-hover);
+ --pico-primary-hover-background: var(--im-accent-hover);
+ --pico-primary-hover-border: var(--im-accent-hover);
+ --pico-primary-underline: var(--im-accent-line);
+ --pico-primary-focus: var(--im-ring);
+ --pico-primary-inverse: #06232a;
+ --pico-secondary: var(--im-text-dim);
+ --pico-secondary-background: var(--im-surface-3);
+ --pico-secondary-border: var(--im-line-strong);
+ --pico-secondary-hover: var(--im-text);
+ --pico-secondary-hover-background: var(--im-line-strong);
+ --pico-secondary-hover-border: var(--im-line-strong);
+ --pico-secondary-inverse: var(--im-text);
+ --pico-card-background-color: var(--im-surface);
+ --pico-card-border-color: var(--im-line);
+ --pico-card-sectioning-background-color: var(--im-surface-2);
+ --pico-card-box-shadow: none;
+ --pico-form-element-background-color: var(--im-surface-2);
+ --pico-form-element-border-color: var(--im-line-strong);
+ --pico-form-element-color: var(--im-text);
+ --pico-form-element-placeholder-color: var(--im-text-faint);
+ --pico-form-element-active-border-color: var(--im-accent);
+ --pico-form-element-focus-color: var(--im-accent);
+ --pico-code-background-color: var(--im-surface-3);
+ --pico-code-color: var(--im-text);
+ --pico-modal-overlay-background-color: rgba(4, 8, 11, .62);
+}
+
+/* --- Typography + global rhythm ----------------------------------------- */
+:root {
+ --im-mono: ui-monospace, "Cascadia Mono", "Cascadia Code", "SF Mono",
+ "JetBrains Mono", Consolas, "Liberation Mono", monospace;
+ --im-r: 8px;
+ --im-r-sm: 5px;
+ --im-sidebar-w: 236px;
+ --pico-font-family-sans-serif: system-ui, -apple-system, "Segoe UI Variable Text",
+ "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ --pico-font-family-monospace: var(--im-mono);
+ --pico-font-size: 93.75%; /* 15px base */
+ --pico-line-height: 1.55;
+ --pico-border-radius: var(--im-r-sm);
+ --pico-spacing: 1rem;
+ --pico-form-element-spacing-vertical: .55rem;
+ --pico-form-element-spacing-horizontal: .7rem;
+ --pico-typography-spacing-vertical: .9rem;
+ --pico-outline-width: 2px;
+ font-variant-numeric: tabular-nums;
+}
+
+body {
+ background: var(--im-bg);
+ color: var(--im-text);
+ -webkit-font-smoothing: antialiased;
+}
+
+h1, h2, h3, h4 { letter-spacing: -.011em; font-weight: 600; }
+
+/* Utility label — the recurring "field label" voice of a control panel. */
+.eyebrow {
+ font-size: .688rem;
+ font-weight: 600;
+ letter-spacing: .085em;
+ text-transform: uppercase;
+ color: var(--im-text-faint);
+ margin: 0;
+}
+
+.mono { font-family: var(--im-mono); font-size: .84em; }
+.dim { color: var(--im-text-dim); }
+.faint { color: var(--im-text-faint); }
+.nowrap { white-space: nowrap; }
+.ico { flex: 0 0 auto; vertical-align: -.18em; }
+
+:where(a, button, [role="button"], summary, input, select, textarea):focus-visible {
+ outline: 2px solid var(--im-accent);
+ outline-offset: 2px;
+ box-shadow: none;
+}
+
+/* ==========================================================================
+ App shell
+ ========================================================================== */
.layout {
display: flex;
@@ -6,24 +272,23 @@
align-items: stretch;
}
-.layout > * {
- box-sizing: border-box;
-}
+.layout > * { box-sizing: border-box; }
-/* Sidebar — override Pico's default nav styling (which is horizontal flex) */
nav.sidebar {
- width: 220px;
- min-width: 220px;
- max-width: 220px;
- flex: 0 0 220px;
- border-right: 1px solid var(--pico-muted-border-color, #e0e0e0);
- padding: 1rem 0;
+ width: var(--im-sidebar-w);
+ flex: 0 0 var(--im-sidebar-w);
+ background: var(--im-surface);
+ border-right: 1px solid var(--im-line);
+ padding: 0;
margin: 0;
display: flex;
flex-direction: column;
- justify-content: flex-start;
- align-items: stretch;
- overflow-x: hidden;
+ gap: 0;
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ overflow-y: auto;
+ overscroll-behavior: contain;
}
nav.sidebar ul,
@@ -31,67 +296,150 @@ nav.sidebar li {
margin: 0;
padding: 0;
list-style: none;
-}
-
-nav.sidebar ul {
display: block;
-}
-
-nav.sidebar li {
- display: block;
-}
-
-.sidebar-brand {
- padding: 0.5rem 1rem 1rem;
- font-size: 1.1rem;
- border-bottom: 1px solid var(--pico-muted-border-color, #e0e0e0);
- margin-bottom: 0.5rem;
-}
-
-/* Override Pico's horizontal nav ul default */
-.sidebar nav,
-.sidebar-nav {
- display: block;
-}
-
-.sidebar-nav {
- list-style: none;
- padding: 0;
- margin: 0;
- flex-direction: column;
-}
-
-.sidebar-nav li {
- display: block;
- margin: 0;
- padding: 0;
width: 100%;
}
+/* Brand: wordmark + a printed-sheet mark built from the accent. */
+.brand {
+ display: flex;
+ align-items: center;
+ gap: .6rem;
+ padding: 1.05rem 1rem .95rem;
+ text-decoration: none;
+ color: var(--im-text);
+ border-bottom: 1px solid var(--im-line);
+}
+
+.brand:hover { background: var(--im-surface-2); }
+
+.brand-mark {
+ width: 26px;
+ height: 26px;
+ border-radius: 6px;
+ background: var(--im-accent);
+ color: var(--pico-primary-inverse);
+ display: grid;
+ place-items: center;
+ font-family: var(--im-mono);
+ font-size: .75rem;
+ font-weight: 700;
+ letter-spacing: -.03em;
+ flex: 0 0 auto;
+}
+
+.brand-name {
+ font-weight: 650;
+ font-size: 1.02rem;
+ letter-spacing: -.02em;
+ line-height: 1.1;
+}
+
+.brand-sub {
+ display: block;
+ font-size: .656rem;
+ font-weight: 500;
+ letter-spacing: .07em;
+ text-transform: uppercase;
+ color: var(--im-text-faint);
+}
+
+.nav-group { padding: .85rem 0 .2rem; }
+.nav-group + .nav-group { border-top: 1px solid var(--im-line); }
+
+.nav-group-label {
+ padding: 0 1rem .45rem;
+ font-size: .656rem;
+ font-weight: 600;
+ letter-spacing: .09em;
+ text-transform: uppercase;
+ color: var(--im-text-faint);
+}
+
.sidebar-nav a {
- display: block;
+ display: flex;
+ align-items: center;
+ gap: .6rem;
box-sizing: border-box;
width: 100%;
- padding: 0.6rem 1rem;
+ padding: .5rem 1rem;
margin: 0;
text-decoration: none;
- color: inherit;
- font-weight: 400;
+ color: var(--im-text-dim);
+ font-size: .906rem;
+ font-weight: 500;
line-height: 1.4;
- border-left: 3px solid transparent;
+ border-left: 2px solid transparent;
+ transition: background-color .12s ease, color .12s ease;
}
+.sidebar-nav a .ico { color: var(--im-text-faint); transition: color .12s ease; }
+
.sidebar-nav a:hover {
- background-color: var(--pico-secondary-background, rgba(0,0,0,0.05));
+ background: var(--im-surface-2);
+ color: var(--im-text);
}
+.sidebar-nav a:hover .ico { color: var(--im-text-dim); }
+
.sidebar-nav a.active {
+ color: var(--im-text);
font-weight: 600;
- background-color: var(--pico-primary-background, rgba(0,0,0,0.08));
- border-left-color: var(--pico-primary, #1a73e8);
+ background: var(--im-accent-soft);
+ border-left-color: var(--im-accent);
}
-/* Main wrapper: fills remaining space beside sidebar, stacks topbar + content */
+.sidebar-nav a.active .ico { color: var(--im-accent); }
+
+.sidebar-foot {
+ margin-top: auto;
+ border-top: 1px solid var(--im-line);
+ padding: .7rem .75rem .85rem;
+}
+
+.sidebar-foot details { margin: 0; }
+
+.sidebar-foot summary {
+ display: flex;
+ align-items: center;
+ gap: .5rem;
+ padding: .4rem .25rem;
+ font-size: .844rem;
+ font-weight: 500;
+ color: var(--im-text-dim);
+ cursor: pointer;
+ list-style: none;
+}
+
+.sidebar-foot summary::after { display: none; }
+.sidebar-foot summary:hover { color: var(--im-text); }
+.sidebar-foot summary .ico { color: var(--im-text-faint); }
+
+.sidebar-foot .foot-links {
+ padding: .1rem 0 .2rem;
+ display: grid;
+ gap: .1rem;
+}
+
+.sidebar-foot .foot-links a {
+ display: block;
+ padding: .3rem .3rem .3rem 1.85rem;
+ font-size: .812rem;
+ color: var(--im-text-dim);
+ text-decoration: none;
+ border-radius: var(--im-r-sm);
+}
+
+.sidebar-foot .foot-links a:hover { background: var(--im-surface-2); color: var(--im-accent); }
+.sidebar-foot .foot-note {
+ padding: .35rem .3rem 0 1.85rem;
+ font-size: .75rem;
+ color: var(--im-text-faint);
+ line-height: 1.4;
+}
+
+/* --- Main column --------------------------------------------------------- */
+
.main-wrapper {
flex: 1 1 auto;
min-width: 0;
@@ -99,68 +447,975 @@ nav.sidebar li {
flex-direction: column;
}
-/* Topbar: holds top-right controls */
.topbar {
+ position: sticky;
+ top: 0;
+ z-index: 20;
display: flex;
- justify-content: flex-end;
align-items: center;
- padding: 0.5rem 1rem;
- border-bottom: 1px solid var(--pico-muted-border-color, #e0e0e0);
- gap: 0.5rem;
+ gap: 1rem;
+ min-height: 58px;
+ padding: .6rem 1.75rem;
+ background: color-mix(in srgb, var(--im-bg) 88%, transparent);
+ backdrop-filter: blur(8px);
+ border-bottom: 1px solid var(--im-line);
}
-.topbar-controls {
+.topbar-title {
+ display: flex;
+ flex-direction: column;
+ gap: .1rem;
+ min-width: 0;
+}
+
+.topbar-title h1 {
+ margin: 0;
+ font-size: 1.1rem;
+ font-weight: 620;
+ line-height: 1.2;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.topbar-title .crumb {
display: flex;
- gap: 0.5rem;
align-items: center;
+ gap: .3rem;
+ font-size: .75rem;
+ color: var(--im-text-faint);
}
-.topbar-controls button {
- padding: 0.3rem 0.6rem;
- font-size: 0.85rem;
- min-width: 2.2rem;
+.topbar-title .crumb a { color: var(--im-text-faint); text-decoration: none; }
+.topbar-title .crumb a:hover { color: var(--im-accent); }
+
+.topbar-actions {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: .5rem;
}
-/* Main content */
+.topbar-sep {
+ width: 1px;
+ height: 22px;
+ background: var(--im-line);
+ margin: 0 .15rem;
+}
+
+.icon-btn {
+ --pico-background-color: transparent;
+ display: inline-grid;
+ place-items: center;
+ width: 2rem;
+ height: 2rem;
+ padding: 0;
+ margin: 0;
+ border: 1px solid transparent;
+ border-radius: var(--im-r-sm);
+ background: transparent;
+ color: var(--im-text-dim);
+ font-size: .75rem;
+ font-weight: 600;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.icon-btn:hover {
+ background: var(--im-surface-3);
+ border-color: var(--im-line);
+ color: var(--im-text);
+}
+
+.sidebar-toggle { display: none; }
+
.main-content {
flex: 1 1 auto;
min-width: 0;
- padding: 1.5rem 2rem;
- overflow-x: hidden;
- overflow-y: auto;
+ width: 100%;
+ max-width: 1180px;
+ padding: 1.6rem 1.75rem 4rem;
}
-/* Quick action buttons */
-.quick-actions {
- display: flex;
- gap: 0.75rem;
- margin-bottom: 1.5rem;
- flex-wrap: wrap;
-}
+/* ==========================================================================
+ Buttons
+ ========================================================================== */
-.btn-action {
- display: inline-block;
- padding: 0.5rem 1rem;
- border: 1px solid var(--pico-primary, #1a73e8);
- border-radius: 4px;
+.btn,
+a[role="button"].btn,
+button.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: .4rem;
+ padding: .48rem .8rem;
+ border-radius: var(--im-r-sm);
+ border: 1px solid var(--im-accent);
+ background: var(--im-accent);
+ color: var(--pico-primary-inverse);
+ font-size: .875rem;
+ font-weight: 570;
+ line-height: 1.3;
text-decoration: none;
- color: var(--pico-primary, #1a73e8);
- font-weight: 500;
+ cursor: pointer;
+ width: auto;
+ transition: background-color .12s ease, border-color .12s ease, color .12s ease;
}
-.btn-action[aria-disabled="true"] {
- opacity: 0.5;
+.btn:hover { background: var(--im-accent-hover); border-color: var(--im-accent-hover); }
+
+.btn.ghost {
+ background: var(--im-surface);
+ border-color: var(--im-line-strong);
+ color: var(--im-text);
+}
+
+.btn.ghost:hover { background: var(--im-surface-2); border-color: var(--im-text-faint); }
+
+.btn.quiet {
+ background: transparent;
+ border-color: transparent;
+ color: var(--im-text-dim);
+ padding: .35rem .5rem;
+}
+
+.btn.quiet:hover { background: var(--im-surface-3); color: var(--im-text); }
+
+.btn.danger { background: transparent; border-color: transparent; color: var(--im-danger); }
+.btn.danger:hover { background: var(--im-danger-soft); border-color: var(--im-danger-soft); }
+
+.btn.sm { padding: .3rem .55rem; font-size: .812rem; }
+
+.btn[aria-disabled="true"],
+.btn:disabled {
+ opacity: .45;
cursor: not-allowed;
pointer-events: none;
}
-/* Empty state */
-.empty-state {
- color: var(--pico-muted-color, #666);
- font-style: italic;
+.btn-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: .5rem;
+ align-items: center;
}
-/* Recent activity */
-.activity-section {
- margin-bottom: 1.5rem;
+/* ==========================================================================
+ Cards, sections, page furniture
+ ========================================================================== */
+
+.card {
+ background: var(--im-surface);
+ border: 1px solid var(--im-line);
+ border-radius: var(--im-r);
+ margin: 0 0 1.1rem;
+ overflow: clip;
+}
+
+.card-head {
+ display: flex;
+ align-items: center;
+ gap: .75rem;
+ padding: .8rem 1rem;
+ border-bottom: 1px solid var(--im-line);
+ background: var(--im-surface-2);
+}
+
+.card-head h2,
+.card-head h3 {
+ margin: 0;
+ font-size: .938rem;
+ font-weight: 620;
+ line-height: 1.3;
+}
+
+.card-head .sub {
+ margin: .1rem 0 0;
+ font-size: .781rem;
+ color: var(--im-text-dim);
+ line-height: 1.35;
+}
+
+.card-head .head-actions { margin-left: auto; display: flex; gap: .4rem; align-items: center; }
+.card-body { padding: 1rem; }
+.card-body > :last-child { margin-bottom: 0; }
+.card-body p:first-child { margin-top: 0; }
+.card-flush > .card-body { padding: 0; }
+
+.card-grid {
+ display: grid;
+ gap: 1.1rem;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ margin-bottom: 1.1rem;
+}
+
+.card-grid > .card { margin-bottom: 0; }
+
+.split {
+ display: grid;
+ gap: 1.1rem;
+ grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr);
+ align-items: start;
+}
+
+@media (max-width: 900px) { .split { grid-template-columns: minmax(0, 1fr); } }
+
+.page-intro {
+ margin: -.35rem 0 1.15rem;
+ max-width: 62ch;
+ color: var(--im-text-dim);
+ font-size: .906rem;
+}
+
+/* ==========================================================================
+ Signature: the pipeline rail (driver -> printer -> package)
+ Order carries real information — each stage unlocks the next.
+ ========================================================================== */
+
+.rail {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0;
+ border: 1px solid var(--im-line);
+ border-radius: var(--im-r);
+ background: var(--im-surface);
+ overflow: clip;
+ margin-bottom: 1.1rem;
+}
+
+.rail-step {
+ position: relative;
+ display: block;
+ padding: 1rem 1.1rem 1.05rem;
+ text-decoration: none;
+ color: inherit;
+ border-left: 1px solid var(--im-line);
+ transition: background-color .12s ease;
+}
+
+.rail-step:first-child { border-left: 0; }
+.rail-step:hover { background: var(--im-surface-2); }
+
+/* Progress hairline across the top of each stage. */
+.rail-step::before {
+ content: "";
+ position: absolute;
+ inset: 0 0 auto 0;
+ height: 2px;
+ background: var(--im-line-strong);
+}
+
+.rail-step.done::before { background: var(--im-ok); }
+.rail-step.next::before { background: var(--im-accent); }
+
+.rail-top {
+ display: flex;
+ align-items: center;
+ gap: .5rem;
+ margin-bottom: .55rem;
+}
+
+.rail-num {
+ width: 1.35rem;
+ height: 1.35rem;
+ flex: 0 0 auto;
+ display: grid;
+ place-items: center;
+ border-radius: 50%;
+ border: 1px solid var(--im-line-strong);
+ color: var(--im-text-faint);
+ font-family: var(--im-mono);
+ font-size: .688rem;
+ font-weight: 600;
+}
+
+.rail-step.done .rail-num {
+ background: var(--im-ok-soft);
+ border-color: var(--im-ok);
+ color: var(--im-ok);
+}
+
+.rail-step.next .rail-num {
+ background: var(--im-accent);
+ border-color: var(--im-accent);
+ color: var(--pico-primary-inverse);
+}
+
+.rail-title { font-weight: 600; font-size: .938rem; }
+
+.rail-count {
+ margin-left: auto;
+ font-family: var(--im-mono);
+ font-size: 1.05rem;
+ font-weight: 600;
+ color: var(--im-text);
+}
+
+.rail-desc {
+ margin: 0;
+ font-size: .812rem;
+ line-height: 1.45;
+ color: var(--im-text-dim);
+}
+
+.rail-cta {
+ display: inline-flex;
+ align-items: center;
+ gap: .3rem;
+ margin-top: .55rem;
+ font-size: .812rem;
+ font-weight: 570;
+ color: var(--im-accent);
+}
+
+.rail-step.done .rail-cta { color: var(--im-text-faint); }
+
+@media (max-width: 720px) {
+ .rail { grid-template-columns: minmax(0, 1fr); }
+ .rail-step { border-left: 0; border-top: 1px solid var(--im-line); }
+ .rail-step:first-child { border-top: 0; }
+}
+
+/* ==========================================================================
+ Badges, pills, status
+ ========================================================================== */
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: .28rem;
+ padding: .12rem .45rem;
+ border-radius: 999px;
+ border: 1px solid var(--im-line-strong);
+ background: var(--im-surface-3);
+ color: var(--im-text-dim);
+ font-size: .719rem;
+ font-weight: 600;
+ letter-spacing: .01em;
+ line-height: 1.5;
+ white-space: nowrap;
+}
+
+.badge .ico { width: 12px; height: 12px; }
+.badge.ok { background: var(--im-ok-soft); border-color: transparent; color: var(--im-ok); }
+.badge.warn { background: var(--im-warn-soft); border-color: transparent; color: var(--im-warn); }
+.badge.info { background: var(--im-accent-soft); border-color: transparent; color: var(--im-accent); }
+
+.pill {
+ display: inline-block;
+ padding: .1rem .4rem;
+ border-radius: var(--im-r-sm);
+ background: var(--im-surface-3);
+ color: var(--im-text-dim);
+ font-family: var(--im-mono);
+ font-size: .719rem;
+ line-height: 1.6;
+ white-space: nowrap;
+}
+
+.pill-row { display: flex; flex-wrap: wrap; gap: .25rem; align-items: center; }
+
+/* A multi-model INF can name a dozen printers: show three, fold the rest. */
+details.more-pills { display: inline-block; margin: 0; }
+
+details.more-pills > summary {
+ cursor: pointer;
+ list-style: none;
+ color: var(--im-accent);
+ background: var(--im-accent-soft);
+}
+
+details.more-pills > summary::marker { content: ""; }
+details.more-pills > summary::-webkit-details-marker { display: none; }
+details.more-pills > summary::after { display: none; }
+details.more-pills[open] > .pill-row { margin-top: .25rem; }
+
+/* ==========================================================================
+ Tables
+ ========================================================================== */
+
+.table-scroll {
+ width: 100%;
+ overflow-x: auto;
+}
+
+.table-scroll table,
+.data-table {
+ margin: 0;
+ width: 100%;
+ border-collapse: collapse;
+ font-size: .875rem;
+}
+
+.data-table thead th {
+ padding: .5rem .75rem;
+ background: var(--im-surface-2);
+ border-bottom: 1px solid var(--im-line);
+ font-size: .688rem;
+ font-weight: 600;
+ letter-spacing: .075em;
+ text-transform: uppercase;
+ color: var(--im-text-faint);
+ text-align: left;
+ white-space: nowrap;
+}
+
+.data-table tbody td {
+ padding: .6rem .75rem;
+ border-bottom: 1px solid var(--im-line);
+ vertical-align: middle;
+ background: transparent;
+}
+
+.data-table tbody tr:last-child td { border-bottom: 0; }
+.data-table tbody tr:hover td { background: var(--im-surface-2); }
+
+.data-table .cell-name {
+ font-weight: 570;
+ color: var(--im-text);
+ text-decoration: none;
+}
+
+.data-table .cell-name:hover { color: var(--im-accent); text-decoration: underline; }
+.data-table .cell-sub {
+ display: block;
+ margin-top: .1rem;
+ font-family: var(--im-mono);
+ font-size: .719rem;
+ color: var(--im-text-faint);
+}
+
+.data-table td.num { font-family: var(--im-mono); font-size: .812rem; }
+.data-table td.actions { width: 1%; white-space: nowrap; text-align: right; }
+.data-table td.actions .btn-row {
+ justify-content: flex-end;
+ flex-wrap: nowrap; /* keep the action cluster on one line */
+ gap: .25rem;
+}
+
+/* Group heading above each client's table */
+.group-head {
+ display: flex;
+ align-items: center;
+ gap: .5rem;
+ padding: .7rem 1rem;
+ border-bottom: 1px solid var(--im-line);
+ background: var(--im-surface-2);
+}
+
+.group-head h3 {
+ margin: 0;
+ font-size: .875rem;
+ font-weight: 620;
+}
+
+.group-head h3 a { color: inherit; text-decoration: none; }
+.group-head h3 a:hover { color: var(--im-accent); text-decoration: underline; }
+.group-head .count {
+ font-family: var(--im-mono);
+ font-size: .75rem;
+ color: var(--im-text-faint);
+}
+.group-head .head-actions { margin-left: auto; }
+
+/* ==========================================================================
+ Toolbar (search + filters + primary action)
+ ========================================================================== */
+
+.toolbar {
+ display: flex;
+ align-items: center;
+ gap: .6rem;
+ flex-wrap: wrap;
+ margin-bottom: 1.1rem;
+}
+
+.search {
+ position: relative;
+ flex: 1 1 240px;
+ max-width: 340px;
+}
+
+.search .ico {
+ position: absolute;
+ left: .6rem;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--im-text-faint);
+ pointer-events: none;
+}
+
+.search input[type="search"],
+.search input[type="text"] {
+ margin: 0;
+ padding-left: 2rem;
+ height: 2.25rem;
+ font-size: .875rem;
+ background-image: none;
+}
+
+.filter-count {
+ font-size: .812rem;
+ color: var(--im-text-faint);
+ font-family: var(--im-mono);
+}
+
+.toolbar .spacer { margin-left: auto; }
+
+/* ==========================================================================
+ Empty states — an invitation to act, not a mood
+ ========================================================================== */
+
+.empty,
+p.empty-state {
+ color: var(--im-text-dim);
+ font-style: normal;
+}
+
+.empty {
+ display: grid;
+ justify-items: start;
+ gap: .3rem;
+ padding: 1.75rem 1rem 1.9rem;
+ text-align: left;
+}
+
+.empty .empty-ico {
+ display: grid;
+ place-items: center;
+ width: 2.25rem;
+ height: 2.25rem;
+ border-radius: var(--im-r-sm);
+ background: var(--im-surface-3);
+ color: var(--im-text-faint);
+ margin-bottom: .3rem;
+}
+
+.empty strong { font-size: .938rem; font-weight: 620; color: var(--im-text); }
+.empty p { margin: 0; font-size: .875rem; max-width: 52ch; }
+.empty .btn { margin-top: .6rem; }
+
+/* ==========================================================================
+ Key/value description grid
+ ========================================================================== */
+
+.kv {
+ display: grid;
+ grid-template-columns: minmax(9rem, auto) minmax(0, 1fr);
+ margin: 0;
+}
+
+.kv dt,
+.kv dd {
+ margin: 0;
+ padding: .5rem .1rem;
+ border-top: 1px solid var(--im-line);
+ font-size: .875rem;
+}
+
+.kv dt:first-of-type,
+.kv dt:first-of-type + dd { border-top: 0; }
+
+.kv dt {
+ color: var(--im-text-faint);
+ font-size: .781rem;
+ font-weight: 500;
+ padding-right: 1rem;
+}
+
+.kv dd { color: var(--im-text); }
+.kv dd.mono-val { font-family: var(--im-mono); font-size: .812rem; }
+
+/* ==========================================================================
+ Command rows + copy
+ ========================================================================== */
+
+.cmd {
+ display: flex;
+ align-items: stretch;
+ gap: 0;
+ border: 1px solid var(--im-line);
+ border-radius: var(--im-r-sm);
+ background: var(--im-surface-2);
+ overflow: clip;
+}
+
+.cmd code {
+ flex: 1 1 auto;
+ min-width: 0;
+ padding: .5rem .65rem;
+ background: transparent;
+ color: var(--im-text);
+ font-family: var(--im-mono);
+ font-size: .781rem;
+ line-height: 1.5;
+ overflow-x: auto;
+ white-space: pre;
+}
+
+.cmd button {
+ flex: 0 0 auto;
+ width: auto;
+ margin: 0;
+ padding: 0 .65rem;
+ border: 0;
+ border-left: 1px solid var(--im-line);
+ border-radius: 0;
+ background: var(--im-surface-3);
+ color: var(--im-text-dim);
+ font-size: .75rem;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.cmd button:hover { background: var(--im-accent-soft); color: var(--im-accent); }
+
+.field-stack { display: grid; gap: .85rem; }
+.field-stack > label { margin: 0; }
+
+/* ==========================================================================
+ Forms — grouped fieldsets so a long form reads as a few short ones
+ ========================================================================== */
+
+.form-section {
+ border: 0;
+ border-top: 1px solid var(--im-line);
+ margin: 0;
+ padding: 1.35rem 0 .2rem;
+ display: grid;
+ gap: 1rem;
+}
+
+.form-section:first-of-type { border-top: 0; padding-top: 0; }
+
+/* A