feat(deploy): add the air-gapped Windows installer
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).
This commit is contained in:
159
deploy/windows/installer/make-branding.py
Normal file
159
deploy/windows/installer/make-branding.py
Normal file
@@ -0,0 +1,159 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user