PrinterInstallerMap: add map wizard page (TBitmapImage hotspots)

Implements the click-to-select site map UI on a custom Inno wizard page,
replacing the blocked Edge .bat-download flow.

- New record fields MapLeft, MapTop on PrinterDataArray; QueryPrinters
  pulls them from the api_printers.asp JSON via the existing
  JsonGetString helper.
- BuildMapPage creates a custom wizard page (CreateCustomPage), places a
  stretched background TBitmapImage with the bundled site map BMP, then
  overlays one TBitmapImage per printer with a non-empty mapleft+maptop.
  Each hotspot is a 14x14 square with its own OnClick.
- MapHotspotClick toggles PrinterSelectionPage.Values[I] for the linked
  printer and swaps the hotspot bitmap (gray<->lime) to reflect the new
  state. Two shared TBitmaps are pre-rendered once via TCanvas.
- PrinterSelectionPage is re-anchored to PrinterMapPage.ID so the
  selections from the map flow into the existing checkbox page where the
  user can review and adjust.
- ShouldSkipPage skips the map page when no printers have map coords,
  preserving legacy behaviour if api_printers.asp is not yet extended or
  if the database has no coordinates set.
- Bundled sitemap2025-dark.bmp (880x680, ~1.7 MB) was scaled down from
  shopdb's sitemap2025-dark.png via PIL (Inno's TBitmap.LoadFromFile is
  BMP-only).

Untested. Needs Inno Setup 6.x compile on Windows + the api_printers.asp
extension to return mapleft, maptop. README documents the privileges
model (admin required, supportuser at UAC) and the Inno class-subset
gotchas verified during design.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-04-30 07:43:54 -04:00
parent ce0abee262
commit 1d044cbbdf
3 changed files with 211 additions and 10 deletions

View File

@@ -53,16 +53,43 @@ Source: "drivers\xerox_x32\*"; DestDir: "{tmp}\xerox_drivers_x32"; Flags: ignore
; Brother MFC Printer Driver (multi-architecture package) ; Brother MFC Printer Driver (multi-architecture package)
Source: "drivers\brother\*"; DestDir: "{tmp}\brother_drivers"; Flags: ignoreversion recursesubdirs Source: "drivers\brother\*"; DestDir: "{tmp}\brother_drivers"; Flags: ignoreversion recursesubdirs
; Site map for the map-UI wizard page (scaled-down BMP of shopdb's
; sitemap2025-dark.png; original is 3300x2550, this copy is 880x680
; to stay below ~2 MB. Inno's TBitmap.LoadFromFile is BMP-only.)
Source: "sitemap2025-dark.bmp"; DestDir: "{tmp}"; Flags: ignoreversion
[Code] [Code]
const
; Source map dimensions in ShopDB pixel-coord space. printers.mapleft
; ranges 0..MAP_SOURCE_W; printers.maptop ranges 0..MAP_SOURCE_H. Both
; are top-left origin in the database (the Leaflet asp page does its
; own Y flip because Leaflet CRS.Simple is Y-up; we are top-down so
; we use them directly).
MAP_SOURCE_W = 3300;
MAP_SOURCE_H = 2550;
HOTSPOT_SIZE = 14;
var var
PrinterMapPage: TWizardPage;
PrinterSelectionPage: TInputOptionWizardPage; PrinterSelectionPage: TInputOptionWizardPage;
PrinterDataArray: array of record PrinterDataArray: array of record
PrinterName: String; PrinterName: String;
FQDN: String; FQDN: String;
Vendor: String; Vendor: String;
Model: String; Model: String;
MapLeft: String;
MapTop: String;
IsInstalled: Boolean; IsInstalled: Boolean;
end; end;
; Per-hotspot state. Indices are aligned: MapHotspots[i] is the visible
; control, MapHotspotIndex[i] is the corresponding PrinterDataArray index.
; Only printers with non-empty MapLeft+MapTop get a hotspot.
MapHotspots: array of TBitmapImage;
MapHotspotIndex: array of Integer;
; Two pre-rendered dot bitmaps, shared by all hotspots (Bitmap.Assign'd
; into each hotspot's Bitmap on toggle).
DotUnselectedBmp: TBitmap;
DotSelectedBmp: TBitmap;
InstallLog: TStringList; InstallLog: TStringList;
// ─── Logging ──────────────────────────────────────────────────────────────── // ─── Logging ────────────────────────────────────────────────────────────────
@@ -219,6 +246,8 @@ begin
PrinterDataArray[Count].FQDN := Address; PrinterDataArray[Count].FQDN := Address;
PrinterDataArray[Count].Vendor := Vendor; PrinterDataArray[Count].Vendor := Vendor;
PrinterDataArray[Count].Model := Model; PrinterDataArray[Count].Model := Model;
PrinterDataArray[Count].MapLeft := JsonGetString(ObjStr, 'mapleft');
PrinterDataArray[Count].MapTop := JsonGetString(ObjStr, 'maptop');
PrinterDataArray[Count].IsInstalled := False; PrinterDataArray[Count].IsInstalled := False;
Count := Count + 1; Count := Count + 1;
Result := True; Result := True;
@@ -534,6 +563,141 @@ begin
end; end;
end; end;
// ─── Map page: dot bitmaps + hotspot click + page builder ───────────────────
// Pre-render two HOTSPOT_SIZE-square BMPs (gray = unselected, lime = selected)
// shared by every hotspot. On toggle we Bitmap.Assign one of them into the
// hotspot's TBitmapImage.Bitmap and call Invalidate. Drawing a square (not
// a circle) sidesteps the lack of a documented transparent-color path on
// Inno's exposed TBitmap; reads cleanly enough on the dark site map.
procedure BuildDotBitmaps();
var
R: TRect;
begin
R.Left := 0; R.Top := 0; R.Right := HOTSPOT_SIZE; R.Bottom := HOTSPOT_SIZE;
DotUnselectedBmp := TBitmap.Create;
DotUnselectedBmp.Width := HOTSPOT_SIZE;
DotUnselectedBmp.Height := HOTSPOT_SIZE;
DotUnselectedBmp.Canvas.Brush.Color := $00777777; // mid-gray (BGR)
DotUnselectedBmp.Canvas.FillRect(R);
DotUnselectedBmp.Canvas.Pen.Color := clBlack;
DotUnselectedBmp.Canvas.MoveTo(0, 0);
DotUnselectedBmp.Canvas.LineTo(HOTSPOT_SIZE - 1, 0);
DotUnselectedBmp.Canvas.LineTo(HOTSPOT_SIZE - 1, HOTSPOT_SIZE - 1);
DotUnselectedBmp.Canvas.LineTo(0, HOTSPOT_SIZE - 1);
DotUnselectedBmp.Canvas.LineTo(0, 0);
DotSelectedBmp := TBitmap.Create;
DotSelectedBmp.Width := HOTSPOT_SIZE;
DotSelectedBmp.Height := HOTSPOT_SIZE;
DotSelectedBmp.Canvas.Brush.Color := clLime;
DotSelectedBmp.Canvas.FillRect(R);
DotSelectedBmp.Canvas.Pen.Color := clBlack;
DotSelectedBmp.Canvas.MoveTo(0, 0);
DotSelectedBmp.Canvas.LineTo(HOTSPOT_SIZE - 1, 0);
DotSelectedBmp.Canvas.LineTo(HOTSPOT_SIZE - 1, HOTSPOT_SIZE - 1);
DotSelectedBmp.Canvas.LineTo(0, HOTSPOT_SIZE - 1);
DotSelectedBmp.Canvas.LineTo(0, 0);
end;
// Toggle the linked checkbox-page Value, swap the hotspot's bitmap to match
// the new state. Sender is the clicked TBitmapImage; its Tag indexes
// MapHotspotIndex which maps to the underlying PrinterDataArray entry.
procedure MapHotspotClick(Sender: TObject);
var
Hotspot: TBitmapImage;
HotIdx, PrnIdx: Integer;
begin
Hotspot := TBitmapImage(Sender);
HotIdx := Hotspot.Tag;
if (HotIdx < 0) or (HotIdx >= GetArrayLength(MapHotspotIndex)) then Exit;
PrnIdx := MapHotspotIndex[HotIdx];
PrinterSelectionPage.Values[PrnIdx] := not PrinterSelectionPage.Values[PrnIdx];
if PrinterSelectionPage.Values[PrnIdx] then
Hotspot.Bitmap.Assign(DotSelectedBmp)
else
Hotspot.Bitmap.Assign(DotUnselectedBmp);
Hotspot.Invalidate;
end;
// Place the site-map background and one hotspot per printer with non-empty
// mapleft+maptop. Called once from InitializeWizard AFTER PrinterMapPage and
// PrinterSelectionPage exist and Values[] are seeded, so initial hotspot
// colors reflect /PRINTER= pre-selection or already-installed state.
procedure BuildMapPage();
var
I, ML, MT, X, Y, ScaledW, ScaledH, OffsetX: Integer;
ScaleX, ScaleY, Scale: Extended;
SurfW, SurfH: Integer;
MapBg, Hotspot: TBitmapImage;
begin
if PrinterMapPage = nil then Exit;
SurfW := PrinterMapPage.SurfaceWidth;
SurfH := PrinterMapPage.SurfaceHeight;
ScaleX := SurfW / MAP_SOURCE_W;
ScaleY := SurfH / MAP_SOURCE_H;
if ScaleX < ScaleY then Scale := ScaleX else Scale := ScaleY;
ScaledW := Round(MAP_SOURCE_W * Scale);
ScaledH := Round(MAP_SOURCE_H * Scale);
OffsetX := (SurfW - ScaledW) div 2;
MapBg := TBitmapImage.Create(PrinterMapPage);
MapBg.Parent := PrinterMapPage.Surface;
MapBg.Left := OffsetX;
MapBg.Top := 0;
MapBg.Width := ScaledW;
MapBg.Height := ScaledH;
MapBg.Stretch := True;
MapBg.AutoSize := False;
try
MapBg.Bitmap.LoadFromFile(ExpandConstant('{tmp}\sitemap2025-dark.bmp'));
except
AddLog('Map page: failed to load site map BMP from {tmp}');
end;
BuildDotBitmaps();
SetArrayLength(MapHotspots, 0);
SetArrayLength(MapHotspotIndex, 0);
for I := 0 to GetArrayLength(PrinterDataArray) - 1 do
begin
if (PrinterDataArray[I].MapLeft = '') or (PrinterDataArray[I].MapTop = '') then
Continue;
try
ML := StrToInt(PrinterDataArray[I].MapLeft);
MT := StrToInt(PrinterDataArray[I].MapTop);
except
Continue;
end;
X := OffsetX + Round(ML * Scale) - (HOTSPOT_SIZE div 2);
Y := Round(MT * Scale) - (HOTSPOT_SIZE div 2);
Hotspot := TBitmapImage.Create(PrinterMapPage);
Hotspot.Parent := PrinterMapPage.Surface;
Hotspot.Left := X;
Hotspot.Top := Y;
Hotspot.Width := HOTSPOT_SIZE;
Hotspot.Height := HOTSPOT_SIZE;
Hotspot.Stretch := True;
Hotspot.AutoSize := False;
Hotspot.Tag := GetArrayLength(MapHotspots);
Hotspot.OnClick := @MapHotspotClick;
if PrinterSelectionPage.Values[I] then
Hotspot.Bitmap.Assign(DotSelectedBmp)
else
Hotspot.Bitmap.Assign(DotUnselectedBmp);
SetArrayLength(MapHotspots, GetArrayLength(MapHotspots) + 1);
MapHotspots[GetArrayLength(MapHotspots) - 1] := Hotspot;
SetArrayLength(MapHotspotIndex, GetArrayLength(MapHotspotIndex) + 1);
MapHotspotIndex[GetArrayLength(MapHotspotIndex) - 1] := I;
end;
end;
// ─── Wizard initialisation ─────────────────────────────────────────────────── // ─── Wizard initialisation ───────────────────────────────────────────────────
procedure InitializeWizard(); procedure InitializeWizard();
@@ -572,11 +736,16 @@ begin
AutoSelectPrinter := ExpandConstant('{param:PRINTER|}'); AutoSelectPrinter := ExpandConstant('{param:PRINTER|}');
FoundMatch := False; FoundMatch := False;
PrinterSelectionPage := CreateInputOptionPage(wpWelcome, PrinterMapPage := CreateCustomPage(wpWelcome,
'Select Printers on Site Map',
'Click a printer marker to select or deselect it. Green = selected. ' +
'Click Next to review the selection list.');
PrinterSelectionPage := CreateInputOptionPage(PrinterMapPage.ID,
'Manage Network Printers', 'Manage Network Printers',
'Select printers to install or uncheck to remove', 'Select printers to install or uncheck to remove',
'Checked printers will be installed. Unchecked printers that are currently installed will be removed. ' + 'Checked printers will be installed. Unchecked printers that are currently installed will be removed. ' +
'Already-installed printers are pre-checked.', 'Already-installed printers are pre-checked. Selections from the site map are reflected here.',
False, False); False, False);
for I := 0 to GetArrayLength(PrinterDataArray) - 1 do for I := 0 to GetArrayLength(PrinterDataArray) - 1 do
@@ -626,6 +795,11 @@ begin
MsgBox('Printer "' + AutoSelectPrinter + '" not found in the database.' + #13#10#13#10 + MsgBox('Printer "' + AutoSelectPrinter + '" not found in the database.' + #13#10#13#10 +
'Please select a printer from the list manually.', 'Please select a printer from the list manually.',
mbInformation, MB_OK); mbInformation, MB_OK);
// Build the map page background + hotspots now that PrinterSelectionPage
// exists and Values[] are seeded. Hotspots only spawn for printers with
// non-empty mapleft+maptop; others remain reachable via the checkbox page.
BuildMapPage();
end; end;
// ─── Page validation ───────────────────────────────────────────────────────── // ─── Page validation ─────────────────────────────────────────────────────────
@@ -1374,4 +1548,10 @@ begin
Result := False; Result := False;
if PageID = wpSelectDir then if PageID = wpSelectDir then
Result := True; Result := True;
// Skip the map page if no printers have map coords (e.g. all rows have
// null mapleft/maptop, or the API was not yet extended to return them).
// The checkbox page still works in that case, matching legacy behaviour.
if (PrinterMapPage <> nil) and (PageID = PrinterMapPage.ID) and
(GetArrayLength(MapHotspots) = 0) then
Result := True;
end; end;

View File

@@ -25,15 +25,36 @@ This variant replaces the web map UI with an in-installer wizard page so the ope
## Status ## Status
Stub. `.iss` header renamed; map wizard page not yet implemented. Tracked work: Map wizard page implemented end-to-end in `.iss`. Untested: needs Windows + Inno Setup 6.x compiler, plus the `api_printers.asp` extension below to return map coords. ShopDB rows that already have `mapleft + maptop` set will populate hotspots; rows missing coords fall through to the checkbox page (legacy behaviour).
- [ ] Extend `api_printers.asp` to return `mapleft, maptop`. Tracked work:
- [ ] Add `sitemap2025-dark.png` to `[Files]`.
- [ ] Implement `MapPage` Pascal procedure (TScrollBox + TImage + click-to-toggle hotspots). - [x] Add `MapLeft, MapTop` fields to `PrinterDataArray` record + `JsonGetString` parsing.
- [ ] Wire selected printer IDs into the existing `SelectedPrinters` array used by the checkbox page. - [x] Add `sitemap2025-dark.bmp` (880x680, scaled-down, ~1.7 MB) to `[Files]`. Inno's `TBitmap.LoadFromFile` is BMP-only, so the source PNG was converted with PIL.
- [ ] Code-signing cert (separate task - see project memory). - [x] Implement `BuildMapPage` (`CreateCustomPage` + stretched background `TBitmapImage` + per-printer hotspot `TBitmapImage` overlays) and `MapHotspotClick` (toggles `PrinterSelectionPage.Values[I]` + swaps the hotspot's bitmap).
- [ ] Rebuild + sign + bake into PXE image. - [x] Anchor `PrinterSelectionPage` after `PrinterMapPage.ID` (was after `wpWelcome`); checkbox page sees the map's selections.
- [x] `ShouldSkipPage` skips the map page when no printers have map coords (graceful degrade if API is not yet extended).
- [ ] **ShopDB**: extend `api_printers.asp` to return `mapleft, maptop` per printer row (additive change).
- [ ] **Build**: Inno Setup compile on Windows. Target: `OutputBaseFilename=PrinterInstallerMap`.
- [ ] **Code-signing cert**: `proudlock.tech` M365 artifact certs are not Authenticode (wrong EKU); pursue GE AD CS cert instead. See project memory note.
- [ ] **Sign + bake into PXE image**.
- [ ] **Test on win11 VM**: cold install, /PRINTER= silent path, map-only-vs-checkbox-only printers, no-coords graceful skip.
## Privileges
`PrivilegesRequired=admin` (inherited from PrinterInstaller). pnputil, TCP/IP port creation, `Add-Printer`, and trusted-publisher cert install all need elevation. Shopfloor user can launch but Windows shows a UAC prompt; supportuser creds satisfy it. Acceptable for the operator-driven adhoc workflow this installer targets. For unattended bulk per-PC deployment, use a GE-Enforce manifest entry (runs as SYSTEM, no UI, no UAC) instead of this installer.
## Implementation notes
- **Map source**: `printers.mapleft, maptop` are top-left-origin pixel coords on a 3300x2550 site map. Inno is also top-left-origin, so coords are used directly with no Y-flip (the Leaflet asp inverts Y because Leaflet `CRS.Simple` is Y-up, which is a Leaflet quirk - not present here).
- **Scaling**: bundled BMP is 880x680 to keep file size sane. At wizard time the BMP is stretch-painted into the wizard surface at fit-and-letterbox scale; hotspots are positioned in the same scaled coordinate space (multiplying `mapleft, maptop` by the same scale factor).
- **Hotspot graphic**: 14x14 square (no transparency, since Inno's exposed TBitmap surface does not document a TransparentColor path). Square reads cleanly enough on the dark map.
- **Inno class subset gotchas verified before coding**:
- `TBitmapImage` has `OnClick(Sender)` only - no `OnMouseDown` with X/Y. Per-hotspot child controls are the documented workaround for click-to-coords.
- `TScrollBox` is not exposed; the bundled BMP is stretched to fit instead of scrolled.
- `TBitmap.LoadFromFile` is BMP-only (PNG path goes through `TPngImage` which has no `LoadFromFile`); PNG was pre-converted.
- `TWizardPage.OnActivate`, `ShouldSkipPage`, `NextButtonClick` all exposed as expected.
## Build / sign / deploy ## Build / sign / deploy
Same as `PrinterInstaller/`. See `../PrinterInstaller/README.md` for the build matrix. Same as `PrinterInstaller/`. See `../PrinterInstaller/README.md` for the build matrix. Output filename is `PrinterInstallerMap.exe`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB