From 1102070b350edcbef532e2550c21c0ec8770de52 Mon Sep 17 00:00:00 2001 From: sethdtwigg <94552489+sethdtwigg@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:18:22 -0400 Subject: [PATCH 1/4] Add an installer and a tag-driven release pipeline Inno Setup installer, built and published by a Release workflow. Upgrades and removing the old version: the .iss carries a fixed AppId, which is the identity Windows and Inno use to recognise an existing installation. A newer build therefore upgrades the existing one in place - old files are replaced, the Add/Remove Programs entry is updated rather than duplicated, and the previous uninstaller is superseded. AppMutex names the single-instance mutex the app now holds, so Setup detects a running VerseLink and asks the user to close it rather than failing part way through on a locked exe. Per-user install to %LOCALAPPDATA%\Programs\VerseLink: no UAC prompt, and the install directory stays writable, which matters because the app runs AsInvoker. Deliberately not shipped: config.json. Settings live in %APPDATA%\VerseLink and are created with defaults on first run. A config.json next to the exe would be picked up by the legacy-config migration and could overwrite what an upgrading user actually had. The same reasoning applies to the portable zip. Uninstall keeps settings unless the user says otherwise, and removes the log. Release pipeline: - A v* tag builds, self-tests, packages and publishes. workflow_dispatch runs the same pipeline without publishing, so packaging can be exercised without creating a release that then has to be deleted. - The tag must match Version.h, checked before anything is built, so a release cannot advertise a version its binary does not report. build-installer.ps1 enforces the same thing for local runs, and additionally checks the built exe's own VERSIONINFO. - Packaging is gated on --selftest passing; a build that cannot pass its own checks is never packaged. - Publishing uses gh rather than a third-party action, consistent with the Node 20 cleanup. Also: the zip is named by version rather than by date so it pairs with the installer, and both now share packaging/DISTRIBUTION-NOTES.txt instead of the script generating its own copy. --- .github/workflows/release.yml | 122 ++++++++++++++++++++++++++++++ packaging/DISTRIBUTION-NOTES.txt | 27 +++++++ packaging/VerseLink.iss | 124 +++++++++++++++++++++++++++++++ packaging/build-installer.ps1 | 98 ++++++++++++++++++++++++ packaging/package.ps1 | 27 ++++--- 5 files changed, 384 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 packaging/DISTRIBUTION-NOTES.txt create mode 100644 packaging/VerseLink.iss create mode 100644 packaging/build-installer.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..42ae308 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,122 @@ +name: Release + +# Pushing a v* tag publishes a release. workflow_dispatch runs the same pipeline +# without publishing, so the packaging can be exercised end to end without +# creating a release that then has to be deleted. +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + publish: + description: "Publish a GitHub Release (off: build and upload artifacts only)" + type: boolean + default: false + +jobs: + release: + runs-on: windows-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v7 + + # A tag must match Version.h, or the release would advertise a version the + # binary does not report. build-installer.ps1 enforces this too; doing it + # up front fails in seconds instead of after a full build. + - name: Determine and check version + id: version + shell: pwsh + run: | + $header = "VerseLinkWindows\Version.h" + $match = Select-String -Path $header -Pattern '#define\s+VERSELINK_VERSION_STRING\s+"([^"]+)"' + if (-not $match) { throw "VERSELINK_VERSION_STRING not found in $header" } + $version = $match.Matches[0].Groups[1].Value + Write-Host "Version.h says $version" + + if ("${{ github.ref_type }}" -eq "tag") { + $tag = "${{ github.ref_name }}".TrimStart('v') + if ($tag -ne $version) { + throw "Tag ${{ github.ref_name }} does not match Version.h ($version). Bump Version.h, or tag the version that is actually in the tree." + } + Write-Host "Tag matches Version.h" + } + + "version=$version" >> $env:GITHUB_OUTPUT + + - name: Build application (Release x64) + shell: pwsh + run: | + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $vsRoot = & $vswhere -latest -products * -requires Microsoft.Component.MSBuild -property installationPath + & "$vsRoot\MSBuild\Current\Bin\MSBuild.exe" "VerseLinkWindows\VerseLinkWindows.vcxproj" /p:Configuration=Release /p:Platform=x64 /m /v:m /nologo + exit $LASTEXITCODE + + # Never package a build that cannot pass its own checks. + - name: Run headless self test + shell: cmd + run: | + VerseLinkWindows\x64\Release\VerseLinkWindows.exe --selftest + if errorlevel 1 exit /b 1 + + - name: Install Inno Setup + shell: pwsh + run: choco install innosetup --no-progress -y + + - name: Build installer + shell: pwsh + run: | + $expect = if ("${{ github.ref_type }}" -eq "tag") { "${{ github.ref_name }}" } else { "" } + .\packaging\build-installer.ps1 -SkipBuild -ExpectVersion $expect + + - name: Build portable zip + shell: pwsh + run: .\packaging\package.ps1 -SkipBuild -Platform x64 + + - name: Upload packages as artifacts + uses: actions/upload-artifact@v7 + with: + name: VerseLink-${{ steps.version.outputs.version }} + path: dist/* + if-no-files-found: error + + - name: Publish GitHub Release + if: github.ref_type == 'tag' || inputs.publish + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $version = "${{ steps.version.outputs.version }}" + $tag = if ("${{ github.ref_type }}" -eq "tag") { "${{ github.ref_name }}" } else { "v$version" } + + $notes = @" + ## Install + + Download **VerseLink-$version-Setup.exe** and run it. It installs per-user, so there is no + administrator prompt, and installing over an existing copy upgrades it in place - the old + version is replaced and your settings are kept. + + Close VerseLink before upgrading if it is running; Setup will ask if you forget. + + ## If you previously ran the zip + + An unzipped copy is invisible to the installer and will not be removed automatically - + nothing recorded it as installed. Your settings are carried over on first run, but delete + the old folder yourself, and remove its Startup shortcut if you added one, so you do not + end up running two copies. + + ## Files + + | File | What it is | + |---|---| + | ``VerseLink-$version-Setup.exe`` | Installer (recommended) | + | ``VerseLink-$version-x64-portable.zip`` | Portable copy, no installer | + + Settings and log live in ``%APPDATA%\VerseLink``. Only the King James Version is bundled; + other translations are copyrighted and must be supplied by you. + "@ + + gh release create $tag (Get-ChildItem dist\* | ForEach-Object { $_.FullName }) ` + --title "VerseLink $version" ` + --notes $notes diff --git a/packaging/DISTRIBUTION-NOTES.txt b/packaging/DISTRIBUTION-NOTES.txt new file mode 100644 index 0000000..5298807 --- /dev/null +++ b/packaging/DISTRIBUTION-NOTES.txt @@ -0,0 +1,27 @@ +VerseLink - distribution notes +============================== + +Bible translations +------------------ +This package includes the King James Version (public domain). + +Other translations such as the NASB or ESV are copyrighted and are NOT +included. To use one, obtain the text legally in the same XML format and place +the file into the Bibles folder inside the installation directory, then pick it +in the tray icon's Settings dialog. + +Where your files live +--------------------- +Settings and log: %APPDATA%\VerseLink +Program files: the folder you chose during installation + +Settings are kept when you upgrade. Uninstalling asks before removing them. + +Upgrading from a zip copy +------------------------- +If you previously ran VerseLink from an unzipped folder rather than an +installer, that copy is invisible to this installer and will not be removed +automatically - nothing recorded it as installed. Your settings are carried +over on first run, but you should delete the old folder yourself, and remove +its shortcut from your Startup folder if you added one, so you do not end up +running two copies. diff --git a/packaging/VerseLink.iss b/packaging/VerseLink.iss new file mode 100644 index 0000000..7686c3f --- /dev/null +++ b/packaging/VerseLink.iss @@ -0,0 +1,124 @@ +; VerseLink installer (Inno Setup 6) +; +; Compile through packaging\build-installer.ps1, which supplies AppVersion from +; VerseLinkWindows\Version.h so the installer and the exe can never disagree. +; +; Upgrades: AppId below is the identity Windows and Inno use to recognise an +; existing installation. Because it never changes between releases, installing a +; newer build upgrades the existing one in place - old files are replaced, the +; Add/Remove Programs entry is updated rather than duplicated, and the previous +; uninstaller is superseded. Changing AppId would strand every copy already +; installed as a separate product, so do not. + +#define AppName "VerseLink" +#define AppExeName "VerseLinkWindows.exe" +#define AppPublisher "sethdtwigg" +#define AppURL "https://github.com/sethdtwigg/VerseLinkWindows" + +#ifndef AppVersion + #define AppVersion "0.0.0" +#endif +#ifndef SourceDir + #define SourceDir "..\" +#endif + +[Setup] +AppId={{B60AB966-8EA7-43D5-AF1F-4DD311F75EA9} +AppName={#AppName} +AppVersion={#AppVersion} +AppVerName={#AppName} {#AppVersion} +AppPublisher={#AppPublisher} +AppPublisherURL={#AppURL} +AppSupportURL={#AppURL}/issues +AppUpdatesURL={#AppURL}/releases +VersionInfoVersion={#AppVersion} + +; Per-user install: no UAC prompt, and - the reason it matters - the install +; directory stays writable. VerseLink runs AsInvoker; under Program Files it +; could not write next to itself at all. +PrivilegesRequired=lowest +DefaultDirName={autopf}\{#AppName} +DefaultGroupName={#AppName} +DisableProgramGroupPage=yes + +; The app holds this mutex while running. A tray app keeps its own exe locked, +; so without this an upgrade started while VerseLink is running would fail part +; way through; instead Setup asks the user to close it first. +AppMutex=VerseLinkWindows.SingleInstance +CloseApplications=yes +RestartApplications=no + +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible + +OutputDir={#SourceDir}\dist +OutputBaseFilename=VerseLink-{#AppVersion}-Setup +SetupIconFile={#SourceDir}\VerseLinkWindows\VerseLinkIcon.ico +UninstallDisplayIcon={app}\{#AppExeName} +UninstallDisplayName={#AppName} {#AppVersion} +Compression=lzma2/max +SolidCompression=yes +WizardStyle=modern +; Shown before installing rather than as a licence: it explains that only the +; KJV is bundled and where settings live. No acceptance checkbox. +InfoBeforeFile={#SourceDir}\packaging\DISTRIBUTION-NOTES.txt + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +; A hotkey utility that is not running does nothing, so this is on by default. +Name: "startup"; Description: "Start {#AppName} automatically when I sign in"; GroupDescription: "Additional options:" +Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional options:"; Flags: unchecked + +[Files] +Source: "{#SourceDir}\VerseLinkWindows\x64\Release\{#AppExeName}"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#SourceDir}\VerseLinkWindows\VerseLinkIcon.ico"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#SourceDir}\README.md"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#SourceDir}\packaging\DISTRIBUTION-NOTES.txt"; DestDir: "{app}"; Flags: ignoreversion + +; Only the KJV is redistributable. NASB, ESV and other modern translations are +; copyrighted and must be supplied by the user into this folder. +Source: "{#SourceDir}\Bibles\KJV.xml"; DestDir: "{app}\Bibles"; Flags: ignoreversion + +; config.json is deliberately not installed. Settings live in +; %APPDATA%\VerseLink\config.json, created with defaults on first run; shipping +; one here would be picked up by the app's legacy-config migration and could +; overwrite what an upgrading user actually had. + +[Icons] +Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"; WorkingDir: "{app}" +Name: "{group}\Uninstall {#AppName}"; Filename: "{uninstallexe}" +Name: "{userstartup}\{#AppName}"; Filename: "{app}\{#AppExeName}"; WorkingDir: "{app}"; Tasks: startup +Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; WorkingDir: "{app}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#AppExeName}"; Description: "Launch {#AppName}"; WorkingDir: "{app}"; Flags: nowait postinstall skipifsilent + +[UninstallDelete] +; The log is generated at runtime, so it is not tracked as an installed file. +Type: files; Name: "{userappdata}\{#AppName}\verselink.log" + +[Code] +// Settings are user data, so they are kept by default and only removed if the +// user asks. Silent uninstalls always keep them. +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +var + SettingsDir: String; +begin + if CurUninstallStep = usPostUninstall then + begin + SettingsDir := ExpandConstant('{userappdata}\{#AppName}'); + if DirExists(SettingsDir) then + begin + if not UninstallSilent then + begin + if MsgBox('Also remove your VerseLink settings?' + #13#10 + #13#10 + + SettingsDir + #13#10 + #13#10 + + 'Choose No to keep them for a future install.', + mbConfirmation, MB_YESNO) = IDYES then + DelTree(SettingsDir, True, True, True); + end; + end; + end; +end; diff --git a/packaging/build-installer.ps1 b/packaging/build-installer.ps1 new file mode 100644 index 0000000..36cada6 --- /dev/null +++ b/packaging/build-installer.ps1 @@ -0,0 +1,98 @@ +# Builds VerseLink and compiles the Inno Setup installer into dist\. +# +# The version comes from VerseLinkWindows\Version.h and is passed to the .iss, +# so the installer, the exe's VERSIONINFO and the About dialog can never +# disagree about which build this is. + +param( + [ValidateSet("Release", "Debug")] + [string]$Configuration = "Release", + [switch]$SkipBuild, + # Fail unless Version.h matches this (e.g. "1.1.0"). The release workflow + # passes the tag so a mistagged release cannot ship. + [string]$ExpectVersion +) + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path $PSScriptRoot -Parent +$projDir = Join-Path $repoRoot "VerseLinkWindows" + +function Get-VerseLinkVersion { + $versionHeader = Join-Path $projDir "Version.h" + if (-not (Test-Path $versionHeader)) { throw "Version.h not found at $versionHeader" } + + $match = Select-String -Path $versionHeader -Pattern '#define\s+VERSELINK_VERSION_STRING\s+"([^"]+)"' + if (-not $match) { throw "VERSELINK_VERSION_STRING not found in $versionHeader" } + return $match.Matches[0].Groups[1].Value +} + +function Find-InnoCompiler { + $candidates = @( + "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", + "${env:ProgramFiles}\Inno Setup 6\ISCC.exe" + ) + $found = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 + if ($found) { return $found } + + $onPath = Get-Command ISCC.exe -ErrorAction SilentlyContinue + if ($onPath) { return $onPath.Source } + + throw "Inno Setup 6 not found. Install it (winget install JRSoftware.InnoSetup) or add ISCC.exe to PATH." +} + +$version = Get-VerseLinkVersion +Write-Host "== VerseLink $version ==" -ForegroundColor Cyan + +if ($ExpectVersion) { + $expected = $ExpectVersion.TrimStart('v') + if ($version -ne $expected) { + throw "Version mismatch: Version.h says $version but $expected was expected. Bump Version.h, or tag the version that is actually in the tree." + } + Write-Host " version matches the expected $expected" -ForegroundColor DarkGray +} + +if (-not $SkipBuild) { + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + if (-not (Test-Path $vswhere)) { throw "vswhere.exe not found - install Visual Studio." } + $vsRoot = & $vswhere -latest -products * -requires Microsoft.Component.MSBuild -property installationPath + $msbuild = Join-Path $vsRoot "MSBuild\Current\Bin\MSBuild.exe" + + Write-Host "== Building $Configuration|x64 ==" -ForegroundColor Cyan + & $msbuild (Join-Path $projDir "VerseLinkWindows.vcxproj") /p:Configuration=$Configuration /p:Platform=x64 /m /v:m /nologo + if ($LASTEXITCODE -ne 0) { throw "Build failed with exit code $LASTEXITCODE" } +} + +$exePath = Join-Path $projDir "x64\$Configuration\VerseLinkWindows.exe" +if (-not (Test-Path $exePath)) { throw "Built exe not found: $exePath" } + +# The exe's own resource must agree with Version.h, or the installer would +# advertise a version the installed file does not report. +$exeVersion = (Get-Item $exePath).VersionInfo.FileVersion +if ($exeVersion -and ($exeVersion.Trim() -ne $version)) { + throw "Built exe reports version '$exeVersion' but Version.h says '$version'. Rebuild so the resource is current." +} + +# Refuse to package a build that cannot pass its own checks. +Write-Host "== Self test ==" -ForegroundColor Cyan +Push-Location $repoRoot +try { + & $exePath --selftest + if ($LASTEXITCODE -ne 0) { throw "Self test failed; refusing to package this build." } +} finally { + Pop-Location +} + +$iscc = Find-InnoCompiler +Write-Host "== Compiling installer with $iscc ==" -ForegroundColor Cyan + +$distDir = Join-Path $repoRoot "dist" +New-Item -ItemType Directory -Path $distDir -Force | Out-Null + +& $iscc "/DAppVersion=$version" "/DSourceDir=$repoRoot" (Join-Path $PSScriptRoot "VerseLink.iss") +if ($LASTEXITCODE -ne 0) { throw "Inno Setup failed with exit code $LASTEXITCODE" } + +$installer = Join-Path $distDir "VerseLink-$version-Setup.exe" +if (-not (Test-Path $installer)) { throw "Installer not produced at $installer" } + +Write-Host "== Installer ready ==" -ForegroundColor Green +Get-Item $installer | Select-Object Name, @{n = "SizeMB"; e = { [Math]::Round($_.Length / 1MB, 2) } } diff --git a/packaging/package.ps1 b/packaging/package.ps1 index 94c93aa..32fd3fb 100644 --- a/packaging/package.ps1 +++ b/packaging/package.ps1 @@ -56,8 +56,14 @@ if (-not $SkipBuild -and (Get-Item $exePath).LastWriteTime -lt $buildStart) { Write-Host "== Packaging $exePath ==" -ForegroundColor Cyan -$stamp = Get-Date -Format "yyyyMMdd" -$distName = "VerseLinkWindows-$stamp-$Platform" +# Named by version rather than by date so a zip pairs unambiguously with the +# installer and the release it belongs to. +$versionHeader = Join-Path $projDir "Version.h" +$match = Select-String -Path $versionHeader -Pattern '#define\s+VERSELINK_VERSION_STRING\s+"([^"]+)"' +if (-not $match) { throw "VERSELINK_VERSION_STRING not found in $versionHeader" } +$version = $match.Matches[0].Groups[1].Value + +$distName = "VerseLink-$version-$Platform-portable" $stage = Join-Path $PSScriptRoot "$distName" $zip = Join-Path $repoRoot "dist\$distName.zip" @@ -65,22 +71,15 @@ if (Test-Path $stage) { Remove-Item $stage -Recurse -Force } New-Item -ItemType Directory -Path $stage\Bibles -Force | Out-Null Copy-Item $exePath $stage -Copy-Item (Join-Path $repoRoot "config.json") $stage Copy-Item (Join-Path $projDir "VerseLinkIcon.ico") $stage -ErrorAction SilentlyContinue Copy-Item (Join-Path $repoRoot "README.md") $stage Copy-Item (Join-Path $repoRoot "Bibles\KJV.xml") $stage\Bibles +Copy-Item (Join-Path $PSScriptRoot "DISTRIBUTION-NOTES.txt") $stage -@' -VerseLink Windows - distribution notes -====================================== - -This package includes the King James Version (public domain). - -Other translations such as the NASB or ESV are copyrighted. To use them, -obtain the text legally and place the XML file(s) into the Bibles folder -next to VerseLinkWindows.exe, then pick the version in the tray icon's -Settings dialog. -'@ | Set-Content (Join-Path $stage "DISTRIBUTION-NOTES.txt") +# config.json is deliberately not shipped. Settings live in +# %APPDATA%\VerseLink\config.json and are created with defaults on first run; +# a config.json next to the exe would be picked up by the legacy-config +# migration and could overwrite what an upgrading user actually had. New-Item -ItemType Directory -Path (Split-Path $zip) -Force | Out-Null if (Test-Path $zip) { Remove-Item $zip -Force } From 359d4a198581c4176ae37ae2e19c0496dff1eba5 Mon Sep 17 00:00:00 2001 From: sethdtwigg <94552489+sethdtwigg@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:19:19 -0400 Subject: [PATCH 2/4] Build packaging on its own pull requests workflow_dispatch only works from the default branch, so a packaging change could not be exercised before merging. A scoped pull_request trigger builds the installer and zip whenever packaging, the release workflow or Version.h change. Publishing stays gated on a tag, so these runs only produce artifacts. --- .github/workflows/release.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42ae308..2fa2fca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,14 @@ name: Release on: push: tags: ["v*"] + # Packaging changes are built (never published) on their own pull requests, so + # a broken .iss or script is caught before it can break a real release. The + # publish step below is gated on a tag, so these runs only produce artifacts. + pull_request: + paths: + - "packaging/**" + - ".github/workflows/release.yml" + - "VerseLinkWindows/Version.h" workflow_dispatch: inputs: publish: From 00015c5af426f3737ff7dadbb4385b5892e72124 Mon Sep 17 00:00:00 2001 From: sethdtwigg <94552489+sethdtwigg@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:55:11 -0400 Subject: [PATCH 3/4] Update in place instead of replacing the old version Reworked to match how this should actually behave: - The install location is chosen by the user. The directory page is always shown, and elevation can be picked from the first dialog, so C:\VerseLink or Program Files work as well as the per-user default. The app does not need a writable install directory - its settings live in %APPDATA%. - An existing version is no longer removed. Before the new executable is copied in, the old one is moved to {app}\previous-versions\VerseLinkWindows-.exe, named by the version it reports so successive updates do not collide. Rolling back is copying that file back over VerseLinkWindows.exe. - Settings are untouched, as before: they live in %APPDATA%\VerseLink, which the installer neither writes nor removes, and no config.json is shipped. - A running VerseLink is closed before the update and relaunched afterwards, so an update does not leave the user without their hotkey. Whether it was running is recorded before Setup prompts, and the post-install "Launch VerseLink" checkbox is suppressed in that case so it cannot start a second copy. For the close to be clean, the app now handles WM_CLOSE, WM_QUERYENDSESSION and WM_ENDSESSION. WM_CLOSE previously fell through to DefWindowProc, which destroyed the hidden window but left the message loop and worker thread running - the process stayed alive with no window and no tray icon, and Restart Manager had nothing to work with. Shutdown now runs the same path as the tray's Quit, which also makes sign-out and any external close request behave. Verified: taskkill without /F (a plain WM_CLOSE) now produces "Shutdown requested by the system or an installer" followed by a full clean shutdown, where it previously left the process alive. --- .github/workflows/release.yml | 23 +++-- VerseLinkWindows/VerseLinkWindows.cpp | 22 +++++ packaging/DISTRIBUTION-NOTES.txt | 26 ++++-- packaging/VerseLink.iss | 129 +++++++++++++++++++++++--- 4 files changed, 171 insertions(+), 29 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2fa2fca..76a3262 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,18 +101,25 @@ jobs: $notes = @" ## Install - Download **VerseLink-$version-Setup.exe** and run it. It installs per-user, so there is no - administrator prompt, and installing over an existing copy upgrades it in place - the old - version is replaced and your settings are kept. + Download **VerseLink-$version-Setup.exe** and run it. You choose where it goes - + ``C:\VerseLink``, Program Files, anywhere. It defaults to a per-user location so there is + no administrator prompt; pick "all users" on the first dialog if you want a shared one. - Close VerseLink before upgrading if it is running; Setup will ask if you forget. + ## Updating an existing installation + + Installing over an existing copy does **not** uninstall or delete it. The previous + executable is moved to ``\previous-versions\VerseLinkWindows-.exe`` + before the new one is copied in, so rolling back is just copying that file back. + + Your settings in ``%APPDATA%\VerseLink`` are never touched. If VerseLink is running, Setup + closes it before updating and starts it again afterwards. ## If you previously ran the zip - An unzipped copy is invisible to the installer and will not be removed automatically - - nothing recorded it as installed. Your settings are carried over on first run, but delete - the old folder yourself, and remove its Startup shortcut if you added one, so you do not - end up running two copies. + An unzipped copy is invisible to the installer - nothing recorded it as installed, so it is + neither updated nor removed. Your settings are carried over on first run, but delete the old + folder yourself, and remove its Startup shortcut if you added one, so you do not end up + running two copies. ## Files diff --git a/VerseLinkWindows/VerseLinkWindows.cpp b/VerseLinkWindows/VerseLinkWindows.cpp index 5d9f9cf..b497e8e 100644 --- a/VerseLinkWindows/VerseLinkWindows.cpp +++ b/VerseLinkWindows/VerseLinkWindows.cpp @@ -371,6 +371,28 @@ LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam) return 0; } + switch (uMsg) { + // An outside request to shut down: the installer closing us before it + // replaces the exe, or Windows signing the user out. Restart Manager + // sends WM_QUERYENDSESSION then WM_ENDSESSION to top-level windows, so + // handling these is what lets an upgrade close VerseLink cleanly + // instead of failing on a locked exe. + // + // WM_CLOSE previously fell through to DefWindowProc, which destroyed + // the window but left the message loop and the worker thread running - + // the process stayed alive with no window and no tray icon. + case WM_QUERYENDSESSION: + return TRUE; // yes, we can shut down + + case WM_CLOSE: + case WM_ENDSESSION: + LOG_INFO("Shutdown requested by the system or an installer"); + g_shouldExit = true; + g_taskQueue.Shutdown(); + PostQuitMessage(0); + return 0; + } + return DefWindowProc(hwnd, uMsg, wParam, lParam); } diff --git a/packaging/DISTRIBUTION-NOTES.txt b/packaging/DISTRIBUTION-NOTES.txt index 5298807..54f29ba 100644 --- a/packaging/DISTRIBUTION-NOTES.txt +++ b/packaging/DISTRIBUTION-NOTES.txt @@ -13,15 +13,29 @@ in the tray icon's Settings dialog. Where your files live --------------------- Settings and log: %APPDATA%\VerseLink -Program files: the folder you chose during installation +Program files: the folder you choose during installation -Settings are kept when you upgrade. Uninstalling asks before removing them. +Settings are never touched by the installer. They are kept across updates, and +uninstalling asks before removing them. + +Updating an existing installation +--------------------------------- +Installing a newer build over an existing one does NOT uninstall or delete the +old version. The previous executable is moved to: + + \previous-versions\VerseLinkWindows-.exe + +before the new one is copied in, so you can roll back by copying that file back +over VerseLinkWindows.exe. + +If VerseLink is running, Setup closes it before updating and starts it again +afterwards, so you are not left without your hotkey. Upgrading from a zip copy ------------------------- If you previously ran VerseLink from an unzipped folder rather than an -installer, that copy is invisible to this installer and will not be removed -automatically - nothing recorded it as installed. Your settings are carried -over on first run, but you should delete the old folder yourself, and remove -its shortcut from your Startup folder if you added one, so you do not end up +installer, that copy is invisible to this installer - nothing recorded it as +installed, so it is not updated or removed. Your settings are carried over on +first run, but you should delete the old folder yourself, and remove its +shortcut from your Startup folder if you added one, so you do not end up running two copies. diff --git a/packaging/VerseLink.iss b/packaging/VerseLink.iss index 7686c3f..fe785ad 100644 --- a/packaging/VerseLink.iss +++ b/packaging/VerseLink.iss @@ -3,17 +3,24 @@ ; Compile through packaging\build-installer.ps1, which supplies AppVersion from ; VerseLinkWindows\Version.h so the installer and the exe can never disagree. ; -; Upgrades: AppId below is the identity Windows and Inno use to recognise an -; existing installation. Because it never changes between releases, installing a -; newer build upgrades the existing one in place - old files are replaced, the -; Add/Remove Programs entry is updated rather than duplicated, and the previous -; uninstaller is superseded. Changing AppId would strand every copy already -; installed as a separate product, so do not. +; Update behaviour, by design: +; - The install location is chosen by the user (C:\VerseLink, Program Files, +; anywhere). AppId lets Setup remember and default to where a previous +; version went, but the directory page is always offered. +; - An existing version is NOT uninstalled or deleted. The old executable is +; moved into {app}\previous-versions\VerseLinkWindows-.exe before +; the new one is copied over, so rolling back is a matter of copying a file. +; - Settings are never touched. They live in %APPDATA%\VerseLink, which this +; installer neither writes nor removes, and no config.json is shipped. +; - If VerseLink is running it is closed before the update and relaunched +; afterwards, so an update does not leave the user without their hotkey. #define AppName "VerseLink" #define AppExeName "VerseLinkWindows.exe" #define AppPublisher "sethdtwigg" #define AppURL "https://github.com/sethdtwigg/VerseLinkWindows" +#define AppMutexName "VerseLinkWindows.SingleInstance" +#define BackupDirName "previous-versions" #ifndef AppVersion #define AppVersion "0.0.0" @@ -33,18 +40,29 @@ AppSupportURL={#AppURL}/issues AppUpdatesURL={#AppURL}/releases VersionInfoVersion={#AppVersion} -; Per-user install: no UAC prompt, and - the reason it matters - the install -; directory stays writable. VerseLink runs AsInvoker; under Program Files it -; could not write next to itself at all. +; Default to a per-user location so the common case needs no UAC prompt, but +; allow elevating from the first dialog so somewhere like C:\VerseLink or +; Program Files can be chosen. The app itself does not need a writable install +; directory - its settings live in %APPDATA%. PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog DefaultDirName={autopf}\{#AppName} DefaultGroupName={#AppName} DisableProgramGroupPage=yes +; The directory page is the point of this installer - never skip it, and show +; the chosen path again on the confirmation page. +DisableDirPage=no +AlwaysShowDirOnReadyPage=yes +UsePreviousAppDir=yes + ; The app holds this mutex while running. A tray app keeps its own exe locked, -; so without this an upgrade started while VerseLink is running would fail part -; way through; instead Setup asks the user to close it first. -AppMutex=VerseLinkWindows.SingleInstance +; so without this an update started while VerseLink is running would fail part +; way through. CloseApplications lets Setup shut it down through Restart +; Manager; the app handles WM_QUERYENDSESSION/WM_ENDSESSION so that is a clean +; exit rather than a kill. Restarting is handled in [Code] instead of by +; RestartApplications, so it happens exactly once. +AppMutex={#AppMutexName} CloseApplications=yes RestartApplications=no @@ -59,6 +77,7 @@ UninstallDisplayName={#AppName} {#AppVersion} Compression=lzma2/max SolidCompression=yes WizardStyle=modern + ; Shown before installing rather than as a licence: it explains that only the ; KJV is bundled and where settings live. No acceptance checkbox. InfoBeforeFile={#SourceDir}\packaging\DISTRIBUTION-NOTES.txt @@ -84,7 +103,7 @@ Source: "{#SourceDir}\Bibles\KJV.xml"; DestDir: "{app}\Bibles"; Flags: ignorever ; config.json is deliberately not installed. Settings live in ; %APPDATA%\VerseLink\config.json, created with defaults on first run; shipping ; one here would be picked up by the app's legacy-config migration and could -; overwrite what an upgrading user actually had. +; overwrite what an updating user actually had. [Icons] Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"; WorkingDir: "{app}" @@ -93,13 +112,93 @@ Name: "{userstartup}\{#AppName}"; Filename: "{app}\{#AppExeName}"; WorkingDi Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; WorkingDir: "{app}"; Tasks: desktopicon [Run] -Filename: "{app}\{#AppExeName}"; Description: "Launch {#AppName}"; WorkingDir: "{app}"; Flags: nowait postinstall skipifsilent +; Only offered when VerseLink was not already running - if it was, [Code] +; relaunches it automatically and this would just start a second copy that +; immediately exits on the mutex. +Filename: "{app}\{#AppExeName}"; Description: "Launch {#AppName}"; WorkingDir: "{app}"; \ + Flags: nowait postinstall skipifsilent; Check: NotRunningBeforeInstall [UninstallDelete] -; The log is generated at runtime, so it is not tracked as an installed file. +; Backups of previous executables are created at runtime, so they are not +; tracked as installed files and would otherwise keep {app} from being removed. +Type: filesandordirs; Name: "{app}\{#BackupDirName}" +; The log is generated at runtime. Type: files; Name: "{userappdata}\{#AppName}\verselink.log" [Code] +var + WasRunning: Boolean; + +function InitializeSetup(): Boolean; +begin + // Recorded before Setup asks the user to close the app, so the decision to + // relaunch afterwards reflects whether it was actually running to begin with. + WasRunning := CheckForMutexes('{#AppMutexName}'); + Result := True; +end; + +function NotRunningBeforeInstall(): Boolean; +begin + Result := not WasRunning; +end; + +// Keeps the outgoing executable instead of letting it be overwritten. Named by +// the version it reports, so several updates do not collide and it is obvious +// which build each backup is. +procedure BackupExistingBinary(); +var + ExistingExe, BackupDir, OldVersion, Target: String; +begin + ExistingExe := ExpandConstant('{app}\{#AppExeName}'); + if not FileExists(ExistingExe) then + exit; + + BackupDir := ExpandConstant('{app}\{#BackupDirName}'); + if not DirExists(BackupDir) then + begin + if not ForceDirectories(BackupDir) then + begin + Log('Could not create ' + BackupDir + '; leaving the existing binary in place'); + exit; + end; + end; + + if not GetVersionNumbersString(ExistingExe, OldVersion) then + OldVersion := GetDateTimeString('yyyymmdd-hhnnss', '-', ''); + + Target := BackupDir + '\VerseLinkWindows-' + OldVersion + '.exe'; + + // Same version installed twice: keep the newer backup rather than failing. + if FileExists(Target) then + DeleteFile(Target); + + if RenameFile(ExistingExe, Target) then + Log('Kept the previous executable as ' + Target) + else + Log('Could not move ' + ExistingExe + ' to ' + Target + '; it will be overwritten'); +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + ResultCode: Integer; +begin + if CurStep = ssInstall then + begin + BackupExistingBinary(); + end + else if CurStep = ssPostInstall then + begin + // Put the user back where they were: if VerseLink was running when Setup + // started, it is running when Setup finishes. + if WasRunning then + begin + if not Exec(ExpandConstant('{app}\{#AppExeName}'), '', ExpandConstant('{app}'), + SW_SHOW, ewNoWait, ResultCode) then + Log('Could not relaunch VerseLink after the update'); + end; + end; +end; + // Settings are user data, so they are kept by default and only removed if the // user asks. Silent uninstalls always keep them. procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); From b27598696400d8bc2ae5ed08f3dbfff1cab5c904 Mon Sep 17 00:00:00 2001 From: sethdtwigg <94552489+sethdtwigg@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:59:58 -0400 Subject: [PATCH 4/4] Close a running VerseLink instead of blocking the update on it AppMutex only blocks. It tells the user to close the app and aborts if they do not, which under /SILENT fails outright: Defaulting to Cancel for suppressed message box (OK/Cancel): Please close all instances of it now, then click OK to continue... Got EAbort exception. That is the opposite of the intended behaviour, and it blocked any install while VerseLink was running anywhere - not just an update of the copy being replaced. PrepareToInstall now closes it: WM_CLOSE is posted to the app's hidden window, which the app handles as a clean shutdown, and Setup waits for the single-instance mutex to clear so the exe is genuinely unlocked before being replaced. If it cannot be closed within 15 seconds the user gets a clear message naming the tray icon, rather than a mid-install failure. CloseApplications stays on as a Restart Manager backstop for any other locked file. The window class name is now noted in both the .iss and the source, since the two have to agree. --- VerseLinkWindows/VerseLinkWindows.cpp | 3 ++ packaging/VerseLink.iss | 62 ++++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/VerseLinkWindows/VerseLinkWindows.cpp b/VerseLinkWindows/VerseLinkWindows.cpp index b497e8e..a665ca4 100644 --- a/VerseLinkWindows/VerseLinkWindows.cpp +++ b/VerseLinkWindows/VerseLinkWindows.cpp @@ -471,6 +471,9 @@ int main() WNDCLASS wc = {}; wc.lpfnWndProc = WindowProc; wc.hInstance = GetModuleHandle(nullptr); + // The installer finds this window by class name to ask VerseLink to close + // before it replaces the exe (packaging\VerseLink.iss, AppWindowClass). + // Renaming it breaks that, and updates would fail on a locked file. wc.lpszClassName = L"VerseLinkHiddenWindow"; if (!RegisterClass(&wc)) { diff --git a/packaging/VerseLink.iss b/packaging/VerseLink.iss index fe785ad..2c5155c 100644 --- a/packaging/VerseLink.iss +++ b/packaging/VerseLink.iss @@ -20,6 +20,8 @@ #define AppPublisher "sethdtwigg" #define AppURL "https://github.com/sethdtwigg/VerseLinkWindows" #define AppMutexName "VerseLinkWindows.SingleInstance" +; Must match the class registered in VerseLinkWindows.cpp. +#define AppWindowClass "VerseLinkHiddenWindow" #define BackupDirName "previous-versions" #ifndef AppVersion @@ -56,13 +58,16 @@ DisableDirPage=no AlwaysShowDirOnReadyPage=yes UsePreviousAppDir=yes -; The app holds this mutex while running. A tray app keeps its own exe locked, -; so without this an update started while VerseLink is running would fail part -; way through. CloseApplications lets Setup shut it down through Restart -; Manager; the app handles WM_QUERYENDSESSION/WM_ENDSESSION so that is a clean -; exit rather than a kill. Restarting is handled in [Code] instead of by -; RestartApplications, so it happens exactly once. -AppMutex={#AppMutexName} +; A tray app keeps its own exe locked, so a running VerseLink has to be closed +; before its executable can be replaced. +; +; AppMutex is deliberately NOT used. It only *blocks*: it tells the user to +; close the app and aborts if they do not, which fails outright under /SILENT +; ("Defaulting to Cancel for suppressed message box... Got EAbort exception"). +; Instead PrepareToInstall closes VerseLink itself by posting WM_CLOSE to its +; window, which the app handles as a clean shutdown. CloseApplications stays on +; as a Restart Manager backstop for any other locked file, and restarting is +; done in [Code] rather than by RestartApplications so it happens exactly once. CloseApplications=yes RestartApplications=no @@ -142,6 +147,49 @@ begin Result := not WasRunning; end; +// Closes a running VerseLink the way its own tray Quit does: WM_CLOSE to the +// hidden window, which the app handles by shutting down cleanly (removing its +// tray icon, releasing the hotkey and joining its worker thread). Waits for the +// single-instance mutex to clear so the exe is genuinely unlocked before Setup +// tries to replace it. +function CloseRunningInstance(): Boolean; +var + Wnd: HWND; + Waited: Integer; +begin + Result := True; + if not CheckForMutexes('{#AppMutexName}') then + exit; + + Wnd := FindWindowByClassName('{#AppWindowClass}'); + if Wnd <> 0 then + begin + Log('Asking the running VerseLink to close'); + PostMessage(Wnd, $0010 { WM_CLOSE }, 0, 0); + end + else + Log('VerseLink appears to be running but its window was not found'); + + Waited := 0; + while (Waited < 15000) and CheckForMutexes('{#AppMutexName}') do + begin + Sleep(250); + Waited := Waited + 250; + end; + + Result := not CheckForMutexes('{#AppMutexName}'); + if Result then + Log('Running VerseLink closed after ' + IntToStr(Waited) + ' ms'); +end; + +function PrepareToInstall(var NeedsRestart: Boolean): String; +begin + Result := ''; + if not CloseRunningInstance() then + Result := 'VerseLink is still running and could not be closed automatically.' + #13#10 + + 'Close it from its system tray icon, then run Setup again.'; +end; + // Keeps the outgoing executable instead of letting it be overwritten. Named by // the version it reports, so several updates do not collide and it is obvious // which build each backup is.