; 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. ; 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" ; 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; 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. 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://:8090/'); DeployPage.Add('Under this server''s existing address - http:///{#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', 'How does this server see who is connecting?', 'ShopDB records the address of every request, and some features decide what ' + 'to show based on it. Choose whichever describes this server.', True, False); ClientIpPage.Add('Clients connect to this server directly (installs URL Rewrite)'); ClientIpPage.Add('A proxy or load balancer sits in front of this server'); 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; // 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' + ' -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 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