Asked why the password box does not pre-fill from .dbpass. It should not - but it should not have been demanding a password either. .dbpass is the ACL'd handoff stage 0 writes when it creates the database itself, and stage 2 already reads it automatically when no password is supplied. The wizard, though, required a password whenever .env was absent, without checking for the handoff. On a server where stage 0 had completed but stage 2 had not - which is exactly what a partly-failed install leaves - the operator was blocked on a secret the installer already had, and sent hunting for a generated password they were never meant to handle. Blank is now accepted when either .env or .dbpass is present, and the sign-in page says so when it sees a handoff. Deliberately NOT pre-filled into the password box, for two reasons. It is the only copy of a generated password, so round-tripping it through a UI control and back out through a temporary password file adds exposure for no benefit - stage 2 reads the file directly. And .dbpass belongs to the BUNDLED database; on the existing-database page the operator is pointing at someone else's server, where a locally generated password is simply the wrong answer.
1212 lines
56 KiB
Plaintext
1212 lines
56 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.4 LTS 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.
|
|
|
|
; Inno Setup 6.6.0 is the floor: WizardStyle below uses the built-in 'windows11'
|
|
; custom style, which 6.5 and earlier reject. Stated here so a build box on an
|
|
; older compiler fails with this sentence rather than with a bare
|
|
; "Value of [Setup] section directive WizardStyle is invalid".
|
|
#if VER < EncodeVer(6,6,0)
|
|
#error This script needs Inno Setup 6.6.0 or newer (WizardStyle=... windows11). Download it from https://jrsoftware.org/isdl.php
|
|
#endif
|
|
|
|
#define AppName "ShopDB-Flask"
|
|
; The PRODUCT version, generated into version.iss by the builder from
|
|
; shopdb/__init__.py. It was hardcoded, and had drifted to 0.9.0 while the
|
|
; product, the frontend and the newest tag all said 0.7.0 - so the delivered exe,
|
|
; its Add/Remove Programs entry and the version stamp written onto the server all
|
|
; disagreed with the code inside it, and 0.9.0 collided with a retired contract
|
|
; version. Never hardcode it here again.
|
|
;
|
|
; Do NOT bump a version to work around a locked output file: the BUILD STAMP
|
|
; below makes every compile a unique filename, which is what that problem needed.
|
|
#include "version.iss"
|
|
#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.
|
|
; The installer supplies Python, the wheels, the IIS modules and optionally MySQL.
|
|
; It does NOT install the IIS Web Server role itself - that is a Windows feature,
|
|
; and the check on the next page confirms it is present rather than adding it.
|
|
WelcomeLabel2=Everything this application needs is included - Python, its packages, the IIS modules and optionally MySQL. No internet connection is used at any point.%n%nThis server must already have the IIS Web Server role installed. The next page checks that, and everything else this needs, before anything is changed.%n%nYou will then be asked a few short questions. Nothing on this server is changed until the final confirmation.
|
|
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.
|
|
;
|
|
; EVERY shortcut passes -AppRoot and -SitePort. The console defaults to
|
|
; C:\shopdb-flask and port 8090, so on any install that chose a different
|
|
; directory or port it looked in the wrong place and reported a perfectly healthy
|
|
; site as broken - from a Start Menu shortcut the installer wrote itself.
|
|
Name: "{group}\ShopDB-Flask Console"; Filename: "powershell.exe"; \
|
|
Parameters: "-NoExit -NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-admin.ps1"" -AppRoot ""{app}"" -SitePort {code:SitePortValue}"; \
|
|
WorkingDir: "{app}"; IconFilename: "{app}\shopdb.ico"; \
|
|
Comment: "Status, restart, logs and backups"
|
|
; Resolves the address from .env at click time rather than baking one in. The
|
|
; literal http://localhost:8090/login this used to carry was wrong for every
|
|
; subpath install and every non-default port, with no operator mistake involved.
|
|
Name: "{group}\Open ShopDB-Flask"; Filename: "powershell.exe"; \
|
|
Parameters: "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""{app}\shopdb-admin.ps1"" open -AppRoot ""{app}"" -SitePort {code:SitePortValue}"; \
|
|
WorkingDir: "{app}"; 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 -AppRoot ""{app}"" -SitePort {code:SitePortValue}"; \
|
|
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 -AppRoot ""{app}"" -SitePort {code:SitePortValue}"; \
|
|
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"" -AppRoot ""{app}"" -SitePort {code:SitePortValue}"; \
|
|
Description: "Open the ShopDB-Flask console"; \
|
|
Flags: postinstall skipifsilent unchecked
|
|
|
|
[Code]
|
|
var
|
|
DbChoicePage: TInputOptionWizardPage;
|
|
DbDetailsPage: TInputQueryWizardPage;
|
|
DbCredsPage: TInputQueryWizardPage;
|
|
SitePage: TInputQueryWizardPage;
|
|
PreflightPage: TWizardPage;
|
|
BannerPanel: TPanel;
|
|
BannerBar: TBitmapImage;
|
|
BannerText: TNewStaticText;
|
|
BannerSub: TNewStaticText;
|
|
DetailPanel: TPanel;
|
|
FooterText: TNewStaticText;
|
|
RecheckButton: TNewButton;
|
|
// Set by the preflight render. The wizard REFUSES to leave the results page
|
|
// while this is True: the check used to be advisory, so a server missing IIS
|
|
// sailed through every page and failed in the middle of installing, having
|
|
// already put Python on the box.
|
|
PreflightBlocked: Boolean;
|
|
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;
|
|
ClientIpPage: TInputOptionWizardPage;
|
|
// 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.
|
|
// Pre-tick to match each plugin's own manifest. These five ship
|
|
// "default_enabled": false, so a site gets them only by asking. The list used to
|
|
// omit measuringtools and printedparts, which meant the wizard installed and
|
|
// enabled them against their manifests on every site that took the defaults -
|
|
// creating tables nobody asked for.
|
|
//
|
|
// Keep this in step with the manifests. tests/test_installer_defaults.py fails
|
|
// if they drift.
|
|
function PluginDefault(const Name: String): Boolean;
|
|
begin
|
|
Result := (Name <> 'usb') and (Name <> 'employees') and (Name <> 'geenforce')
|
|
and (Name <> 'measuringtools') and (Name <> 'printedparts');
|
|
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;
|
|
|
|
// Defined further down, next to the preflight rendering they drive. Declared here
|
|
// because InitializeWizard wires RecheckClick to a button before that point.
|
|
procedure RunPreflight; forward;
|
|
procedure RecheckClick(Sender: TObject); forward;
|
|
|
|
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 - ScaleX(84);
|
|
FooterText.AutoSize := False;
|
|
FooterText.Caption := '';
|
|
|
|
// A blocking page needs a way forward that is not "cancel the installer". The
|
|
// operator fixes what the page told them to fix - installs the IIS role, frees
|
|
// the port - and re-checks without starting over.
|
|
RecheckButton := TNewButton.Create(WizardForm);
|
|
RecheckButton.Parent := PreflightPage.Surface;
|
|
RecheckButton.Width := ScaleX(78);
|
|
RecheckButton.Height := ScaleY(23);
|
|
RecheckButton.Left := PreflightPage.SurfaceWidth - RecheckButton.Width;
|
|
RecheckButton.Top := PreflightPage.SurfaceHeight - ScaleY(23);
|
|
RecheckButton.Caption := 'Check again';
|
|
RecheckButton.OnClick := @RecheckClick;
|
|
|
|
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?',
|
|
'Unticked features are simply not set up, and their database tables are not ' +
|
|
'created. On an upgrade this shows what the site already has - ticking a new ' +
|
|
'box adds it, but UNTICKING one does NOT remove a feature that is already ' +
|
|
'installed. Removing is a deliberate step, not a side effect of an upgrade.',
|
|
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.4 LTS (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 - the installer does ' +
|
|
'not create them, so it never needs administrative rights on your database ' +
|
|
'server. The exact SQL to give your DBA is in the INSTALL-WINDOWS guide.');
|
|
DbDetailsPage.Add('Host:', False);
|
|
DbDetailsPage.Add('Port:', False);
|
|
DbDetailsPage.Add('Database:', False);
|
|
DbDetailsPage.Values[0] := '127.0.0.1';
|
|
DbDetailsPage.Values[1] := '3306';
|
|
DbDetailsPage.Values[2] := 'shopdb_flask';
|
|
|
|
// SPLIT ACROSS TWO PAGES on purpose. CreateInputQueryPage stacks its fields
|
|
// below the description and does not scroll or shrink: with five fields the
|
|
// last one is drawn past the bottom of the surface and simply does not appear.
|
|
// Trimming the description bought one field back and lost the next, which is
|
|
// guessing at a pixel budget that varies with DPI and font. Three fields and
|
|
// two fields both fit under any reasonable description, at any scaling.
|
|
DbCredsPage := CreateInputQueryPage(DbDetailsPage.ID,
|
|
'Existing database', 'Sign-in',
|
|
'The account the application uses to reach that database. It needs full ' +
|
|
'rights on it, and nothing outside it.');
|
|
DbCredsPage.Add('Username:', False);
|
|
DbCredsPage.Add('Password:', True);
|
|
DbCredsPage.Values[0] := '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(DbCredsPage.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';
|
|
|
|
// Where the real client IP comes from. Not cosmetic: IIS sends no
|
|
// X-Forwarded-For of its own, so with neither option applied every request
|
|
// reads as 127.0.0.1 and the GE-Enforce IP allowlist, the dashboard's
|
|
// visitor-location lookup and per-host login rate limiting all fail silently.
|
|
//
|
|
// The two answers are mutually exclusive, and picking the wrong one is worse
|
|
// than picking neither: the rule OVERWRITES the header with REMOTE_ADDR, which
|
|
// is exactly right when IIS faces clients (it defeats spoofing) and exactly
|
|
// wrong behind a proxy (REMOTE_ADDR is the proxy, so the real client IP is
|
|
// discarded). Hence a question rather than a default.
|
|
ClientIpPage := CreateInputOptionPage(SitePage.ID,
|
|
'Client addresses', 'Does anything sit between your users and this server?',
|
|
'ShopDB records the address of every request, and some features decide what '
|
|
+ 'to show based on it. If you are not sure, choose the first option - it is '
|
|
+ 'correct for a server users reach directly.',
|
|
True, False);
|
|
ClientIpPage.Add('No - users reach this server directly (installs URL Rewrite)');
|
|
// Phrased as "already adds the visitor's address" rather than "is a proxy":
|
|
// the operator can check that with their network team, whereas "is there a
|
|
// proxy" invites a guess, and guessing wrong here silently discards the real
|
|
// client address on every request.
|
|
ClientIpPage.Add('Yes - a load balancer or gateway that already adds the visitor''s address');
|
|
ClientIpPage.SelectedValueIndex := 0;
|
|
end;
|
|
|
|
// 'direct' installs URL Rewrite from the bundle and enables the X-Forwarded-For
|
|
// rule; 'proxy' leaves both alone because the proxy already sets the header.
|
|
function ClientIpSourceArg: String;
|
|
begin
|
|
if ClientIpPage.SelectedValueIndex = 1 then
|
|
Result := 'proxy'
|
|
else
|
|
Result := 'direct';
|
|
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;
|
|
|
|
// Run the read-only check and paint its results. Called from Next on the welcome
|
|
// page, and again whenever the operator fixes something and presses Check again.
|
|
//
|
|
// Stage 1 changes nothing, so running it before the operator commits to anything
|
|
// is free, and it catches the blockers that are expensive later: IIS absent,
|
|
// locked config sections, the port in use, MySQL 5.6 missing its index flags.
|
|
procedure RunPreflight;
|
|
var
|
|
I, ResultCode: Integer;
|
|
Output: TExecOutput;
|
|
PreflightScript, Detail: String;
|
|
HasBlockers: Boolean;
|
|
begin
|
|
// 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);
|
|
|
|
// Every run repaints from scratch. The old code rendered ONCE and latched,
|
|
// so a re-check could not have shown a different answer even if the operator
|
|
// had fixed everything.
|
|
while DetailPanel.ControlCount > 0 do
|
|
DetailPanel.Controls[0].Free;
|
|
|
|
PreflightDone := True;
|
|
if (not Output.Error) and (GetArrayLength(Output.StdOut) > 0) then
|
|
begin
|
|
RenderPreflight(Output.StdOut, HasBlockers);
|
|
PreflightBlocked := HasBlockers;
|
|
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 := 'Nothing has been verified. Continuing is a risk.';
|
|
AddRow(ScaleY(4), 'swatch-warn.bmp',
|
|
'Check did not run (exit ' + IntToStr(ResultCode) + ')', Detail);
|
|
FooterText.Caption := 'Nothing has been changed on this server.';
|
|
// A check that could not RUN is not a check that PASSED. It does not block -
|
|
// there is no evidence of a problem - but it must not read like a green light.
|
|
PreflightBlocked := False;
|
|
end;
|
|
finally
|
|
CheckingPage.Hide;
|
|
end;
|
|
end;
|
|
|
|
procedure RecheckClick(Sender: TObject);
|
|
begin
|
|
RunPreflight;
|
|
end;
|
|
|
|
// The port the operator chose, for the [Icons]/[Run] entries. A subpath install
|
|
// is reached on the parent site's port, and the console works that out from
|
|
// MOUNT_PATH in .env, so the value only has to be right for the own-site case.
|
|
function SitePortValue(Param: String): String;
|
|
begin
|
|
Result := SitePage.Values[1];
|
|
if Trim(Result) = '' then Result := '8090';
|
|
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;
|
|
begin
|
|
Result := True;
|
|
|
|
if CurPageID = wpWelcome then
|
|
RunPreflight;
|
|
|
|
// The results page BLOCKS while anything is failing. It used to be advisory:
|
|
// the operator read "IIS is not installed", pressed Next, answered five more
|
|
// pages, and the install then died partway through with Python already on the
|
|
// box. Refusing here costs them nothing - the server is untouched at this
|
|
// point - and the page already says what to do about each failure.
|
|
if CurPageID = PreflightPage.ID then
|
|
begin
|
|
if PreflightBlocked then
|
|
begin
|
|
MsgBox('This server is not ready yet.' + #13#10#13#10
|
|
+ 'Each problem above says what to do about it. Fix them, then choose '
|
|
+ '"Check again".' + #13#10#13#10
|
|
+ 'Nothing has been changed on this server.', mbError, MB_OK);
|
|
Result := False;
|
|
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;
|
|
end;
|
|
end;
|
|
|
|
if CurPageID = DbCredsPage.ID then
|
|
begin
|
|
if not UseBundledDb then
|
|
begin
|
|
if DbCredsPage.Values[0] = '' then
|
|
begin
|
|
MsgBox('Enter the username the application connects with.', mbError, MB_OK);
|
|
Result := False;
|
|
end
|
|
// Blank is allowed when the password is already ON DISK in a form the
|
|
// installer can read: .env from a previous install, or the ACL'd .dbpass
|
|
// handoff stage 0 leaves when it created the database itself. Stage 2
|
|
// consumes .dbpass on its own, so demanding the password here blocked the
|
|
// operator on something the installer already had - and sent them looking
|
|
// for a generated secret they were never meant to handle.
|
|
else if (DbCredsPage.Values[1] = '') and
|
|
(not FileExists(InstalledDir + '.env')) and
|
|
(not FileExists(InstalledDir + '.dbpass')) 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;
|
|
// A stage-0 handoff means the database was created by a previous run of this
|
|
// installer and the password is already on disk. Nobody should be asked to
|
|
// find or retype a secret the installer generated.
|
|
if FileExists(InstalledDir + '.dbpass') then
|
|
DbCredsPage.SubCaptionLabel.Caption :=
|
|
'This server already has a database created by this installer. Leave the '
|
|
+ 'password blank and it will be used automatically.';
|
|
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
|
|
DbCredsPage.Values[0] := 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.
|
|
DbCredsPage.Values[1] := '';
|
|
DbDetailsPage.SubCaptionLabel.Caption :=
|
|
'These are the settings this server is using now.';
|
|
// The hint belongs where the password field actually is - it moved to
|
|
// the next page when this one was split.
|
|
DbCredsPage.SubCaptionLabel.Caption :=
|
|
'Leave the password blank to keep the one this server already uses.';
|
|
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) or (PageID = DbCredsPage.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' +
|
|
' -ClientIpSource ' + ClientIpSourceArg +
|
|
' -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 DbCredsPage.Values[1] <> '' then
|
|
begin
|
|
PwFile := ExpandConstant('{tmp}\dbpw.txt');
|
|
SaveStringToFile(PwFile, DbCredsPage.Values[1] + #13#10, False);
|
|
end
|
|
else
|
|
PwFile := '';
|
|
Args := Common +
|
|
' -DbHost "' + DbDetailsPage.Values[0] + '"' +
|
|
' -DbPort ' + DbDetailsPage.Values[1] +
|
|
' -DbName "' + DbDetailsPage.Values[2] + '"' +
|
|
' -DbUser "' + DbCredsPage.Values[0] + '"';
|
|
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) + ').';
|
|
// NOT "nothing was left running". The stages run with -OnFailure never, so
|
|
// nothing is rolled back: whatever had been created by the failing point is
|
|
// still there. Claiming otherwise sent operators away believing the server
|
|
// was clean when it was half-configured, and the next thing they did was
|
|
// install again on top of it.
|
|
Result := Result + #13#10#13#10
|
|
+ 'This server has been part-configured. Whatever had been done '
|
|
+ 'before the failure is still in place.' + #13#10#13#10
|
|
+ 'What to do:' + #13#10
|
|
+ ' - Fix the cause above, then run this installer again. Re-running '
|
|
+ 'is safe and picks up where it left off.' + #13#10
|
|
+ ' - Or remove it entirely from Settings > Apps.' + #13#10#13#10
|
|
+ 'Full details, including everything that was created, 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
|
|
begin
|
|
MsgBox(Failure, mbCriticalError, MB_OK);
|
|
// The final page is headed "ShopDB-Flask is ready" from [Messages]. After a
|
|
// failed install that is the last thing the operator reads, and it
|
|
// contradicts the error box they just dismissed. Setup cannot be made to
|
|
// fail from here, so at least stop it claiming success.
|
|
WizardForm.FinishedHeadingLabel.Caption := 'ShopDB-Flask is NOT installed';
|
|
WizardForm.FinishedLabel.Caption :=
|
|
'The installation did not complete. This server has been part-configured.'
|
|
+ #13#10#13#10
|
|
+ 'Fix the problem reported above and run this installer again - re-running '
|
|
+ 'is safe. Or remove it from Settings > Apps.'
|
|
+ #13#10#13#10
|
|
+ 'The log is at ' + ExpandConstant('{log}');
|
|
end
|
|
else
|
|
begin
|
|
// Recorded so the Start Menu shortcut and the console open the address this
|
|
// install actually serves, rather than a compile-time guess.
|
|
SaveStringToFile(ExpandConstant('{app}\.installed-url'), FinalUrl, False);
|
|
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.'
|
|
+ #13#10#13#10
|
|
+ 'Day-to-day: use the ShopDB-Flask Console in the Start Menu, or read '
|
|
+ ExpandConstant('{app}\docs\OPERATE-WINDOWS.md'),
|
|
mbInformation, MB_OK);
|
|
end;
|
|
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
|