Roughly 2500 lines of tested installer had been living in ~/Downloads and an untracked folder - nothing was under version control. It goes here rather than in a repo of its own because it depends on application internals: the `flask plugin` verbs, site-profile.json, MOUNT_PATH, and the plugin registry. Versioned separately it would drift out of step with the thing it installs. Contents: the read-only preflight, the staged installer (bundled MySQL, runtime, schema, IIS, verify, uninstall), the operator console, the Inno Setup wizard, the bundle builder and the artwork generator. bundle/ and Output/ are ignored - regenerable, and ~220MB. plugins.iss is ignored because build-installer.sh generates it from the staged payload. The artwork IS committed so a Windows build box does not need Python and cairosvg. Verified end to end on Windows Server 2025 against a bundled MySQL 8.0 and an existing MySQL 5.6: fresh install, upgrade with backup and rollback, re-run idempotency, uninstall, and both deployment methods including switching between them. Not yet verified: a hypervisor-level air-gapped run, and any load from a real browser (every HTTP check so far used curl, which sends no Origin header).
160 lines
5.9 KiB
Python
160 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate Inno Setup wizard artwork from the app's own brand assets.
|
|
|
|
Everything here is derived from frontend/public/*.svg so the installer and the
|
|
running application are visibly the same product. Nothing is redrawn by hand.
|
|
|
|
Inno stretches artwork to fit and does not resample well, so render at the exact
|
|
sizes it asks for and supply the 125%/250% variants for high-DPI displays.
|
|
|
|
WizardImageFile 164x314, 192x386, 384x772
|
|
WizardSmallImageFile 55x55, 64x64, 138x138
|
|
SetupIconFile .ico with 16/24/32/48/64/128/256
|
|
|
|
Usage: python3 make-branding.py [output-dir]
|
|
"""
|
|
import io
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import cairosvg
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
ASSETS = Path.home() / "projects/shopdb-flask/frontend/public"
|
|
OUT = Path(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).parent)
|
|
|
|
# Sampled from the application's own palette so the installer does not look like
|
|
# a different product wearing the same badge.
|
|
NAVY = (10, 34, 74) # deep base
|
|
BLUE = (16, 74, 150) # GE blue
|
|
CYAN = (0, 158, 224) # accent
|
|
WHITE = (255, 255, 255)
|
|
MUTED = (176, 197, 226)
|
|
|
|
|
|
def render_svg(name, width=None, height=None):
|
|
png = cairosvg.svg2png(url=str(ASSETS / name), output_width=width, output_height=height)
|
|
return Image.open(io.BytesIO(png)).convert("RGBA")
|
|
|
|
|
|
def recolour(img, colour):
|
|
"""Replace RGB while keeping the alpha mask. Source marks are dark-on-light;
|
|
on a dark panel they must be inverted or they disappear."""
|
|
solid = Image.new("RGBA", img.size, colour + (255,))
|
|
solid.putalpha(img.getchannel("A"))
|
|
return solid
|
|
|
|
|
|
def font(size, bold=False):
|
|
for path in (
|
|
f"/usr/share/fonts/truetype/dejavu/DejaVuSans{'-Bold' if bold else ''}.ttf",
|
|
f"/usr/share/fonts/truetype/liberation/LiberationSans{'-Bold' if bold else '-Regular'}.ttf",
|
|
):
|
|
if Path(path).exists():
|
|
return ImageFont.truetype(path, size)
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def vertical_gradient(size, top, bottom):
|
|
w, h = size
|
|
img = Image.new("RGB", size)
|
|
draw = ImageDraw.Draw(img)
|
|
for y in range(h):
|
|
t = y / max(1, h - 1)
|
|
# Ease the ramp so the middle does not look flat.
|
|
t = t * t * (3 - 2 * t)
|
|
draw.line(
|
|
[(0, y), (w, y)],
|
|
fill=tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3)),
|
|
)
|
|
return img
|
|
|
|
|
|
def banner(w, h):
|
|
img = vertical_gradient((w, h), BLUE, NAVY)
|
|
draw = ImageDraw.Draw(img)
|
|
k = w / 164.0 # scale factor from the 100% design
|
|
|
|
# Faint diagonal wash: stops the flat area under the text reading as empty.
|
|
glow = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
|
gd = ImageDraw.Draw(glow)
|
|
gd.polygon([(0, int(h * 0.52)), (w, int(h * 0.30)), (w, h), (0, h)],
|
|
fill=(255, 255, 255, 10))
|
|
img = Image.alpha_composite(img.convert("RGBA"), glow).convert("RGB")
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
margin = int(22 * k)
|
|
|
|
# GE Aerospace wordmark at the top, above the product name - the corporate
|
|
# mark leads, the product sits under it. (Previously the bare monogram was
|
|
# here and the wordmark was stranded at the bottom.)
|
|
mark_w = w - (margin * 2)
|
|
mark = render_svg("ge-aerospace-logo.svg", mark_w, int(mark_w * 32 / 138))
|
|
mark = recolour(mark, WHITE)
|
|
img.paste(mark, (margin, int(34 * k)), mark)
|
|
|
|
# Product name, directly beneath it.
|
|
y = int(34 * k) + mark.height + int(30 * k)
|
|
draw.text((margin, y), "ShopDB", font=font(int(26 * k), bold=True), fill=WHITE)
|
|
y += int(31 * k)
|
|
|
|
# Hairline rule, then the descriptor. Cheap way to look considered.
|
|
draw.rectangle([margin, y, margin + int(30 * k), y + max(1, int(2 * k))], fill=CYAN)
|
|
y += int(14 * k)
|
|
for line in ("Asset management", "for the shop floor"):
|
|
draw.text((margin, y), line, font=font(int(10.5 * k)), fill=MUTED)
|
|
y += int(15 * k)
|
|
|
|
# Accent bar flush to the bottom edge.
|
|
bar = max(2, int(4 * k))
|
|
draw.rectangle([0, h - bar, w, h], fill=CYAN)
|
|
return img
|
|
|
|
|
|
def small(size):
|
|
"""Header mark on every page after the welcome page. White plate so it sits
|
|
correctly on the wizard's own header, in light or dark mode."""
|
|
img = Image.new("RGB", (size, size), WHITE)
|
|
m = int(size * 0.80)
|
|
mono = recolour(render_svg("ge-monogram.svg", m, m), BLUE)
|
|
off = (size - m) // 2
|
|
img.paste(mono, (off, off), mono)
|
|
return img
|
|
|
|
|
|
def icon(path):
|
|
"""Installer icon. Rounded navy tile with the monogram, so it reads at 16px
|
|
instead of turning into mush."""
|
|
base = 256
|
|
img = Image.new("RGBA", (base, base), (0, 0, 0, 0))
|
|
d = ImageDraw.Draw(img)
|
|
d.rounded_rectangle([0, 0, base - 1, base - 1], radius=int(base * 0.22), fill=BLUE + (255,))
|
|
d.rounded_rectangle([0, 0, base - 1, int(base * 0.5)], radius=int(base * 0.22),
|
|
fill=(30, 96, 175, 255))
|
|
d.rounded_rectangle([0, int(base * 0.3), base - 1, base - 1], radius=int(base * 0.22),
|
|
fill=BLUE + (255,))
|
|
m = int(base * 0.62)
|
|
mono = recolour(render_svg("ge-monogram.svg", m, m), WHITE)
|
|
img.paste(mono, ((base - m) // 2, (base - m) // 2), mono)
|
|
img.save(path, sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64),
|
|
(128, 128), (256, 256)])
|
|
|
|
|
|
def main():
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
made = []
|
|
for w, h, name in [(164, 314, "wizard-image.bmp"),
|
|
(192, 386, "wizard-image@125.bmp"),
|
|
(384, 772, "wizard-image@250.bmp")]:
|
|
banner(w, h).save(OUT / name, "BMP"); made.append((name, f"{w}x{h}"))
|
|
for s, name in [(55, "wizard-small.bmp"), (64, "wizard-small@125.bmp"),
|
|
(138, "wizard-small@250.bmp")]:
|
|
small(s).save(OUT / name, "BMP"); made.append((name, f"{s}x{s}"))
|
|
icon(OUT / "shopdb.ico"); made.append(("shopdb.ico", "multi-res"))
|
|
for name, dims in made:
|
|
print(f" {name:<26} {dims:<10} {(OUT / name).stat().st_size // 1024} KB")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|