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).
1003 lines
44 KiB
Plaintext
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
|