Files
shopdb-flask/deploy/windows/installer/ShopDBFlask.iss
cproudlock 0fa5f1e910 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).
2026-08-03 01:47:34 -04:00

1003 lines
44 KiB
Plaintext

; ShopDB-Flask air-gapped Windows installer
; Wraps the tested PowerShell stages in an operator-facing wizard.
; Version 1.0
;
; DESIGN NOTE - why this is a thin wrapper and not a reimplementation:
; every install action lives in shopdb-install.ps1, which is verified end to end
; on Windows Server 2025 against both a bundled MySQL 8.0 and an existing MySQL
; 5.6. Reimplementing any of it in Pascal Script would create a second code path
; that nobody tests. This file only collects operator input, runs the stages in
; order, and reports which one failed.
#define AppName "ShopDB-Flask"
; Pre-release. This has not shipped, so it is 0.x by definition. It becomes
; 1.0.0 when a real site installs from it successfully - not before.
; Do NOT bump this to work around a locked output file: the BUILD STAMP below
; makes every compile a unique filename, which is what that problem needed.
#define AppVersion "0.9.0"
#define AppPublisher "GE Aerospace"
#define BundleDir "bundle"
; Single source for the install directory - used by DefaultDirName and by the
; wizard's pre-fill fallback, so the two cannot disagree.
#define DefaultDir "C:\shopdb-flask"
; Generated by build-installer.sh from what is actually in the bundle, so the
; plugin page can never offer something the payload does not contain.
#include "plugins.iss"
[Setup]
AppId={{7C4E1A93-2F86-4D5B-9E31-8A0C6B5D4F27}
AppName={#AppName}
AppVersion={#AppVersion}
AppPublisher={#AppPublisher}
DefaultDirName={#DefaultDir}
DisableDirPage=no
CreateAppDir=yes
PrivilegesRequired=admin
OutputDir=.\Output
; Version identifies the RELEASE; the stamp identifies the BUILD. Separating them
; means recompiling never collides with an exe you happen to have open, and the
; version only moves when something meaningful changed.
#define BuildStamp GetDateTimeString('yyyymmdd-hhnn', '', '')
OutputBaseFilename=ShopDBFlask_Installer_{#AppVersion}_{#BuildStamp}
; The payload is ~200MB of wheels, Python and an MSI, all already compressed.
; lzma2/max on top of that costs minutes and saves almost nothing.
Compression=lzma2/normal
SolidCompression=yes
; --- Appearance -------------------------------------------------------------
; 'modern' larger layout, Segoe UI, no 1990s bevels
; 'windows11' built-in custom style (6.6.0+) - rounded controls, current palette
; 'dynamic' follows the machine's light/dark setting automatically
; 'hidebevels' removes the remaining sunken separator lines
; Custom styles switch themselves off under a high-contrast theme or /NOSTYLE,
; so accessibility is not broken by any of this.
WizardStyle=modern windows11 dynamic hidebevels
; 15% larger than default without scaling the font up - the preflight page is
; dense and benefits from the extra room.
WizardSizePercent=115
DisableWelcomePage=no
; Name the Start Menu folder and skip the "choose a folder" page - a server tool
; does not need the operator to invent a location for its shortcuts.
DefaultGroupName=ShopDB-Flask
DisableProgramGroupPage=yes
; Always write a setup log. Inno writes one only when asked, and every streamed
; line from the install stages goes into it, so this is the difference between a
; diagnosable failure and "exit 1" with nothing to look at.
SetupLogging=yes
; Artwork is generated by make-branding.py from the app's OWN brand assets, so
; the installer and the running site look like the same product. The @125/@250
; variants let Inno pick a crisp image on high-DPI displays rather than upscaling.
WizardImageFile=wizard-image.bmp,wizard-image@125.bmp,wizard-image@250.bmp
WizardSmallImageFile=wizard-small.bmp,wizard-small@125.bmp,wizard-small@250.bmp
; Installer executable icon, and the icon shown in Apps & Features. Without this
; Windows falls back to a generic setup icon and, for uninstall, to whatever exe
; is named - which was python.exe, i.e. a Python logo for a ShopDB entry.
SetupIconFile=shopdb.ico
UninstallDisplayIcon={app}\shopdb.ico
UninstallDisplayName=ShopDB-Flask
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Messages]
WelcomeLabel1=Set up ShopDB-Flask on this server
; Short, and says the two things an operator actually wants up front: it will not
; need the internet, and it will tell them before it changes anything.
WelcomeLabel2=Everything needed is included in this installer. No internet connection is used at any point.%n%nYou will be asked four short questions, and nothing on this server is changed until you reach the final confirmation.%n%nThe next page checks that this server is ready. It only reads - it changes nothing.
ClickNext=Click Next to check this server.
FinishedHeadingLabel=ShopDB-Flask is ready
SetupAppTitle=ShopDB-Flask Setup
SetupWindowTitle=ShopDB-Flask Setup
[Files]
; The whole verified bundle, staged next to this script by build-installer.sh.
; Extracted during the install step, so it is available from ssPostInstall onward
; but NOT during the wizard pages.
Source: "{#BundleDir}\*"; DestDir: "{tmp}\shopdb-bundle"; \
Flags: ignoreversion recursesubdirs createallsubdirs deleteafterinstall
; The preflight has to run on a WIZARD PAGE, which happens long before the [Files]
; section is processed. 'dontcopy' plus ExtractTemporaryFile is the only way to get
; a file on disk that early. Listed twice on purpose - once for each phase.
Source: "{#BundleDir}\shopdb-preflight.ps1"; Flags: dontcopy
; The operator's day-to-day tool. Installed into the app directory and given
; Start Menu shortcuts, so nobody has to open IIS Manager to restart the site.
; [UninstallRun] executes this from {app}. It was only ever staged into {tmp}
; with deleteafterinstall, so uninstall ran powershell against a path that no
; longer existed, exited non-zero unnoticed (runhidden, no result check), and
; Windows reported success while the site, app pool, firewall rule and .env with
; its plaintext password were all left in place.
Source: "{#BundleDir}\shopdb-install.ps1"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#BundleDir}\shopdb-preflight.ps1"; DestDir: "{app}"; Flags: ignoreversion
Source: "shopdb-admin.ps1"; DestDir: "{app}"; Flags: ignoreversion
; Kept on disk so the Start Menu shortcuts and the uninstall entry have an icon.
Source: "shopdb.ico"; DestDir: "{app}"; Flags: ignoreversion
; Status colours. These are BITMAPS, not control colours: a custom VCL style
; (WizardStyle=... windows11) repaints styled controls and ignores both
; Font.Color and TPanel.Color, so a bitmap is the only thing guaranteed to show
; the colour the operator is meant to see.
Source: "swatch-ok.bmp"; Flags: dontcopy
Source: "swatch-warn.bmp"; Flags: dontcopy
Source: "swatch-bad.bmp"; Flags: dontcopy
[Icons]
; A folder rather than loose icons: this is a server tool, not a desktop app.
Name: "{group}\ShopDB-Flask Console"; Filename: "powershell.exe"; \
Parameters: "-NoExit -NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-admin.ps1"""; \
WorkingDir: "{app}"; IconFilename: "{app}\shopdb.ico"; \
Comment: "Status, restart, logs and backups"
Name: "{group}\Open ShopDB-Flask"; Filename: "http://localhost:8090/login"; \
IconFilename: "{app}\shopdb.ico"; Comment: "Open the application in a browser"
Name: "{group}\Restart ShopDB-Flask"; Filename: "powershell.exe"; \
Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-admin.ps1"" restart"; \
WorkingDir: "{app}"; IconFilename: "{app}\shopdb.ico"; \
Comment: "Recycle the application pool"
Name: "{group}\Back up the database"; Filename: "powershell.exe"; \
Parameters: "-NoExit -NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-admin.ps1"" backup"; \
WorkingDir: "{app}"; IconFilename: "{app}\shopdb.ico"; \
Comment: "Write a .sql dump to ProgramData"
[Run]
; Offer the console at the end, unticked - finishing the wizard should not
; surprise anyone with a shell.
; Setup is a 32-bit process, so a bare "powershell.exe" here resolves through
; WOW64 to the 32-bit build, whose Get-Website fails with REGDB_E_CLASSNOTREG
; and makes the console report "cannot read IIS". Sysnative reaches the real
; System32 from a 32-bit process. The script also self-corrects, but launching
; it correctly avoids a visible relaunch.
Filename: "{win}\Sysnative\WindowsPowerShell\v1.0\powershell.exe"; \
Parameters: "-NoExit -NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-admin.ps1"""; \
Description: "Open the ShopDB-Flask console"; \
Flags: postinstall skipifsilent unchecked
[Code]
var
DbChoicePage: TInputOptionWizardPage;
DbDetailsPage: TInputQueryWizardPage;
SitePage: TInputQueryWizardPage;
PreflightPage: TWizardPage;
BannerPanel: TPanel;
BannerBar: TBitmapImage;
BannerText: TNewStaticText;
BannerSub: TNewStaticText;
DetailPanel: TPanel;
FooterText: TNewStaticText;
PreflightDone: Boolean;
// Shown while the preflight runs. Without it, clicking Next appears to hang:
// the check takes a few seconds, runs hidden, and gives no sign of life.
CheckingPage: TOutputProgressWizardPage;
PluginPage: TInputOptionWizardPage;
PluginNames: TArrayOfString;
PluginPageReady: Boolean;
DbPageReady: Boolean;
DeployPage: TInputOptionWizardPage;
DeployPageReady: Boolean;
// Set from the streamed stage output so a failure can name its cause. Without
// this the wizard could only report "exit 1", which points at nothing.
FailCause: String;
FailDetail: String;
UseBundledDb: Boolean;
LogPath: String;
const
DB_BUNDLED = 0;
DB_EXISTING = 1;
// Row geometry, in the page's own coordinate space. ScaleY/ScaleX keep these
// correct at 125%/150% DPI - hardcoded pixels would overlap on a 4K display.
ROW_GAP = 4;
// Plugin directory names are developer-facing. Show operators what the feature
// actually is; anything unlisted falls back to its raw name so a new plugin
// still appears rather than vanishing.
function PluginLabel(const Name: String): String;
begin
if Name = 'computers' then Result := 'Computers and workstations'
else if Name = 'machines' then Result := 'Machines (CNC, CMM, lathes)'
else if Name = 'printers' then Result := 'Printers and supplies'
else if Name = 'network' then Result := 'Network devices'
else if Name = 'measuringtools' then Result := 'Measuring tools and gauges'
else if Name = 'printedparts' then Result := '3D printed parts'
else if Name = 'knowledgebase' then Result := 'Knowledge base articles'
else if Name = 'slides' then Result := 'Shopfloor display slides'
else if Name = 'warranty' then Result := 'Warranty tracking'
else if Name = 'notifications' then Result := 'Notifications and announcements'
else if Name = 'usb' then Result := 'USB device checkout'
else if Name = 'employees' then Result := 'Employee directory'
else if Name = 'geenforce' then Result := 'GE-Enforce manifests'
else Result := Name;
end;
// True when a fresh install should tick this by default. The five omitted here
// are specialised; a site that wants them can tick them.
function PluginDefault(const Name: String): Boolean;
begin
Result := (Name <> 'usb') and (Name <> 'employees') and (Name <> 'geenforce');
end;
// Comma-separated list of what the operator ticked.
function SelectedPlugins: String;
var
I, Count: Integer;
begin
Result := '';
if PluginPage = nil then Exit;
// Never index past what was actually ADDED to the page. PluginNames comes from
// a build-time define and CheckListBox.Items from the Add() calls; if those
// ever disagree, reading Values[] past the end is a runtime error rather than
// a graceful miss.
Count := GetArrayLength(PluginNames);
if PluginPage.CheckListBox.Items.Count < Count then
Count := PluginPage.CheckListBox.Items.Count;
for I := 0 to Count - 1 do
if PluginPage.Values[I] then
begin
if Result <> '' then Result := Result + ',';
Result := Result + PluginNames[I];
end;
end;
procedure SetSwatch(Img: TBitmapImage; const Swatch: String);
begin
ExtractTemporaryFile(Swatch);
Img.Bitmap.LoadFromFile(ExpandConstant('{tmp}\') + Swatch);
end;
// A solid colour block, drawn as a stretched bitmap so no VCL style can repaint
// it. Used for the row markers and the banner's accent bar.
function AddSwatch(Parent: TWinControl; L, T, W, H: Integer; const Swatch: String): TBitmapImage;
begin
Result := TBitmapImage.Create(WizardForm);
Result.Parent := Parent;
Result.Left := L;
Result.Top := T;
Result.Width := W;
Result.Height := H;
Result.Stretch := True;
ExtractTemporaryFile(Swatch);
Result.Bitmap.LoadFromFile(ExpandConstant('{tmp}\') + Swatch);
end;
procedure InitializeWizard;
var
I: Integer;
Domain: String;
begin
LogPath := ExpandConstant('{tmp}\shopdb-installer-wizard.log');
// A custom page, not a memo. Status belongs in controls with colour, not in a
// wall of monospace text that has to be read line by line.
PreflightPage := CreateCustomPage(wpWelcome,
'Server check', 'Confirming this server is ready');
// Verdict banner. Colour is the whole point: an operator should know within a
// second whether they can continue, without reading anything.
BannerPanel := TPanel.Create(WizardForm);
BannerPanel.Parent := PreflightPage.Surface;
BannerPanel.Left := 0;
BannerPanel.Top := 0;
BannerPanel.Width := PreflightPage.SurfaceWidth;
BannerPanel.Height := ScaleY(58);
BannerPanel.BevelOuter := bvNone;
// No Color here: the style would repaint it. The verdict colour is carried by
// BannerBar, a stretched bitmap down the left edge, which the style cannot touch.
BannerBar := AddSwatch(BannerPanel, 0, 0, ScaleX(6), ScaleY(58), 'swatch-ok.bmp');
BannerText := TNewStaticText.Create(WizardForm);
BannerText.Parent := BannerPanel;
BannerText.Left := ScaleX(18);
BannerText.Top := ScaleY(10);
BannerText.Font.Size := 12;
BannerText.Font.Style := [fsBold];
BannerText.Caption := 'Checking...';
BannerSub := TNewStaticText.Create(WizardForm);
BannerSub.Parent := BannerPanel;
BannerSub.Left := ScaleX(18);
BannerSub.Top := ScaleY(33);
BannerSub.Width := PreflightPage.SurfaceWidth - ScaleX(28);
BannerSub.AutoSize := False;
BannerSub.Caption := '';
// Rows are added here at run time, once the results are known.
DetailPanel := TPanel.Create(WizardForm);
DetailPanel.Parent := PreflightPage.Surface;
DetailPanel.Left := 0;
DetailPanel.Top := BannerPanel.Height + ScaleY(12);
DetailPanel.Width := PreflightPage.SurfaceWidth;
DetailPanel.Height := PreflightPage.SurfaceHeight - BannerPanel.Height - ScaleY(34);
DetailPanel.BevelOuter := bvNone;
DetailPanel.Color := clWindow;
FooterText := TNewStaticText.Create(WizardForm);
FooterText.Parent := PreflightPage.Surface;
FooterText.Left := 0;
FooterText.Top := PreflightPage.SurfaceHeight - ScaleY(16);
FooterText.Width := PreflightPage.SurfaceWidth;
FooterText.AutoSize := False;
FooterText.Caption := '';
CheckingPage := CreateOutputProgressPage('Checking this server',
'Reading the current configuration. Nothing is being changed.');
// Which features this site uses. Multi-select, and pre-ticked from the
// EXISTING install when there is one - so an upgrade shows what you already
// have and ticking a new box adds it.
PluginPage := CreateInputOptionPage(PreflightPage.ID,
'Features', 'Which parts of ShopDB-Flask does this site use?',
'Everything is included in this installer. Unticked features are simply not ' +
'set up, and their database tables are not created.',
False, True);
PluginNames := StringSplit('{#AvailablePlugins}', [','], stAll);
for I := 0 to GetArrayLength(PluginNames) - 1 do
if Trim(PluginNames[I]) <> '' then
PluginPage.Add(PluginLabel(Trim(PluginNames[I])));
DbChoicePage := CreateInputOptionPage(PluginPage.ID,
'Database', 'Where should ShopDB-Flask store its data?',
'Most sites already run MySQL for the existing shopdb application. If so, ' +
'choose the second option - installing a second server would collide on ' +
'port 3306.',
True, False);
DbChoicePage.Add('Install the bundled MySQL 8.0 (new servers only)');
DbChoicePage.Add('Use a MySQL server this site already runs');
DbChoicePage.SelectedValueIndex := DB_EXISTING;
DbDetailsPage := CreateInputQueryPage(DbChoicePage.ID,
'Existing database', 'Connection details',
'The database and application user must already exist. Your DBA creates ' +
'them; the installer does not, so that it never needs administrative ' +
'rights on your database server.');
DbDetailsPage.Add('Host:', False);
DbDetailsPage.Add('Port:', False);
DbDetailsPage.Add('Database:', False);
DbDetailsPage.Add('Username:', False);
DbDetailsPage.Add('Password:', True);
DbDetailsPage.Values[0] := '127.0.0.1';
DbDetailsPage.Values[1] := '3306';
DbDetailsPage.Values[2] := 'shopdb_flask';
DbDetailsPage.Values[3] := 'shopdb';
// How the application is published. Offered only when the bundle actually
// carries a subpath SPA build - Vite compiles the base path in, so this can
// never be a pure runtime switch.
DeployPage := CreateInputOptionPage(DbDetailsPage.ID,
'Address', 'How should people reach ShopDB-Flask?',
'Both options serve the same application. The second needs no new DNS name '
+ 'and no port number, because it rides this server''s existing address.',
True, False);
DeployPage.Add('Its own address, on a port - http://<server>:8090/');
DeployPage.Add('Under this server''s existing address - http://<server>/{#SubpathAlias}/');
DeployPage.SelectedValueIndex := 0;
SitePage := CreateInputQueryPage(DeployPage.ID,
'Web site', 'How the site is published',
'The host name is used for CORS. It must be the name operators actually ' +
'type in the browser, or the page will load but its data requests will fail.');
SitePage.Add('Host name:', False);
SitePage.Add('Port:', False);
// FQDN, not the NetBIOS name: this value becomes CORS_ORIGINS, and a browser
// arriving at the fully qualified address would be refused by a bare hostname.
Domain := '';
RegQueryStringValue(HKEY_LOCAL_MACHINE,
'SYSTEM\CurrentControlSet\Services\Tcpip\Parameters', 'Domain', Domain);
if Domain <> '' then
SitePage.Values[0] := GetComputerNameString + '.' + Domain
else
SitePage.Values[0] := GetComputerNameString;
SitePage.Values[1] := '8090';
end;
// Full path to the 64-bit PowerShell.
//
// Setup is a 32-bit process, so 'powershell.exe' resolves through WOW64 to the
// 32-bit SysWOW64 build, which CANNOT instantiate IIS's 64-bit COM objects:
// "Retrieving the COM class factory for component with CLSID ... 80040144"
// from Get-Website, while the identical script run from a normal shell is fine.
// 'Sysnative' is the alias that lets a 32-bit process reach the real System32;
// it exists ONLY for 32-bit processes, hence the IsWin64 guard.
//
// Inno has ExecAndCaptureOutputWithNativeSysDir for this, but there is no
// ExecAndLogOutput equivalent, so resolving the path ourselves keeps both call
// sites on one mechanism instead of two that can drift apart.
function PowerShellPath: String;
begin
if IsWin64 then
Result := ExpandConstant('{win}\Sysnative\WindowsPowerShell\v1.0\powershell.exe')
else
Result := ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe');
end;
// Streamed line by line from ExecAndLogOutput, so the operator sees the install
// happening instead of a frozen wizard. Stage 2 alone installs 47 wheels and can
// run for minutes with nothing on screen.
procedure OnStageLog(const S: String; const Error, FirstLine: Boolean);
var
Line: String;
begin
// Everything goes to the setup log verbatim - that is the diagnostic record.
Log('[shopdb] ' + S);
if Error then Exit;
Line := Trim(S);
if Line = '' then Exit;
// Recognise the failures an operator can actually act on, and translate them
// into plain language. Matching on the SYMPTOM text (not an exit code) is what
// lets the wizard say "wrong password" instead of "exit 1".
if Pos('Access denied for user', Line) > 0 then
begin
FailCause := 'The database rejected the username or password.';
FailDetail := 'Go back to the Database page and re-enter them. Check for '
+ 'capital letters - the password is case-sensitive.';
end
else if Pos('Unknown database', Line) > 0 then
begin
FailCause := 'That database does not exist on the server.';
FailDetail := 'Your DBA must create it before installing, along with the '
+ 'application user that owns it.';
end
else if (Pos('Can''t connect to MySQL server', Line) > 0) or (Pos('timed out', Line) > 0) then
begin
FailCause := 'The database server did not answer.';
FailDetail := 'Check the host name and port on the Database page, and that '
+ 'MySQL is running and reachable from this server.';
end
else if Pos('index flags', Line) > 0 then
begin
FailCause := 'This MySQL 5.6 server is missing three required settings.';
FailDetail := 'innodb_file_per_table, innodb_file_format=Barracuda and '
+ 'innodb_large_prefix must be set, then MySQL restarted.';
end
else if Pos('does not represent a valid object', Line) > 0 then
begin
FailCause := 'IIS refused to create the site or application pool.';
FailDetail := 'This usually means a previous ShopDB site or pool is in a '
+ 'half-removed state. Run IISRESET and try again.';
end;
// The status caption is a DIFFERENT job: it tells an operator what is happening
// right now. Raw log lines are useless for that - "exec python.exe (7 args)"
// says nothing, and the argument COUNT is only there because argument VALUES
// must never be logged (they can carry a password).
// So: translate the few lines that mark real progress, and ignore the rest.
if Pos('exec ', Line) > 0 then Exit;
if Pos('STAGE 0', Line) > 0 then
WizardForm.StatusLabel.Caption := 'Installing the bundled MySQL database...'
else if Pos('STAGE 2', Line) > 0 then
WizardForm.StatusLabel.Caption := 'Installing Python and the application...'
else if Pos('wheelhouse', Line) > 0 then
WizardForm.StatusLabel.Caption := 'Installing dependencies (offline)...'
else if Pos('STAGE 3', Line) > 0 then
WizardForm.StatusLabel.Caption := 'Preparing the database...'
else if Pos('db upgrade', Line) > 0 then
WizardForm.StatusLabel.Caption := 'Creating the database schema...'
else if Pos('seed ', Line) > 0 then
WizardForm.StatusLabel.Caption := 'Adding reference data...'
else if Pos('apply-profile', Line) > 0 then
WizardForm.StatusLabel.Caption := 'Installing the plugins this site uses...'
else if Pos('upgrade-all', Line) > 0 then
WizardForm.StatusLabel.Caption := 'Applying plugin migrations...'
else if Pos('prune-schema', Line) > 0 then
WizardForm.StatusLabel.Caption := 'Removing unused plugin tables...'
else if Pos('STAGE 4', Line) > 0 then
WizardForm.StatusLabel.Caption := 'Configuring IIS...'
else if Pos('STAGE 5', Line) > 0 then
WizardForm.StatusLabel.Caption := 'Checking the site responds...'
else if Pos('[FAIL]', Line) > 0 then
WizardForm.StatusLabel.Caption := 'Failed - see the message that follows';
end;
function RunPowerShell(const ScriptArgs: String; var ResultCode: Integer): Boolean;
var
Cmd: String;
begin
Cmd := '-NoProfile -ExecutionPolicy Bypass -File "' +
ExpandConstant('{tmp}\shopdb-bundle\shopdb-install.ps1') + '" ' + ScriptArgs;
// ExecAndLogOutput streams output through OnStageLog as it is produced, and
// also writes it into the setup log - so a failed install leaves a full trace
// without the operator having to find the PowerShell log in TEMP.
// 64-bit PowerShell: stage 4 configures IIS, which the 32-bit build cannot do.
Result := ExecAndLogOutput(PowerShellPath, Cmd, '', SW_HIDE,
ewWaitUntilTerminated, ResultCode, @OnStageLog);
end;
// One result row: coloured dot, bold title, wrapped detail. Returns the Y for
// the next row so the caller does not have to guess heights.
//
// Status marker. Four approaches failed before this one, all recorded so nobody
// re-treads them:
// - PNG into TBitmapImage.Bitmap -> "Bitmap image is not valid" (BMP only).
// - Chr(9679) for U+25CF -> renders 'I'-diaeresis; Chr takes a BYTE.
// - Wingdings glyph + Font.Color -> draws, but BLACK: a custom VCL style owns
// text painting and ignores Font.Color.
// - TPanel + Color -> also repainted by the style; no colour.
// A stretched BMP is painted verbatim, so the colour always shows.
function AddRow(Y: Integer; const Swatch, Title, Detail: String): Integer;
var
T, D: TNewStaticText;
begin
AddSwatch(DetailPanel, ScaleX(3), Y + ScaleY(4), ScaleX(10), ScaleY(10), Swatch);
T := TNewStaticText.Create(WizardForm);
T.Parent := DetailPanel;
T.Left := ScaleX(22);
T.Top := Y;
T.Width := DetailPanel.Width - ScaleX(24);
T.Font.Style := [fsBold];
T.Caption := Title;
D := TNewStaticText.Create(WizardForm);
D.Parent := DetailPanel;
D.Left := ScaleX(22);
D.Top := Y + ScaleY(15);
D.Width := DetailPanel.Width - ScaleX(24);
D.AutoSize := False;
D.WordWrap := True;
D.Height := ScaleY(28);
D.Font.Color := clGrayText;
D.Caption := Detail;
Result := Y + ScaleY(46) + ScaleY(ROW_GAP);
end;
// Turn the delimited records into a verdict plus rows. Passing checks are NEVER
// listed: 10 green lines bury the one amber line that actually needs reading.
// If nothing is wrong the page collapses to a single reassuring statement.
procedure RenderPreflight(Lines: TArrayOfString; var HasBlockers: Boolean);
var
I, Y, Shown, Fails, Warns, Passes: Integer;
Parts: TArrayOfString;
Check, Detail, Fix: String;
begin
Fails := 0; Warns := 0; Passes := 0;
Y := ScaleY(4);
Shown := 0;
// Blockers first, then warnings - a second pass rather than one, so severity
// ordering does not depend on the order the checks happen to run in.
for I := 0 to GetArrayLength(Lines) - 1 do
begin
Parts := StringSplit(Lines[I], ['|'], stAll);
if GetArrayLength(Parts) < 4 then Continue;
if Parts[0] = 'FAIL' then Fails := Fails + 1
else if Parts[0] = 'WARN' then Warns := Warns + 1
else if Parts[0] = 'PASS' then Passes := Passes + 1;
end;
for I := 0 to GetArrayLength(Lines) - 1 do
begin
Parts := StringSplit(Lines[I], ['|'], stAll);
if GetArrayLength(Parts) < 4 then Continue;
if Parts[0] <> 'FAIL' then Continue;
// Qualify the check with its area: "Service" and "Port 3306" on their own
// say nothing about WHAT service or whose port.
Check := Parts[1] + ' - ' + Parts[2]; Detail := Parts[3];
if GetArrayLength(Parts) > 4 then Fix := Parts[4] else Fix := '';
if Fix <> '' then Detail := Detail + ' - ' + Fix;
Y := AddRow(Y, 'swatch-bad.bmp', Check, Detail);
Shown := Shown + 1;
end;
for I := 0 to GetArrayLength(Lines) - 1 do
begin
if Shown >= 6 then Break;
Parts := StringSplit(Lines[I], ['|'], stAll);
if GetArrayLength(Parts) < 4 then Continue;
if Parts[0] <> 'WARN' then Continue;
Check := Parts[1] + ' - ' + Parts[2]; Detail := Parts[3];
if GetArrayLength(Parts) > 4 then Fix := Parts[4] else Fix := '';
if Fix <> '' then Detail := Detail + ' - ' + Fix;
Y := AddRow(Y, 'swatch-warn.bmp', Check, Detail);
Shown := Shown + 1;
end;
HasBlockers := Fails > 0;
if Fails > 0 then
begin
SetSwatch(BannerBar, 'swatch-bad.bmp');
BannerText.Caption := 'This server is not ready';
BannerSub.Caption := IntToStr(Fails) + ' problem(s) must be fixed before installing.';
end
else if Warns > 0 then
begin
SetSwatch(BannerBar, 'swatch-warn.bmp');
BannerText.Caption := 'Ready, with notes';
BannerSub.Caption := 'Installation can continue. ' + IntToStr(Warns) +
' item(s) below are worth reading first.';
end
else
begin
SetSwatch(BannerBar, 'swatch-ok.bmp');
BannerText.Caption := 'This server is ready';
BannerSub.Caption := 'Everything needed for ShopDB-Flask is in place.';
// Nothing to list, so say so rather than leaving an empty white box.
AddRow(ScaleY(4), 'swatch-ok.bmp', 'All checks passed',
'IIS, HttpPlatformHandler, disk space, ports and the database were all '
+ 'verified. Nothing needs your attention.');
end;
if (Shown >= 6) and (Warns > 6 - Fails) then
FooterText.Caption := 'Some notes are not shown. The full check is in the install log.'
else
FooterText.Caption := IntToStr(Passes) + ' checks passed. Nothing has been changed on this server.';
end;
// Where an EXISTING install would be, for the pre-fill reads.
//
// Not WizardDirValue() alone: these pages are inserted after wpWelcome and so
// run BEFORE the directory page, where that value can still be empty - and an
// empty base silently turned every pre-fill into "no existing install", which
// is why the Address page kept defaulting to 8090 on a /shopdb server.
// Falls back to the compile-time default, which is also the only directory the
// operator could have meant at that point in the wizard.
function InstalledDir: String;
begin
Result := WizardDirValue;
if Trim(Result) = '' then Result := ExpandConstant('{#DefaultDir}');
Result := AddBackslash(Result);
Log('[shopdb] pre-fill base directory: ' + Result);
end;
function NextButtonClick(CurPageID: Integer): Boolean;
var
ResultCode, I: Integer;
Output: TExecOutput;
PreflightScript, Detail: String;
HasBlockers: Boolean;
begin
Result := True;
if CurPageID = wpWelcome then
begin
// Stage 1 is read-only, so running it before the operator commits to
// anything is free and catches the common blockers (IIS absent, locked
// config sections, port in use, MySQL 5.6 missing its index flags).
// The bundle is NOT extracted yet at wizard time - [Files] is processed during
// the install step. Pull just this one script out of the compressed payload.
ExtractTemporaryFile('shopdb-preflight.ps1');
PreflightScript := ExpandConstant('{tmp}\shopdb-preflight.ps1');
// Visible feedback for the few seconds the check takes. SW_HIDE means the
// operator sees nothing at all otherwise, and a frozen wizard reads as a
// crash. ProgressBar.Style := npbstMarquee because the duration is unknown -
// a bar that sits at 0% is worse than no bar.
CheckingPage.SetText('Looking at IIS, disk, ports and the database...', '');
CheckingPage.SetProgress(0, 0);
CheckingPage.ProgressBar.Style := npbstMarquee;
CheckingPage.Show;
try
// ExecAndCaptureOutput (6.4.0+) hands back stdout and stderr as string arrays,
// so output never touches disk. That deletes BOTH bugs this page shipped with:
// - Exec() has no shell, so "> file" went to PowerShell as a literal
// argument and no file was ever written (page came up empty);
// - PowerShell 5.1 writes UTF-16LE, and LoadStringFromFile reads bytes as
// ANSI, so the page rendered as garbage with a null between characters.
// Neither failure mode can recur now: there is no file and no encoding step.
// 64-bit PowerShell - see PowerShellPath. The IIS checks fail without it.
ExecAndCaptureOutput(PowerShellPath,
'-NoProfile -ExecutionPolicy Bypass -File "' + PreflightScript + '" -Delimited',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode, Output);
// Build the rows once. Going Back and Next again must not stack a second set
// of controls on top of the first.
if not PreflightDone then
begin
PreflightDone := True;
if (not Output.Error) and (GetArrayLength(Output.StdOut) > 0) then
begin
RenderPreflight(Output.StdOut, HasBlockers);
if HasBlockers then
MsgBox('This server is not ready. The problems are listed on the next '
+ 'page, each with what to do about it.', mbError, MB_OK);
end
else
begin
// stderr is captured SEPARATELY, so a failure can report what actually
// went wrong instead of only an exit code.
Detail := '';
for I := 0 to GetArrayLength(Output.StdErr) - 1 do
Detail := Detail + Output.StdErr[I] + ' ';
if Trim(Detail) = '' then Detail := 'No error output was produced.';
SetSwatch(BannerBar, 'swatch-warn.bmp');
BannerText.Caption := 'Could not check this server';
BannerSub.Caption := 'Installation can continue, but nothing has been verified.';
AddRow(ScaleY(4), 'swatch-warn.bmp',
'Check did not run (exit ' + IntToStr(ResultCode) + ')', Detail);
FooterText.Caption := 'Nothing has been changed on this server.';
end;
end;
finally
CheckingPage.Hide;
end;
end;
if CurPageID = DbChoicePage.ID then
UseBundledDb := (DbChoicePage.SelectedValueIndex = DB_BUNDLED);
if CurPageID = DbDetailsPage.ID then
begin
if not UseBundledDb then
begin
if DbDetailsPage.Values[0] = '' then
begin
MsgBox('Enter the database host.', mbError, MB_OK);
Result := False;
end
else if (DbDetailsPage.Values[4] = '') and
(not FileExists(InstalledDir + '.env')) then
begin
// Only required on a FRESH install. On an upgrade, blank means "keep the
// password already in .env", so the operator never has to know it.
MsgBox('Enter the password for the application database user.', mbError, MB_OK);
Result := False;
end;
end;
end;
end;
// Pre-tick the boxes the first time the page appears. On an upgrade the existing
// site-profile.json is already on disk, so the page opens showing exactly what
// this server has today - and ticking another box adds it.
procedure CurPageChanged(CurPageID: Integer);
var
I, Count: Integer;
Existing, Creds, Rest: String;
Lines: TArrayOfString;
begin
// Pre-fill the database page from the EXISTING .env on an upgrade. Its
// defaults are 127.0.0.1 / shopdb_flask, so a site whose database lives on
// another server would otherwise have its real connection string overwritten
// by defaults the operator never looked at - and .env was the only record.
// Pre-select the method this server already uses. Without this, an upgrade of
// a /shopdb install would sit on the default and create a SECOND deployment -
// a new site on 8090 beside the existing Application, same directory, wrong
// SPA build, MOUNT_PATH still set. Read it from .env, which is the same value
// wsgi.py mounts on.
if (CurPageID = DeployPage.ID) and (not DeployPageReady) then
begin
DeployPageReady := True;
if LoadStringsFromFile(InstalledDir + '.env', Lines) then
for I := 0 to GetArrayLength(Lines) - 1 do
if Pos('MOUNT_PATH=', Lines[I]) = 1 then
begin
Log('[shopdb] existing install is a subpath deployment: ' + Lines[I]);
DeployPage.SelectedValueIndex := 1;
DeployPage.SubCaptionLabel.Caption :=
'This server is currently published under its existing address. '
+ 'Changing this will move where people reach ShopDB-Flask.';
end;
end;
if (CurPageID = DbDetailsPage.ID) and (not DbPageReady) then
begin
DbPageReady := True;
if LoadStringsFromFile(InstalledDir + '.env', Lines) then
for I := 0 to GetArrayLength(Lines) - 1 do
if Pos('DATABASE_URL=', Lines[I]) = 1 then
begin
// mysql+pymysql://USER:PASS@HOST:PORT/NAME?charset=...
Existing := Copy(Lines[I], Pos('//', Lines[I]) + 2, Length(Lines[I]));
if Pos('@', Existing) > 0 then
begin
Creds := Copy(Existing, 1, Pos('@', Existing) - 1);
Rest := Copy(Existing, Pos('@', Existing) + 1, Length(Existing));
if Pos(':', Creds) > 0 then
DbDetailsPage.Values[3] := Copy(Creds, 1, Pos(':', Creds) - 1);
if Pos(':', Rest) > 0 then
begin
DbDetailsPage.Values[0] := Copy(Rest, 1, Pos(':', Rest) - 1);
Rest := Copy(Rest, Pos(':', Rest) + 1, Length(Rest));
if Pos('/', Rest) > 0 then
begin
DbDetailsPage.Values[1] := Copy(Rest, 1, Pos('/', Rest) - 1);
Rest := Copy(Rest, Pos('/', Rest) + 1, Length(Rest));
if Pos('?', Rest) > 0 then Rest := Copy(Rest, 1, Pos('?', Rest) - 1);
DbDetailsPage.Values[2] := Rest;
end;
end;
end;
// Password intentionally left blank: blank means "keep the current
// one", so an upgrade never needs the operator to know it.
DbDetailsPage.Values[4] := '';
DbDetailsPage.SubCaptionLabel.Caption :=
'These are the settings this server is using now. Leave the password '
+ 'blank to keep the current one.';
end;
end;
if (CurPageID = PluginPage.ID) and (not PluginPageReady) then
begin
PluginPageReady := True;
Existing := '';
// Read instance\plugins.json - the PLUGIN REGISTRY, which is what is actually
// installed. site-profile.json only records the last SELECTION, and the two
// drift: unticking a plugin shrinks the profile but does NOT uninstall it
// (apply-profile never removes), so the profile would show a plugin as absent
// while its tables and data are still on the server.
//
// NOT ExpandConstant('{app}') here: this page runs BEFORE the directory page
// and {app} is not initialised yet - expanding it raises "attempt was made to
// expand the app constant before it was initialized". WizardDirValue() is the
// chosen directory and is safe at any point.
if LoadStringsFromFile(InstalledDir + 'instance\plugins.json', Lines) then
for I := 0 to GetArrayLength(Lines) - 1 do Existing := Existing + Lines[I]
else if LoadStringsFromFile(InstalledDir + 'site-profile.json', Lines) then
// Fallback for an install predating the registry, or a partial install.
for I := 0 to GetArrayLength(Lines) - 1 do Existing := Existing + Lines[I];
Count := GetArrayLength(PluginNames);
if PluginPage.CheckListBox.Items.Count < Count then
Count := PluginPage.CheckListBox.Items.Count;
for I := 0 to Count - 1 do
if Existing <> '' then
// Crude but sufficient: the profile lists plugins as quoted strings.
PluginPage.Values[I] := (Pos('"' + PluginNames[I] + '"', Existing) > 0)
else
PluginPage.Values[I] := PluginDefault(PluginNames[I]);
end;
end;
function UsingSubpath: Boolean;
begin
Result := ('{#SubpathAlias}' <> '') and (DeployPage.SelectedValueIndex = 1);
end;
function ShouldSkipPage(PageID: Integer): Boolean;
begin
// A bundle without a subpath build cannot offer the choice at all.
if (PageID = DeployPage.ID) and ('{#SubpathAlias}' = '') then
begin
Result := True;
Exit;
end;
// The bundled path generates its own credentials, so asking for them would be
// meaningless - and any value typed here would be silently ignored.
Result := (PageID = DbDetailsPage.ID) and UseBundledDb;
end;
// Runs from ssPostInstall, NOT PrepareToInstall.
//
// PrepareToInstall fires BEFORE the [Files] section is processed, so
// {tmp}\shopdb-bundle does not exist yet and powershell.exe fails on a missing
// -File path (exit -196608 / 0xFFFD0000). Everything here depends on the
// extracted bundle, so it has to run after the install step.
// Set the "Run as administrator" bit on a .lnk (byte 21, flag 0x20).
//
// The console needs Administrator to read IIS state. The script self-elevates,
// but that spawns a SECOND window after a UAC prompt, which is easy to miss and
// looks broken. Marking the shortcut makes Windows elevate up front: one window,
// one prompt.
//
// Inno cannot set this flag, and its file helpers are string-based rather than
// binary safe, so use PowerShell - already a dependency here.
procedure MarkShortcutRunAs(const LnkPath: String);
var
ResultCode: Integer;
Cmd: String;
begin
if not FileExists(LnkPath) then Exit;
Cmd := '-NoProfile -ExecutionPolicy Bypass -Command "'
+ '$p='''' + LnkPath + ''''; '
+ '$b=[IO.File]::ReadAllBytes($p); '
+ '$b[21]=$b[21] -bor 0x20; '
+ '[IO.File]::WriteAllBytes($p,$b)"';
Exec(PowerShellPath, Cmd, '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;
function RunInstallStages: String;
var
ResultCode: Integer;
PwFile, Args, Common: String;
begin
Result := '';
Common := '-BundleRoot "' + ExpandConstant('{tmp}\shopdb-bundle') + '"' +
' -AppRoot "' + ExpandConstant('{app}') + '"' +
' -SiteHost "' + SitePage.Values[0] + '"' +
' -SitePort ' + SitePage.Values[1] +
' -OnFailure never' +
' -SitePlugins "' + SelectedPlugins + '"';
// Subpath deployment: an IIS Application under the existing site instead of a
// site of its own. Appended here because Pascal has no conditional expression.
if UsingSubpath then
Common := Common + ' -MountAlias "{#SubpathAlias}"';
if UseBundledDb then
begin
if not RunPowerShell('-Stage 0 ' + Common, ResultCode) or (ResultCode <> 0) then
begin
if FailCause <> '' then
Result := FailCause + #13#10#13#10 + FailDetail
else
Result := 'The bundled MySQL database could not be installed (exit '
+ IntToStr(ResultCode) + ').';
Result := Result + #13#10#13#10 + 'Full details are in:' + #13#10
+ ExpandConstant('{log}');
Exit;
end;
// Stage 0 leaves an ACL'd handoff file that stage 2 picks up, so no password
// is passed here or anywhere else.
Args := Common;
end
else
begin
// The password goes via an ACL'd FILE, never on the command line: command
// lines are readable by any user through Win32_Process and are captured in
// PowerShell transcripts. The installer shreds the file after reading it.
// No password typed on an upgrade means "leave .env alone" - so send no
// password file, and the installer keeps the existing DATABASE_URL.
if DbDetailsPage.Values[4] <> '' then
begin
PwFile := ExpandConstant('{tmp}\dbpw.txt');
SaveStringToFile(PwFile, DbDetailsPage.Values[4] + #13#10, False);
end
else
PwFile := '';
Args := Common +
' -DbHost "' + DbDetailsPage.Values[0] + '"' +
' -DbPort ' + DbDetailsPage.Values[1] +
' -DbName "' + DbDetailsPage.Values[2] + '"' +
' -DbUser "' + DbDetailsPage.Values[3] + '"';
if PwFile <> '' then Args := Args + ' -DbPasswordFile "' + PwFile + '"';
end;
if not RunPowerShell('-Stage all ' + Args, ResultCode) or (ResultCode <> 0) then
begin
if FailCause <> '' then
Result := FailCause + #13#10#13#10 + FailDetail
else
Result := 'The installation could not be completed (exit '
+ IntToStr(ResultCode) + ').';
Result := Result + #13#10#13#10
+ 'Nothing was left running. Full details are in:' + #13#10
+ ExpandConstant('{log}');
end;
end;
procedure CurStepChanged(CurStep: TSetupStep);
var
Failure, FinalUrl: String;
begin
if CurStep = ssPostInstall then
begin
if UsingSubpath then
FinalUrl := 'http://' + SitePage.Values[0] + '/{#SubpathAlias}/login'
else
FinalUrl := 'http://' + SitePage.Values[0] + ':' + SitePage.Values[1] + '/login';
Failure := RunInstallStages;
// After the stages: the shortcuts exist by now, and this is cosmetic enough
// that it must never be able to fail the install.
MarkShortcutRunAs(ExpandConstant('{group}\ShopDB-Flask Console.lnk'));
MarkShortcutRunAs(ExpandConstant('{group}\Restart ShopDB-Flask.lnk'));
MarkShortcutRunAs(ExpandConstant('{group}\Back up the database.lnk'));
// NOTE: never start a line with #13#10. The Inno PREPROCESSOR treats any line
// whose first non-blank character is '#' as a directive and fails with
// "Unknown preprocessor directive" before Pascal parsing happens. Keep the
// concatenation operator at the start of continuation lines instead.
if Failure <> '' then
MsgBox(Failure, mbCriticalError, MB_OK)
else
MsgBox('ShopDB-Flask is installed.' + #13#10#13#10
+ 'Open ' + FinalUrl
+ #13#10#13#10
+ 'With no user in the database that page offers to create the first '
+ 'administrator and then runs the setup wizard.',
mbInformation, MB_OK);
end;
end;
[UninstallRun]
; Delegate to the same tested script rather than duplicating removal logic here.
; It removes the site, app pool, firewall rule and application directory, and
; deliberately does NOT drop the database or uninstall MySQL.
Filename: "powershell.exe"; \
Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-install.ps1"" -Stage uninstall -BundleRoot ""{app}"" -AppRoot ""{app}"" -OnFailure never"; \
RunOnceId: "ShopDBFlaskUninstall"; Flags: waituntilterminated runhidden