Skip to content

Latest commit

 

History

89 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PDT (Printer Deployment Tool)

A Wails (Go + web frontend) rewrite of Create-Printers.ps1, the original PowerShell/WinForms tool for bulk-deploying network printers (Standard TCP/IP ports, vendor drivers, print configuration) from a CSV or saved JSON list of rows. This rewrite exists to get off the PowerShell/WinForms DataGridView layer entirely while keeping every behavior the original tool's real-world use already proved out - the Win32 mechanics below are ported (not reinvented) from that tool's own hard-won findings, cross-checked against Microsoft's documentation and, wherever that documentation didn't cover it, verified directly against this machine's real print spooler.

Download the latest installer - see "Installing PDT" below for what the installer does and does not do.

PDT.exe requests elevation on launch (build/windows/wails.exe.manifest) - every real operation it performs needs Administrator, so Windows prompts for UAC automatically rather than the app starting unelevated and failing partway through a deploy. There is no unelevated fallback mode. This is unrelated to (and unaffected by) the installer's own elevated-vs-unelevated install choice described in "Installing PDT" below - wherever the installed PDT.exe ends up sitting, launching it still UAC-prompts every time, per its own manifest.

Status

  • Phase 1 - data layer (internal/config, internal/driver, internal/printer types): CSV/JSON import-export, the driver catalog (multi-version/multi-arch scanning of a Drivers/Windows/<any version folder>/<Manufacturer>/... tree - see "Drivers folder layout" below - Archive subfolders excluded), driver-name/version resolution, and the platform-independent PrinterRow/DeployRequest/DeployResult shapes every phase builds on. Done.
  • Phase 2 - Win32 bindings (internal/printer/windows): hand-written syscall bindings for every winspool.drv/setupapi.dll operation the tool needs - golang.org/x/sys/windows has no winspool.drv coverage at all. Done; see "Windows bindings" below.
  • Phase 3 - orchestration (internal/printer/windows/deploy_windows.go, internal/printer/batch.go): the actual per-row Deploy sequence (resolve driver -> port -> driver install -> create/update printer -> conditional NUL:-to-real-port rebind -> print config -> APF) wired on top of phases 1-2, plus the confirmation-dialog flows for driver-version changes and printer updates. Done; see "Deploy sequence" below.
  • Phase 4 - Wails frontend (app.go, frontend/src/): the actual GUI - a plain HTML/CSS/vanilla-JS grid (no framework), the App struct's bound methods the frontend calls, and native OS dialogs for file pickers and deploy confirmations. Done; see "Frontend" below.
  • macOS support (internal/printer/darwin, internal/driver's Mac* additions): a second real printer.Deployer implementation - installs a .dmg/.pkg driver package and creates/reuses a CUPS LPD print queue, verified against real vendor packages and this machine's own real queues. package main now compiles and runs as a real macOS .app, with a reduced frontend UI for the Windows-only concepts (Spooler, SNMP/port config, APF, DEVMODE capture, app self-update) that have no CUPS/macOS equivalent yet. Not yet installer-packaged. What's still genuinely open: the open question of whether a real signed .app avoids the ad-hoc-signing AMFI rejection a bare test binary hit - see "macOS support" below and the Changelog for the current list.
  • Not started: Linux support.

Windows bindings (internal/printer/windows)

golang.org/x/sys/windows has zero winspool.drv coverage, so every binding here is hand-written against Microsoft's Win32 documentation, and - for the handful of things that documentation doesn't cover at all - verified directly against this machine's real print spooler (see cmd/pdtdebug below).

File What it does
printer_windows.go OpenPrinter/GetInfo2/SetInfo2/CreatePrinter/DeletePrinterByName/EnumLocalPrinterNames - the PRINTER_INFO_2 read/write core everything else builds on. Always requests PRINTER_ALL_ACCESS explicitly (a NULL PRINTER_DEFAULTS grants a default access level too low for SetPrinter to succeed - the original tool's own early bug).
apf_windows.go The printer Advanced tab's "Enable advanced printing features" toggle: PRINTER_ATTRIBUTE_RAW_ONLY, inverted (enabling APF clears the bit) - confirmed against Microsoft's docs after an earlier wrong guess (PRINTER_ATTRIBUTE_ENABLE_DEVQ) in the original tool.
port_windows.go NUL: port creation via XcvData "AddPort" against the "Local Port" monitor. The obvious-looking AddPortEx API was tried first and rejected - it's documented as NT4-legacy and returns ERROR_INVALID_PARAMETER against the modern Local Port monitor in practice; XcvData is the real, current mechanism (and what Standard TCP/IP port creation below also needs).
tcpport_windows.go Standard TCP/IP port creation via XcvData "AddPort"/PORT_DATA_1 against the "Standard TCP/IP Port" monitor - the one piece with no PowerShell-cmdlet internals to port from at all (the original tool used the Win32_TCPIPPrinterPort WMI class, a completely different mechanism), built from Microsoft's TCPMON Xcv Commands documentation and verified end-to-end (see below).
driverinstall_windows.go Driver installation via SetupCopyOEMInfW (stage into the driver store) + InstallPrinterDriverFromPackageW (register with the spooler). The latter's real export turned out to live in winspool.drv on this machine, not spoolss.dll as Microsoft's own docs list - found via the actual runtime error, not assumed.
devmode_windows.go Duplex/color via DocumentPropertiesW/DEVMODE, replacing the original tool's unreliable Set-PrintConfiguration + PrintTicket-XML fallback entirely. Known limitation, ported forward from the original tool: Kyocera's driver won't reliably switch back from monochrome to color via DEVMODE (confirmed non-transient); logged as a [WARN], never fatal.
driverinfo_windows.go Reads an installed driver's real version straight from the registry (...\Environments\<env>\Drivers\Version-3\<name>, values DriverDate/DriverVersion) - confirmed on a real machine to hold the same version string a vendor .inf's own DriverVer= line declares, unlike Get-PrinterDriver's CIM-derived Date/DriverVersion properties, which the original tool found came back unusable regardless of how the driver was installed.
portlookup_windows.go Finds an existing Standard TCP/IP port already targeting a given host, by reading every port's HostName value from the registry (...\Monitors\Standard TCP/IP Port\Ports\<name>) - the registry-level equivalent of the original tool's Win32_TCPIPPrinterPort.HostAddress WMI lookup.
structs_windows.go Every Win32 struct used above (DEVMODE, PRINTER_INFO_2, PORT_DATA_1, DELETE_PORT_DATA_1, ...), laid out to match the real C structs byte-for-byte - see structs_windows_test.go's unsafe.Sizeof assertions against the documented sizes (DEVMODE=220, PRINTER_INFO_2=136, PORT_DATA_1=964, DELETE_PORT_DATA_1=236).

cmd/pdtdebug

A throwaway CLI for verifying the bindings above against this machine's real print spooler - mirroring how the driver-catalog logic was validated headlessly against real local files. It has no role in the shipped app; delete it once the Wails UI exercises the same codepaths directly. Needs an elevated terminal (every printer/port operation requires Administrator).

Notably: deployrow <driversRoot> <manufacturer> <driverSelection> <ip> [nocleanup] [useexisting] runs the entire Phase 3 Deploy sequence end to end against a real local driver catalog and this machine's real spooler for one throwaway printer, auto-confirming every prompt, then cleans up after itself (unless nocleanup is given, to set up a same-row redeploy test). Run pdtdebug with no arguments for the full command list.

macOS support (internal/printer/darwin, internal/driver's Mac* additions)

printer.Deployer's own platform-independent design (from the original Windows-only build) meant a second implementation could be added with no changes to the interface itself. The macOS side installs a .dmg/.pkg driver package and creates a CUPS LPD print queue, instead of a Standard TCP/IP port + .inf driver the Windows side uses - CUPS has no separate "port" object at all, so there's nothing analogous to create ahead of the queue itself.

File What it does
internal/driver/maccatalog.go, maczip.go Scans Drivers/macOS/<Manufacturer>/<any version folder>/*.dmg/*.pkg/*.zip (version nested under manufacturer, the other way from the Windows side - see "Drivers folder layout" below for why), plus a flat Drivers/macOS/OpenPrinting/<Manufacturer>/*.ppd fallback bucket. A .zip (Canon: wraps one .dmg; Konica Minolta: wraps a further-nested .zip) is recorded as its own catalog entry directly, never extracted at scan time at all - GitHub issue #11's fix: the previous eager-extract-into-a-permanent-sibling-folder behavior (ensureMacZipsExtracted) was confirmed live to regenerate real, repeated disk bloat (~2.6G/36 folders on a real machine) on every single catalog rebuild. resolveMacZipSource now extracts a .zip on demand, into a throwaway temp directory, only when something (cataloging, or a real Deploy) actually needs the real bytes inside - reusing the Windows side's own extractZip/flattenRedundantWrapperDir, no Windows-only dependency in either. Any leftover pre-fix sibling folder still found on disk is recognized (ExtractedSiblingDirs, the same predicate Sync already used to skip these) and deleted outright. __MACOSX/ and ._-prefixed AppleDouble resource-fork stubs (macOS's own zip tooling litters these into any zip made on a Mac) are explicitly skipped - confirmed live one of these shares its real file's own .dmg extension, at a few hundred bytes instead of 80+ MB, so without this it would show up as a second, bogus catalog entry that fails the moment something tries to mount it.
internal/driver/macmount.go LocatePkg resolves a .dmg to the real .pkg inside it (mounts via hdiutil, recurses into one level of nested .dmg - confirmed necessary against a real Kyocera package that wraps a nested image), with no bundled extraction tool needed - unlike Windows' bundled 7-Zip, macOS driver packages need no pre-extraction step at all. PackageLabel is a best-effort display label only (see below) - never used to decide which package is newest. LocateLoosePPDs is LocatePkg's sibling for the opposite case - a .dmg with no .pkg inside at all, confirmed live to be Canon's own "PPD" bucket shape (a nested .dmg wrapping a plain folder-per-model tree of *.PPD.gz files, no installer anywhere) - same mount/one-nested-level convention, collecting every loose PPD found instead of a single .pkg.
internal/driver/macresolve.go ResolveMac picks the newest package for a manufacturer by file modification time, not by any version parsed out of the package - confirmed against a real Kyocera distribution-style package that there's no reliable per-package version field on macOS at all (every component's own declared "version" was boilerplate 1.0/0); the file's own mtime is the only honest signal available. ResolveOpenPrintingPPD/OpenPrintingCandidates fuzzy-match a technician-typed driver/model string against the OpenPrinting fallback bucket's own filenames (normalized from Ricoh_MP_C3003.ppd-style underscores to spaces first - confirmed necessary, FuzzyMatchScore's subsequence matching is strict about order and does not treat _ and as interchangeable).
internal/driver/macppd.go ReadPPDNickName reads a PPD's own *NickName (falling back to *ModelName), transparently gzip-decompressing .ppd.gz - confirmed live necessary against real Canon PPDs, whose filenames (CNPZUIRAC5840ZU.ppd.gz) are cryptic vendor codes sharing no matchable substring, or even in-order character sequence, with how a technician would actually type the model (iR-ADV C5840); FuzzyMatchScore against the raw filename is a hard -1. PackagePPDNickNames/PackageBestModelScore do the same read-only pkgutil --expand-full inspection PackageLabel already does, but collect every PPD's *NickName and score them against a model string - lets a package be checked for whether it even supports a given model before installing it.
internal/driver/macfamily.go ResolveMacFamily wraps ResolveMac for a manufacturer that ships more than one genuinely distinct driver as separate packages, not just version variants of one driver (macFamilyPreference: "Canon": {"UFRII", "PS", "PPD"}, Ken's own stated preference order) - tries each family newest-first, pre-checking its PPD payload against Model via PackageBestModelScore before committing to it, falling through to the next-preferred family on no match. Degrades silently to plain ResolveMac for any manufacturer with no family table. Today only the guess-based fallback path still calls this directly - see macmodel.go's own row below for the build-once catalog that normally answers this ahead of time. Kyocera's own single entry ("Kyocera": {"Kyocera"}) isn't a real multi-family preference list at all - it exists purely to opt Kyocera into the same catalog-driven model index Canon gets, since classifyMacFamily's substring match against the manufacturer's own name trivially matches any real Kyocera download's filename. Sharp's own entry ("Sharp": {"MacPS", "PPD"}) is the same trick, but its real driver filename never contains "Sharp" at all - "MacPS" is a real, verified-collision-free substring of the driver's own filename instead (MX-C55c_2512a_MacPS.dmg), chosen specifically to never match the zero-PPD decoy package (Generic_GUC_PrinterSoftware_11202025.dmg) that sits alongside it in a real Drivers folder; "PPD" is a second, permanently-empty token that only exists so stripLanguageSuffix strips Sharp's own generic " PPD" *NickName suffix from the friendly model name.
internal/driver/macmodel.go BuildMacModelIndex builds, once per catalog build/refresh, a MacModelIndex (manufacturer -> friendly model -> []MacPPDVariant) for every macFamilyPreference manufacturer - inspecting only each family's own single newest package (never every version-folder's own copy) and only when it's actually new or changed (see maccatalogdb.go below - most launches skip inspection entirely now). MacVariantForDeploy then resolves a row's own (Model, Driver) straight to a MacPPDVariant - which package to install and which exact PPD filename it registers, or (for a family that ships loose PPDs with no installer at all - confirmed live against Canon's own "PPD" bucket) a permanently-cached local copy to hand lpadmin directly - with no per-deploy package re-inspection at all.
internal/driver/maccatalogdb.go MacManufacturerCatalog - the persistent, per-manufacturer JSON record BuildMacModelIndex reads/writes (Drivers/macOS/<Manufacturer>/catalog.<manufacturer>.json) so a package it's already indexed never gets mounted/expanded again on a later launch. IsCurrent is the cheap (no mounting) staleness check - path/modtime/size against BuildMacCatalog's own free directory-scan info; DiffModels is the "what changed" comparison surfaced to the frontend Log panel via CatalogStatus.ModelChanges when a package has changed. See "Model-driven PPD selection on macOS" above for the full story, including why the PPD cache itself (deploy-time bytes for a no-installer family) deliberately stays per-machine instead of living here too.
internal/driver/macppd.go packagePPDEntries is the actual package inspection indexFamilyPackage/PackagePPDNickNames both use - pkgutil --expand (structure only) plus selective cpio extraction of just *.ppd/*.ppd.gz from each sub-package's own gzip-compressed Payload, not the original pkgutil --expand-full + full-tree walk (confirmed live: ~20x less I/O for the same result - see "Fast, selective PPD extraction" above).
internal/printer/darwin/elevate_darwin.go Every privileged command (installer, lpadmin) runs through osascript's do shell script ... with administrator privileges - the closest available equivalent to Windows' manifest-driven auto-UAC-elevation without a paid code-signing certificate. Confirmed live that a bare, ad-hoc-signed CLI binary gets killed by AMFI (AppleMobileFileIntegrityError -423) the moment the privileged command actually starts, even after the password prompt is accepted - whether a real signed .app bundle avoids this too is still an open question (see the file's own doc comment for the full story and what to try next).
internal/printer/darwin/install_darwin.go, ppdinventory_darwin.go Installs a resolved package via installer -pkg ... -target /, and diffs /Library/Printers/PPDs/Contents/Resources before/after to discover which PPD(s) it actually registered - there's no Windows-registry-like "installed driver version" to read directly on macOS, so a before/after PPD-directory diff is the closest real signal (confirmed live against a real Kyocera install: hundreds of new PPDs appeared, including an exact match for a real deployed printer's own model). This is the fallback path now - see canoninstall_darwin.go below for the selective path a catalog-driven Canon UFR II row takes instead.
internal/driver/maccanonselective.go, internal/printer/darwin/canoninstall_darwin.go The selective Canon UFR II install (see "Real deploy bugs found via live testing" below for the full story of why this exists) - installs the Core sub-package for real, then cpio-extracts just the one target model's own PPD + matching per-model "Recipe" bundle straight out of Device's own Payload, skipping the other ~548 pairs and Icons/Profiles/cnaccm entirely. CanonCoreDevicePackages locates the two sub-packages by name suffix; ExtractCanonDeviceFiles does the selective extraction (three cpio patterns - the PPD, the Recipe bundle, and a sibling Recipe/<model>.rcp symlink into it, found by diffing a real BOM). installCanonSelective re-packs the expanded Core sub-package via pkgutil --flatten before installing it (installer -pkg rejects an expanded component directory outright, confirmed live even without any privilege at all) and copies the staged files into place with cp -RX (-X: skip extended attributes - plain cp -R fails against /Library itself even as root). Falls back to the plain full-package install above whenever a package doesn't match this exact shape.
internal/driver/mackyocera.go, internal/printer/darwin/kyoceraselective_darwin.go The selective Kyocera "Web Build" install - installCanonSelective's own sibling, same shape, different Distribution layout. kyoceraDistributionPkgRefs parses the Distribution's own <pkg-ref id="..." ...>#<file>.pkg</pkg-ref> XML (bundle identifiers like com.kyocera.ppd, far more stable across different Kyocera downloads than file names, which are just the build date) to find the baseline PPD-only sub-package and exclude two further choices ("Duplex On"/"Net Manager On") confirmed to ship the exact byte-identical PPD set, differing only in a default value each one's own postinstall script patches in afterward through a slow per-file sed+cp loop - since PDT sets its own duplex/color defaults via lpadmin regardless, neither is ever useful. ExtractKyoceraPPD extracts just the one target model's own PPD (a flat file, no Recipe-bundle-style companion the way Canon has); the other 15 real sub-packages (driver framework/CUPS filters/PDEs/etc) get flattened and installed for real, same as Canon's Core. One further choice, "Print Panel App" (com.kyocera.printpanel), is skipped outright even though it isn't a redundant PPD variant: it's the only one of Kyocera's real choices whose own PackageInfo targets /Applications rather than /Library or /usr, and confirmed live that installing it through do shell script ... with administrator privileges fails with PKInstallErrorDomain Code=120 (NSPOSIXErrorDomain Code=1 "Operation not permitted") - a TCC/SIP-flavored restriction on this specific elevation path that a real GUI-driven Installer.app run doesn't hit. Not required for actual CUPS printing (the functional pieces - filters/PDEs/the driver Framework - all target /Library or /usr and install fine).
internal/printer/darwin/canonbatch_darwin.go Deployer.PrepareBatch (implements the shared printer.BatchPreparer optional interface) - collapses every batchable row's own install+queue-create+defaults into one elevated call for the whole deploy run, since do shell script ... with administrator privileges never reuses a recent grant (confirmed live, repeatedly - every call shows its own fresh prompt). Scoped to catalog-driven Canon UFR II, Kyocera Web Build, Ricoh, Sharp, Xerox, Toshiba, and Konica Minolta rows with no existing queue to reuse - planCanonBatchRow/planKyoceraBatchRow each render their own manufacturer-specific selective-install mechanics down into a plain installScript string (the same logic the non-batched installCanonSelective/installKyoceraSelective paths use), while planRicohBatchRow/planSharpBatchRow/planXeroxBatchRow/planToshibaBatchRow/planKonicaMinoltaBatchRow each just fold their own manufacturer's plain full installer -pkg run in instead - none has a selective-install lever to pull (Ricoh/Sharp confirmed fast enough for real; Xerox's, Toshiba's, and Konica Minolta's own timing isn't live-confirmed yet, though Toshiba's real package is by far the smallest of any manufacturer here - see the Xerox/Toshiba/Konica Minolta sections below). Whichever planner recognizes a row's package shape claims it; everything else (a different manufacturer, a loose-PPD family, or an existing queue) falls back to the old per-row path, own prompts included. Per-row success/failure comes back through a plain results file (printf '%d:%d\n' <rowIndex> $? >> file, one line per row's own ( ... ) subshell) rather than parsing the combined call's own stdout - do shell script mangles \n to \r and buffers everything until the whole script exits, both confirmed live, both sidestepped by reading a file off disk afterward instead.
internal/printer/darwin/queue_darwin.go Creates/reuses a CUPS queue via lpadmin/lpstat - device URI built by deploy_darwin.go's own lpdDeviceURI (see below), confirmed against this machine's own already-deployed real queues (Jenks_Kyocera, Jackson_Streets) that a bare lpd://<ip>/ with no queue name is the working convention for most manufacturers. Reuses an existing queue already targeting the same device URI rather than ever creating a duplicate, the same rule portlookup_windows.go applies to Standard TCP/IP ports.
internal/printer/darwin/printdefaults_darwin.go Best-effort duplex/color defaults, by reading a PPD's own declared option keywords/choices - either a live queue's lpoptions -l (SetPrintDefaults, for a reused queue) or the PPD file directly off disk (PrintDefaultsForNewQueue/readPPDFileOptions, for a brand-new queue, so the -o args can ride along on the same lpadmin call that creates it). findOption matches by an exact (case-insensitive) allowlist of known keywords - Duplex/ColorModel (standard) and CNDuplex/CNColorMode (Canon) - never a suffix or substring match: confirmed live that a real Canon PPD declares both CNColorMode (the real switch) and the unrelated CNProcessColorMode (a boolean toggle), both ending in "ColorMode", so an earlier suffix-based version picked whichever came first in that model's own option order and silently left color mode untouched.
internal/printer/darwin/deploy_darwin.go The orchestrator (Deployer.Deploy) - resolve driver -> ensure it's installed -> resolve/create queue -> best-effort print defaults, or (when PrepareBatch already handled this row) just format the result it already computed. No NUL:-port workaround (nothing here is ever created against a placeholder port; CUPS queue creation doesn't have the multi-minute-against-a-live-port problem that motivated it on Windows) and no APF/"print spooled documents first" (both Windows spooler-specific concepts with no CUPS equivalent). lpdDeviceURI builds the device URI from the row's own optional LPDQueueName (grid column "LPD-Q", right after IP, on both platforms) - most manufacturers ignore the LPD queue-name segment entirely, but HP (raw) and Xerox (lp) are two confirmed exceptions, auto-filled by the frontend whenever a row's Manufacturer is set to either (defaultLpdQueueFor in frontend/src/main.js) and still freely overridable per row.
pickfolder_darwin.go (repo root) Settings > General's Browse ("...") buttons show a native folder picker via AppleScript's choose folder (through osascript) instead of Wails' own runtime.OpenDirectoryDialog - confirmed live that every Wails-bound method (this one included) runs on its own freshly-spawned goroutine, never the process's real main thread, and AppKit's NSOpenPanel is only safe to drive from the main thread; calling it off-thread silently swallowed every click, with the sheet never appearing and no error surfaced either. osascript sidesteps the whole problem by running in its own process with its own main thread/run loop. Also captures/restores whichever app was frontmost around the call - choose folder run bare like this belongs to no particular app, and macOS attributes its window to Finder, activating it as a side effect that otherwise visibly buries PDT's own window.

Model-driven PPD selection on macOS

The grid's row-level Model field (row.model in frontend/src/main.js, PrinterRow.Model/ SavedRow.Model - always existed in the data layer/CSV/JSON, only got a grid UI once the Windows side brought the field back) drives App.Models and App.DriverCandidates on macOS exactly the way it drives Windows' own Kyocera model-narrowing: pick a Model first, then Driver's own candidate list narrows to just that model's options. On macOS this matters for exactly the manufacturers macFamilyPreference lists (Canon today) - a manufacturer that ships more than one genuinely distinct driver as separate packages (UFR II, PostScript, and a plain-PPD-only package, each supporting a different, overlapping-but-not-identical set of models) needs Model to know which package even applies, not just which PPD inside one package to pick.

Model is mandatory, and Driver is fully derived from it, for exactly these manufacturers - not just narrowed. App.MacModelManufacturers() (drivercatalog_darwin.go) lists them (Canon today) without hardcoding a name in the frontend; macModelDriven() (frontend/src/main.js) is the one predicate every call site below keys off:

  • The grid row's own Model field is flagged (the same yellow input-needs-value styling Name/IP/Driver already use) when left blank for one of these manufacturers, since there's no single correct Driver to guess at without it.
  • Committing a Model (typed or picked) auto-fills Driver to DriverCandidates(manufacturer, model, "")'s first result - already preference-ordered UFR II-first (see below), so this is exactly "the model's own UFR II variant" with no extra Go-side lookup - and clears Driver back to blank/flagged the moment the typed Model text stops resolving to a real one.
  • The Defaults panel's own Driver field is blank and not flagged for these manufacturers (App.DefaultDriverFor returns "" rather than guessing) - the Defaults panel has no Model field to narrow by at all, so there's no single package it could honestly default to among Canon's UFR II/PostScript/Generic PPD (a raw, unparsed package label like "UFRII_v10.19.25_mac" used to leak through here - a real, previously-shipped bug).
  • A grid row's Driver also now auto-fills from the Defaults panel on a plain Manufacturer change (previously cleared to blank) on both platforms - the Defaults panel's own current Driver value when its Manufacturer already matches (preserving a manual override typed there), otherwise a fresh per-manufacturer default. On Windows this is how a Canon row picks up "Canon Generic Plus UFR II" automatically (internal/driver/default.go's own defaultDriverTokens); Windows' Model field itself is unaffected by any of the above - it stays optional, and fuzzy search only ever has real data for Kyocera, since indexing Canon's own mac model data depends on hdiutil/pkgutil (macOS-only tools) - a technician can still type a Model on Windows anyway, for a config meant to be opened on a Mac later.

The catalog is built once, not re-inspected per deploy. driver.BuildMacModelIndex (internal/driver/macmodel.go) runs at the same points BuildMacCatalog already does - app startup and the toolbar's Refresh Drivers - inspecting only each family's own single newest package (never every version-folder's own copy) and recording, for every friendly model name it finds, which package registers it and under what exact PPD filename (or, for a family with no installer package at all - see below - a permanent local copy of the PPD itself). The result (driver.MacModelIndex, App.macModelIndex) is a plain in-memory map from then on: App.Models/App.DriverCandidates are pure lookups, and deploy_darwin.go's own resolveDriver resolves a row's (Model, Driver) straight to a driver.MacPPDVariant (driver.MacVariantForDeploy) with no package re-inspection at deploy time at all - a real change from how this worked before: ResolveMacFamily/choosePPD used to shell out to pkgutil --expand-full fresh on every single deploy just to guess. That guess-based pair still exists and still runs (see below), but only as the fallback for a manufacturer/model the catalog doesn't have an entry for.

Not re-inspected per launch either, once a package has been indexed once. Mounting and inspecting a real vendor package is itself expensive - confirmed live that pkgutil --expand-full alone cost 6.4s and 255MB written per package (fixed - see "Fast, selective PPD extraction" below)

  • and every family gets re-mounted on every single launch/Refresh otherwise. driver. MacManufacturerCatalog (internal/driver/maccatalogdb.go) is a persistent, human-readable JSON record - one file per manufacturer, Drivers/macOS/<Manufacturer>/catalog.<manufacturer, lowercased>.json (e.g. Drivers/macOS/Canon/catalog.canon.json, not one combined file, so rebuilding or deleting one manufacturer's own catalog never touches any other's) - of every model/PPD indexed so far, plus exactly which package (and its own parent chain - outer .dmg -> nested .dmg -> installer .pkg -> sub-package, each with path/modtime/size/version where one exists) produced it. BuildMacModelIndex skips re-inspecting a family entirely once its recorded package identity (path/modtime/size - free from BuildMacCatalog's own directory scan already, no mounting needed) still matches - confirmed live: a second build against the same real Canon packages dropped from 17.7s to 0.147s (~120x), with byte-identical results, and the real app's own launch time dropped from 35-45s to ~2s the same way. Lives inside the Drivers folder itself (Drivers/macOS/, not installedAppDataDir()), deliberately, so it travels with a portable/flash- drive copy between machines; a no-installer family's actual cached PPD bytes stay per-machine (~/Library/Application Support/PDT/PPDCache/ - a flash drive is normally write-protected in the field, and re-inspection is cheap enough now that there's no real benefit to those bytes traveling too) - cachedVariantFilesExist notices when a fresh machine's own cache doesn't have what a borrowed catalog.json references yet and re-inspects rather than trusting a path that doesn't resolve locally. Never written back to at all when this exact running copy is itself on a removable drive (app_darwin.go's loadCatalog, flashdrive.IsRemovableDrive - the same reasoning the Windows side's own BuildCatalogNoExtract already applies) - a technician's laptop is where catalog.json gets built/updated, on local NVMe; a flash drive plugged into a different machine only ever reads whatever's already there. When a family's package has changed, driver.DiffModels compares its freshly-indexed model names against what was recorded before, and CatalogStatus.ModelChanges carries a human-readable summary back to the frontend Log panel after a Refresh - "what changed between this package and the last one", not PDT trying to judge staleness or regressions itself.

Fast, selective PPD extraction (internal/driver/macppd.go's packagePPDEntries) is what a first-ever (or genuinely changed) inspection now costs, replacing the original pkgutil --expand-full + full-tree walk. --expand-full fully decompresses a package's entire payload - driver binaries, a dozen languages of README/license text, icons, everything - just to find *.ppd(.gz) files. Confirmed live against a real Canon UFR II package: 6.4s and 255MB written, for a package whose actual PPDs total 25MB. pkgutil --expand (structure only, ~0.1s) leaves each sub-package's own Payload as what it actually is on disk - plain gzip-compressed cpio data (confirmed via file, no dependency on a more exotic format like pbzx) - which the system cpio tool extracts selectively: gunzip -c Payload | cpio -idm "*.ppd" "*.ppd.gz" pulls out just the matching entries in 0.2-0.4s. A ~20x cut on the actual bottleneck, using tools already in this codebase's own style (already shells out to hdiutil/pkgutil/installer).

How a model unifies across languages: confirmed against all three of Canon's real macOS downloads for the same physical printer (an iR-ADV C5840/5850) that each family's own PPD *NickName differs only by a trailing language token - "Canon iR-ADV C5840/5850" (UFR II, no suffix), "...C5840/5850 PS" (PostScript), "...C5840/5850 PPD" (the plain-PPD family) - so stripLanguageSuffix strips exactly that known trailing token (macFamilyPreference's own tokens) to get one canonical model name shared across all three, which is what App.Models actually offers in the dropdown. Picking that model then offers its own language variants in the Driver dropdown, labeled the way a technician would actually read them ("Canon iR-ADV C5840/5850 (UFR II)" / "(PostScript)" / "(Generic PPD)") rather than as a raw NickName or filename.

The third family needs no installer at all - a real, separate finding. Inspecting Canon's actual "PPD" bucket download live turned up something the original UFR II/PS-only design didn't account for: PPDv5.50_mac.dmg wraps one nested .dmg that's just a plain folder-per-model tree of loose *.PPD.gz files - no .pkg installer anywhere inside it at all. Confirmed by reading one of those PPDs directly: no *cupsFilter line, *LanguageLevel: "3" - a real Generic PostScript Level 3 PPD, not a proprietary Canon driver, so there's genuinely nothing to install (Canon's UFR II/PS PPDs, by contrast, each declare *cupsFilter entries pointing at vendor filter binaries under /Library/Printers/Canon/... that only installer -pkg deposits - confirmed by expanding a real UFR II package and finding exactly those filter binary paths referenced). driver.LocateLoosePPDs (internal/driver/macmount.go) is LocatePkg's sibling for this shape - same mount/one-nested-level convention, collecting every loose PPD instead of a single .pkg - and BuildMacModelIndex permanently copies each one it finds into a local cache (driver.CachePPDFile, under ~/Library/Application Support/PDT/PPDCache/, since the source .dmg won't still be mounted at deploy time) rather than trying to "install" a package that doesn't exist. At deploy time, a variant from this family goes straight to lpadmin -P <cached path> with no installer call at all - the only family of the three that skips installation entirely.

The guess-based fallback (driver.ResolveMacFamily + choosePPD in deploy_darwin.go) still exists and still runs, for exactly the cases the catalog can't answer: a manufacturer macFamilyPreference doesn't list, or Model left blank, or a typo that doesn't fold-match any known model. ResolveMacFamily tries each family newest-first, pre-checking whether its own PPD payload even supports Model (driver.PackageBestModelScore) before committing to it - logging a [WARN] on every fallback tier, same as before. Once a package installs this way, choosePPD fuzzy-matches Model against the newly-registered PPDs' own *NickName content (driver.ReadPPDNickName) rather than their filenames - confirmed necessary against real Canon filenames (CNPZUIRAC5840ZU.ppd.gz), which share no matchable substring, or even in-order character sequence, with how a technician would type the model (iR-ADV C5840): FuzzyMatchScore's subsequence fallback specifically fails on the -/ characters the filename never contains, so filename-based matching there was a hard -1, not just weaker. This path is also what a manufacturer with just one real driver package (Kyocera, Ricoh, Sharp - no macFamilyPreference entry, so no pre-built model index at all) still uses for its own post-install PPD pick - confirmed still correct against a real Kyocera fixture (Kyocera TASKalfa MZ6001ci vs. MZ6001i) that a bare-substring Model query doesn't always land on a clean winner the way it looks like it should, since FuzzyMatchScore's own tie-break (shorter matched text wins) can outscore a match even when the model text alone doesn't actually distinguish the two candidates - a choosePPD implementation detail worth knowing about, not a bug.

  • When there's no installer package at all (the OpenPrinting-bucket fallback, a manufacturer with neither a model index entry nor any local installer package), row.Driver is checked first for an exact OpenPrintingCandidates label match (driver.OpenPrintingPPDByLabel - the technician explicitly picked one from the dropdown, the most authoritative signal available) before falling back to fuzzy-matching Model via driver.ResolveOpenPrintingPPD.
  • Still not built: an interactive disambiguation prompt for a genuinely ambiguous fallback-path match (two candidates tying for best score, or Model left blank with several candidates to choose from) - today this logs a [WARN] naming the best guess it made instead (choosePPD's own ambiguous return, or ResolveMacFamily's own note return), rather than pausing to ask. Not a concern at all for a catalog-indexed manufacturer/model, which is never ambiguous - MacVariantForDeploy always resolves to a real, known PPD once Model matches an index entry. A real prompt would need more than printer.Confirm's yes/no shape (a candidate-list picker), which is a real, separable piece of future work if the [WARN]-and-guess behavior turns out not to be good enough in practice.

Japan-market variants are filtered out of the index. Confirmed against real Canon PPD *NickName data that a Japan-only variant's nickname ends in a literal " JP" token, after the language token stripLanguageSuffix already strips (e.g. "...PS JP") - isJapanMarketOnly (macmodel.go) checks the raw NickName for that suffix before any other processing, at both PPD-entry loops inside indexFamilyPackage (installer-backed and loose-PPD-bucket paths), so a JP-only variant never reaches App.Models/App.DriverCandidates at all. Only takes effect on a package that's actually (re)inspected - a catalog.<manufacturer>.json already indexed before this filter existed keeps whatever it already recorded until that package changes or the catalog file is deleted/rebuilt.

Real deploy bugs found via live testing (v0.6.1)

Deploying real Canon UFR II rows live against this machine surfaced two bugs the design above didn't anticipate - both are about what happens after a package/PPD has already been correctly resolved, not about resolution itself:

  • CUPS queue names reject whitespace. lpadmin -p fails outright (Printer name can only contain printable characters) for a queue name containing a space - confirmed live against a row literally named "Copy Room". man lpadmin documents the real restriction: no SPACE, TAB, /, or #. deploy_darwin.go's sanitizeCUPSQueueName maps each of those to _ for the actual -p argument only (strings.Map) - the row's own Description (and everything else about the row) keeps the original, unsanitized name; a [WARN] logs when the two actually differ.
  • A package used to get reinstalled from scratch for every row that needed it, even multiple rows in the same deploy run needing the identical package - install_darwin.go's EnsureDriverInstalled has no existing-install check of its own, it always shells out to installer -pkg unconditionally. Confirmed live against a real Canon UFR II package that one install costs 4m38s, so 3 identical test rows cost ~14 minutes of pure redundant work - and since each install routinely outlasts macOS's own few-minutes Authorization Services cache, this was also why deploying a handful of same-manufacturer rows kept prompting for the admin password over and over instead of once. Deployer.installedThisRun (keyed by the resolved package's own path) plus ensureInstalledOnce - the new call site both resolveDriver's guess-based fallback and installVariant use instead of calling EnsureDriverInstalled directly - makes a real install happen at most once per deploy run regardless of how many rows need that same package, logging an [INFO] skip line on every row after the first.

Two smaller, frontend-only fixes landed alongside these:

  • The grid's Driver field used to flash a raw, unparsed package label ("UFRII_v10.19.25_mac") for a macModelDriven manufacturer while a technician was still typing into the Model field, before settling once a real Model was actually selected - setupCombobox's Driver auto-fill logic was wired to fire on every keystroke (onChange), which fed partial/non-matching text through DriverCandidates' own guess-based fallback. Moved to a new onCommit parameter (fired only when a value is actually committed - Enter or a dropdown pick, not every keystroke) that setupCombobox now accepts alongside onChange.
  • The Defaults panel's Subnet field had no input validation at all. isValidSubnetPrefix (frontend/src/main.js) accepts blank (stays white, not flagged) or exactly 3 dot-delimited octets (each 0-255, no leading zeros, an optional trailing dot) and flags anything else with the same yellow input-needs-value styling used elsewhere in the grid - wired on the field's own input event, same on both platforms.

Selective Canon UFR II install and one-prompt batching (v0.6.5-v0.6.9)

A real Canon UFR II install (the full installer -pkg <Distribution>.pkg -target /) took a confirmed-live 5m02s and, worse, prompted for the admin password once per row even when several rows shared the identical package. Two changes fixed this, in order:

1. Install only what's load-bearing for the one model being deployed. Inspecting a real UFR II package (pkgutil --expand, read-only, no install) found a Distribution wrapping 5 sub-packages totaling 22,900 files / ~224MB: Core (the real driver framework/backend/PDE filter binaries - 9,548 files, genuinely needed), Device (7,146 files: one PPD + one per-model "Recipe" bundle for each of 549 models the family supports - only one pair ever needed per row), and Icons/Profiles/cnaccm (cosmetic icons, ICC color profiles, the Canon Accounting Manager Client utility - 6,206 files total, none required for functional duplex/color/network/finishing- feature printing, all of which live directly in the PPD's own *OpenUI options: confirmed live that a real Canon PPD declares *CNFinisher/*CNPuncher/*CNFolder/*CNSaddleStitch/ *CNVfolding/*CNCopyTray directly). canoninstall_darwin.go's installCanonSelective now installs Core for real and cpio-extracts just the one target PPD + matching Recipe bundle out of Device's own Payload (driver.ExtractCanonDeviceFiles - three patterns: the PPD, the whole bundle tree, and a sibling Recipe/<model>.rcp symlink into it, found by diffing a real BOM, not guessed), skipping Device's installer run and Icons/Profiles/cnaccm entirely. Two real bugs surfaced getting this working, both confirmed live before being fixed: installer -pkg rejects a pkgutil --expand-produced sub-package directory outright (reproduced the identical error with no privilege at all, proving it's a format issue, not permissions) - fixed by re-flattening it with pkgutil --flatten first; and a plain cp -R src/. /Library/ fails even as root ("unable to copy extended attributes to /Library/.: Operation not permitted", since cp -R also tries to copy the source directory's own attributes onto the destination directory entry itself) - fixed with -X (don't copy extended attributes; none of these freshly-extracted files carry any worth preserving anyway).

2. Batch every row's privileged work into one elevated call per deploy run. Even after (1), each row still cost up to 2 separate native password prompts (one for its own install, one for its own queue-create) - confirmed live, repeatedly, that do shell script ... with administrator privileges never reuses a recent grant, no matter how little time passed between two separate calls. printer.BatchPreparer is an optional Deployer interface extension (PrepareBatch(ctx, reqs, confirm), checked via a type assertion in DeployAllWithProgress - Windows' own Deployer doesn't implement it, a no-op there) that macOS's own canonbatch_darwin.go uses to do a first, entirely unprivileged pass over every row - resolving each one's package, extracting its staged files, computing its queue name/device URI/print- defaults args - then combining every batchable row's own commands into one script and running it through one elevated call for the whole run, still deduplicating each unique package's own Core install. Deliberately narrow scope: only a row that resolves to a catalog-driven Canon UFR II package with no existing queue to reuse gets batched; everything else (a different manufacturer, the guess-based fallback, a loose-PPD family, or an existing queue) falls back to the old per-row path, own separate prompts included - the one path proven correct end to end across several live-tested rounds, covering every row actually tested so far. Per-row success/failure comes back through a plain results file, not by parsing the combined call's own stdout - confirmed live (twice) that do shell script silently mangles every \n in captured output to \r, and buffers a command's entire output until it fully exits regardless of how many pipe stages run inside the script, both real problems for structured multi-row output that reading a file off disk afterward sidesteps entirely. Confirmed live: 1 auth prompt for a 2-row same-package Canon deploy, both rows created successfully, print defaults correct - the accepted trade-off (every batched row's result becomes known only once the one combined call returns, not streamed in per-row as it otherwise would be) showed up in the log as a real but expected pause before either row's own result appeared, not a bug.

An earlier attempt at a different improvement - real-time phase-by-phase install progress/timing via installer -verboseR piped through the same elevated call - was built, broke three separate ways across three live tests, and was ultimately abandoned as an architectural dead end: confirmed live (via fast, harmless synthetic tests rather than more real 5-minute installs) that do shell script's own privileged-execution mechanism buffers a command's entire output until it fully exits no matter how many pipe stages run inside the script - there is no way to get genuine real-time progress or timing out of it at all. Real-time progress during a privileged operation would need a fundamentally different mechanism (e.g. an elevated script writing to a file an unprivileged goroutine tails independently, entirely bypassing do shell script's return value) - not attempted, since the actual speed fix in (1) above didn't end up needing precise timing data to justify itself.

Kyocera "Web Build" catalog, selective install, and batching parity (v0.7.0-v0.7.2)

Kyocera ships a fundamentally different macOS package shape than Canon - a "Web Build" tool generates a single Distribution wrapping 19 choices rather than Canon's fixed 5 sub-packages - but suffered the identical two problems once support was added: no catalog-driven model index at all (every Kyocera row went through the slow guess-based fallback), and a full real install still took a confirmed-live 3-5+ minutes. Brought to parity with Canon in three steps:

v0.7.0 - catalog + selective install. kyoceraDistributionPkgRefs parses the Distribution's own <pkg-ref id="..." ...>#<file>.pkg</pkg-ref> XML into an id -> sub-package map, keyed by Kyocera's own stable bundle identifiers (com.kyocera.ppd, etc.) rather than file names (which are just the build date and carry no other signal). KyoceraSelectivePackages separates one baseline PPD-only sub-package (ExtractKyoceraPPD selectively cpio-extracts just the one target model's PPD out of its Payload, never installed via installer at all) from two further choices confirmed to ship the exact byte-identical PPD set ("Duplex On"/"Net Manager On" - each just patches a default value via a slow per-file sed+cp postinstall loop, useless here since PDT sets its own defaults via lpadmin regardless) - both excluded from install and from catalog indexing (kyoceraRestrictSubPackages, otherwise every model would index 3 times over with duplicate variants). A real, previously-latent bug surfaced building this: the cpio extraction glob (macppd.go) was case-sensitive (*.ppd/*.ppd.gz only), silently dropping 336 of a real download's 460 PPDs that happened to use an uppercase .PPD extension - fixed by also matching *.PPD/*.PPD.gz, confirmed via a regression test built against a real cpio+gzip fixture archive (not synthetic bytes) that fails without the fix.

v0.7.1 - fix a real install failure found live. The first live test found 6 of 16 non-PPD sub-packages installing successfully before "Print Panel App" failed with PKInstallErrorDomain Code=120 (NSPOSIXErrorDomain Code=1 "Operation not permitted"). Root-caused (not guessed) via direct installer -verboseR -dumplog testing: it's the only one of Kyocera's real choices whose own PackageInfo declares install-location="/Applications" rather than /Library or /usr - a TCC/SIP-flavored restriction specific to this do shell script-driven elevation path (Ken's own earlier manual GUI install of the same package completed without issue). Fixed by excluding it entirely - not required for actual CUPS printing, since every functional piece (CUPS filters, PDEs, the driver Framework) targets /Library//usr and installs fine.

v0.7.2 - extend one-prompt batching to Kyocera. With the install itself fixed and fast, Ken's next live test surfaced the same problem Canon had already solved: 4 separate auth prompts for a 2-row deploy, since canonbatch_darwin.go's batching only recognized Canon UFR II-shaped packages. Refactored into a manufacturer-agnostic core: planCanonBatchRow/planKyoceraBatchRow each render their own manufacturer-specific mechanics (using exactly the same selective-install logic as the non-batched paths) down into a plain pre-rendered installScript string, and whichever planner recognizes a row's actual package shape claims it - everything else still falls back to the old per-row path unaffected. Confirmed live: a 2-row same-package Kyocera deploy (TASKalfa 2550ci

  • TASKalfa 6052ci) completed both rows' install+queue-create within the same second after a single wait for the one auth prompt.

macOS Ricoh support (v0.8.0) and multi-version driver selection (v0.9.0-v0.9.13)

Ricoh's own real macOS shape turned out different again from both Canon and Kyocera: many small, independent downloads side by side (10 real files), each covering its own small, disjoint model group - not one driver line periodically superseded. macricoh.go builds a real catalog-driven index the same way Canon/Kyocera get one, plus a content-verified extraction fallback (ppdExtractionFallback) for two real PPD-naming shapes the existing extension-based cpio glob couldn't match at all (no extension whatsoever on modern Ricoh downloads; a bare .gz with no ".ppd" anywhere on one legacy Apple-distributed bundle) - zero cost or behavior change for Canon/Kyocera, whose real PPDs still hit the fast path. Since Ricoh's own packages are tiny (confirmed live: a full installer -pkg run completes in ~20s, no Canon/Kyocera-style selective install needed), planRicohBatchRow just folds a plain full install into the existing 1-auth-prompt batching.

Checking whether two versions of the same driver could coexist the way Windows' own Kyocera handling already allows (internal/driver/candidates.go's per-version decorated Driver labels) found a real gap: macOS's newestInFamily always collapsed straight to the single newest package, silently discarding anything older. BuildMacModelIndex now indexes every compatible package per family (packagesInFamily), not just the newest, each with its own independent staleness cache (MacManufacturerCatalog.ExtraProvenance - additive, existing catalog.<mfg>.json files keep working unmodified) so an intentionally-kept older version doesn't get re-inspected on every launch. A model's Driver-dropdown label only gets decorated once a real second version exists (decorateMultiVersionLabels) - filename + the file's own modification date, not a real declared version field, which (confirmed against real Canon/Kyocera/Ricoh downloads) macOS installer packages don't reliably carry at all. A blank/ambiguous selection always still resolves to the newest version - packagesInFamily returns newest-first, so every consumer gets this for free from append order.

Confirmed live, not just against synthetic fixtures - and this is exactly why that mattered: staging a real second Canon UFR II version turned up a real, more serious bug synthetic tests alone never would have caught. The established Drivers/macOS/<Manufacturer>/<OS-version>/... convention has a technician copy the same downloaded file into several OS-version folders side by side (a real download showed up identically in 6 different folders) - before a fix, each of those 6 byte-identical copies surfaced as its own "distinct coexisting version" in the dropdown. packagesInFamily now deduplicates by (basename, size) first, the same "never silently modified in place" assumption IsCurrent's own staleness check already relies on. A second real bug (a nil-map panic on a technician's very first multi-version build) was caught by this feature's own new tests before ever reaching live verification.

v0.9.1 - a real regression Ken's own first live test of v0.9.0 found: Canon worked, but Kyocera/Ricoh's Model dropdown disappeared entirely, silently falling back to the pre-catalog guess-based behavior. Two real bugs, both confirmed against Ken's own actual (not synthetic) catalog files: every pre-v0.9.0 catalog.<mfg>.json has each entry's new SourcePackagePath field empty (didn't exist yet when written) - the new per-package cache lookup matched on it and came back empty, and an empty result was being silently trusted as a valid cache hit instead of triggering a real reindex, leaving Kyocera/Ricoh's real, untouched packages simply never looked at again. Fixed by requiring the cache lookup to actually return something before trusting it, plus making the reindex that follows correctly replace leftover pre-v0.9.0 entries rather than adding alongside them. Fixing that surfaced a second, related bug immediately: the same file copied into several OS-version folders (a real Kyocera download in 10) means the other 9 copies, each individually tracked before packagesInFamily's own deduplication existed, were never revisited once they dropped out of the current set - their stale entries lingered forever, showing every real Kyocera model with 10 duplicate "versions" of the identical download. Fixed with an explicit cleanup pass that prunes any entry/provenance for a package no longer part of a family's current set, once that family's own current packages have all been processed. Both bugs have dedicated regression tests and were confirmed live against Ken's own real, previously-broken catalog files - restored to 460 (Kyocera) and 354 (Ricoh) models with zero duplicates.

macOS: lazy zip extraction (issue #11), real Lexmark support, and OpenPrinting improvements (2026-09-15/16)

Issue #11: the eager-extract-and-never-clean-up .zip behavior described above (now fixed

  • see maccatalog.go's own table row) mirrors GitHub issue #10's own Windows-side fix, found the same way: Ken deleted the same regenerated Foo/ sibling folders by hand twice before asking why mac never got the same treatment. Unlike Windows' .inf text (readable straight out of zip bytes, no extraction needed at all until Deploy), macOS's hdiutil/pkgutil need a real file on disk, so the fix is "extract on demand into a throwaway temp directory" rather than "never extract until Deploy" - see resolveMacZipSource's own doc comment. A real regression this surfaced, caught only by tracing the code (the existing test suite stayed green the whole time): Konica Minolta's own family-classification token was a bare ".pkg" file extension, which only ever matched because the old eager extraction had already unwrapped the zip first - widened to {".zip", ".pkg", ".dmg"} once scanMacPackages started recording the outer .zip directly.

Real Lexmark support: Ken's own real download (Universal_Color_Print.pkg) turned out to be a genuine Universal Print Driver - exactly one PPD ("Lexmark Universal Color.gz", no .ppd in the name, same content-based extraction fallback Ricoh/Xerox/Toshiba/Konica Minolta already needed), no per-model *Product list the way Toshiba/Konica Minolta's own generic PDL-variant PPDs have. Still needed a full macFamilyPreference entry despite there being only one real model to index - PrepareBatch's own batching only ever recognizes a row once driver.MacVariantForDeploy resolves it to a real catalog variant, so without one, Lexmark kept falling through to the old per-row path forever, paying its own separate auth prompt every deploy (confirmed live). planLexmarkBatchRow folds a plain full install into the existing 1-auth-prompt batching, the same shape Ricoh/Sharp/Xerox/Toshiba/Konica Minolta's own planners already use.

A real, live-confirmed bug this surfaced immediately: the very next real batched deploy after Lexmark landed, Ricoh failed with installer: Error - This update requires macOS version 15.0 or earlier. - the real installer binary enforcing Ricoh's own <installation-check> version-gate predicate against a macOS release newer than Ricoh validated this specific download against. This was only diagnosable at all because of a separate fix landed the same night: PrepareBatch's own batch script now redirects each row's own subshell stderr to a per-row file (previously only an exit code ever survived - "batched install/queue-create failed (exit 1)" with zero detail on why). Tracked as GitHub issue #12 - fixed and confirmed live the same night, see the v0.9.23 section immediately below for the full story (including two more real bugs the fix itself needed before it actually worked).

OpenPrinting fallback PPDs (the community-maintained generic bucket, never a real vendor-branded driver) now carry a trailing " (OP)" marker everywhere they're shown (ppdMatchLabel) - confirmed live as a real point of confusion: a Lexmark deploy used one of these with nothing in the Driver dropdown distinguishing it from a genuine Lexmark driver. DriverCandidates also now always offers matching OpenPrinting PPDs alongside whatever real driver/catalog match already resolved, not just when nothing else is available - a technician can explicitly override the auto-resolved driver when it doesn't actually cover their printer's real model. And a row that resolves only via an OpenPrinting fallback (no real installer package at all) now also joins the shared batch (planOpenPrintingBatchRow - no install step, lpadmin -P straight against the loose PPD's own real path) instead of always paying its own separate elevated call just to run one lpadmin command.

macOS: issue #12's installer-version-gate fallback, confirmed live end-to-end (v0.9.23, 2026-09-16)

Same-night follow-through on the section above's own newly-filed issue #12. Live-inspected Ricoh's, Xerox's, and Sharp's real packages before writing any code: every one has a real PPD whose *cupsFilter line points at a vendor filter binary that installer places under /Library/Printers/<Vendor>/Filters/... - for Ricoh specifically, in a separate sub-package from the one the PPD itself lives in (ppds.pkg vs CupsFilter.pkg). A PPD-only fallback (Ken's own literal original wording) would have created a queue that looks deployed but can't actually print. DriverFootprintForVersionGateFallback (macppd.go) instead extracts every payload entry under a /Printers/ path fragment from every sub-package whose own install-location is / or starts with /Library/Printers/ - a shared, manufacturer-agnostic wrapper (wrapInstallerWithVersionGateFallback) applies it to all six "plain full install" planners plus the older non-batched fallback, guarded by OSVersionFolderAtLeast(14) (Ken's own conservative threshold) and a keyword match against the real failure text.

Genuinely lazy, not just conditional: an early version of this fix called the extraction step eagerly, at plan time, for every row whose package merely sat in a 14+-OS-version folder - confirmed live to add ~29 seconds to an 8-row batch's own planning phase (Canon and Xerox alone cost 12+ seconds each), even though only one row in that batch actually needed it. Since batching plans its whole combined shell script before the one elevated call runs, there's no way to run more Go code partway through an already-running script - the fix is a hidden self-re-exec entrypoint (PDT __macversiongatefallback, versiongatefallback_cli.go) the elevated script invokes as a subprocess, but only after installer has actually failed with a matching error. Measured: a qualifying row's own plan-time cost dropped from ~12 seconds to single-digit microseconds.

Two more real bugs found only by testing this against Ken's own real Ricoh package, in order: the extraction logic wrongly skipped a sub-package that declares no install-location at all (Apple's own installer treats that identically to an explicit /) - exactly the shape Ricoh's own real legacy bundle (RicohPrinterDrivers.pkg) uses, so the fallback found nothing to fall back to on the very package that motivated it. Once fixed, the fallback's own chown -Rh root:admin /Library/Printers swept the entire shared directory rather than just what it had extracted, and failed outright ("Operation not permitted", even running as root) on Canon's own already-installed, code-signed autoSetupTool.app sitting alongside it in the same directory - scopedFallbackChown now walks the fallback's own stage directory and chowns only the exact mirrored paths it actually placed. A real live deploy afterward installed Ricoh's own "RICOH imagio MP C7501 PS" queue successfully via the fallback path, confirming the whole chain end-to-end.

Also found and fixed the same night: Lexmark's real PPD spells its color/mono option *ColorMode (values TrueM/FalseM, via self-describing Color/Monochrome labels), not the standard *ColorModel findOption's own keyword allowlist was matching - a real deploy always warned "declares no ColorModel option" despite a driver that supports both; the existing label-matching machinery (already built for Sharp's similarly abbreviated values) needed no further change once the keyword itself was added. And build-mac.sh - wails build plus issue #9's own confirmed local-only re-sign workaround - since a plain wails build re-signs ad-hoc every time and silently re-triggers issue #9's own AMFI SIGKILL crash on the very next Deploy.

v0.9.2 - catalog.<mfg>.json now prunes a fully-removed/archived package too. Moving a real vendor package into an Archive folder (or deleting it outright) always correctly dropped it from the live in-memory model index, but left the persisted catalog file completely untouched - BuildMacModelIndex's own len(all) == 0 branch (a family with zero real packages left at all) continued straight past the pruning logic every time, never reaching it; in the worst case (a whole manufacturer's Drivers folder removed at once) the entire catalog file would have survived on disk forever, fully stale. Fixed by giving that branch the same treatment a real content change already gets - DiffModels against an empty map correctly reports every model the family previously had as removed, cat.Models's own entries for that family get dropped, and both cat.Provenance[family]/ cat.ExtraProvenance[family] are deleted outright. Confirmed live by replaying the original bug's own exact reproduction (archiving a real Ricoh package) - catalog.ricoh.json now correctly drops from 354 to 351 models and removes its own stale provenance entry.

v0.9.3 - driver resolution now filters by the current machine's own OS version. Ken's first real test of v0.9.1 found a UFR II version placed specifically for macOS 10.15 (Catalina) listed as a selectable "coexisting version" on a real macOS 26 (Tahoe) machine, with nothing distinguishing it as OS-incompatible. Investigated first: confirmed this filtering never existed anywhere, on either platform - Windows merges every version folder deliberately, and macOS's own MacPackage never recorded which OS-version folder a package came from at all; v0.9.0's dropdown just made a pre-existing, always-latent gap visible for the first time. Ken's own follow-up made clear this isn't a dropdown cosmetic fix either: PDT travels on a synced flash drive to whichever client endpoint it gets plugged into next, which may be on an older macOS release than the laptop that built the catalog - the right driver is always whichever matches the machine PDT is actually running on at that moment. filterToCurrentOSVersionFolder (macosversion.go) queries the real running machine's own macOS version live via sw_vers (never persisted, never assumed from a different machine) and parses only the leading version number out of a folder name ("26-Tahoe" -> "26", "10.15-Catalina" -> "10.15") - deliberately not a hardcoded codename table, since Apple ships a new one yearly and this project's own scaffold code already rejected that approach for exactly this reason. Falls back to the full, unfiltered set whenever filtering can't be done with real confidence (OS undetermined, filtering would leave nothing, or an unrecognized folder name) - never a hard failure. Applied to both real resolution paths, not just the dropdown: packagesInFamily/newestInFamily and the plain guess-based ResolveMac all share the same filtering step now. Confirmed live: rebuilding the real Canon catalog on this machine (26/Tahoe) now shows only the 26-Tahoe-appropriate versions, correctly excluding every older-OS-specific one Ken's own real Drivers folder had never placed a Tahoe copy of.

v0.9.4 - Apple's own Generic PostScript/PCL drivers as a real fallback. A direct follow-on from v0.9.3: Ken asked whether an OS-mismatched vendor driver could actually fail on the older endpoint (yes - a real installer OS-version check can refuse outright, or worse, a driver that "installs successfully" might not actually work), then proposed offering Apple's own generic drivers instead of either risking that or a dead-end "unavailable" state. Scoped explicitly: only ever offered when no real driver candidate exists at all - never alongside a real option, never auto-picked. driver.GenericDriverCandidates/ GenericDriverModelByLabel (macgeneric.go) wrap the two real, CUPS-bundled model strings confirmed live via lpinfo -m - drv:///sample.drv/generic.ppd ("Generic PostScript Printer") and drv:///sample.drv/generpcl.ppd ("Generic PCL Laser Printer") - part of the OS itself, not a vendor download, so genuinely OS-version-proof. DriverCandidates falls through to these only once the catalog-driven, guess-based, and OpenPrinting sources are all empty; resolveDriver recognizes an explicit selection and skips straight to queue creation with a new -m <model> mode (buildEnsureQueueArgv's third mode alongside the existing -P <path>/-m everywhere) - nothing to install, CUPS already has it built in. Required reversing filterToCurrentOSVersionFolder's own v0.9.3 fallback: when the current OS is known but nothing matches, it now returns empty instead of the unfiltered set - a confirmed-empty result is exactly what lets this new fallback trigger instead of silently risking a wrong-OS install.

v0.9.5 - real Sharp driver support (147 models). Same "inspect real files first" investigation, extended to Sharp: confirmed live against every real file across all 8 OS-version folders in Ken's own Drivers folder that only two distinct filenames ever appear - MX-C55c_2512a_MacPS.dmg (the real driver; its own sub-package holds 147 real Sharp PPDs, confirmed via *NickName, covering nearly Sharp's whole current BP-/MX- lineup) and Generic_GUC_PrinterSoftware_11202025.dmg (a Lexmark-licensed, white-labeled generic print-dialog-enhancement package referencing com.lexmark.ColorSeriesProductConfig in its own bundle list - zero real PPDs, and the exact file the old guess-based fallback was wrongly auto-populating into the Driver field, matching Ken's own earlier bug-report screenshot). Unlike Kyocera, Sharp's real driver filename never contains the manufacturer's own name, so macFamilyPreference["Sharp"] uses "MacPS" instead - a real, collision-free substring of the driver's own filename, confirmed to never match the decoy's. Every one of Sharp's real PPDs' *NickName carries a generic, non-language " PPD" suffix rather than a distinguishing family; "PPD" is listed second purely so stripLanguageSuffix strips it (the same trick Canon's own real "PPD" family already relies on), giving a clean "SHARP MX-3071S (Driver)" label. No Japan-market-only convention found in Sharp's real data, and no custom PPD-extraction fallback needed - its real PPDs are consistently .PPD.gz, found correctly by the existing extension-based cpio glob. pdtdebugmac models confirmed all 147 models correctly indexed against the real Drivers folder, with the decoy never appearing.

v0.9.6 - two real Sharp deploy bugs, both found by Ken's own first real deploy. A 2-row deploy ("zCom Two"/"zCom Three") triggered 3 separate elevated osascript prompts instead of 1 - Sharp had a real model index (v0.9.5) but no batched-deploy planner yet, so every row fell through to the old per-row path, each paying its own prompt. Added planSharpBatchRow (canonbatch_darwin.go) - the same "just fold a plain full installer -pkg run into the shared batch" shape planRicohBatchRow already uses, since Sharp's own real package installs in well under 20s, no selective extraction needed. What was RicohPPDPathForDefaults is now a manufacturer-agnostic driver.PPDPathForDefaults (macppd.go) - Ricoh and Sharp share the same extraction helper rather than duplicating it. Separately, zCom Two's own Color Mode stayed "Automatic" instead of the requested Black & White (zCom Three's own PPD is genuinely monochrome-only - *ColorDevice: False - not a bug, matching Ken's own read of it). Root- caused to two layered bugs against Sharp's real *OpenUI *ARCMode/Color Mode: PickOne block: findOption's exact-keyword allowlist didn't recognize ARCMode at all (added "arcmode"), and Sharp's own real choice values are abbreviated, non-self-describing codes ("CMAuto", "CMColor", "CMBW") whose only human-readable meaning lives in each choice's own label ("Automatic", "Color", "Black and White") - pickChoice only ever matched against the raw value. Split ppdOption's choices into ppdChoice{value, label} - value is still exactly what gets sent to lpadmin -o Key=Value, but matching now searches value and label together, so "black" in "Black and White" correctly resolves to CMBW. listPPDOptions (an already- existing queue, via lpoptions -l) has no way to recover a label at all, so it stays value- only there - unaffected for Canon/Kyocera/Ricoh, whose real values were already self- describing. Confirmed live: Ken re-ran a real 2-row Sharp deploy against the rebuilt app - exactly 1 elevated prompt for both rows, no ColorModel warning on either.

v0.9.7 - real Xerox driver support (178 models). Ken: "Let's work on Xerox" - the Drivers/macOS/Xerox folder was completely empty at first (confirmed ensureMacDriversScaffold already correctly creates the bare manufacturer folder, but deliberately never invents an OS-version subfolder itself). Created the same 11 OS-version subfolders Canon already has for both Xerox and Toshiba; real Xerox files landed across 8 of them. Confirmed live: Xerox ships exactly one real driver line, periodically superseded ("XeroxDrivers_5.6.0_2187.dmg" through "..._5.19.3_2562.dmg") - the same one-driver-line shape as Kyocera, and like Kyocera the manufacturer's own name is always in the real filename, so macFamilyPreference["Xerox"] = {"Xerox"} unlocks the catalog-driven model index the same trivial way. Xerox's real package (identifier com.xerox.drivers.pkg, install-location /) holds 178 real PPDs alongside ~6,371 unrelated files (frameworks, filters, PDE plugins, a config-utility app) sharing the same Payload - every real PPD named Xerox <model>.gz, no .ppd anywhere, the exact same real gotcha Ricoh's legacy bundle had. Generalized what was Ricoh-only (ricohPPDExtractionFallback) into a manufacturer-agnostic pathFragmentPPDExtractionFallback (macppd.go) rather than duplicating it - a real, confirmed-live bonus along the way: macOS's own cpio silently never writes out the AppleDouble resource-fork sidecar entries (._Xerox <model>.gz) sharing the same path fragment as the real PPDs, so no extra filtering was even needed. findOption now recognizes Xerox's own real ColorModel-equivalent keyword, XROutputColor - unlike Sharp's ARCMode, Xerox's own choice values are already self-describing ("PrintAsGrayscale"/"PrintAsColor"), so just the missing keyword, no label-matching gap this time. Added planXeroxBatchRow for the same shared batching Ricoh/Sharp get, flagged honestly rather than claimed as confirmed: no real Xerox deploy has run yet, and its own package is meaningfully bigger (60MB Payload, 6549 files) than Ricoh/Sharp's - closer in scale to Canon's own UFR II package that specifically needed selective install - and Xerox's own installer has no selectable choices to select down even if a full install does turn out slow. One real, confirmed-but-dormant quirk, documented but not fixed: Xerox's own *NickName bakes its driver's own version string directly into the name ("Xerox C300 Color Printer, 5.19.3") - if a technician ever keeps two different Xerox driver versions side by side in the same OS-version folder, the same physical model would register as two different friendly model names rather than two coexisting variants of one model; no real file demonstrating this combination exists yet, so left alone rather than guessed at. Confirmed live: Ken ran a real 2-row Xerox deploy against the rebuilt app - exactly 1 elevated prompt for both rows (full install completed in ~39s under that one prompt - slower than Ricoh/Sharp but still fast enough that no selective-install treatment is needed), no ColorModel warning on either row, and CUPS' own "Xerox Black and White" option showed the correct value for each row's own intended Mono setting.

v0.9.8 - real Toshiba driver support (4 generic PDL variants) plus a genuine mounting gap fixed. Ken: "Let's work on Toshiba" - the one real download placed ("TOSHIBA_ColorMFP.dmg.gz") turned out to be a plain gzip-compressed UDIF image, a shape none of Canon/Kyocera/Ricoh/Sharp/Xerox's own real downloads have: confirmed live that hdiutil attach doesn't auto-detect a bare gzip wrapper on its own ("image not recognized"), and the catalog scanner's own filepath.Ext-based check only ever saw the trailing ".gz", silently never cataloging the file as a package at all - a real gap, not a design choice, fixed by teaching mountDmg to transparently decompress a ".dmg.gz" to a temp file first (its own cleanup folded into detach, since the mounted volume needs the decompressed copy to keep existing for as long as it stays mounted) and scanMacPackages to recognize the compound suffix. Also fixed along the way: PackageLabel's own filename fallback only stripped the trailing ".gz", leaving ".dmg" in the displayed label - fixed with a second, narrowly-scoped strip specific to the ".dmg.gz" shape (never a second blind extension-based strip, which would mangle a real filename with a legitimate dot in its own version number, like Xerox's own "XeroxDrivers_5.19.3_2562.dmg"). Once mountable, Toshiba's real package turned out to be a genuinely different shape from every other manufacturer here: its own sub-package holds only 4 real PPDs total - generic PDL/controller-generation variants ("ColorMFP", "-X7", "-S2", "-CN") covering its whole e-STUDIO Color MFP line, not one PPD per specific model number. Asked Ken how the Model/Driver dropdown should handle this, since there's no way to resolve a real e-STUDIO model number against these generic names automatically - his choice: surface all 4 as selectable models directly, same as every other manufacturer. macFamilyPreference["Toshiba"] = {"Toshiba"} unlocks the catalog-driven index the same trivial way Kyocera's/Xerox's own single tokens do; findOption now recognizes Toshiba's own real ColorModel-equivalent keyword, ColorType (choices Auto/Color/Mono/Black&Red, "Mono" already self-describing); and planToshibaBatchRow folds its own install into the same shared batching - by far the smallest real package of any manufacturer here (6649 KB installed), though (like Xerox) no real Toshiba deploy has timed it live yet. Only Color MFP models are covered by the one real download placed so far - no separate monochrome-line driver exists in the Drivers folder yet. Confirmed live: Ken ran a real 4-row Toshiba deploy (one row per real PDL variant - ColorMFP, -CN, -S2, -X7) against the rebuilt app - exactly 1 elevated prompt for all 4 rows (the shared install completed in ~32s under that one prompt), no ColorModel warning on any row.

v0.9.9 - real Toshiba model numbers (136 models), not just 4 generic PDL variants. Ken added TOSHIBA_MonoMFP.dmg.gz and asked whether the Color PPDs could be used on a B&W MFD - answering that meant inspecting each PPD's own *Product lines for the first time, which turned up real per-model data v0.9.8 never looked for (it only checked *NickName, generic per file, e.g. "TOSHIBA ColorMFP-X7"). Each of Toshiba's 8 real files actually declares 9 to 29 *Product lines (128 total) naming every specific e-STUDIO model it covers (e.g. *Product: "(TOSHIBA e-STUDIO6570C)") - confirmed live that Color models always end "C"/"AC"/"CS" and Mono models never do, matching the color/mono question that started this. Ken then asked to rebuild Toshiba's catalog support around it. toshibaExpandProductEntries/ toshibaCanonicalModelName (mactoshiba.go, new file) expand each generic file-level entry into one entry per real model instead, so the Model dropdown now shows 136 real e-STUDIO numbers - the same convention every other manufacturer's own dropdown already uses. Real dedup needed: the same physical model can appear as more than one differently-spelled raw *Product line (underscore/space and an inconsistent "TOSHIBA " prefix) - toshibaCanonicalModelName normalizes and dedupes these down to one real model entry. packagePPDEntriesFilteredFallback/ indexFamilyPackage gained a third optional hook (expand) for this - and a real, confirmed-live bug along the way: the first version ran this hook in the caller (indexFamilyPackage), after packagePPDEntriesFilteredFallback had already returned, by which point its own defer os.RemoveAll(tmpDir) had already deleted every extracted PPD file, so ReadPPDProducts always failed and silently fell back to the unexpanded generic entry every time (Toshiba's own model count came back as 8, not ~136, until this was caught). Fixed by running the hook inside packagePPDEntriesFilteredFallback itself, before its own cleanup.

v0.9.10 - Toshiba's Driver field now shows the real PDL-variant name, not a repeat of the model. Ken: selecting "TOSHIBA e-STUDIO2525AC" (v0.9.9's own real model numbers) populated the Driver field with "TOSHIBA e-STUDIO2525AC (Driver)" - reading as if the driver name just repeats the model, when the real underlying file is "TOSHIBA ColorMFP-S2". Not a deploy bug (the correct file was always installed), just a real, confusing loss of genuinely useful information once a model's own friendly name IS the real e-STUDIO number. labelSuffix (macmodel.go, new) replaces the bare languageDisplayName(family) call every variant's own Label suffix used - for every manufacturer except Toshiba this is unchanged. For Toshiba specifically, it derives the real underlying PDL-variant name from the variant's own Filename instead (toshibaDriverHintFromFilename, mactoshiba.go: "TOSHIBA_ColorMFP_S2.gz" -> "ColorMFP-S2") - so the Driver field now reads "TOSHIBA e-STUDIO2525AC (ColorMFP-S2)", the real answer. Computed from Filename (already available and already persisted in catalog.toshiba.json everywhere a Label gets built) rather than threading a new field through ppdEntry/MacPPDVariant/MacCatalogVariant - no schema change needed, and the fix applies identically whether a variant comes from a fresh index build, a cached catalog reload, or the multi-version-coexistence label path (which would otherwise have silently reverted to the generic filler the moment two Toshiba package versions ever sit side by side).

v0.9.11 - Toshiba's Driver field now shows exactly what macOS itself shows, no model name at all. Ken: v0.9.10's own fix still composed the Driver field as " ()" - e.g. "TOSHIBA e-STUDIO2525AC (ColorMFP-S2)". He asked for it to stop mimicking the model at all and just show the driver exactly as it appears in macOS's own Printers & Scanners > Printer Details for that queue - the real PPD's own *NickName alone, "TOSHIBA ColorMFP-S2", nothing else appended (the model is already shown in its own separate Model field/column). macVariantLabel (macmodel.go) replaces v0.9.10's labelSuffix - instead of only computing the parenthetical half of "<model> (<suffix>)", it now builds a variant's own whole Label. For Toshiba specifically, that whole Label is just "TOSHIBA " + toshibaDriverHintFromFilename(filename) (unchanged from v0.9.10) - the model name never appears in it at all. Every other manufacturer keeps the existing "<model> (<family>)" shape. Confirmed live that this reconstructs the real PPD's own *NickName byte-for-byte for all 8 real Toshiba files. decorateMultiVersionLabels now takes an optional version-tag parameter, appended after the real driver name for Toshiba rather than after the model name - still dormant (no real multi-version Toshiba data exists yet), but consistent with the new shape. MacVariantForDeploy's own deploy-time matching is unaffected - it matches purely by exact Label string equality. Confirmed live: Ken deployed 3 real Toshiba models (one row each for the base ColorMFP, -X7, and -CN PDL variants) against the rebuilt app - each resolved to and installed from its own correct real PPD file, exactly 1 elevated prompt for all 3 rows (the shared install completed in ~22s under that one prompt).

v0.9.12 - real Konica Minolta driver support (60 models), and a real PPD-parsing bug found along the way. Ken: "Let's do Konica Minolta" - the last unexplored manufacturer. Real files needed real fixes at every layer before any catalog work could even begin. ensureMacZipsExtracted only did a single extraction pass - a real Konica Minolta download wraps two region subfolders, each holding its own inner zip wrapping the real .pkg (two levels of zip nesting, not the one level Canon's own shape needed), and a single pass never discovers a zip only created by that same pass's own extraction - silently leaving the real .pkg permanently unextracted, no error at all. Now repeats the whole walk (capped at 5 passes) until nothing new gets extracted. flattenRedundantWrapperDir required literally the only top-level entry to be the wrapper folder - a real zip's own top level also has a stray Finder .DS_Store, defeating the check; now ignores it, which uncovered a second bug in the same function (the cleanup step's plain os.Remove needs an empty directory, silently failing once a stray file remained - switched to os.RemoveAll). Most significant: ppdOpenUIRe required exactly one space between *OpenUI and the keyword - every real Konica Minolta PPD uses a literal double space (confirmed via a raw hex dump), so this matched zero options in any real Konica Minolta PPD at all - Duplex/ ColorModel would never have been set on a real deploy, with no warning either. Now matches one-or-more spaces. Once parseable: findOption gained Konica Minolta's own real Duplex keyword, KMDuplex (choices Single/Double/Booklet - its own ColorModel option is the one manufacturer inspected so far already using the plain CUPS-standard spelling); the one-sided/ two-sided want-lists grew "single"/"double", since neither existing want-list vocabulary matched these choices at all. macFamilyPreference["Konica Minolta"] = {".pkg"} - unlike every other single-driver-line manufacturer here, no real filename anywhere ever contains "Konica" or "Minolta", and no model-number substring survives across all 3 real download generations either, so .pkg is used instead (every MacPackage entry already ends in .pkg/.dmg by construction). Every real download also splits into two paper-region variants (WW_A4/A4 vs. WW_Letter/Letter - confirmed genuinely different files, not duplicates) - asked Ken which PDT should use; his call was Letter only, matching every other US-region default already established. isKonicaMinoltaA4RegionDir (mackonicaminolta.go) skips the A4 folder entirely during the scan, since the two region copies share the exact same real filename. konicaMinoltaCleanNickNames strips the generic, non-distinguishing " PS" NickName suffix every real PPD carries while deliberately preserving a real "(S)" qualifier some models carry (a genuinely different, separately-installable driver variant, not a naming difference). planKonicaMinoltaBatchRow folds its install into the same shared batching - timing not live-confirmed yet, flagged honestly since its own real package (59214 KB) is closer to Xerox's scale than Ricoh/Sharp/Toshiba's.

v0.9.13 - Konica Minolta's "(S)" duplicate models dropped entirely (60 -> 30). Ken asked what the real "(S)" PPD variant meant - answering it meant checking the real package's own Resources/en.lproj/Localizable.strings, which spells it out directly: "TITLE" = "Print (2-Sided) Driver Default", "TITLE_S" = "Print (1-Sided) Driver Default". Confirmed against the real PPD content too - for the exact same physical model, the plain PPD's own *DefaultKMDuplex is "Double" and the "(S)" PPD's is "Single", nothing else differs (same 30 real model numbers in both sub-packages, one-to-one, same underlying PDE/framework bundles). Ken's own conclusion: since PDT always sets its own explicit Duplex default on every queue it creates anyway, the "(S)" copy offers no real capability PDT doesn't already control - konicaMinoltaCleanNickNames now drops every "(S)" PPD outright during indexing (v0.9.12's own original design kept it as a separately selectable model, made before the real meaning of "(S)" was known), halving Konica Minolta's own real model count from 60 down to the 30 physical models that actually exist - confirmed live via pdtdebugmac models.

cmd/pdtdebugmac

The macOS analog of cmd/pdtdebug - catalog/models/installpkg/deployqueue commands for exercising the catalog/model-index/install/queue-creation codepaths by hand against real state, validated before the Wails UI could drive them directly. installpkg/deployqueue run real privileged commands and prompt for the admin password the same way a real Deploy does. models <driversRoot> builds the real BuildMacModelIndex and prints every manufacturer/model/variant it finds, plus what App.Models/App.DriverCandidates would actually return for a blank Model and filter - what surfaced a real, previously-shipped bug precisely: the model index itself was indexing real data correctly the whole time (641 real Canon models, confirmed live), while MacModelCandidates alone returned nothing for a blank Model (see "Model-driven PPD selection on macOS" above).

package main on darwin

app.go's Windows-only pieces are split into app_windows.go/drivercatalog_windows.go/ update_windows.go/openfolder_windows.go, each with a _darwin.go counterpart where one makes sense; spooler.go/devmode.go/sevenzip.go are renamed outright to _windows.go (Print Spooler control, DEVMODE/Device Settings capture, and the bundled-7-Zip tooling for self-extracting Windows archives all remain Windows-only - no CUPS/macOS equivalent built yet). App.Platform() (runtime.GOOS) is the frontend's one feature-detection signal, gating the reduced macOS UI described above (see frontend/src/main.js's own isMac()/state.platform).

Flash Drive/Sync are fully ported too (internal/flashdrive/flashdrive_darwin.go) - removable-drive enumeration and exFAT formatting via diskutil (its own RemovableMediaOrExternalDevice field, converted from plist to JSON via plutil for reliable parsing) and syscall.Statfs for free/total space, in place of Windows' GetDriveType/GetDiskFreeSpaceEx family.

Drivers folder layout

driver.BuildCatalog(driversRoot) (internal/driver/catalog.go) expects:

Drivers/
  Windows/
    11/                          <- any name; every folder under Windows/ is scanned and merged
      Canon/...
      HP/...
      Konica Minolta/...         <- or "KonicaMinolta" - see naming below
      Kyocera/...
      Ricoh/...
      Sharp/...
      Toshiba/...
      Xerox/...

Every folder found directly under Drivers/Windows/ is scanned and merged into one catalog - a printer driver is rarely genuinely Windows-version-specific the way it can be for macOS (see below), so there's no attempt to detect/match the running Windows version to a specific folder. Each manufacturer folder's own internal structure (multi-version, multi-arch, Archive subfolders excluded) has no required layout beyond that - BuildCatalog recursively walks every subfolder looking for .inf files, wherever they end up nested.

Manufacturer folder naming: matched against driver.Manufacturers case-insensitively and space-insensitively (foldMatchIgnoringSpaces) - confirmed necessary against the real Drivers folder, where "Konica Minolta" (this app's own display name, spaced for readability everywhere it shows up in the UI) sits on disk as KonicaMinolta, no space. Either spelling works; a folder name that doesn't fold-match any entry in driver.Manufacturers at all is silently skipped, not an error - useful if you keep other, unsupported manufacturers' packages in the same Drivers tree.

Every manufacturer is offered regardless of whether its drivers are present locally. The Defaults panel's Manufacturer dropdown (and each grid row's) lists all of driver.Manufacturers (App.Manufacturers()), same set Settings > Download Centers uses (App.AllManufacturers(), just alphabetical instead of the user's own drag/drop order there) - deliberately not filtered down to driver.ManufacturersWithDrivers, so a brand-new install with an empty Drivers folder can still pick a manufacturer and use Download Center to reach its download page (see the banner PDT shows on launch when no manufacturer has any driver present yet). Picking a manufacturer with nothing in its Drivers folder just leaves the Driver field with no candidates to offer yet - not an error, just nothing to deploy from until a driver package is downloaded there.

Back-compat: if driversRoot has no Windows subfolder at all, it's treated as the older flat layout (Drivers/<Manufacturer>/... directly) - this is what the unit tests under internal/driver/testdata/ still use, and what an old, pre-reorg Drivers folder would still work against unmodified.

.zip packages are extracted automatically (internal/driver/zip.go, ensureZipsExtracted, called before each manufacturer folder is scanned): confirmed necessary against a real package (Sharp's UD3 driver ships as UD3_07_PCL6_2510a.zip) - BuildCatalog only ever looks for .inf files already sitting on disk, so a driver that's never been extracted is otherwise completely invisible to it. Foo.zip extracts to a sibling Foo/ folder the first time it's seen; if that folder already exists (however it got there - this, or a manual extraction), it's left alone and not re-extracted. A zip that fails to extract (corrupt, or an entry that would land outside the destination folder) is skipped rather than failing the whole catalog scan, and any partial output is cleaned up so a later run - once whatever's wrong is fixed - retries instead of mistaking a partial extraction for a complete one. Kyocera no longer ships .zip packages at all (as of roughly 2026) - see "Kyocera: self-extracting .exe packages" below for how to get an equivalent already- extracted folder onto disk by hand, since there's nothing here that can extract a self-extracting .exe automatically.

Two drivers, one entry - preferring the descriptive/versioned name over a generic alias: several vendors' INFs register the exact same underlying driver under more than one friendly name - a vague, manufacturer-less alias alongside a properly branded one (Ricoh: "PCL6 Driver for Universal Print" next to "RICOH PCL6 UniversalDriver V4.45"), or a generic branded name alongside a version-numbered one (Xerox: "Xerox Global Print Driver PCL6" next to "Xerox GPD PCL6 V5.1076.4.0"; Konica Minolta: "KONICA MINOLTA Universal PCL" next to "...Universal PCL v3.9.13"). PDT always prefers whichever name is more descriptive - the one carrying the manufacturer's own brand and/or an actual version number - the same way you'd pick it by hand from the list Windows' own driver-install dialog shows for that INF:

  • A name with no manufacturer identification at all (Ricoh's vague alias) is filtered out of the catalog entirely - isUsableDriverName/vagueNameFilterBrand in catalog.go - it never appears as a selectable option at all, since nothing about it identifies which vendor's driver it even is.
  • Between two branded names that are otherwise the same driver, the Defaults panel's own pre-selected default (DefaultDriverNameFor in default.go) prefers the one carrying a version number, and - when there genuinely are multiple different versions on disk at once - the newest one. Both names stay selectable in the Driver dropdown either way; this only decides which one is pre-filled.
  • This preference is not a one-time snapshot. DefaultDriverTokens matches by token, not exact string, deliberately excluding the version number itself - so "Xerox GPD PCL6 V5.1076.4.0" today keeps resolving correctly once a newer V5.1078.x.x (or whatever the next one is called) replaces it on disk, with no code change needed. The same holds for Ricoh and any other manufacturer whose preferred driver's own name embeds a version number.
  • A newer date doesn't always mean "the newer version of the same driver." Confirmed against the real Lexmark package: alongside "Lexmark Universal v2" there's a genuinely different, more specialized "Lexmark Universal v2 XL" (an extra-large-format variant, and the one actually preferred here) built two days later - a different product, not a newer build of the other one. Lexmark's own token rule requires "XL" specifically to settle which one is meant, but for a future manufacturer with a similar surprise before its tokens get tightened the same way, DefaultDriverNameFor's tie-break still matters: when neither name carries a version number of its own, it prefers the shorter matching name (a name that's a superset of another, with an extra qualifier tacked on, is presumed to be the more specialized variant) before ever considering date - see its doc comment in default.go for the complete tie-break order.

Default driver per manufacturer (internal/driver/default.go, DefaultDriverNameFor): the Defaults panel pre-selects a specific driver name when a manufacturer is chosen, matched by token presence (case- and whitespace-insensitive, order-independent) against the real catalog rather than an exact string - confirmed necessary since vendors aren't consistent about it even within this one Drivers folder ("PCL 6" vs "PCL6", and Sharp's own driver is literally named "SHARP UD3 PCL6", tokens reversed from how "PCL 6 UD3" reads out loud). Today's rules:

Manufacturer Preferred driver Token match
Canon Canon Generic Plus UFR II UFR, II
HP HP Universal Printing PCL 6 PCL, 6
Ricoh RICOH PCL6 UniversalDriver Vx.xx PCL, 6
Sharp SHARP UD3 PCL6 PCL, 6, UD3
Toshiba TOSHIBA Universal Printer 2 Universal, Printer, 2
Xerox Xerox GPD PCL6 Vx.xxxx.x.x GPD, PCL, 6
Konica Minolta KONICA MINOLTA Universal PCL vx.x.xx Universal, PCL
Lexmark Lexmark Universal v2 XL Universal, v2, XL
Kyocera (no rule - pick per model instead; see below) -

Kyocera has no manufacturer-wide default: its driver names are per-model ("Kyocera <model> KX"), so the Defaults panel's Model field narrows the Driver dropdown instead of pre-filling one fixed name.

Am I missing any major brands? These eight cover the large majority of enterprise MFP fleets. Brother was considered and deliberately left out: it lacks a universal print driver compatible with most of its larger models, and its lineup skews home/small-office rather than the fleet-deployment scale this tool is for. Beyond that, there isn't an obvious major brand still missing - if one comes up, add a Drivers/Windows/<version>/<Manufacturer>/... folder with a real package and ask for it to be wired up (a driver.Manufacturers entry, a defaultDriverTokens rule once you know the real driver name, and a default URL in settings.go) the same way Toshiba/Xerox/Konica Minolta/Lexmark were.

Lexmark: self-extracting RAR + .msi-packaged drivers

Lexmark's package is a self-extracting RAR archive (confirmed by its Rar! signature, not a ZIP or 7z) with its actual driver files packaged inside .msi installers one level in.

The RAR layer is auto-extracted (internal/driver/sfx.go, ensureSfxArchivesExtracted) - unlike .zip extraction, this can't use Go's standard library (it has no RAR reader at all), and the one pure-Go RAR library evaluated (nwaples/rardecode) was found to silently corrupt exactly the .msi files this needs (confirmed by feeding its output to msiexec, which rejected it as an invalid package - ERROR_INSTALL_PACKAGE_INVALID). 7-Zip's own easily-redistributable "Extra" console-only package doesn't include RAR support either (confirmed directly - it errors "Cannot open the file as archive"; only the full 7z.dll does). What actually works, and is what PDT bundles: 7z.exe + 7z.dll (~2.5MB total) copied out of a full 7-Zip install with no installer needed - confirmed these two files run completely standalone. They're embedded directly into PDT.exe (sevenzip.go, go:embed third_party/7zip/...) and extracted once to %LocalAppData%\PDT\tools\7zip\ at startup; BuildCatalog then auto-detects any .exe containing a RAR, 7z, or Zip signature (isSelfExtractingArchive - a byte-signature scan, not a naming convention, so it works for a future self-extracting package from any manufacturer) and extracts it via the bundled 7z.exe - which auto-detects the exact format itself, so nothing downstream needs to know which one matched - the same skip-if-already-extracted convention as .zip auto-extraction. Confirmed live against a real self-extracting 7z package too: Konica Minolta's own driver ships as a 7z.sfx.exe stub with the real archive simply appended after it (same layout as Lexmark's RAR, different signature), extracted correctly with no Kyocera-style special casing needed (see below). Redistributing 7z.exe/7z.dll is permitted under 7-Zip's own license (LGPL + an "unRAR restriction" that only bars using the code to build a RAR compressor, not redistributing the decoder) - third_party/7zip/License.txt travels with the binaries per that license's own terms.

The .msi layer past that point is also auto-extracted (internal/driver/msi.go, ensureMsiExtracted) - unlike the RAR layer, this needs no bundled tool at all, since msiexec.exe and expand.exe are both already part of Windows itself. For every .msi BuildCatalog finds (e.g. print64PCL.msi under InstallationPackage\Drivers\x64\, reachable only after the RAR layer above has already been unpacked), it:

  1. Runs an MSI administrative install to unpack it with real filenames/paths intact - this does not install anything, it only extracts:
    msiexec /a "print64PCL.msi" /qn TARGETDIR="<sibling folder>"
    
    This alone gets the main .inf out correctly (LMUD1o40.inf, not the mangled LMUD1o40inf a plain archive-tool extraction of the .msi produces) - the .msi's own file table maps its internal mangled CAB entry names back to real ones, which only an administrative install (not a generic un-zip/un-cab tool) actually reads. The result lands under <sibling folder>\Lexmark\Lexmark Universal v2\Drivers\Print\GDI\ - the real .inf alongside its .dl_/.gd_/.gp_/.tx_/.in_/.xm_/.pn_/.ex_ compressed siblings (Microsoft's legacy single-file-compressed form) and amd64/i386 subfolders of compiled binaries the INF references.
  2. Decompresses every compressed sibling with Windows' own expand.exe, using -R (restore original name) rather than guessing the real extension from the compressed one. This matters more than it looks: the extension-to-extension mapping isn't the simple 1:1 scheme it looks like at a glance - .gd_ decompresses to .gdl and .in_ to .ini here, not to .gpd/.inf as their names suggest (there's a genuinely separate .gp_ -> .gpd pair too). An early pass at this guessed the mapping instead of asking expand.exe, and paid for it: the wrong guess silently overwrote the .msi's own correctly-extracted .inf with unrelated .ini content sharing the same assumed filename, and left a genuinely-required .gdl file missing entirely - both errors SetupCopyOEMInf/BuildCatalog tolerated quietly enough at staging time to look like success, while AddPrinter failed outright the moment something tried to actually use the driver (ERROR_CAN_NOT_COMPLETE) - a good example of why "it staged with no error" isn't the same as "it actually works." -R sidesteps the whole problem by asking expand.exe itself, which reads the real name straight out of the compressed file's own header instead of guessing from its extension.

Putting it together: drop the downloaded Lexmark_..._Installation_Package_*.exe directly into Drivers\Windows\<version>\Lexmark\ and run PDT once - the RAR layer, then every .msi inside it, extract automatically with no manual steps at all. Confirmed end to end against the real package: the resulting .inf is found and cataloged correctly, and driver install, printer creation, duplex/color, and APF all work through PDT's existing, unmodified SetupCopyOEMInf-based install path (driverinstall_windows.go - the exact same mechanism used for every other manufacturer here, no Lexmark-specific code needed).

One real surprise worth knowing about: Lexmark's package contains more than one product variant sharing a base name - alongside print64PCL.msi's "Lexmark Universal v2" there's also print64XL.msi's "Lexmark Universal v2 XL" (an extra-large-format variant), built two days later. Auto-extracting all the .msi files in the tree means both show up in the catalog, and XL is the one actually preferred here, so defaultDriverTokens["Lexmark"] requires "XL" specifically (not just "Universal"/"v2", which the base driver also matches) to select it. The base driver stays fully selectable in the Driver dropdown either way - this only decides which one is pre-filled. Finding this also exercised a real, more general tie-break question worth knowing about for any future manufacturer with a similar surprise: DefaultDriverNameFor prefers the shorter of two same-token-matching names over a merely-newer one that carries no version number of its own, rather than trusting "newest date" blindly (see its doc comment in default.go for the full tie-break order, and the README's own note in "Default driver per manufacturer" above).

Kyocera: self-extracting .exe packages

Kyocera stopped shipping .zip-packaged drivers roughly 8 months before this was written; current downloads are a self-extracting .exe that launches Kyocera's own installer UI instead of just unpacking to a folder. Three ways to get an already-extracted folder onto disk under Drivers\Windows\<version>\Kyocera\, all ending at the same result - a folder full of .inf files and friends, exactly like every other manufacturer's already-extracted package:

Method 1 - automatic (internal/driver/kyoceraexe.go). BuildCatalog now does this for you: on every PDT startup, ensureKyoceraExesExtracted looks directly inside each Drivers\Windows\<version>\ Kyocera\ folder for a .exe matching Kyocera's current naming (KXDRIVER 8.6A.1412.exe, KXDriver_8.6.1022.exe, etc. - kyoceraExeNameRe, case-insensitive), pulls the version token out of the filename, and - unless a sibling folder's name already contains that same version token - runs the identical two-stage 7-Zip extraction Method 2 describes by hand, landing the result in a new KXDriver_<version> folder right next to the .exe. So the real manual step, in practice, is just: drop the freshly downloaded .exe directly into the right Drivers\Windows\<version>\Kyocera\ folder and start PDT once - no scratch folders, no running the installer. Uses the same bundled 7z.exe as the Lexmark/Konica Minolta self-extracting-archive auto-extraction (ensureSfxArchivesExtracted) and is equally a no-op if driver.SevenZipPath isn't set (go test, pdtdebug, or extraction failing for that one package never blocks the rest of the catalog scan - the same "best-effort, clean up and retry next time" convention every ensure*Extracted helper in this package already follows). Methods 2 and 3 below remain useful as a manual fallback (a driver naming variant the regex doesn't recognize, or wanting to inspect the raw extraction yourself).

Method 2 - extract with 7-Zip by hand, no installer run at all. A Kyocera "self-extracting" .exe is actually a normal PE executable with a large embedded archive resource; 7-Zip can pull that resource out directly without ever launching the installer:

  1. Make a scratch folder (e.g. Downloads\temp) and copy the downloaded .exe into it (e.g. KXDRIVER 8.6A.1412.exe).
  2. Right-click it -> 7-Zip -> Extract to "foldername". This produces a handful of files, one of which - always named .text - is many times larger than the rest (hundreds of MB): that's the embedded archive itself, and everything else in that first extraction is installer scaffolding to discard.
  3. Move just the .text file into a second, empty scratch folder (keeps its contents from mixing with the first extraction's leftovers) and 7-Zip-extract it too. This second extraction is the real driver data - Setup.exe, KmInstall.exe, a 32bit/64bit/arm64 split, Document, MetaData, etc.
  4. Rename that folder to something version-identifying (e.g. KXDRIVER_8.6A.1412) and move it into Drivers\Windows\<version>\Kyocera\.

Method 3 - let the installer extract, then take its temp copy before it does anything else.

  1. Run the downloaded .exe. When Kyocera's "Product Library" installer window appears, stop - don't proceed with the install.
  2. Open File Explorer and go to %LocalAppData%. Find a KX Driver folder, and inside it an originalfiles folder (there's also an originalfiles.zip alongside it - the folder, already extracted, is the one you want).
  3. Copy originalfiles into Drivers\Windows\<version>\Kyocera\ and rename it to something version-identifying (e.g. KXDRIVER_8.6A.1412).
  4. Exit the Product Library installer without installing anything.

A few things worth knowing before relying on either manual method:

  • The installer's own temp extraction (Method 3) has been observed to survive under %LocalAppData% even after exiting the installer without installing - useful, since it means you can grab a copy after the fact if you forgot to before closing it, but not guaranteed to hold true for every Kyocera installer version; if KX Driver isn't there, you'll need Method 2 instead, or to retry Method 3 and copy the folder out before exiting the installer.
  • Installers generally extract to %LocalAppData%\Temp, not %LocalAppData% itself, so if a future Kyocera installer version relocates this, checking under Temp first (or using Sysinternals' Process Monitor to watch what the installer actually writes and where) is the way to re-find it.

macOS is not read by this function at all - BuildCatalog only ever reads the Windows side; the macOS side of the Drivers tree has its own scanner now (driver.BuildMacCatalog, see "macOS support" above), and nests the other way - Drivers/macOS/<Manufacturer>/<macOS version>/... (version under manufacturer, not manufacturer under version like Windows) - reflecting that macOS driver packages genuinely do vary by OS release in a way Windows ones generally don't. There is also a Drivers/macOS/OpenPrinting/<Manufacturer>/*.ppd bucket (flat, no version breakdown) as a fallback source for manufacturers/models with nothing better - both are described in full in "macOS support" above, including how .dmg/.pkg extraction actually ended up working out (mounting via hdiutil, no bundled tool needed - simpler than the Windows side's own 7-Zip/msiexec/expand.exe pipeline, since macOS packages need no pre-extraction at install time at all).

ensureMacDriversScaffold (driversfolder.go), the darwin analog of ensureDriversScaffold above, runs at startup too, but isn't a straight port - it only ever ensures a bare Drivers/macOS/<Manufacturer> folder exists for every entry in driver.Manufacturers (there's no one macOS version it could hardcode the way Windows/11 is hardcoded above, since macOS driver packages genuinely do vary by release) and retroactively backfills Archive/README.txt into whatever version folders already exist under each manufacturer - it never invents a version folder itself.

Deploy sequence (internal/printer/windows/deploy_windows.go)

Deployer.Deploy ports Create-Printers.ps1's Deploy-PrinterRow, in the same order (port -> driver -> printer object -> conditional rebind -> print config -> APF), with one deliberate simplification versus the original: there is no BindNulPort checkbox. Instead:

  • Typing NUL (or NUL:) as a row's IP permanently binds it to the local NUL: port (no network I/O at all) - for a placeholder/test row.
  • Any row whose resolved driver is HP's Universal Print Driver family, or whose manufacturer is Kyocera (any driver), automatically gets the create-against-NUL:-then-rebind-to-the-real-port treatment with no user toggle - printer.RequiresNulPortWorkaround - since both have been directly observed to take noticeably longer to create against a live TCP/IP port than against NUL: (HP's Universal family: several minutes vs. a few seconds).

Fatal vs. warning, per spec: creating or updating the printer object itself (port resolution, driver install, CreatePrinter/SetInfo2, the NUL:-to-real-port rebind) is the one thing that matters most - failing any of those steps is fatal for that row only (DeployResult.Err, logged [ERR]) and printer.DeployAll always continues to the next row regardless. Everything after the object exists - duplex/color, advanced printing features, "Print spooled documents first" - is best-effort: a failure there is a [WARN], never stops the row, and all of them are always attempted even if an earlier one already failed.

Duplex/color is set two ways, deliberately - SetDuplexAndColor (raw DEVMODE) and SetPrintConfigurationViaShell (shells out to Set-PrintConfiguration). Confirmed necessary against a real Canon UFR II printer: DEVMODE alone was already correct (verified via .NET's own PrinterSettings, independent of this tool), yet the printer's own Properties dialog and Get-PrintConfiguration still showed the opposite settings - they read from a separate PrintTicket-based store DEVMODE never touches, apparently left at the driver's install-time default until something updates it explicitly. Both calls target the same end state, so this is redundant on drivers where DEVMODE alone would have been enough - the cost is one extra powershell.exe launch per row, worth it since there's no way to tell in advance whether a given driver needs it.

"Print spooled documents first" (SetPrintSpooledDocumentsFirst, PRINTER_ATTRIBUTE_DO_COMPLETE_FIRST) is enabled unconditionally for every row - unlike APF, there's no per-row checkbox for it; it's just a fixed default every deployment gets.

Every log line is timestamped and level-tagged (internal/printer/log.go's Logger: [INFO]/[OK]/[WARN]/[ERR]) so the UI can show a row's progress directly with no further formatting.

Driver version handling

  • Plain name selection (the common case): silently upgrades an older installed driver to the newer local package; never silently downgrades.
  • An explicit version pin (one of the driver catalog's <name> (vVersion - date) labels): honored upgrade or downgrade, but only after a confirmation dialog, since Windows shares one driver registration per name across every printer already using it. Declining leaves the currently-installed version in place ([WARN], not fatal).

Existing printer objects

If a printer with the row's exact name already exists, its current driver/port/comment are diffed against what this row would set; a confirmation dialog lists exactly what would change (any combination of the three, or none - no prompt at all if nothing would actually change). Declining leaves it completely untouched; print configuration and APF still apply independently either way.

Port resolution / UseExistingPort

Every row first checks whether a Standard TCP/IP port already targets its IP (registry-based, portlookup_windows.go) and reuses it if so - this also doubles as the fix for ever creating a genuine duplicate port for a host that already has one, e.g. on redeploy. Otherwise a new port is created, named <prefix><ip> (or just <ip> with no prefix set), with a numeric suffix appended only if that exact name is already in use by a different host - including when UseExistingPort is checked but no existing port targets the IP: that's a fallback to creating one, not a failure.

Frontend (app.go, frontend/src/)

Plain HTML/CSS/vanilla JS (no framework) - a single-page grid mirroring the original tool's layout: top bar (SalesChain ID, Open/Save Configuration), a Defaults panel (Manufacturer/Model/Driver, then the Port / Print Defaults / Advanced subsections, all used by "Add Printer"), a toolbar (Add Printer/Remove Selected on the left, New CSV/Import CSV, then Deploy on the right), the row grid itself, and a live log panel.

Model and Driver are a small custom combobox (setupCombobox() in main.js), not a native <input list=...><datalist> - datalist only offers suggestions once the user starts typing (no "click to see everything available" the way the original WinForms ComboBox did) and its filtering is inconsistent across browsers, so it didn't actually deliver the original "type to filter" feel. Every instance (Defaults panel and each grid row) owns its own DOM elements and closure state, so - unlike the WinForms DataGridView bug that forced a full rewrite of the original tool's grid, rooted in cells sharing one live editing control - there's no shared state for one row's combobox to leak into another's. Model candidates are filtered client-side (substring match) against Models()'s full per-manufacturer list; Driver candidates are forwarded straight to DriverCandidates(), which already fuzzy-filters/ranks server-side.

app.go's App struct is the only thing the frontend talks to (Wails auto-generates frontend/wailsjs/go/main/App.d.ts/.js from its exported methods on every wails build/wails dev

  • regenerate with wails generate module after changing that struct's method set). Two things shaped its design:

  • Wails only supports a bound method returning (T) or (T, error) - never more outputs, and anything else is silently dropped - so every method that can both fail and needs a second signal (a canceled file dialog, a row's error message) wraps its result in a small DTO (PathResult, ImportResult, DeployRowResult, ...) instead.

  • A bare Go error value doesn't JSON-marshal its message at all (most concrete error types have no exported fields - it would cross the wire as {}), so DeployRowResult.Error is a plain string ("" on success), never a Go error.

Deploying streams live progress: Deploy emits a "deploy-progress" event (one DeployRowResult) after each row finishes - necessary since a single HP row alone can run for minutes even with the NUL: workaround declined - in addition to returning every result once the whole run completes. Confirmation dialogs (driver-version change, printer update) are native OS Yes/No message boxes (runtime.MessageDialog), the same kind of blocking modal the original tool used (a WinForms MessageBox), just through Wails' cross-platform equivalent. The driver catalog is built once at startup from a Drivers/ folder next to the running executable (falling back to ./Drivers under the working directory for wails dev).

The grid never does a full-table re-render while a deploy is running or while progress events are arriving - only the specific row a progress event is about gets its success/failure class toggled, by a stable per-row _id rather than by array position or by name (which the tool has never required to be unique). A full re-render there would destroy focus and in-progress edits in any other row the user might be editing while a multi-minute deploy is still running elsewhere in the grid.

The log panel has its own right-click context menu (Select All / Copy / Clear Log, wireLogContextMenu() in main.js) rather than the browser/webview's native one - positioned at the cursor via the contextmenu event, dismissed on an outside click or Escape, the same transient-popup shape the Spooler dropdown and Model/Driver combobox already use elsewhere in this file. Select All/Copy always act on the log's own full text (state.logLines joined the same way renderLog itself joins them), not whatever happened to be selected when the menu was opened - closer to what a technician pasting a log into a support ticket actually wants. Copy tries the async Clipboard API first, falling back to a hidden-textarea execCommand('copy') for a webview context where that API might be restricted.

Settings (gear icon, top-right)

A modal with four tabs: General (Save File Base Path, plus a drag-and-drop Manufacturer sort order list - see below), Download Centers (one editable URL field per manufacturer - every manufacturer PDT knows about, not just ones with drivers currently on disk; see "Drivers folder layout" above - seeded with defaultManufacturerURLs in settings.go, saved together with the rest of Settings), Cloud Sync (R2 bucket connection details - see below), and About (version, author, a clickable GitHub link, and Check for Updates - see below). The modal is a fixed size regardless of which tab is showing or how many manufacturers there are - both Download Centers and the sort-order list scroll internally rather than growing the window once they're taller than that fixed size. The Defaults panel's own Download Center button (a different one - printer driver updates, not app updates) opens the currently-selected manufacturer's configured URL in the system browser (OpenManufacturerURL -> runtime.BrowserOpenURL) - no vendor exposes an API to actually check the latest driver version, so this only ever hands a human the page to look at themselves; true automated version-checking would mean scraping each vendor's download portal individually; fragile, and high-maintenance per vendor, so deliberately out of scope here.

Manufacturer sort order (Settings > General)

A plain HTML5 drag-and-drop list (no external library) of every manufacturer PDT knows about, persisted as Settings.ManufacturerOrder (settings.go) and applied by App.Manufacturers() (applyManufacturerOrder in app.go) - this is what actually controls the order of the Manufacturer dropdown in both the Defaults panel and every grid row (both read from the same state.manufacturers in the frontend). Saving triggers refreshManufacturerDropdowns(), which re-fetches the reordered list and rebuilds every already-rendered Manufacturer <select>'s options in place - the Defaults panel's and each existing grid row's - preserving each one's current selection rather than resetting it.

Settings > Download Centers is deliberately exempt - it's always alphabetical (App.AllManufacturers() sorts it every time), regardless of this custom order, since its job is finding a specific manufacturer to edit a URL for, not deployment convenience.

reconcileManufacturerOrder (settings.go) keeps the saved order valid across changes to driver.Manufacturers itself: on load and on every save, it keeps the user's own ordering for manufacturers still present, drops any name no longer recognized, and appends any manufacturer not yet in the saved order (freshly added to driver.Manufacturers, or never dragged by this user) at the end

  • so adding a ninth/tenth manufacturer later never causes it to silently disappear from either the reorder list or the dropdowns.

Cloud Sync (toolbar icon, Settings > Cloud Sync, internal/cloudsync, cloudsync_app.go)

A shared Cloudflare R2 bucket every technician's own PDT syncs its Drivers folder against, so a driver package one technician downloads becomes available to everyone else without a flash drive physically changing hands - the multi-technician equivalent of flash-drive Sync (see "Which drivers should I use?" above), between a laptop and a bucket every technician reads from and writes to, rather than between a laptop and one specific plugged-in drive. R2 was chosen specifically for its zero egress fee - with several technicians repeatedly pulling a growing, multi-GB shared catalog, egress (not storage) is where a conventional S3-compatible provider's real cost would show up.

Settings > Cloud Sync holds the connection details: Endpoint, Bucket, Folder (the prefix within the bucket PDT is scoped to - "Drivers/" for Ken's own bucket), Access Key ID, and Concurrent Transfers (see below). The Secret Access Key is the one field handled differently - it's written to the OS keychain (internal/cloudsync/keyring.go, github.com/zalando/go-keyring) via a write-only Settings.CloudSyncSecretKey field that's never persisted to the plaintext settings.json and never echoed back to the UI once saved (leaving it blank on a later Save keeps whatever secret is already stored, rather than clearing it) - the one credential in this whole app worth that treatment, since it grants write access to a bucket every technician shares.

The toolbar's Cloud Sync button (vertical bidirectional-arrows icon) opens a tree view built client-side from GetCloudSyncPlan's own flat list of relative paths (internal/cloudsync/plan.go's BuildPlan, which diffs the local Drivers folder against the bucket by size alone - the same size-as-proxy-for-identical reasoning copyTreeMerge already uses locally, see its own doc comment). Every path lands in one of four buckets: upload (local only), download (bucket only), synced (present on both sides, same size - nothing to do, not shown in the tree at all), or conflict (present on both sides with different sizes - never auto-resolved in either direction, since guessing wrong on a shared bucket could destroy another technician's real content; surfaced in the tree with no checkbox at all, left completely alone until resolved by hand). Sync is additive-only - a file missing on one side only ever triggers a copy, never a deletion, so no technician's local mistake (or a bug) can cascade into removing the shared bucket's content for everyone else.

The tree's checkboxes are per-file and tri-state per-folder; the selection persists per-technician across sessions (cloudsyncstate.go's own Deselected set - everything not explicitly unchecked defaults to selected, so a brand-new file nobody has looked at yet still syncs by default). A file the tree hasn't shown this technician before is highlighted (amber, plus a small dot) until the next time the tree is opened, then clears for good (cloudSyncState.Seen).

Transfers are genuinely resumable, not just retried from scratch: files at or above 32MiB (cloudsync.MultipartThreshold) use real S3 multipart upload, tracking which parts already landed on the server (internal/cloudsync/transfer.go's resumeOrCreateUpload/ listAndValidateParts) so an interrupted upload - Cancel, a lost connection, or PDT simply being closed mid-transfer - continues from the parts already there rather than from byte zero (a mismatch against what the current local file would produce, e.g. because it changed since the interrupted attempt, is detected and the stale upload is abandoned rather than trusted). Downloads resume the same way via an HTTP Range request against a .pdt-partial sidecar file. Pause (cloudsync.PauseGate) blocks between reads without losing any already-transferred bytes - Resume continues mid-file, not just mid-batch. Cancel is immediate and destructive on purpose: deletes a download's own partial file and aborts an upload's own incomplete multipart upload on the bucket (R2, like S3, bills for abandoned multipart storage left sitting there otherwise).

Concurrent Transfers (Settings > Cloud Sync, default 3) bounds how many files transfer at once - implemented as a semaphore in SyncCloud (cloudsync_app.go) whose slot is released the moment a file reaches 95% done, not strictly at 100%, so the next queued file starts while the outgoing one is still finishing its own last few percent (often mostly finalization overhead - a multipart CompleteMultipartUpload round trip, a final rename) rather than a lane sitting idle waiting that out first. Actual simultaneous transfers can therefore transiently run a little over the configured count right at a handoff.

The progress dialog shows a rolling single "spotlight" file (the longest-running transfer still in progress) with its own full path/big bar/per-file ETA, everything else concurrently transferring or still queued below it (concurrent ones get their own small inline bar), and the whole batch's combined progress/transfer-rate/ETA at the bottom - driven by two Wails events, cloudsync-progress (one file) and cloudsync-total-progress (the whole run), both throttled to 150ms like flashcopy-progress already is.

Original file timestamps survive the round trip - S3-compatible storage otherwise always stamps an object's LastModified as upload time, not the source file's own mtime, which would make every driver package downloaded by another technician look freshly created today. Each upload attaches its own mtime as custom object metadata (x-amz-meta-mtime, internal/cloudsync/cloudsync.go's mtimeMetaKey); each download reads it back and restores it locally via os.Chtimes. copyFile (copytree.go) does the same for flash-drive Sync/Write to Flash Drive, which had the identical gap before Cloud Sync's own version of it prompted the fix.

Checking for and applying app updates (internal/update, About tab)

Unlike driver updates above, PDT's own updates genuinely can be checked and applied automatically, since this project's GitHub Releases are under our own control: About's Check for Updates queries GET /repos/keteague/PDT/releases/latest and compares the release tag against AppVersion (numerically, via driver.CompareVersions - a generic comparator despite living in the driver package). If newer, Update Now downloads that release's PDT.exe asset and installs it in place of the running executable, then relaunches it - see internal/update's doc comment for how that works with no separate installer: Windows lets a running executable's file be renamed out of the way while it keeps running from the renamed file, which is enough to drop the new exe in at the original name; the process finishes on its own a moment later and its renamed-away .old file is cleaned up the next time the app starts.

For this to find anything, a release actually has to exist: bump AppVersion (version.go) and wails.json's info.productVersion together, then push a tag v<AppVersion> (e.g. v0.9.27). .github/workflows/release.yml takes it from there - it refuses to run at all if the tag doesn't match the VERSION file, then builds both platforms (Windows: wails build + the Inno Setup installer; macOS: wails build, ad-hoc signed only - see the workflow's own comments on why build-mac.sh's extra signing step can't run in CI) and creates the GitHub Release itself, with PDT.exe uploaded under that exact name (the one CheckForUpdate looks for), alongside the Windows installer and a zipped PDT.app.

Keeping the bundled 7-Zip up to date (sevenzip.go, About tab)

About also credits 7-Zip (by Igor Pavlov) - the tool bundled to auto-extract self-extracting RAR/7z/Zip driver packages, see "Lexmark" above - and shows the version currently cached (GetSevenZipVersion, parsed from 7z.exe i's own startup banner). Check for 7-Zip Updates queries 7-Zip's own GitHub Releases (development now lives at ip7z/7zip, not 7-zip.org directly) via the exact same internal/update.FetchLatest PDT's own update check uses - the package was already generic enough that checking a different project's releases needed no changes to it beyond adding Release.AssetMatching(re), since 7-Zip's own release asset names embed a version number ("7z2603-x64.exe") that can't be looked up by exact name the way PDT's own "PDT.exe" can.

Update 7-Zip Now does exactly what was tested by hand first: downloads that release's x64 GUI installer, then uses the currently cached 7z.exe to pull 7z.exe/7z.dll/License.txt back out of it directly - confirmed the installer is itself an extractable 7-Zip archive, no need to actually run it as an installer - and overwrites the cached copies with the result. Extracts to a scratch folder first and only overwrites the real cached files once that fully succeeds, so a bad download or a failed extraction never touches the existing, working files. Verified end to end against the real, live release.

App identity (titlebar, icon, version)

version.go holds the single source of truth for the app's display name, version, author, and repo URL (AppVersion must be kept in sync by hand with wails.json's info.productVersion, since Go can't read that value back out of the compiled resource at runtime). The titlebar (main.go) reads "Printer Deployment Tool v0.1.0" from these same constants, and Settings > About displays them directly. build/appicon.png and build/windows/icon.ico (the titlebar/File-Explorer icon) are generated by cmd/geniconassets, a small standalone tool that draws the printer-glyph icon procedurally - re-run go run ./cmd/geniconassets if the icon design itself ever needs to change.

Startup readiness (App.ready)

Every method reading catalog/modelIndex/settings blocks on <-a.ready (closed once startup finishes populating them) before proceeding. This turned out to be a real bug, not just defensive coding: Wails does not block the frontend's own script from running until OnStartup returns, and BuildCatalog scanning a real Drivers folder - particularly with zip extraction added - easily takes longer than the frontend needs to fire its first catalog-dependent call (DefaultDriverFor on page load), which was silently seeing catalog/modelIndex still at their nil zero value and returning empty/wrong results with no error at all.

On-launch scan skips extraction on removable media (Windows)

loadCatalog (app_windows.go) calls driver.BuildCatalogNoExtract instead of driver.BuildCatalog whenever this exact running PDT.exe sits on a removable drive (flashdrive.IsRemovableDrive) - BuildCatalogNoExtract does the same .inf scan but never runs any of the ensure*Extracted helpers (internal/driver/catalog.go). Confirmed live: the extracting scan popped up a visible expand.exe console window per Lexmark .msi and could take a long time over USB 2.0, even when every archive on the drive was already extracted. This assumes the field workflow described under "Write to Flash Drive"/Sync below - flash drives get a physical write-protect switch and are only ever written to from a technician's local install, which always runs the full extracting BuildCatalog (postSyncDriversHook) - so a USB-run copy can safely skip straight to reading already-extracted .infs. A local/fixed-drive install is unaffected either way.

Building / testing

go build ./...
go vet ./...
go test ./...

wails dev / wails build build the actual application (see wails.json); the Go backend above has no dependency on the frontend and is fully testable on its own.

Building the installer (installer/pdt.iss)

wails build
iscc installer\pdt.iss

Requires Inno Setup 6 (iscc.exe on PATH, or invoke it by full path - winget install JRSoftware.InnoSetup is the fastest way to get it). Produces build\bin\PDT-Setup-<version>.exe. Deliberately packages only PDT.exe itself - no Drivers folder, which would bloat the installer for no benefit (driver packages are hundreds of MB each; see "Drivers folder layout" above) since PDT already scaffolds an empty Drivers\Windows\11\<Manufacturer>\ structure on first launch regardless (ensureDriversScaffold, driversfolder.go) ready for a technician to drop real packages into. Version numbering has one canonical source, the repo-root VERSION file: version.go embeds it directly (go:embed), and installer/pdt.iss reads it at compile time via its own preprocessor (FileOpen/FileRead) - a version bump only needs to edit VERSION itself. The one holdout is wails.json's info.productVersion (used for the compiled exe's own Win32 version resource) - Wails has no mechanism to read it from elsewhere, so it still needs updating by hand to match VERSION on every bump.

Installing PDT

The installer is elevation-optional by design (Inno Setup's PrivilegesRequired=lowest + PrivilegesRequiredOverridesAllowed, plus DefaultDirName={autopf}\PDT - {autopf} is Inno Setup's own elevation-aware constant, resolving differently depending on how the installer itself ends up running): double-clicking it normally runs unelevated, no UAC prompt, installing to %LocalAppData%\Programs\PDT. Explicitly choosing "Run as administrator" instead installs to %ProgramFiles%\PDT. Neither choice affects whether PDT itself elevates once installed - see this README's very first section: PDT.exe's own manifest always requests Administrator on launch, regardless of which folder it's running from.

Either way, PDT's Drivers and Configs folders (Settings.DriversBasePath/SaveFileBasePath, settings.go) default to %LocalAppData%\PDT\Drivers / %LocalAppData%\PDT\Configs - not wherever the executable itself landed - since %ProgramFiles% isn't writable by an ordinary user, and using the same location either way means both install modes behave identically once PDT is actually running (a technician can drop a new driver package into %LocalAppData%\PDT\Drivers from an ordinary, non-elevated Explorer window regardless of which mode PDT itself was installed in). Both paths are editable in Settings > General if a different location is ever needed - Drivers Base Path takes effect after restarting PDT (BuildCatalog only scans once, at startup); Configuration Files Base Path takes effect immediately.

This default-path detection is shared with the portable/flash-drive case (see "Write to Flash Drive"): defaultDriversBasePath/defaultSaveFileBasePath (settings.go) both check for a real, already- populated Drivers folder sitting next to the running executable first - true for a flash drive with Drivers/Configs already on it, false for a freshly-installed copy - before falling back to %LocalAppData%\PDT.

The installer is unsigned (no code-signing certificate) - Windows SmartScreen will likely show an "unrecognized app" warning on first run ("More info" -> "Run anyway"). This is expected for a small internal tool without a paid signing certificate, not a build error.

Which drivers should I use?

I tried to make PDT as future-proof as possible when it comes to new drivers. Every manufacturer packages their drivers their own way, and usually has more than one driver package to choose from. I use generic/universal drivers wherever possible - but on macOS, most drivers are model-specific. That said, here's what to grab when you go looking for updated drivers, to have the best success with PDT.

To start: the Defaults section of the main PDT window has a Manufacturer dropdown with a Download Center button next to it. Clicking Download Center takes you straight to the selected manufacturer's download page. Those URLs are configured in Settings > Download Centers

  • the goal is just to make it easy to get to the right starting point.

Canon

  1. From the Canon download page, search for any major MFD model (e.g. imageRUNNER ADVANCE DX C5840).
  2. From that MFD's info page, click Software & Drivers.
  3. Select your OS and version.
  4. For Windows, get all 3 Generic_Plus_[UFRII|PCL6|PS]_vX.YY.zip files. For macOS, get the UFRII_vXX.YY.ZZ_mac.zip file, the PS_vX.YY.ZZ_mac.zip file, and the PPDvX.YY_mac.zip file.

Kyocera

  1. From the Kyocera Download Center page, search for any major MFD model (e.g. MZ6001ci).
  2. From the MFD's download page, select your OS.
  3. For Windows, download the KX driver package (e.g. "KX Print Driver (8.6A.1412)"). For macOS, download the Mac Print Driver package (e.g. "Mac Print Driver (6.4)") - it contains a full set of KPDL drivers.

Ricoh

Windows: there's a Universal Print Driver link under the red rectangle where you can download the PCL6 Driver for Universal Print. This is a Type 3 driver, and it's what I recommend you use. Don't confuse it with the PCL6 V4 Driver for Universal Print, which is a Type 4 driver - you're welcome to try the V4 driver, but it isn't tested or supported by PDT.

macOS: it's a lot more convoluted, since you need model-specific drivers and each one only supports a small range of MFDs.

  1. Search for your specific model - you'll be taken to its download page.
  2. Download the "PPD Installer" for your OS version. In my own testing, Ricoh appears to use the same PPD Installer for macOS v13, v14, v15, and v26, and I've been copying the same installer .dmg file into each of those OS-version folders under PDT's own Drivers folder (more on that below). macOS v27 will be out very soon and I'd expect the same driver to work there too. If you're on a Mac running something older than these, your best options are Ricoh's own Generic PS Driver (available through PDT), Apple's built-in Generic PostScript Driver or Generic PCL Driver (comes with macOS itself, not installable through PDT), or an IPP port with the classless "Everywhere" driver (also not installable through PDT).

Sharp

Download the latest UD3 PCL driver.

Toshiba, Xerox, Konica Minolta, Lexmark, and HP

All of these use a universal driver.

Where do I put these downloaded driver files?

PDT has a Drivers repo. When you (the technician) install PDT on your own laptop, the idea is that you maintain the Drivers repo from that laptop - not from the portable flash drive. The workflow is:

  1. From your local (laptop) copy of PDT, download any new drivers.
  2. Copy them into the matching folder in the Drivers repo. For example, a new Canon Generic Plus UFR II driver's .zip file goes to:
    • Windows: %LocalAppData%\PDT\Drivers\Windows\11\Canon
    • macOS: ~/Library/Application Support/PDT/Drivers/macOS/Canon/26-Tahoe
  3. In PDT, click the Rescan button at the top (two arrows in a circle). This tells PDT to look for and process any new drivers. On macOS, that includes updating the catalog.<mfg>.json file that lists every known driver and which file it came from - these catalog files live under Drivers/macOS/<mfg> (e.g. Drivers/macOS/Canon/catalog.canon.json).
  4. Insert your flash drive and click the Sync button (two arrows pointing in opposite directions). This syncs your local Drivers repo onto the flash drive.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages