Commit initial

This commit is contained in:
2026-04-15 17:57:12 +02:00
parent 005d8e797e
commit 55516ee10f
269 changed files with 26854 additions and 0 deletions
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+54
View File
@@ -0,0 +1,54 @@
"""Client CRUD API — POST /clients, GET /clients."""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from peewee import IntegrityError
from imptune.db.models import Client
router = APIRouter(prefix="/clients")
templates = Jinja2Templates(
directory=str(Path(__file__).parent.parent / "templates")
)
def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
"""Return an HTMX-friendly error fragment swapped into #client-list."""
return HTMLResponse(
content=f"<div id='client-list' class='error'><p>{message}</p></div>",
status_code=status_code,
)
def _render_client_list(request: Request) -> HTMLResponse:
"""Render the client list partial for HTMX swap."""
clients = list(Client.select().order_by(Client.name))
return templates.TemplateResponse(
request=request,
name="partials/client_list.html",
context={"clients": clients},
)
@router.post("", response_class=HTMLResponse)
def create_client(request: Request, name: str = Form(...)) -> HTMLResponse:
"""Create a new client.
Accepts form-encoded `name`. Validates non-empty. Returns HTMX partial
with updated client list on success, or error fragment on failure.
"""
name = name.strip()
if not name:
return _error_response("Client name is required.")
try:
Client.create(name=name)
except IntegrityError:
return _error_response(f"Client '{name}' already exists.", status_code=409)
return _render_client_list(request)
+132
View File
@@ -0,0 +1,132 @@
"""Driver upload API — POST /drivers/upload."""
from __future__ import annotations
import io
import json
import zipfile
from pathlib import Path
from fastapi import APIRouter, Form, Request, UploadFile
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
import imptune.config as _cfg
from imptune.db.models import Driver
from imptune.services.inf_parser import _detect_encoding, parse_inf
from imptune.storage.driver_store import DriverStore
router = APIRouter(prefix="/drivers")
templates = Jinja2Templates(
directory=str(Path(__file__).parent.parent / "templates")
)
MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB
def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
"""Return an HTMX-friendly error fragment swapped into #driver-list."""
return HTMLResponse(
content=f"<div id='driver-list' class='error'><p>{message}</p></div>",
status_code=status_code,
)
@router.post("/upload", response_class=HTMLResponse)
def upload_driver(
request: Request,
file: UploadFile,
caller: str = Form(""),
) -> HTMLResponse:
"""Accept a driver ZIP, parse its INF, persist via DriverStore + Peewee ORM.
Returns an HTMX partial (partials/driver_list.html) on success, or an
inline error fragment with HTTP 400 on validation failure.
"""
data = file.file.read(MAX_UPLOAD_BYTES + 1)
if len(data) > MAX_UPLOAD_BYTES:
return _error_response("File exceeds 100 MB limit.")
# Must end with .zip
filename = file.filename or ""
if not filename.lower().endswith(".zip"):
return _error_response("Only .zip files are accepted.")
# Must be a valid ZIP archive
if not zipfile.is_zipfile(io.BytesIO(data)):
return _error_response("Uploaded file is not a valid ZIP archive.")
with zipfile.ZipFile(io.BytesIO(data)) as zf:
zip_names = zf.namelist()
# Reject zip-slip paths
for name in zip_names:
if ".." in name or name.startswith("/"):
return _error_response("ZIP contains unsafe paths.")
# Find .inf files
inf_names = [n for n in zip_names if n.lower().endswith(".inf")]
if not inf_names:
return _error_response("No .inf file found in the uploaded ZIP.")
# Prefer amd64/x64 INF when multiple exist; fall back to alphabetical first
preferred = [
n for n in inf_names if "amd64" in n.lower() or "x64" in n.lower()
]
chosen_inf = preferred[0] if preferred else sorted(inf_names)[0]
raw_inf = zf.read(chosen_inf)
# Decode INF
encoding = _detect_encoding(raw_inf)
inf_text = raw_inf.decode(encoding)
# Parse INF
parsed = parse_inf(inf_text, inf_filename=chosen_inf, zip_names=zip_names)
# Persist file (content-addressed, dedup automatic)
# Read DRIVERS_DIR at call time so tests can monkeypatch imptune.config.DRIVERS_DIR
store = DriverStore(_cfg.DRIVERS_DIR)
sha256 = store.save(data)
# Upsert Driver record (no duplicate if same SHA256)
new_driver, _created = Driver.get_or_create(
sha256=sha256,
defaults={
"original_filename": filename,
"size_bytes": len(data),
"driver_desc": json.dumps(parsed.driver_names),
"inf_filename": parsed.inf_filename,
"architecture": parsed.architecture,
"has_cat_file": parsed.has_cat_file,
},
)
# Build driver_data for template
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})
# When called from the printer form, emit primary fragment + OOB select refresh
if caller == "printer_form":
return templates.TemplateResponse(
request=request,
name="partials/driver_upload_with_oob.html",
context={
"driver_data": driver_data,
"new_driver_id": new_driver.id,
"parsed": parsed,
},
)
# Default: existing behavior — driver list fragment only
return templates.TemplateResponse(
request=request,
name="partials/driver_list.html",
context={
"driver_data": driver_data,
"parsed": parsed,
},
)
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
def health():
return {"status": "ok"}
+88
View File
@@ -0,0 +1,88 @@
"""Icon upload API — POST /printers/{printer_id}/icon."""
from __future__ import annotations
import hashlib
import io
from pathlib import Path
from fastapi import APIRouter, UploadFile
from fastapi.responses import HTMLResponse
from PIL import Image
import imptune.config as cfg
from imptune.db.models import Icon, Printer
router = APIRouter(prefix="/printers")
MAX_ICON_BYTES = 750 * 1024 # 750 KB
@router.post("/{printer_id}/icon", response_class=HTMLResponse)
def upload_icon(printer_id: int, file: UploadFile) -> HTMLResponse:
"""Accept a printer icon PNG, validate it, store it, and update the Icon record.
Validation rules:
- Format: PNG only
- Dimensions: exactly 256x256 pixels
- Size: at most 750 KB
Replaces any previously uploaded icon for this printer.
Returns an HTMX-friendly HTML fragment.
"""
# Check printer exists
printer = Printer.get_or_none(Printer.id == printer_id)
if printer is None:
return HTMLResponse(
content="<p>Printer not found.</p>",
status_code=404,
)
# Read file (read one byte extra to detect oversized files)
data = file.file.read(MAX_ICON_BYTES + 1)
if len(data) > MAX_ICON_BYTES:
return HTMLResponse(
content="<p>Icon exceeds 750 KB limit.</p>",
status_code=422,
)
# Validate with Pillow
try:
img = Image.open(io.BytesIO(data))
except Exception:
return HTMLResponse(
content="<p>Icon must be PNG format.</p>",
status_code=422,
)
if img.format != "PNG":
return HTMLResponse(
content="<p>Icon must be PNG format.</p>",
status_code=422,
)
if img.size != (256, 256):
return HTMLResponse(
content=f"<p>Icon must be 256x256 pixels, got {img.size}.</p>",
status_code=422,
)
# Store SHA256-addressed on disk
sha256 = hashlib.sha256(data).hexdigest()
icons_dir = Path(cfg.DATA_DIR) / "icons"
icons_dir.mkdir(parents=True, exist_ok=True)
icon_path = icons_dir / sha256
icon_path.write_bytes(data)
# Replace existing Icon record for this printer
Icon.delete().where(Icon.printer == printer_id).execute()
Icon.create(
printer=printer_id,
sha256=sha256,
original_filename=file.filename or "icon.png",
size_bytes=len(data),
)
return HTMLResponse(
content="<p>Icon uploaded successfully</p>",
status_code=200,
)
+166
View File
@@ -0,0 +1,166 @@
"""Package export endpoints — serves deployment packages for NinjaRMM and Microsoft Intune."""
import io
import json
import os
import shutil
import tempfile
import zipfile
from fastapi import APIRouter
from fastapi.responses import PlainTextResponse, Response
import imptune.config as cfg
from imptune.db.models import Icon, Printer
from imptune.generators.intunewin_builder import build_intunewin
from imptune.generators.script_generator import render_detect, render_install, render_uninstall
from imptune.storage.driver_store import DriverStore
router = APIRouter(prefix="/printers")
def _get_printer_and_driver(printer_id: int):
"""Fetch printer and validate driver — returns (printer, driver, driver_name) or PlainTextResponse error."""
printer = Printer.get_or_none(Printer.id == printer_id)
if printer is None:
return None, PlainTextResponse("Printer not found", status_code=404)
driver = printer.driver
if driver is None:
return None, PlainTextResponse("No driver assigned", status_code=422)
if not driver.inf_filename:
return None, PlainTextResponse("Driver has no INF file", status_code=422)
if not driver.driver_desc:
return None, PlainTextResponse("Driver has no description", status_code=422)
try:
desc_list = json.loads(driver.driver_desc)
driver_name = desc_list[0]
except (json.JSONDecodeError, IndexError, TypeError):
return None, PlainTextResponse("Driver description is invalid", status_code=422)
return (printer, driver, driver_name), None
def _get_driver_zip_path(driver) -> str:
return str(DriverStore(cfg.DRIVERS_DIR).get_path(driver.sha256))
@router.get("/{printer_id}/packages/ninja")
def get_ninja_package(printer_id: int):
"""Download a NinjaRMM-ready ZIP containing install.ps1 and the driver files."""
result, error = _get_printer_and_driver(printer_id)
if error is not None:
return error
printer, driver, driver_name = result
# Validate driver file exists on disk
driver_zip_path = _get_driver_zip_path(driver)
if not os.path.isfile(driver_zip_path):
return PlainTextResponse("Driver file not found on disk", status_code=422)
safe_name = printer.name.replace(" ", "_")
# Render install script
install_script = render_install(
printer_name=printer.name,
ip_address=printer.ip_address,
port_name=printer.port_name,
driver_name=driver_name,
inf_filename=driver.inf_filename,
duplex_mode=printer.duplex_mode,
color_mode=printer.color_mode,
paper_size=printer.paper_size,
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)
# 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)
return Response(
content=buf.getvalue(),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{safe_name}_ninja.zip"'},
)
@router.get("/{printer_id}/packages/intunewin")
def get_intunewin_package(printer_id: int):
"""Download a Microsoft Intune .intunewin deployment package."""
result, error = _get_printer_and_driver(printer_id)
if error is not None:
return error
printer, driver, driver_name = result
# Validate driver file exists on disk
driver_zip_path = _get_driver_zip_path(driver)
if not os.path.isfile(driver_zip_path):
return PlainTextResponse("Driver file not found on disk", status_code=422)
safe_name = printer.name.replace(" ", "_")
# Render all three scripts
install_script = render_install(
printer_name=printer.name,
ip_address=printer.ip_address,
port_name=printer.port_name,
driver_name=driver_name,
inf_filename=driver.inf_filename,
duplex_mode=printer.duplex_mode,
color_mode=printer.color_mode,
paper_size=printer.paper_size,
collate=printer.collate,
)
uninstall_script = render_uninstall(
printer_name=printer.name,
driver_name=driver_name,
port_name=printer.port_name,
)
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)
# Extract driver ZIP contents into tmpdir/drivers/
drivers_subdir = os.path.join(tmpdir, "drivers")
os.makedirs(drivers_subdir, exist_ok=True)
with zipfile.ZipFile(driver_zip_path, "r") as driver_zf:
driver_zf.extractall(drivers_subdir)
# Copy icon into staging if one exists for this printer
icon_record = Icon.get_or_none(Icon.printer == printer.id)
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"))
# Build .intunewin
output_path = os.path.join(tmpdir, "out.intunewin")
build_intunewin(tmpdir, "install.ps1", output_path)
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"'},
)
+213
View File
@@ -0,0 +1,213 @@
import json
from collections import defaultdict
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from peewee import JOIN
router = APIRouter()
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
@router.get("/", response_class=HTMLResponse)
def dashboard(request: Request):
from imptune.db.models import Printer
recent_printers = list(
Printer.select().order_by(Printer.created_at.desc()).limit(5)
)
recent_packages = list(
Printer.select()
.where(Printer.driver.is_null(False))
.order_by(Printer.created_at.desc())
.limit(5)
)
return templates.TemplateResponse(
request=request,
name="dashboard.html",
context={
"recent_printers": recent_printers,
"recent_packages": recent_packages,
},
)
@router.get("/drivers", response_class=HTMLResponse)
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},
)
@router.get("/printers", response_class=HTMLResponse)
def printers_page(request: Request):
from imptune.db.models import Client, Driver, Printer
query = (
Printer.select(Printer, Client)
.join(Client, JOIN.LEFT_OUTER)
.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)
clients = list(Client.select().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})
return templates.TemplateResponse(
request=request,
name="printers.html",
context={
"grouped": grouped,
"clients": clients,
"driver_data": driver_data,
},
)
@router.get("/printers/new", response_class=HTMLResponse)
def printers_new_page(request: Request):
from imptune.db.models import Client, Driver
clients = list(Client.select().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},
)
@router.get("/printers/{printer_id}", response_class=HTMLResponse)
def printer_detail(request: Request, printer_id: int):
from imptune.db.models import Client, Driver, Icon, Printer
printer = (
Printer.select(Printer, Client, Driver)
.join(Client, JOIN.LEFT_OUTER)
.switch(Printer)
.join(Driver, JOIN.LEFT_OUTER)
.where(Printer.id == printer_id)
.first()
)
if printer is None:
return HTMLResponse(
content="<h1>404 Not Found</h1><p>Printer not found.</p>",
status_code=404,
)
driver_names: list[str] = []
if printer.driver_id and printer.driver.driver_desc:
driver_names = json.loads(printer.driver.driver_desc)
has_driver = printer.driver_id is not None and bool(driver_names)
icon = Icon.get_or_none(Icon.printer == printer_id)
install_cmd = "powershell.exe -ExecutionPolicy Bypass -File install.ps1"
uninstall_cmd = "powershell.exe -ExecutionPolicy Bypass -File uninstall.ps1"
return templates.TemplateResponse(
request=request,
name="printer_detail.html",
context={
"printer": printer,
"driver_names": driver_names,
"has_driver": has_driver,
"has_icon": icon is not None,
"install_cmd": install_cmd,
"uninstall_cmd": uninstall_cmd,
},
)
@router.get("/clients", response_class=HTMLResponse)
def clients_page(request: Request):
from imptune.db.models import Client
clients = list(Client.select().order_by(Client.name))
return templates.TemplateResponse(
request=request,
name="clients.html",
context={"clients": clients},
)
@router.get("/clients/{client_id}", response_class=HTMLResponse)
def client_detail(request: Request, client_id: int):
from imptune.db.models import Client, Driver, Printer
client = Client.get_or_none(Client.id == client_id)
if client is None:
return HTMLResponse(
content="<h1>404 Not Found</h1><p>Client not found.</p>",
status_code=404,
)
query = (
Printer.select(Printer, Client)
.join(Client, JOIN.LEFT_OUTER)
.where(Printer.client == client_id)
.order_by(Printer.name)
)
grouped = {client.name: list(query)}
clients = list(Client.select().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="client_detail.html",
context={
"client": client,
"grouped": grouped,
"clients": clients,
"driver_data": driver_data,
},
)
@router.get("/packages", response_class=HTMLResponse)
def packages_page(request: Request):
from imptune.db.models import Client, Driver, Printer
printers = list(
Printer.select(Printer, Client, Driver)
.join(Client, JOIN.LEFT_OUTER)
.switch(Printer)
.join(Driver, JOIN.LEFT_OUTER)
.where(Printer.driver.is_null(False))
.order_by(Printer.name)
)
return templates.TemplateResponse(
request=request,
name="packages.html",
context={"printers": printers},
)
+174
View File
@@ -0,0 +1,174 @@
"""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.templating import Jinja2Templates
from peewee import JOIN
from imptune.db.models import Client, Driver, Printer
router = APIRouter(prefix="/printers")
templates = Jinja2Templates(
directory=str(Path(__file__).parent.parent / "templates")
)
_VALID_DUPLEX = {"OneSided", "LongEdge", "ShortEdge"}
_VALID_PAPER = {"A4", "Letter", "Legal"}
def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
"""Return an HTMX-friendly error fragment swapped into #printer-list."""
return HTMLResponse(
content=f"<div id='printer-list' class='error'><p>{message}</p></div>",
status_code=status_code,
)
def _render_printer_list(request: Request) -> HTMLResponse:
"""Query printers with LEFT JOIN on client and render grouped partial."""
import json
query = (
Printer.select(Printer, Client)
.join(Client, JOIN.LEFT_OUTER)
.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)
clients = list(Client.select().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="partials/printer_list.html",
context={"grouped": grouped, "clients": clients, "driver_data": driver_data},
)
@router.post("", response_class=HTMLResponse)
def create_printer(
request: Request,
name: str = Form(...),
ip_address: str = Form(...),
port_name: str = Form(...),
duplex_mode: str = Form("OneSided"),
color_mode: str = Form(""),
paper_size: str = Form("A4"),
collate: str = Form(""),
client_id: str = Form(""),
driver_id: str = Form(""),
) -> HTMLResponse:
"""Create a new printer configuration.
Boolean fields (color_mode, collate) use HTML checkbox convention:
"on" = True, absent/empty = False.
"""
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"
# Resolve optional FK IDs
client_fk = int(client_id) if client_id.strip() else None
driver_fk = int(driver_id) if driver_id.strip() else None
Printer.create(
name=name,
ip_address=ip_address,
port_name=port_name,
duplex_mode=duplex_mode,
color_mode=color_mode_bool,
paper_size=paper_size,
collate=collate_bool,
client=client_fk,
driver=driver_fk,
)
return RedirectResponse(url="/printers", status_code=303)
@router.delete("/{printer_id}", response_class=HTMLResponse)
def delete_printer(request: Request, printer_id: int) -> HTMLResponse:
"""Delete a printer by ID. Returns updated printer list partial."""
deleted = Printer.delete().where(Printer.id == printer_id).execute()
if not deleted:
return _error_response(f"Printer {printer_id} not found.", status_code=404)
return _render_printer_list(request)
@router.patch("/{printer_id}", response_class=HTMLResponse)
def update_printer(
request: Request,
printer_id: int,
name: str = Form(...),
ip_address: str = Form(""),
port_name: str = Form(""),
duplex_mode: str = Form("OneSided"),
color_mode: str = Form(""),
paper_size: str = Form("A4"),
collate: str = Form(""),
client_id: str = Form(""),
driver_id: str = Form(""),
) -> HTMLResponse:
"""Update an existing printer configuration in-place."""
from datetime import UTC, datetime
printer = Printer.get_or_none(Printer.id == printer_id)
if printer is None:
return _error_response(f"Printer {printer_id} not found.", status_code=404)
name = name.strip()
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}.")
printer.name = name
printer.ip_address = ip_address
printer.port_name = port_name
printer.duplex_mode = duplex_mode
printer.color_mode = color_mode == "on"
printer.paper_size = paper_size
printer.collate = collate == "on"
printer.client = int(client_id) if client_id.strip() else None
printer.driver = int(driver_id) if driver_id.strip() else None
printer.updated_at = datetime.now(UTC).replace(tzinfo=None)
printer.save()
return _render_printer_list(request)
+121
View File
@@ -0,0 +1,121 @@
"""Script download endpoints — generates and serves PowerShell scripts for a printer."""
import json
from fastapi import APIRouter
from fastapi.responses import PlainTextResponse
from imptune.db.models import Printer
from imptune.generators.script_generator import render_detect, render_install, render_uninstall
router = APIRouter(prefix="/printers")
def _get_printer_and_driver(printer_id: int):
"""Fetch printer and validate driver — returns (printer, driver_name) or PlainTextResponse error."""
printer = Printer.get_or_none(Printer.id == printer_id)
if printer is None:
return None, PlainTextResponse("Printer not found", status_code=404)
driver = printer.driver
if driver is None:
return None, PlainTextResponse("No driver assigned", status_code=422)
if not driver.inf_filename:
return None, PlainTextResponse("Driver has no INF file", status_code=422)
if not driver.driver_desc:
return None, PlainTextResponse("Driver has no description", status_code=422)
try:
desc_list = json.loads(driver.driver_desc)
driver_name = desc_list[0]
except (json.JSONDecodeError, IndexError, TypeError):
return None, PlainTextResponse("Driver description is invalid", status_code=422)
return (printer, driver, driver_name), None
def _install_response(printer_id: int):
result, error = _get_printer_and_driver(printer_id)
if error is not None:
return error
printer, driver, driver_name = result
rendered = render_install(
printer_name=printer.name,
ip_address=printer.ip_address,
port_name=printer.port_name,
driver_name=driver_name,
inf_filename=driver.inf_filename,
duplex_mode=printer.duplex_mode,
color_mode=printer.color_mode,
paper_size=printer.paper_size,
collate=printer.collate,
)
return PlainTextResponse(
content=rendered,
headers={"Content-Disposition": 'attachment; filename="install.ps1"'},
)
def _uninstall_response(printer_id: int):
result, error = _get_printer_and_driver(printer_id)
if error is not None:
return error
printer, driver, driver_name = result
rendered = render_uninstall(
printer_name=printer.name,
driver_name=driver_name,
port_name=printer.port_name,
)
return PlainTextResponse(
content=rendered,
headers={"Content-Disposition": 'attachment; filename="uninstall.ps1"'},
)
def _detect_response(printer_id: int):
result, error = _get_printer_and_driver(printer_id)
if error is not None:
return error
printer, driver, driver_name = result
rendered = render_detect(printer_name=printer.name)
return PlainTextResponse(
content=rendered,
headers={"Content-Disposition": 'attachment; filename="detect.ps1"'},
)
@router.get("/{printer_id}/scripts/install")
def get_install_script(printer_id: int):
"""Download the PowerShell install script for a printer."""
return _install_response(printer_id)
@router.get("/{printer_id}/scripts/install.ps1")
def get_install_script_ps1(printer_id: int):
"""Download the PowerShell install script for a printer (.ps1 alias)."""
return _install_response(printer_id)
@router.get("/{printer_id}/scripts/uninstall")
def get_uninstall_script(printer_id: int):
"""Download the PowerShell uninstall script for a printer."""
return _uninstall_response(printer_id)
@router.get("/{printer_id}/scripts/uninstall.ps1")
def get_uninstall_script_ps1(printer_id: int):
"""Download the PowerShell uninstall script for a printer (.ps1 alias)."""
return _uninstall_response(printer_id)
@router.get("/{printer_id}/scripts/detect")
def get_detect_script(printer_id: int):
"""Download the PowerShell detection script for a printer."""
return _detect_response(printer_id)
@router.get("/{printer_id}/scripts/detect.ps1")
def get_detect_script_ps1(printer_id: int):
"""Download the PowerShell detection script for a printer (.ps1 alias)."""
return _detect_response(printer_id)
+13
View File
@@ -0,0 +1,13 @@
import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
DATA_DIR = os.environ.get("DATA_DIR", "/data")
PORT = int(os.environ.get("PORT", "8000"))
DB_PATH = str(Path(DATA_DIR) / "imptune.db")
DRIVERS_DIR = str(Path(DATA_DIR) / "drivers")
ICONS_DIR = str(Path(DATA_DIR) / "icons")
+1
View File
@@ -0,0 +1 @@
# Database package
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
"""Peewee SQLite database instance and initialization."""
from peewee import SqliteDatabase
from imptune.config import DB_PATH
# Deferred init — path is set at runtime via init_db() so tests can override DB_PATH
db = SqliteDatabase(None)
def init_db() -> None:
"""Initialize the SQLite database with WAL mode and foreign keys.
Idempotent — safe to call on every application startup.
Creates all ORM tables if they do not already exist.
Closes any existing connection before re-initializing so that test
fixtures can monkeypatch DB_PATH between test runs.
"""
from imptune.db.models import Client, Driver, Printer, Icon
# Re-read DB_PATH each time so tests can patch imptune.config.DB_PATH
import imptune.config as cfg
# Close any lingering connection from a previous run (important for tests)
if not db.is_closed():
db.close()
db.init(
cfg.DB_PATH,
pragmas={
"journal_mode": "wal",
"foreign_keys": 1,
},
)
db.connect(reuse_if_open=True)
db.create_tables([Client, Driver, Printer, Icon], safe=True)
+82
View File
@@ -0,0 +1,82 @@
"""Peewee ORM models — full schema for phases 1-5."""
from datetime import UTC, datetime
def _utcnow():
return datetime.now(UTC).replace(tzinfo=None)
from peewee import (
BooleanField,
CharField,
DateTimeField,
ForeignKeyField,
IntegerField,
Model,
)
from imptune.db.database import db
class BaseModel(Model):
"""Base model that binds all models to the shared db instance."""
class Meta:
database = db
class Client(BaseModel):
"""Represents a deployment target (AD client / OU)."""
name = CharField(unique=True)
created_at = DateTimeField(default=_utcnow)
class Meta:
table_name = "client"
class Driver(BaseModel):
"""Uploaded printer driver package (content-addressed by SHA256)."""
sha256 = CharField(unique=True, index=True)
original_filename = CharField()
size_bytes = IntegerField()
uploaded_at = DateTimeField(default=_utcnow)
driver_desc = CharField(null=True)
inf_filename = CharField(null=True)
architecture = CharField(null=True)
has_cat_file = BooleanField(default=False)
class Meta:
table_name = "driver"
class Printer(BaseModel):
"""Printer configuration record."""
name = CharField()
ip_address = CharField()
port_name = CharField()
client = ForeignKeyField(Client, null=True, backref="printers")
driver = ForeignKeyField(Driver, null=True, backref="printers")
duplex_mode = CharField(default="OneSided")
color_mode = BooleanField(default=True)
paper_size = CharField(default="A4")
collate = BooleanField(default=True)
created_at = DateTimeField(default=_utcnow)
updated_at = DateTimeField(default=_utcnow)
class Meta:
table_name = "printer"
class Icon(BaseModel):
"""Printer icon image (one per printer)."""
printer = ForeignKeyField(Printer, unique=True, backref="icons")
sha256 = CharField()
original_filename = CharField()
size_bytes = IntegerField()
uploaded_at = DateTimeField(default=_utcnow)
class Meta:
table_name = "icon"
+1
View File
@@ -0,0 +1 @@
"""Generators package for ImpTune."""
+132
View File
@@ -0,0 +1,132 @@
"""
Python-native .intunewin file assembler.
Generates valid .intunewin packages using AES-256-CBC encryption with HMAC-SHA256,
producing the exact byte layout that Microsoft Intune expects.
Encrypted blob layout (from svrooij.io reverse-engineering):
[0:32] HMAC-SHA256 of the ciphertext (32 bytes, mac_key)
[32:48] AES-256-CBC Initialization Vector (16 bytes — standard AES block size)
[48:] AES-256-CBC ciphertext (PKCS7-padded to 16-byte boundary)
IMPORTANT: IV is 16 bytes, NOT 32. STACK.md has a documentation error on this point.
Detection.xml format matches the reference IntuneWinAppUtil.exe output exactly:
- ToolVersion is an XML *attribute* on <ApplicationInfo> (not a child element)
- No xmlns namespace declaration (reference uses [XmlRoot("ApplicationInfo")] with no namespace)
- No <?xml ...?> declaration header (reference uses OmitXmlDeclaration=true)
- No <MacAlgorithm> element (not present in reference FileEncryptionInfo model)
Outer ZIP structure:
IntuneWinPackage/
├── Contents/
│ └── IntunePackage.intunewin (the encrypted blob)
└── Metadata/
└── Detection.xml (encryption metadata)
"""
import base64
import hashlib
import hmac
import io
import os
import zipfile
from xml.etree.ElementTree import Element, SubElement, indent, tostring
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
# Version string that matches the reference IntuneWinAppUtil.exe tool.
# Intune's upload wizard validates or uses this field to confirm the package
# was produced by a compatible tool version.
_TOOL_VERSION = "1.8.6.0"
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.
Args:
source_dir: Path to the directory containing files to package.
setup_file: Name of the setup/entry-point file (e.g., "install.ps1").
Must be present in source_dir. Used in Detection.xml metadata.
output_path: Destination path for the generated .intunewin file.
Raises:
FileNotFoundError: If source_dir does not exist.
ValueError: If setup_file is empty.
"""
# --- 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()
# --- 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 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 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 5: Assemble encrypted blob: [HMAC(32)] + [IV(16)] + [ciphertext] ---
encrypted_blob = mac_digest + iv + ciphertext
# --- Step 6: Compute plaintext (inner ZIP) SHA256 digest for Detection.xml ---
file_digest = hashlib.sha256(plaintext).digest()
# --- Step 7: Build Detection.xml ---
# Format MUST match IntuneWinAppUtil.exe reference output exactly:
# - ToolVersion is an XML attribute on ApplicationInfo (not a child element)
# - No xmlns namespace (reference omits it)
# - No <?xml?> declaration header
# - No MacAlgorithm element (not in reference FileEncryptionInfo model)
app_info = Element(
"ApplicationInfo",
attrib={"ToolVersion": _TOOL_VERSION},
)
SubElement(app_info, "Name").text = setup_file
SubElement(app_info, "UnencryptedContentSize").text = str(len(plaintext))
SubElement(app_info, "FileName").text = "IntunePackage.intunewin"
SubElement(app_info, "SetupFile").text = setup_file
enc_info = SubElement(app_info, "EncryptionInfo")
SubElement(enc_info, "EncryptionKey").text = base64.b64encode(aes_key).decode()
SubElement(enc_info, "MacKey").text = base64.b64encode(mac_key).decode()
SubElement(enc_info, "InitializationVector").text = base64.b64encode(iv).decode()
SubElement(enc_info, "Mac").text = base64.b64encode(mac_digest).decode()
SubElement(enc_info, "ProfileIdentifier").text = "ProfileVersion1"
SubElement(enc_info, "FileDigest").text = base64.b64encode(file_digest).decode()
SubElement(enc_info, "FileDigestAlgorithm").text = "SHA256"
# indent() adds pretty-print whitespace in-place (Python 3.9+).
# tostring with xml_declaration=False omits the <?xml?> header.
indent(app_info, space=" ")
detection_xml = tostring(app_info, encoding="unicode", xml_declaration=False)
# --- Step 8: Build outer ZIP (STORED — no extra compression on encrypted content) ---
with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_STORED) as outer:
outer.writestr(
"IntuneWinPackage/Contents/IntunePackage.intunewin",
encrypted_blob,
)
outer.writestr(
"IntuneWinPackage/Metadata/Detection.xml",
detection_xml.encode("utf-8"),
)
+105
View File
@@ -0,0 +1,105 @@
"""Script generator module — renders PowerShell scripts from Jinja2 templates.
Provides render_install(), render_uninstall(), and render_detect() which produce
complete PowerShell scripts for Intune deployment:
- render_install: WOW64 guard, UAC self-elevation, pnputil two-step, idempotent setup
- render_uninstall: Ordered removal of printer, driver, and port
- render_detect: Intune detection contract (Write-Output + exit 0/1)
"""
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
_SCRIPTS_DIR = Path(__file__).parent.parent / "templates" / "scripts"
_env = Environment(
loader=FileSystemLoader(str(_SCRIPTS_DIR)),
trim_blocks=True,
lstrip_blocks=True,
keep_trailing_newline=True,
)
_duplex_map = {
"OneSided": "OneSided",
"LongEdge": "TwoSidedLongEdge",
"ShortEdge": "TwoSidedShortEdge",
}
def render_install(
printer_name: str,
ip_address: str,
port_name: str,
driver_name: str,
inf_filename: str,
duplex_mode: str,
color_mode: bool,
paper_size: str,
collate: bool,
) -> str:
"""Render install.ps1.j2 with the given printer configuration.
Args:
printer_name: Display name of the printer.
ip_address: IP address for the printer TCP/IP port.
port_name: Port name (e.g. "IP_192.168.1.10").
driver_name: Exact driver name as registered in Windows.
inf_filename: INF filename inside the drivers/ subfolder.
duplex_mode: One of "OneSided", "LongEdge", "ShortEdge" (model values).
color_mode: True for color printing, False for mono.
paper_size: Paper size string (e.g. "A4", "Letter").
collate: True to enable collation.
Returns:
Rendered PowerShell script as a string.
"""
tpl = _env.get_template("install.ps1.j2")
return tpl.render(
printer_name=printer_name,
ip_address=ip_address,
port_name=port_name,
driver_name=driver_name,
inf_filename=inf_filename,
duplex_mode=_duplex_map.get(duplex_mode, duplex_mode),
color=str(color_mode).lower(),
paper_size=paper_size,
collate=str(collate).lower(),
)
def render_uninstall(printer_name: str, driver_name: str, port_name: str) -> str:
"""Render uninstall.ps1.j2 — removes printer, driver, and port in safe order.
Removal order: Printer first (so driver is no longer referenced), then Driver,
then Port. All operations use -ErrorAction SilentlyContinue for idempotency.
Args:
printer_name: Display name of the printer to remove.
driver_name: Exact driver name as registered in Windows.
port_name: Port name (e.g. "IP_192.168.1.10").
Returns:
Rendered PowerShell script as a string.
"""
tpl = _env.get_template("uninstall.ps1.j2")
return tpl.render(
printer_name=printer_name,
driver_name=driver_name,
port_name=port_name,
)
def render_detect(printer_name: str) -> str:
"""Render detect.ps1.j2 — Intune detection script.
Follows the Intune detection contract: Write-Output + exit 0 when printer
is found, exit 1 when absent. Intune considers exit 0 as "app installed".
Args:
printer_name: Display name of the printer to detect.
Returns:
Rendered PowerShell script as a string.
"""
tpl = _env.get_template("detect.ps1.j2")
return tpl.render(printer_name=printer_name)
+39
View File
@@ -0,0 +1,39 @@
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from imptune.api import clients, drivers, health, icons, pages, packages, printers, scripts
from imptune.config import DATA_DIR, DRIVERS_DIR, ICONS_DIR
from imptune.db.database import db, init_db
@asynccontextmanager
async def lifespan(app: FastAPI):
os.makedirs(DATA_DIR, exist_ok=True)
os.makedirs(DRIVERS_DIR, exist_ok=True)
os.makedirs(ICONS_DIR, exist_ok=True)
init_db()
yield
# Close DB connection on shutdown so test fixtures can re-initialize cleanly
if not db.is_closed():
db.close()
app = FastAPI(title="ImpTune", lifespan=lifespan)
# Serve baked-in static assets (pico.min.css, htmx.min.js, alpine.min.js)
_static_dir = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
# Register routers
app.include_router(health.router)
app.include_router(pages.router)
app.include_router(drivers.router)
app.include_router(printers.router)
app.include_router(clients.router)
app.include_router(scripts.router)
app.include_router(packages.router)
app.include_router(icons.router)
View File
+202
View File
@@ -0,0 +1,202 @@
"""
INF parser service for Windows driver INF files.
Extracts driver names (DriverDesc), resolves %TOKEN% references,
auto-detects encoding (ANSI/UTF-8/UTF-16), handles multi-model INFs,
detects unused files, architecture, and presence of .cat files.
Source: Microsoft WDK — General Syntax Rules for INF Files
https://learn.microsoft.com/en-us/windows-hardware/drivers/install/general-syntax-rules-for-inf-files
"""
from __future__ import annotations
import configparser
import re
from dataclasses import dataclass, field
@dataclass
class ParsedInf:
"""Result of parsing a Windows INF file."""
driver_names: list[str] # resolved DriverDesc values, deduplicated and sorted
inf_filename: str # which .inf file was parsed (basename from ZIP)
architecture: str | None # 'x64', 'x86', 'arm64', or None if ambiguous/unknown
has_cat_file: bool # True if a .cat file exists in the ZIP member list
unused_files: list[str] # ZIP members not referenced anywhere in the INF text
def _detect_encoding(raw: bytes) -> str:
"""Detect INF file encoding by sniffing BOM bytes.
INF files from real vendors arrive as:
- ANSI / Windows-1252 (cp1252) — most legacy drivers
- UTF-8 with BOM — modern drivers
- UTF-16 LE with BOM — HP/Canon x64 signed drivers (most common UTF-16)
- UTF-16 BE with BOM — rare
Source: Microsoft WDK — general-syntax-rules-for-inf-files
"""
if raw[:2] in (b"\xff\xfe", b"\xfe\xff"):
return "utf-16" # UTF-16 LE or BE with BOM
if raw[:3] == b"\xef\xbb\xbf":
return "utf-8-sig" # UTF-8 with BOM
return "cp1252" # ANSI / Windows-1252 safe fallback
def _neutralize_bare_lines(inf_text: str) -> str:
"""Rewrite INF lines that lack ``=`` so configparser can ingest the file.
Real vendor INFs contain sections like ``[SourceDisksFiles]`` or copy-list
sections whose entries are bare filenames (no key/value). configparser is
strict and aborts on those. We only care about ``key = value`` lines for
DriverDesc extraction, so it's safe to convert each bare payload line into
a synthetic ``__bare_N = <original>`` entry.
"""
out: list[str] = []
counter = 0
for line in inf_text.splitlines():
stripped = line.strip()
if (
not stripped
or stripped.startswith(";")
or stripped.startswith("#")
or stripped.startswith("[")
or "=" in stripped
):
out.append(line)
continue
counter += 1
out.append(f"__bare_{counter} = {stripped}")
return "\n".join(out)
def _resolve_tokens(value: str, strings: dict[str, str]) -> str:
"""Expand %TOKEN% placeholders using the [Strings] section lookup dict.
Keys in ``strings`` must already be lowercased (configparser lowercases
option names by default).
Source: Microsoft WDK — general-syntax-rules-for-inf-files — strkey% syntax
"""
def replacer(match: re.Match) -> str:
key = match.group(1).lower()
return strings.get(key, match.group(0))
return re.sub(r"%([^%]+)%", replacer, value)
def parse_inf(inf_text: str, inf_filename: str, zip_names: list[str]) -> ParsedInf:
"""Parse a Windows INF file and extract driver metadata.
Args:
inf_text: Decoded text content of the .inf file.
inf_filename: Name of the .inf file (used as-is in the result).
zip_names: List of all member paths from the containing ZIP archive.
Used for unused-file detection and .cat presence check.
Returns:
ParsedInf dataclass with driver_names, architecture, has_cat_file,
unused_files, and inf_filename.
Notes:
- Uses RawConfigParser (NOT ConfigParser) to avoid %(interpolation)s
interference with %TOKEN% INF syntax.
- strict=False is required because real INF files frequently have
duplicate option keys within a section (multiple hardware IDs).
- Architecture detection: if exactly one arch hint found -> return it;
multiple arch hints -> None (ambiguous / multi-arch INF).
- driver_names are sorted for deterministic dropdown order.
"""
parser = configparser.RawConfigParser(
comment_prefixes=(";", "#"),
strict=False, # real INFs have duplicate keys
delimiters=("=",),
)
# Preserve original option-key casing so DriverDesc literals keep their case.
# configparser lowercases keys by default, which would mangle "Acme SuperPrint 9000"
# into "acme superprint 9000". We disable that behaviour here and manually lowercase
# only when building the [Strings] lookup dict.
parser.optionxform = str # type: ignore[assignment]
parser.read_string(_neutralize_bare_lines(inf_text))
# Build strings lookup with LOWERCASED keys (case-insensitive token resolution).
# INF token references are case-insensitive per WDK spec.
strings: dict[str, str] = {}
if parser.has_section("Strings"):
for key, val in parser.items("Strings"):
# INF string values are typically surrounded by double-quotes; strip them.
strings[key.lower()] = val.strip('"')
# Collect Models section base names from [Manufacturer]
# Format per WDK: mfg-id = models-section-name[,target-OS-version[,target-OS-version...]]
models_section_names: list[str] = []
if parser.has_section("Manufacturer"):
for _mfg_key, mfg_val in parser.items("Manufacturer"):
# Resolve any %TOKEN% in the manufacturer value (rare, but safe)
resolved_val = _resolve_tokens(mfg_val, strings)
parts = [p.strip() for p in resolved_val.split(",")]
if parts:
models_section_names.append(parts[0])
# For each referenced Models section base name, find all matching sections
# (undecorated, .NTamd64, .NTarm64, .NTx86, etc.) and extract DriverDesc entries.
driver_names: set[str] = set()
arch_hints: set[str] = set()
all_sections_lower = {s.lower(): s for s in parser.sections()}
for base_name in models_section_names:
base_lower = base_name.lower()
for section_lower, section in all_sections_lower.items():
# Match: exact base name (undecorated) OR base name + .NT<arch> decoration
if section_lower == base_lower:
# Undecorated section — architecture hint: x86
arch_hints.add("x86")
suffix = ""
elif section_lower.startswith(base_lower + ".nt"):
suffix = section_lower[len(base_lower):] # e.g. ".ntamd64"
if "amd64" in suffix:
arch_hints.add("x64")
elif "arm64" in suffix:
arch_hints.add("arm64")
elif "x86" in suffix:
arch_hints.add("x86")
else:
# Generic .NT decoration (no specific arch) — treat as x86
arch_hints.add("x86")
else:
continue
# Each option key in a Models section is a device-description (DriverDesc)
for key, _val in parser.items(section):
if key.startswith("__bare_"):
continue
resolved = _resolve_tokens(key, strings)
# Skip empty, purely numeric, or clearly non-driver-name entries
if resolved and not resolved.isdigit():
driver_names.add(resolved)
# Architecture: unambiguous only when exactly one arch hint found
architecture: str | None = arch_hints.pop() if len(arch_hints) == 1 else None
# .cat file detection
has_cat_file = any(name.lower().endswith(".cat") for name in zip_names)
# Unused files: ZIP members whose basename does not appear anywhere in INF text
inf_lower = inf_text.lower()
unused_files: list[str] = []
for member in zip_names:
# Normalise path separators, then take basename
basename = member.replace("\\", "/").rsplit("/", 1)[-1]
if basename.lower() not in inf_lower:
unused_files.append(member)
return ParsedInf(
driver_names=sorted(driver_names),
inf_filename=inf_filename,
architecture=architecture,
has_cat_file=has_cat_file,
unused_files=unused_files,
)
+5
View File
File diff suppressed because one or more lines are too long
+166
View File
@@ -0,0 +1,166 @@
/* ImpTune layout — supplements Pico CSS */
.layout {
display: flex;
min-height: 100vh;
align-items: stretch;
}
.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;
margin: 0;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
overflow-x: hidden;
}
nav.sidebar ul,
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%;
}
.sidebar-nav a {
display: block;
box-sizing: border-box;
width: 100%;
padding: 0.6rem 1rem;
margin: 0;
text-decoration: none;
color: inherit;
font-weight: 400;
line-height: 1.4;
border-left: 3px solid transparent;
}
.sidebar-nav a:hover {
background-color: var(--pico-secondary-background, rgba(0,0,0,0.05));
}
.sidebar-nav a.active {
font-weight: 600;
background-color: var(--pico-primary-background, rgba(0,0,0,0.08));
border-left-color: var(--pico-primary, #1a73e8);
}
/* Main wrapper: fills remaining space beside sidebar, stacks topbar + content */
.main-wrapper {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
}
/* Topbar: holds top-right controls */
.topbar {
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;
}
.topbar-controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
.topbar-controls button {
padding: 0.3rem 0.6rem;
font-size: 0.85rem;
min-width: 2.2rem;
}
/* Main content */
.main-content {
flex: 1 1 auto;
min-width: 0;
padding: 1.5rem 2rem;
overflow-x: hidden;
overflow-y: auto;
}
/* Quick action buttons */
.quick-actions {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.btn-action {
display: inline-block;
padding: 0.5rem 1rem;
border: 1px solid var(--pico-primary, #1a73e8);
border-radius: 4px;
text-decoration: none;
color: var(--pico-primary, #1a73e8);
font-weight: 500;
}
.btn-action[aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
/* Empty state */
.empty-state {
color: var(--pico-muted-color, #666);
font-style: italic;
}
/* Recent activity */
.activity-section {
margin-bottom: 1.5rem;
}
+1
View File
File diff suppressed because one or more lines are too long
+4
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
# Storage package
Binary file not shown.
Binary file not shown.
+29
View File
@@ -0,0 +1,29 @@
"""Content-addressed file storage for driver packages."""
import hashlib
from pathlib import Path
class DriverStore:
"""SHA256 content-addressed file storage for printer driver packages.
Files are stored as DRIVERS_DIR/{sha256}.zip so identical uploads are
deduplicated automatically. The .zip suffix lets operators identify
stored driver packages by type when browsing the volume directly.
"""
def __init__(self, base_dir: str) -> None:
self._base = Path(base_dir)
self._base.mkdir(parents=True, exist_ok=True)
def save(self, data: bytes) -> str:
digest = hashlib.sha256(data).hexdigest()
dest = self.get_path(digest)
if not dest.exists():
dest.write_bytes(data)
return digest
def get_path(self, sha256: str) -> Path:
return self._base / f"{sha256}.zip"
def exists(self, sha256: str) -> bool:
return self.get_path(sha256).exists()
+341
View File
@@ -0,0 +1,341 @@
<!DOCTYPE html>
<html lang="en" data-theme="auto">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ImpTune</title>
<link rel="stylesheet" href="/static/pico.min.css">
<link rel="stylesheet" href="/static/app.css">
<script>
document.addEventListener('alpine:init', () => {
// Theme store: cycles Light -> Dark -> System, persists in localStorage
Alpine.store('theme', {
current: localStorage.getItem('imptune_theme') || 'auto',
icons: { light: '&#9728;', dark: '&#9790;', auto: '&#9681;' },
init() {
document.documentElement.setAttribute('data-theme', this.current);
},
cycle() {
const order = ['light', 'dark', 'auto'];
this.current = order[(order.indexOf(this.current) + 1) % order.length];
localStorage.setItem('imptune_theme', this.current);
document.documentElement.setAttribute('data-theme', this.current);
}
});
// i18n store: FR/EN toggle, persists in localStorage
Alpine.store('i18n', {
lang: (() => {
const saved = localStorage.getItem('imptune_lang');
if (saved) return saved;
return navigator.language && navigator.language.startsWith('en') ? 'en' : 'fr';
})(),
t(key) {
return (this.translations[this.lang] || {})[key] || key;
},
toggle() {
this.lang = this.lang === 'fr' ? 'en' : 'fr';
localStorage.setItem('imptune_lang', this.lang);
},
translations: {
fr: {
dashboard: 'Tableau de bord',
drivers: 'Pilotes',
printers: 'Imprimantes',
clients: 'Clients',
packages: 'Paquets',
add_printer: 'Ajouter une imprimante',
add_client: 'Ajouter un client',
printer_library: 'Biblioth\u00e8que d\u2019imprimantes',
edit: 'Modifier',
delete: 'Supprimer',
save: 'Enregistrer',
cancel: 'Annuler',
upload_driver: 'T\u00e9l\u00e9charger un pilote',
printer_name: 'Nom de l\u2019imprimante',
ip_address: 'Adresse IP',
port_name: 'Nom du port',
driver: 'Pilote',
duplex_mode: 'Mode recto-verso',
one_sided: 'Recto simple',
long_edge: 'Grand c\u00f4t\u00e9',
short_edge: 'Petit c\u00f4t\u00e9',
color_mode: 'Mode couleur',
paper_size: 'Format papier',
collate: 'Assembler',
client: 'Client',
edit_printer: 'Modifier l\u2019imprimante',
no_printers: 'Aucune imprimante configur\u00e9e.',
no_clients: 'Aucun client configur\u00e9.',
client_list: 'Liste des clients',
name: 'Nom',
created: 'Cr\u00e9\u00e9 le',
back_to_printers: 'Retour aux imprimantes',
theme_label: 'Th\u00e8me',
lang_label: 'FR',
// Dashboard page
new_printer: 'Nouvelle imprimante',
upload_driver_btn: 'T\u00e9l\u00e9charger un pilote',
export_package: 'Exporter un paquet',
recent_activity: 'Activit\u00e9 r\u00e9cente',
recent_printers: 'Imprimantes r\u00e9centes',
recent_packages: 'Paquets r\u00e9cents',
no_packages: 'Aucun paquet export\u00e9 pour l\u2019instant.',
// Printers new page
add_printer_title: 'Ajouter une imprimante',
back_to_printer_library: '\u2190 Retour \u00e0 la biblioth\u00e8que',
save_printer: 'Enregistrer l\u2019imprimante',
upload_new_driver: 'T\u00e9l\u00e9charger un nouveau pilote',
no_driver_option: '-- Aucun pilote --',
unassigned_option: '-- Non assign\u00e9 --',
// Printer list table headers
th_name: 'Nom',
th_ip: 'Adresse IP',
th_port: 'Port',
th_driver: 'Pilote',
th_duplex: 'Recto-verso',
th_color: 'Couleur',
th_paper: 'Format',
th_collate: 'Assemblage',
th_actions: 'Actions',
yes: 'Oui',
no: 'Non',
no_driver_assigned: '\u2014',
// Clients page
client_name_label: 'Nom du client',
add_client_section: 'Ajouter un client',
client_list_section: 'Liste des clients',
// Client detail page
back_to_clients: '\u2190 Tous les clients',
printers_section: 'Imprimantes',
// Drivers page
drivers_title: 'Pilotes',
upload_driver_section: 'T\u00e9l\u00e9charger un package de pilote',
driver_library: 'Biblioth\u00e8que de pilotes',
uploading: 'T\u00e9l\u00e9chargement...',
upload_btn: 'T\u00e9l\u00e9charger',
driver_filename: 'Nom du fichier',
driver_names_col: 'Nom(s) du pilote',
architecture: 'Architecture',
uploaded_at: 'T\u00e9l\u00e9charg\u00e9 le',
unknown: 'Inconnu',
no_drivers: 'Aucun pilote t\u00e9l\u00e9charg\u00e9.',
// Packages page
packages_title: 'Paquets',
packages_description: 'Imprimantes avec pilotes assign\u00e9s \u2014 pr\u00eates pour l\u2019export.',
printer_col: 'Imprimante',
client_col: 'Client',
driver_col: 'Pilote',
downloads_col: 'T\u00e9l\u00e9chargements',
no_packages_ready: 'Aucune imprimante pr\u00eate. Assignez un pilote pour activer l\u2019export.',
// Printer detail page
configuration: 'Configuration',
duplex_mode_label: 'Mode recto-verso',
color_mode_label: 'Mode couleur',
color_value: 'Couleur',
grayscale_value: 'Niveaux de gris',
paper_size_label: 'Format papier',
collate_label: 'Assemblage',
client_label: 'Client',
unassigned: 'Non assign\u00e9',
driver_section: 'Pilote',
package_label: 'Package',
driver_names_label: 'Nom(s) du pilote',
architecture_label: 'Architecture',
no_driver_detail: 'Aucun pilote assign\u00e9',
intune_commands: 'Commandes Intune',
install_cmd_label: 'Commande d\u2019installation',
uninstall_cmd_label: 'Commande de d\u00e9sinstallation',
copy: 'Copier',
copied: 'Copi\u00e9\u00a0!',
scripts_section: 'Scripts',
download_install: 'T\u00e9l\u00e9charger le script d\u2019installation',
download_uninstall: 'T\u00e9l\u00e9charger le script de d\u00e9sinstallation',
download_detect: 'T\u00e9l\u00e9charger le script de d\u00e9tection',
export_section: 'Export',
download_ninja: 'T\u00e9l\u00e9charger NinjaRMM ZIP',
download_intunewin: 'T\u00e9l\u00e9charger .intunewin',
icon_section: 'Ic\u00f4ne',
icon_uploaded: 'Ic\u00f4ne t\u00e9l\u00e9charg\u00e9e',
upload_icon: 'T\u00e9l\u00e9charger l\u2019ic\u00f4ne',
back_to_printers_btn: 'Retour aux imprimantes',
// Edit modal
edit_printer_title: 'Modifier l\u2019imprimante',
// Driver upload section
driver_package_label: 'Package de pilote (ZIP contenant .inf + fichiers pilote)'
},
en: {
dashboard: 'Dashboard',
drivers: 'Drivers',
printers: 'Printers',
clients: 'Clients',
packages: 'Packages',
add_printer: 'Add Printer',
add_client: 'Add Client',
printer_library: 'Printer Library',
edit: 'Edit',
delete: 'Delete',
save: 'Save',
cancel: 'Cancel',
upload_driver: 'Upload Driver',
printer_name: 'Printer Name',
ip_address: 'IP Address',
port_name: 'Port Name',
driver: 'Driver',
duplex_mode: 'Duplex Mode',
one_sided: 'One-Sided',
long_edge: 'Long Edge',
short_edge: 'Short Edge',
color_mode: 'Color Mode',
paper_size: 'Paper Size',
collate: 'Collate',
client: 'Client',
edit_printer: 'Edit Printer',
no_printers: 'No printers configured yet.',
no_clients: 'No clients configured yet.',
client_list: 'Client List',
name: 'Name',
created: 'Created',
back_to_printers: 'Back to Printers',
theme_label: 'Theme',
lang_label: 'EN',
// Dashboard page
new_printer: 'New Printer',
upload_driver_btn: 'Upload Driver',
export_package: 'Export Package',
recent_activity: 'Recent Activity',
recent_printers: 'Recent Printers',
recent_packages: 'Recent Packages',
no_packages: 'No packages exported yet.',
// Printers new page
add_printer_title: 'Add Printer',
back_to_printer_library: '\u2190 Back to Printer Library',
save_printer: 'Save Printer',
upload_new_driver: 'Upload New Driver',
no_driver_option: '-- No driver --',
unassigned_option: '-- Unassigned --',
// Printer list table headers
th_name: 'Name',
th_ip: 'IP Address',
th_port: 'Port',
th_driver: 'Driver',
th_duplex: 'Duplex',
th_color: 'Color',
th_paper: 'Paper',
th_collate: 'Collate',
th_actions: 'Actions',
yes: 'Yes',
no: 'No',
no_driver_assigned: '\u2014',
// Clients page
client_name_label: 'Client Name',
add_client_section: 'Add Client',
client_list_section: 'Client List',
// Client detail page
back_to_clients: '\u2190 All Clients',
printers_section: 'Printers',
// Drivers page
drivers_title: 'Drivers',
upload_driver_section: 'Upload Driver Package',
driver_library: 'Driver Library',
uploading: 'Uploading...',
upload_btn: 'Upload',
driver_filename: 'Filename',
driver_names_col: 'Driver Name(s)',
architecture: 'Architecture',
uploaded_at: 'Uploaded',
unknown: 'Unknown',
no_drivers: 'No drivers uploaded yet.',
// Packages page
packages_title: 'Packages',
packages_description: 'Printers with drivers assigned \u2014 ready for deployment package export.',
printer_col: 'Printer',
client_col: 'Client',
driver_col: 'Driver',
downloads_col: 'Downloads',
no_packages_ready: 'No package-ready printers yet. Assign a driver to a printer to enable package export.',
// Printer detail page
configuration: 'Configuration',
duplex_mode_label: 'Duplex Mode',
color_mode_label: 'Color Mode',
color_value: 'Color',
grayscale_value: 'Grayscale',
paper_size_label: 'Paper Size',
collate_label: 'Collate',
client_label: 'Client',
unassigned: 'Unassigned',
driver_section: 'Driver',
package_label: 'Package',
driver_names_label: 'Driver Name(s)',
architecture_label: 'Architecture',
no_driver_detail: 'No driver assigned',
intune_commands: 'Intune Commands',
install_cmd_label: 'Install command',
uninstall_cmd_label: 'Uninstall command',
copy: 'Copy',
copied: 'Copied!',
scripts_section: 'Scripts',
download_install: 'Download Install Script',
download_uninstall: 'Download Uninstall Script',
download_detect: 'Download Detect Script',
export_section: 'Export',
download_ninja: 'Download NinjaRMM ZIP',
download_intunewin: 'Download .intunewin',
icon_section: 'Icon',
icon_uploaded: 'Icon uploaded',
upload_icon: 'Upload Icon',
back_to_printers_btn: 'Back to Printers',
// Edit modal
edit_printer_title: 'Edit Printer',
// Driver upload section
driver_package_label: 'Driver Package (ZIP containing .inf + driver files)'
}
}
});
});
</script>
<script defer src="/static/alpine.min.js"></script>
<script src="/static/htmx.min.js"></script>
</head>
<body>
<div class="layout">
<nav class="sidebar">
<div class="sidebar-brand">
<strong>ImpTune</strong>
</div>
<ul class="sidebar-nav">
<li><a href="/" {% if request.url.path == "/" %}class="active"{% endif %}
x-data x-text="$store.i18n.t('dashboard')">Dashboard</a></li>
<li><a href="/drivers" {% if request.url.path == "/drivers" %}class="active"{% endif %}
x-data x-text="$store.i18n.t('drivers')">Drivers</a></li>
<li><a href="/printers" {% if request.url.path == "/printers" %}class="active"{% endif %}
x-data x-text="$store.i18n.t('printers')">Printers</a></li>
<li><a href="/clients" {% if request.url.path == "/clients" %}class="active"{% endif %}
x-data x-text="$store.i18n.t('clients')">Clients</a></li>
<li><a href="/packages" {% if request.url.path == "/packages" %}class="active"{% endif %}
x-data x-text="$store.i18n.t('packages')">Packages</a></li>
</ul>
</nav>
<div class="main-wrapper">
<header class="topbar">
<div class="topbar-controls" x-data>
<!-- Theme toggle button: cycles Light -> Dark -> System -->
<button class="secondary outline"
x-html="$store.theme.icons[$store.theme.current]"
:aria-label="$store.theme.current"
@click="$store.theme.cycle()"
title="Toggle theme">&#9681;</button>
<!-- Language toggle button -->
<button class="secondary outline"
x-text="$store.i18n.t('lang_label')"
@click="$store.i18n.toggle()"
title="Toggle language">FR</button>
</div>
</header>
<main class="main-content">
{% block content %}{% endblock %}
</main>
</div>
</div>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ client.name }}</h1>
<p><a href="/clients" x-data x-text="$store.i18n.t('back_to_clients')">&larr; All Clients</a></p>
<section>
<h2 x-data x-text="$store.i18n.t('printers_section')">Printers</h2>
{% include "partials/printer_list.html" %}
</section>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block content %}
<h1 x-data x-text="$store.i18n.t('clients')">Clients</h1>
<section>
<h2 x-data x-text="$store.i18n.t('add_client_section')">Add Client</h2>
<form hx-post="/clients" hx-target="#client-list" hx-swap="outerHTML">
<label>
<span x-data x-text="$store.i18n.t('client_name_label')">Client Name</span>
<input type="text" name="name" placeholder="e.g. Contoso" required>
</label>
<button type="submit" x-data x-text="$store.i18n.t('add_client')">Add Client</button>
</form>
</section>
<section>
<h2 x-data x-text="$store.i18n.t('client_list_section')">Client List</h2>
{% include "partials/client_list.html" %}
</section>
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block content %}
<h1 x-data x-text="$store.i18n.t('dashboard')">Dashboard</h1>
<div class="quick-actions">
<a href="/printers" class="btn-action" x-data x-text="$store.i18n.t('new_printer')">New Printer</a>
<a href="/drivers" class="btn-action" x-data x-text="$store.i18n.t('upload_driver_btn')">Upload Driver</a>
<a href="/packages" class="btn-action" x-data x-text="$store.i18n.t('export_package')">Export Package</a>
</div>
<section class="recent-activity">
<h2 x-data x-text="$store.i18n.t('recent_activity')">Recent Activity</h2>
<div class="activity-section">
<h3 x-data x-text="$store.i18n.t('recent_printers')">Recent Printers</h3>
{% if recent_printers %}
<ul>
{% for printer in recent_printers %}
<li><a href="/printers/{{ printer.id }}">{{ printer.name }}</a> — {{ printer.ip_address }}</li>
{% endfor %}
</ul>
{% else %}
<p class="empty-state" x-data x-text="$store.i18n.t('no_printers')">No printers configured yet.</p>
{% endif %}
</div>
<div class="activity-section">
<h3 x-data x-text="$store.i18n.t('recent_packages')">Recent Packages</h3>
{% if recent_packages %}
<ul>
{% for package in recent_packages %}
<li><a href="/printers/{{ package.id }}">{{ package.name }}</a>{% if package.client_id %} — {{ package.client.name }}{% endif %}</li>
{% endfor %}
</ul>
{% else %}
<p class="empty-state" x-data x-text="$store.i18n.t('no_packages')">No packages exported yet.</p>
{% endif %}
</div>
</section>
{% endblock %}
+25
View File
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block content %}
<h1 x-data x-text="$store.i18n.t('drivers_title')">Drivers</h1>
<section>
<h2 x-data x-text="$store.i18n.t('upload_driver_section')">Upload Driver Package</h2>
<form
hx-post="/drivers/upload"
hx-encoding="multipart/form-data"
hx-target="#driver-list"
hx-swap="outerHTML"
hx-indicator="#upload-spinner"
>
<label for="driver-file" x-data x-text="$store.i18n.t('driver_package_label')">Driver Package (ZIP containing .inf + driver files)</label>
<input type="file" id="driver-file" name="file" accept=".zip" required>
<button type="submit" x-data x-text="$store.i18n.t('upload_btn')">Upload</button>
<span id="upload-spinner" class="htmx-indicator" aria-busy="true" x-data x-text="$store.i18n.t('uploading')">Uploading...</span>
</form>
</section>
<section>
<h2 x-data x-text="$store.i18n.t('driver_library')">Driver Library</h2>
{% include "partials/driver_list.html" %}
</section>
{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block content %}
<h1 x-data x-text="$store.i18n.t('packages_title')">Packages</h1>
<p x-data x-text="$store.i18n.t('packages_description')">Printers with drivers assigned — ready for deployment package export.</p>
{% if printers %}
<figure>
<table role="grid">
<thead>
<tr>
<th scope="col" x-data x-text="$store.i18n.t('printer_col')">Printer</th>
<th scope="col" x-data x-text="$store.i18n.t('client_col')">Client</th>
<th scope="col" x-data x-text="$store.i18n.t('driver_col')">Driver</th>
<th scope="col" x-data x-text="$store.i18n.t('downloads_col')">Downloads</th>
</tr>
</thead>
<tbody>
{% for printer in printers %}
<tr>
<td><a href="/printers/{{ printer.id }}">{{ printer.name }}</a></td>
<td>{% if printer.client_id %}{{ printer.client.name }}{% else %}—{% endif %}</td>
<td>{{ printer.driver.original_filename }}</td>
<td>
<a href="/printers/{{ printer.id }}/packages/intunewin">.intunewin</a>
&nbsp;|&nbsp;
<a href="/printers/{{ printer.id }}/packages/ninja">NinjaRMM ZIP</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</figure>
{% else %}
<p class="empty-state" x-data x-text="$store.i18n.t('no_packages_ready')">No package-ready printers yet. Assign a driver to a printer to enable package export.</p>
{% endif %}
{% endblock %}
@@ -0,0 +1,22 @@
<div id="client-list">
{% if not clients %}
<p x-data x-text="$store.i18n.t('no_clients')">No clients configured yet.</p>
{% else %}
<table>
<thead>
<tr>
<th x-data x-text="$store.i18n.t('th_name')">Name</th>
<th x-data x-text="$store.i18n.t('created')">Created</th>
</tr>
</thead>
<tbody>
{% for c in clients %}
<tr>
<td><a href="/clients/{{ c.id }}">{{ c.name }}</a></td>
<td>{{ c.created_at.strftime('%Y-%m-%d') if c.created_at else '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
@@ -0,0 +1,50 @@
<div id="driver-list">
{% if parsed is defined and parsed.unused_files %}
<p class="notice">
{{ parsed.unused_files | length }} file(s) may be unused (not referenced by the INF):
<details>
<summary>Show unused files</summary>
<ul>
{% for f in parsed.unused_files %}
<li>{{ f }}</li>
{% endfor %}
</ul>
</details>
</p>
{% endif %}
{% if driver_data %}
<table>
<thead>
<tr>
<th x-data x-text="$store.i18n.t('driver_filename')">Filename</th>
<th x-data x-text="$store.i18n.t('driver_names_col')">Driver Name(s)</th>
<th x-data x-text="$store.i18n.t('architecture')">Architecture</th>
<th x-data x-text="$store.i18n.t('uploaded_at')">Uploaded</th>
</tr>
</thead>
<tbody>
{% for item in driver_data %}
<tr>
<td>{{ item.driver.original_filename }}</td>
<td>
{% if item.names %}
<select aria-label="Driver names">
{% for name in item.names %}
<option>{{ name }}</option>
{% endfor %}
</select>
{% else %}
<em x-data x-text="$store.i18n.t('unknown')">Unknown</em>
{% endif %}
</td>
<td>{% if item.driver.architecture %}{{ item.driver.architecture }}{% else %}<span x-data x-text="$store.i18n.t('unknown')">Unknown</span>{% endif %}</td>
<td>{{ item.driver.uploaded_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p x-data x-text="$store.i18n.t('no_drivers')">No drivers uploaded yet.</p>
{% endif %}
</div>
@@ -0,0 +1,11 @@
{% include "partials/driver_list.html" %}
<select name="driver_id" id="printer-form-driver-select" hx-swap-oob="true">
<option value="">-- No driver --</option>
{% for item in driver_data %}
<option value="{{ item.driver.id }}"
{% if item.driver.id == new_driver_id %}selected{% endif %}>
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
</option>
{% endfor %}
</select>
@@ -0,0 +1,103 @@
<!-- Edit trigger button — placed in Actions column -->
<button class="secondary outline"
onclick="document.getElementById('edit-modal-{{ p.id }}').showModal()"
x-data x-text="$store.i18n.t('edit')">
Edit
</button>
<!-- Edit dialog — Pico CSS native dialog, no extra library -->
<dialog id="edit-modal-{{ p.id }}">
<article>
<header>
<button aria-label="Close" rel="prev"
onclick="document.getElementById('edit-modal-{{ p.id }}').close()"></button>
<h3 x-data x-text="$store.i18n.t('edit_printer_title')">Edit Printer</h3>
</header>
<div x-data="{ ip: '{{ p.ip_address }}', port: '{{ p.port_name }}', portEdited: true }">
<form hx-patch="/printers/{{ p.id }}"
hx-target="#printer-list"
hx-swap="outerHTML"
hx-on::after-request="document.getElementById('edit-modal-{{ p.id }}').close()">
<label><span x-text="$store.i18n.t('printer_name')">Printer Name</span>
<input type="text" name="name" value="{{ p.name }}" required>
</label>
<label><span x-text="$store.i18n.t('ip_address')">IP Address</span>
<input type="text" name="ip_address"
x-model="ip"
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
required>
</label>
<label><span x-text="$store.i18n.t('port_name')">Port Name</span>
<input type="text" name="port_name"
x-model="port"
@change="portEdited = true"
@keydown="portEdited = true">
</label>
<label><span x-text="$store.i18n.t('driver')">Driver</span>
<select name="driver_id">
<option value="" x-text="$store.i18n.t('no_driver_option')">-- No driver --</option>
{% for item in driver_data %}
<option value="{{ item.driver.id }}"
{% if p.driver_id == item.driver.id %}selected{% endif %}>
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
</option>
{% endfor %}
</select>
</label>
<label><span x-text="$store.i18n.t('duplex_mode')">Duplex Mode</span>
<select name="duplex_mode">
<option value="OneSided" {% if p.duplex_mode == 'OneSided' %}selected{% endif %} x-text="$store.i18n.t('one_sided')">One-Sided</option>
<option value="LongEdge" {% if p.duplex_mode == 'LongEdge' %}selected{% endif %} x-text="$store.i18n.t('long_edge')">Long Edge</option>
<option value="ShortEdge" {% if p.duplex_mode == 'ShortEdge' %}selected{% endif %} x-text="$store.i18n.t('short_edge')">Short Edge</option>
</select>
</label>
<label>
<input type="checkbox" name="color_mode" value="on"
{% if p.color_mode %}checked{% endif %}>
<span x-text="$store.i18n.t('color_mode')">Color Mode</span>
</label>
<label><span x-text="$store.i18n.t('paper_size')">Paper Size</span>
<select name="paper_size">
<option value="A4" {% if p.paper_size == 'A4' %}selected{% endif %}>A4</option>
<option value="Letter" {% if p.paper_size == 'Letter' %}selected{% endif %}>Letter</option>
<option value="Legal" {% if p.paper_size == 'Legal' %}selected{% endif %}>Legal</option>
</select>
</label>
<label>
<input type="checkbox" name="collate" value="on"
{% if p.collate %}checked{% endif %}>
<span x-text="$store.i18n.t('collate')">Collate</span>
</label>
<label><span x-text="$store.i18n.t('client')">Client</span>
<select name="client_id">
<option value="" x-text="$store.i18n.t('unassigned_option')">-- Unassigned --</option>
{% for c in clients %}
<option value="{{ c.id }}"
{% if p.client_id == c.id %}selected{% endif %}>
{{ c.name }}
</option>
{% endfor %}
</select>
</label>
<footer>
<button type="submit" x-text="$store.i18n.t('save')">Save</button>
<button type="button" class="secondary"
onclick="document.getElementById('edit-modal-{{ p.id }}').close()"
x-text="$store.i18n.t('cancel')">
Cancel
</button>
</footer>
</form>
</div>
</article>
</dialog>
@@ -0,0 +1,99 @@
<div x-data="{ ip: '{{ printer.ip_address if printer else '' }}', port: '{{ printer.port_name if printer else '' }}', portEdited: {{ 'true' if printer else 'false' }} }">
<form hx-post="/printers" hx-target="#printer-list" hx-swap="outerHTML">
<label>
Printer Name
<input type="text" name="name" placeholder="e.g. HP LaserJet 4050" required
value="{{ printer.name if printer else '' }}">
</label>
<label>
IP Address
<input type="text" name="ip_address"
x-model="ip"
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
placeholder="e.g. 192.168.1.100"
required>
</label>
<label>
Port Name
<input type="text" name="port_name"
x-model="port"
@change="portEdited = true"
@keydown="portEdited = true"
placeholder="e.g. IP_192_168_1_100">
</label>
<label>
Driver
<select name="driver_id" id="printer-form-driver-select">
<option value="">-- No driver --</option>
{% for item in driver_data %}
<option value="{{ item.driver.id }}"
{% if printer and printer.driver_id == item.driver.id %}selected{% endif %}>
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
</option>
{% endfor %}
</select>
</label>
<label>
Duplex Mode
<select name="duplex_mode">
<option value="OneSided" {% if not printer or printer.duplex_mode == 'OneSided' %}selected{% endif %}>One-Sided</option>
<option value="LongEdge" {% if printer and printer.duplex_mode == 'LongEdge' %}selected{% endif %}>Long Edge</option>
<option value="ShortEdge" {% if printer and printer.duplex_mode == 'ShortEdge' %}selected{% endif %}>Short Edge</option>
</select>
</label>
<label>
<input type="checkbox" name="color_mode" value="on"
{% if not printer or printer.color_mode %}checked{% endif %}>
Color Mode
</label>
<label>
Paper Size
<select name="paper_size">
<option value="A4" {% if not printer or printer.paper_size == 'A4' %}selected{% endif %}>A4</option>
<option value="Letter" {% if printer and printer.paper_size == 'Letter' %}selected{% endif %}>Letter</option>
<option value="Legal" {% if printer and printer.paper_size == 'Legal' %}selected{% endif %}>Legal</option>
</select>
</label>
<label>
<input type="checkbox" name="collate" value="on"
{% if not printer or printer.collate %}checked{% endif %}>
Collate
</label>
<label>
Client
<select name="client_id">
<option value="">-- Unassigned --</option>
{% for c in clients %}
<option value="{{ c.id }}"
{% if printer and printer.client_id == c.id %}selected{% endif %}>
{{ c.name }}
</option>
{% endfor %}
</select>
</label>
<button type="submit">Save Printer</button>
</form>
<form hx-post="/drivers/upload"
hx-target="#driver-list"
hx-encoding="multipart/form-data"
hx-swap="outerHTML">
<input type="hidden" name="caller" value="printer_form">
<label>
Upload New Driver
<input type="file" name="file" accept=".zip" required>
</label>
<button type="submit" class="secondary">Upload Driver</button>
</form>
<div id="driver-list" style="display:none"></div>
</div>
@@ -0,0 +1,58 @@
<div id="printer-list">
{% if not grouped %}
<p x-data x-text="$store.i18n.t('no_printers')">No printers configured yet.</p>
{% else %}
{% for client_name, printers in grouped.items() %}
<section>
{% set group_client_id = printers[0].client_id if printers else None %}
{% if group_client_id %}
<h3><a href="/clients/{{ group_client_id }}">{{ client_name }}</a></h3>
{% else %}
<h3>{{ client_name }}</h3>
{% endif %}
<table>
<thead>
<tr>
<th x-data x-text="$store.i18n.t('th_name')">Name</th>
<th x-data x-text="$store.i18n.t('th_ip')">IP Address</th>
<th x-data x-text="$store.i18n.t('th_port')">Port</th>
<th x-data x-text="$store.i18n.t('th_driver')">Driver</th>
<th x-data x-text="$store.i18n.t('th_duplex')">Duplex</th>
<th x-data x-text="$store.i18n.t('th_color')">Color</th>
<th x-data x-text="$store.i18n.t('th_paper')">Paper</th>
<th x-data x-text="$store.i18n.t('th_collate')">Collate</th>
<th x-data x-text="$store.i18n.t('th_actions')">Actions</th>
</tr>
</thead>
<tbody>
{% for p in printers %}
<tr>
<td><a href="/printers/{{ p.id }}">{{ p.name }}</a></td>
<td>{{ p.ip_address }}</td>
<td>{{ p.port_name }}</td>
<td>{{ p.driver.original_filename if p.driver_id else '—' }}</td>
<td>{{ p.duplex_mode }}</td>
<td x-data="{ val: {{ 'true' if p.color_mode else 'false' }} }"
x-text="val ? $store.i18n.t('yes') : $store.i18n.t('no')">{{ 'Yes' if p.color_mode else 'No' }}</td>
<td>{{ p.paper_size }}</td>
<td x-data="{ val: {{ 'true' if p.collate else 'false' }} }"
x-text="val ? $store.i18n.t('yes') : $store.i18n.t('no')">{{ 'Yes' if p.collate else 'No' }}</td>
<td>
{% include "partials/printer_edit_modal.html" %}
<button
hx-delete="/printers/{{ p.id }}"
hx-target="#printer-list"
hx-swap="outerHTML"
hx-confirm="Delete '{{ p.name }}'?"
x-data x-text="$store.i18n.t('delete')">
Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endfor %}
{% endif %}
</div>
+82
View File
@@ -0,0 +1,82 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ printer.name }}</h1>
<article>
<h2 x-data x-text="$store.i18n.t('configuration')">Configuration</h2>
<dl>
<dt x-data x-text="$store.i18n.t('ip_address')">IP Address</dt><dd>{{ printer.ip_address }}</dd>
<dt x-data x-text="$store.i18n.t('port_name')">Port Name</dt><dd>{{ printer.port_name }}</dd>
<dt x-data x-text="$store.i18n.t('duplex_mode_label')">Duplex Mode</dt><dd>{{ printer.duplex_mode }}</dd>
<dt x-data x-text="$store.i18n.t('color_mode_label')">Color Mode</dt>
{% if printer.color_mode %}<dd x-data x-text="$store.i18n.t('color_value')">Color</dd>{% else %}<dd x-data x-text="$store.i18n.t('grayscale_value')">Grayscale</dd>{% endif %}
<dt x-data x-text="$store.i18n.t('paper_size_label')">Paper Size</dt><dd>{{ printer.paper_size }}</dd>
<dt x-data x-text="$store.i18n.t('collate_label')">Collate</dt>
{% if printer.collate %}<dd x-data x-text="$store.i18n.t('yes')">Yes</dd>{% else %}<dd x-data x-text="$store.i18n.t('no')">No</dd>{% endif %}
<dt x-data x-text="$store.i18n.t('client_label')">Client</dt>
{% if printer.client_id %}<dd>{{ printer.client.name }}</dd>{% else %}<dd x-data x-text="$store.i18n.t('unassigned')">Unassigned</dd>{% endif %}
</dl>
<h2 x-data x-text="$store.i18n.t('driver_section')">Driver</h2>
{% if printer.driver_id %}
<dl>
<dt x-data x-text="$store.i18n.t('package_label')">Package</dt><dd>{{ printer.driver.original_filename }}</dd>
<dt x-data x-text="$store.i18n.t('driver_names_label')">Driver Name(s)</dt><dd>{{ driver_names | join(", ") }}</dd>
<dt x-data x-text="$store.i18n.t('architecture_label')">Architecture</dt>
<dd>{% if printer.driver.architecture %}{{ printer.driver.architecture }}{% else %}<span x-data x-text="$store.i18n.t('unknown')">Unknown</span>{% endif %}</dd>
</dl>
{% else %}
<p x-data x-text="$store.i18n.t('no_driver_detail')">No driver assigned</p>
{% endif %}
{% if has_driver %}
<h2 x-data x-text="$store.i18n.t('intune_commands')">Intune Commands</h2>
<div x-data="{ copiedInstall: false }">
<label x-text="$store.i18n.t('install_cmd_label')">Install command</label>
<code id="install-cmd">{{ install_cmd }}</code>
<button @click="
const text = document.getElementById('install-cmd').innerText;
navigator.clipboard.writeText(text).then(() => { copiedInstall = true; setTimeout(() => copiedInstall = false, 2000) })
.catch(() => { /* fallback: text is visible for manual copy */ })
" x-text="copiedInstall ? $store.i18n.t('copied') : $store.i18n.t('copy')" class="secondary outline">Copy</button>
</div>
<div x-data="{ copiedUninstall: false }">
<label x-text="$store.i18n.t('uninstall_cmd_label')">Uninstall command</label>
<code id="uninstall-cmd">{{ uninstall_cmd }}</code>
<button @click="
const text = document.getElementById('uninstall-cmd').innerText;
navigator.clipboard.writeText(text).then(() => { copiedUninstall = true; setTimeout(() => copiedUninstall = false, 2000) })
.catch(() => { /* fallback: text is visible for manual copy */ })
" x-text="copiedUninstall ? $store.i18n.t('copied') : $store.i18n.t('copy')" class="secondary outline">Copy</button>
</div>
<h2 x-data x-text="$store.i18n.t('scripts_section')">Scripts</h2>
<a href="/printers/{{ printer.id }}/scripts/install.ps1" role="button" class="secondary" x-data x-text="$store.i18n.t('download_install')">
Download Install Script
</a>
<a href="/printers/{{ printer.id }}/scripts/uninstall.ps1" role="button" class="secondary" x-data x-text="$store.i18n.t('download_uninstall')">
Download Uninstall Script
</a>
<a href="/printers/{{ printer.id }}/scripts/detect.ps1" role="button" class="secondary" x-data x-text="$store.i18n.t('download_detect')">
Download Detect Script
</a>
<h2 x-data x-text="$store.i18n.t('export_section')">Export</h2>
<a href="/printers/{{ printer.id }}/packages/ninja" role="button" x-data x-text="$store.i18n.t('download_ninja')">Download NinjaRMM ZIP</a>
<a href="/printers/{{ printer.id }}/packages/intunewin" role="button" x-data x-text="$store.i18n.t('download_intunewin')">Download .intunewin</a>
{% endif %}
<h2 x-data x-text="$store.i18n.t('icon_section')">Icon</h2>
{% if has_icon %}
<p x-data x-text="$store.i18n.t('icon_uploaded')">Icon uploaded</p>
{% endif %}
<form hx-post="/printers/{{ printer.id }}/icon"
hx-target="#icon-status" hx-swap="innerHTML"
enctype="multipart/form-data">
<input type="file" name="file" accept="image/png" required>
<button type="submit" x-data x-text="$store.i18n.t('upload_icon')">Upload Icon</button>
</form>
<div id="icon-status"></div>
<a href="/printers" role="button" class="secondary" x-data x-text="$store.i18n.t('back_to_printers_btn')">Back to Printers</a>
</article>
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block content %}
<h1 x-data x-text="$store.i18n.t('printers')">Printers</h1>
<p><a href="/printers/new" role="button" x-data x-text="$store.i18n.t('add_printer')">Add Printer</a></p>
<section>
<h2 x-data x-text="$store.i18n.t('printer_library')">Printer Library</h2>
{% include "partials/printer_list.html" %}
</section>
{% endblock %}
+100
View File
@@ -0,0 +1,100 @@
{% extends "base.html" %}
{% block content %}
<h1 x-data x-text="$store.i18n.t('add_printer_title')">Add Printer</h1>
<p><a href="/printers" x-data x-text="$store.i18n.t('back_to_printer_library')">&larr; Back to Printer Library</a></p>
<div x-data="{ ip: '', port: '', portEdited: false }">
<form action="/printers" method="post">
<label>
<span x-text="$store.i18n.t('printer_name')">Printer Name</span>
<input type="text" name="name" placeholder="e.g. HP LaserJet 4050" required>
</label>
<label>
<span x-text="$store.i18n.t('ip_address')">IP Address</span>
<input type="text" name="ip_address"
x-model="ip"
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
placeholder="e.g. 192.168.1.100"
required>
</label>
<label>
<span x-text="$store.i18n.t('port_name')">Port Name</span>
<input type="text" name="port_name"
x-model="port"
@change="portEdited = true"
@keydown="portEdited = true"
placeholder="e.g. IP_192_168_1_100">
</label>
<label>
<span x-text="$store.i18n.t('driver')">Driver</span>
<select name="driver_id" id="printer-form-driver-select">
<option value="" x-text="$store.i18n.t('no_driver_option')">-- No driver --</option>
{% for item in driver_data %}
<option value="{{ item.driver.id }}">
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
</option>
{% endfor %}
</select>
</label>
<label>
<span x-text="$store.i18n.t('duplex_mode')">Duplex Mode</span>
<select name="duplex_mode">
<option value="OneSided" selected x-text="$store.i18n.t('one_sided')">One-Sided</option>
<option value="LongEdge" x-text="$store.i18n.t('long_edge')">Long Edge</option>
<option value="ShortEdge" x-text="$store.i18n.t('short_edge')">Short Edge</option>
</select>
</label>
<label>
<input type="checkbox" name="color_mode" value="on" checked>
<span x-text="$store.i18n.t('color_mode')">Color Mode</span>
</label>
<label>
<span x-text="$store.i18n.t('paper_size')">Paper Size</span>
<select name="paper_size">
<option value="A4" selected>A4</option>
<option value="Letter">Letter</option>
<option value="Legal">Legal</option>
</select>
</label>
<label>
<input type="checkbox" name="collate" value="on" checked>
<span x-text="$store.i18n.t('collate')">Collate</span>
</label>
<label>
<span x-text="$store.i18n.t('client')">Client</span>
<select name="client_id">
<option value="" x-text="$store.i18n.t('unassigned_option')">-- Unassigned --</option>
{% for c in clients %}
<option value="{{ c.id }}">{{ c.name }}</option>
{% endfor %}
</select>
</label>
<button type="submit" x-text="$store.i18n.t('save_printer')">Save Printer</button>
</form>
<form hx-post="/drivers/upload"
hx-target="#driver-list"
hx-encoding="multipart/form-data"
hx-swap="outerHTML">
<input type="hidden" name="caller" value="printer_form">
<label>
<span x-text="$store.i18n.t('upload_new_driver')">Upload New Driver</span>
<input type="file" name="file" accept=".zip" required>
</label>
<button type="submit" class="secondary" x-text="$store.i18n.t('upload_driver')">Upload Driver</button>
</form>
<div id="driver-list" style="display:none"></div>
</div>
{% endblock %}
+8
View File
@@ -0,0 +1,8 @@
# Generated by ImpTune — Detection script for {{ printer_name }}
$printer = Get-Printer -Name "{{ printer_name }}" -ErrorAction SilentlyContinue
if ($printer) {
Write-Output "Installed: {{ printer_name }}"
exit 0
} else {
exit 1
}
+67
View File
@@ -0,0 +1,67 @@
# Generated by ImpTune — Printer: {{ printer_name }}
# Install command: powershell.exe -NoProfile -ExecutionPolicy Bypass -File "install.ps1"
#
# This script installs the printer "{{ printer_name }}" via Intune Win32 app deployment.
# It must be launched with -File (not -Command) so $PSScriptRoot is populated.
# ---------------------------------------------------------------------------
# WOW64 Guard — relaunch in 64-bit PowerShell if running under WOW64 (Intune
# runs Win32 app scripts in a 32-bit process; pnputil is 64-bit only).
# This block MUST be the first executable code in the script.
# ---------------------------------------------------------------------------
if ($env:PROCESSOR_ARCHITECTURE -eq "x86" -and $env:PROCESSOR_ARCHITEW6432) {
$ps64 = "$env:WINDIR\SysNative\WindowsPowerShell\v1.0\powershell.exe"
& $ps64 -NoProfile -ExecutionPolicy Bypass -File "$PSCommandPath" @args
exit $LASTEXITCODE
}
# ---------------------------------------------------------------------------
# SYSTEM vs User context detection + UAC self-elevation
# Skip elevation when already running as SYSTEM (Intune MDM context).
# ---------------------------------------------------------------------------
$id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
$isSystem = $id.IsSystem
$isAdmin = ([System.Security.Principal.WindowsPrincipal]$id).IsInRole(
[System.Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isSystem -and -not $isAdmin) {
Start-Process powershell.exe `
-Verb Runas `
-ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`"" `
-Wait
exit $LASTEXITCODE
}
# ---------------------------------------------------------------------------
# pnputil two-step driver staging
# Step 1: Stage INF + all associated files into Windows Driver Store
# Step 2: Install the named driver from the Driver Store
# ---------------------------------------------------------------------------
pnputil.exe /add-driver "$PSScriptRoot\drivers\{{ inf_filename }}" /install
Add-PrinterDriver -Name "{{ driver_name }}"
# ---------------------------------------------------------------------------
# Idempotent port creation
# ---------------------------------------------------------------------------
if (-not (Get-PrinterPort -Name "{{ port_name }}" -ErrorAction SilentlyContinue)) {
Add-PrinterPort -Name "{{ port_name }}" -PrinterHostAddress "{{ ip_address }}"
}
# ---------------------------------------------------------------------------
# Idempotent printer creation
# ---------------------------------------------------------------------------
if (-not (Get-Printer -Name "{{ printer_name }}" -ErrorAction SilentlyContinue)) {
Add-Printer -Name "{{ printer_name }}" `
-PortName "{{ port_name }}" `
-DriverName "{{ driver_name }}"
}
# ---------------------------------------------------------------------------
# Configure print settings
# ---------------------------------------------------------------------------
Set-PrintConfiguration -PrinterName "{{ printer_name }}" `
-DuplexingMode {{ duplex_mode }} `
-Color ${{ color }} `
-PaperSize {{ paper_size }} `
-Collate ${{ collate }}
@@ -0,0 +1,4 @@
# Generated by ImpTune — Uninstall script for {{ printer_name }}
Remove-Printer -Name "{{ printer_name }}" -ErrorAction SilentlyContinue
Remove-PrinterDriver -Name "{{ driver_name }}" -ErrorAction SilentlyContinue
Remove-PrinterPort -Name "{{ port_name }}" -ErrorAction SilentlyContinue