Installer, uninstaller and updates
How AW Next is installed, removed and kept up to date on Windows, and the reasons behind the parts that are not electron-builder defaults. BUILD.md covers building, RELEASE_WORKFLOW.md covers publishing.
| File | What it owns |
|---|---|
app/electron-builder.yml |
NSIS options, the 27 installer languages, the GitHub release feed |
app/build/installer.nsh |
Everything custom in the installer and uninstaller |
native/setup-ui/ |
The installer and uninstaller window, an NSIS plug-in in Rust; its text is strings.json |
app/build/x86-unicode/AwSetupUi.dll |
That plug-in, built by node native/setup-ui/build.js |
app/patches/app-builder-lib+26.15.3.patch |
The changes to electron-builder’s NSIS templates the two rely on |
app/electron/init.js (getUpdater, scheduleUpdateCheck, registerUpdaterEvents) |
The in-app updater |
app/electron/updateWindow.js, app/view/update.html |
The update window |
app/util/updateSignature.js |
Which certificates may sign an update |
app/util/updateCacheHousekeeping.js |
Removes an installer that is already installed from the update cache |
app/build/build.js |
Signs the release and refuses one clients would reject, including any packaged .exe that is unsigned, signed by an unpinned certificate, or a native helper that differs from watchdog/native/ (app/build/packagedExecutables.js) |
On this page
The setup window
The installer and the uninstaller keep electron-builder’s NSIS script: its sections, registry keys,
shortcuts, multi-user logic and the command line electron-updater relies on (--updated, /S,
--force-run) are unchanged. What the user sees is drawn by AwSetupUi.dll, a Direct2D window in
the app’s colours and fonts, with the brand mark, rounded corners, the system shadow, keyboard
navigation and progress on the taskbar button.
- One window, three steps.
customPagesininstaller.nshreplaces electron-builder’s page list: the choices (a custom page), NSIS’s own progress page, where the sections run, and the end. NSIS’s dialog stays alive behind the window, fully transparent and click-through, so every page, section and message box of the template keeps working. - The template’s message boxes come to the front. They belong to that invisible dialog, so
electron-builder’s “cannot be closed, retry?” (a file of the installation is in use) or NSIS’s own
file errors opened behind the window and the installer looked frozen.
src/modal.rswatches for them, raises each one above the window, centred on it, and disables the window until it is answered. - No empty first frame. The first frame takes a few hundred milliseconds (fonts, layouts, the mark); the window stays cloaked until it is drawn instead of showing a bare panel meanwhile.
- Its own thread. NSIS sends its main thread a message for every instruction a section runs, to move its progress bar. A window drawing on that thread made each instruction wait for a frame (a scan of the running processes took 30 seconds), so the window runs on a thread of its own.
- Measured progress. The bar follows the run’s steps, each owning a slice in proportion to how
long it takes. The archive extraction reports its real progress (
Nsis7z::ExtractWithCallback), so does the uninstaller’s file removal; a step that cannot report creeps toward the end of its slice without reaching it. - Fallback. If the window cannot be created, the script continues with NSIS’s stock progress
page and default choices. Setting
AW_SETUP_CLASSICin the environment forces that.
Installer
Per user by default (%LOCALAPPDATA%\Programs\Achievement Watcher), with the choice of an
all-users install and of the folder under Options.
- Language. Once the app has run, the language picked in its Settings (
[achievement] langincfg\options.ini) wins, so an update card, a reinstall and the uninstaller speak the app’s language (AW_PICK_APP_LANGUAGE). Otherwise the window and NSIS follow the Windows display language, matched on the primary language (LANGID & 0x3FF), so regional variants (fr-CA, de-CH, es-MX, zh-HK…) get their language instead of English. There is one installer language per bundled app locale (Latin American Spanish shares Spanish): 27. Adding one means adding it toinstallerLanguages, to both pickers ininstaller.nsh, tostrings.jsonand to the picker innative/setup-ui/src/strings.rs;test/updates-security/updateInstall.test.jsandsetupUiStrings.test.jsfail until they agree. The window speaks to the user the way the app does (informally where the app is). - Folder. A picked folder always gets the installation folder’s name appended (
D:\GamesbecomesD:\Games\Achievement Watcher), in the window and again in the script: the uninstaller removes the whole installation folder. The window takes that name from the per-user default the script hands it, so a renamed build (the probe below) appends its own. The window checks free space and, for a per-user install, that the folder can be written, and keeps the user on the options with the reason otherwise. - All users. Choosing it relaunches the installer elevated through electron-builder’s UAC plug-in; the elevated instance reads the choices back from the first one and installs at once. A declined elevation returns to the options, with the choices kept.
- Running app. The window says when AW Next is running (it looks again every second and a half).
The install then closes every process started from the installation folder, which includes the
Watchdog (it runs as
Achievement Watcher.exe watchdog.jsunderELECTRON_RUN_AS_NODE) and the native helpers.AwSetupUi::AppRunningand::CloseAppask Windows directly; electron-builder’s own check starts PowerShell three times for the same answer, and sandboxes such as Hybrid Analysis score an installer starting PowerShell as malicious. - Speed. electron-builder extracts the archive into the installer’s temp folder and copies the
430 MB into place. For a per-user install both folders live in the same profile, so each entry is
moved instead (
customMoveExtractedFiles), which costs nothing; the copy only handles what could not be moved. An all-users install keeps the copy: a moved file keeps the permissions of the user’s temp folder, and other accounts could not read it. - Disk space. Once the files are in place,
AwSetupUi::Compressapplies Windows’ own executable compression (WOF XPRESS16K, what CompactOS uses for the system files) on several threads: about 210 MB on disk instead of 410 MB, read back transparently, in under a second. It asks the file system directly (FSCTL_SET_EXTERNAL_BACKING) rather than runningcompact.exe, for the same sandbox reason as above. It runs incustomInstall, so on updates too and before the app starts, then writes the compressed size asEstimatedSize, the figure Windows lists under Installed apps. The welcome screen announces that size, measured on each build bybuild/afterPack.js(AW_INSTALLED_KB); the free-space check keeps the uncompressed one, since the files are written whole first. A volume without WOF (FAT32, exFAT) keeps the files as they are. - User data is never moved by the installer:
%APPDATA%\Achievement Watcher Nextis only created, and the app imports an olderAchievement Watcher 3.0profile on first launch.
Uninstaller
The window asks once, with one option, off by default: also delete settings, cache, backups and playtime. It measures how much space that frees, and warns that it cannot be undone when ticked.
A real uninstall always removes what the app wrote outside its folder:
| Trace | Why it has to go |
|---|---|
HKCU\...\CurrentVersion\Run\io.github.shirowwww.achievement.watcher and its StartupApproved flag |
Windows would try to start a missing executable at every sign-in |
HKCU\Software\Classes\achievement-watcher |
The protocol the toast buttons open |
%LOCALAPPDATA%\achievement-watcher-updater |
Up to two copies of the installer, 127 MB each |
With the option ticked (or --delete-app-data on a silent uninstall) it also removes
%APPDATA%\Achievement Watcher Next, the older %APPDATA%\Achievement Watcher 3.0, and their
playtime keys under HKCU\Software.
It never touches %APPDATA%\Achievement Watcher or HKCU\Software\Achievement Watcher: they belong
to the unrelated 1.6.8 app, which may still be installed. Upstream electron-builder deleted that
folder on --delete-app-data, because it is named after the install folder; the patch removes that
line.
The installation folder goes in one step. electron-builder renamed each of the ~2100 files
into its temp folder one NSIS instruction at a time (so an update could put them back if one was
busy), then deleted them again on exit: four seconds of every update. customRemoveFiles renames
the folder itself, which fails at once while any file inside is in use, then
AwSetupUi::RemoveTree deletes the renamed tree on several threads. For an update a refused rename
aborts before anything is touched, as before.
An all-users uninstall started without elevation runs twice: the first copy relaunches itself
elevated through UAC and waits. electron-builder’s un.onInit makes the installation folder the
working directory, so the waiting copy held it open and C:\Program Files\<app> survived the
uninstall, empty. The patch moves the waiting copy to $TEMP before it elevates (multiUser.nsh).
An update runs the old version’s uninstaller. electron-builder always passes --updated to it,
and every removal above is skipped when ${isUpdated} is set. Without that, each update would
switch off “Start with Windows” and empty the update cache.
Up to 3.10 the delete-data checkbox did nothing: customUnInstall reset its value to
"0" before reading it, so only --delete-app-data ever deleted anything. For an all-users install
it also pointed at the all-users %APPDATA%. Both are fixed and covered by tests.
Updates
The app checks the GitHub release feed eight seconds after start, then every hour, never during a
game. A new version found by that check is announced by a desktop notification (a Windows toast, or
the desktop’s notification on Linux) and the title-bar chip, so nothing takes the focus from a game
or another app; clicking either opens the update window (app/electron/updateWindow.js).
Settings → Check for updates opens it at once. The window shows the versions, the download size
and the release notes, with Update now, Later and Skip this version. The notes come
from the release body, which the installer’s signature does not cover, so they are reduced to plain
headings, items and paragraphs (app/util/releaseNotes.js) and set as text, never as markup.
After Update now the same window follows the download (size, speed, Cancel) and can be sent to the background; the title-bar chip brings it back, and so does Settings > Check for updates while an offer or a download is under way. When the download lands the app says it is installing and quits, and the installer shows a compact card (versions, progress) until AW Next starts again. An update held back by a running game can be installed from the window anyway.
The window, the title-bar chip and the Settings line read the same state and say the same thing:
“Checking the download” (nothing left to cancel) once every byte is in, and a failed download or
install is reported as such, not as a failed check. A refused signature and a broken download each
get a plain sentence of what happened and what to do; the updater’s own English message stays in
the log, with only its error code (ERR_UPDATER_INVALID_SIGNATURE…) under the title.
Download size
Differential downloads are on. electron-builder publishes a .blockmap with each installer, and
electron-updater fetches only the blocks that changed since the installed version, instead of the
full 127 MB. The base it patches is installer.exe in the update cache, which every install
rewrites from the installer that just ran, so it always matches the running version. If patching
fails for any reason, electron-updater downloads the full installer on its own. If the full
download then fails its checksum, the app clears the cache and tries once more before offering the
release page.
They were switched off in 3.8.6 on the belief that a corrupted base would break every later update. That is not how electron-updater behaves: the patched file is checked inside the differential downloader, and a mismatch there falls back to the full download.
Update cache
electron-updater keeps the last downloaded installer in pending\ until the next update downloads,
beside the installer.exe base: 254 MB for one version. On the first check of each run the app
now removes pending\ once the version in it is installed. A newer download waiting there (held
back while a game runs) is left alone.
Signature
Before installing, the app checks the installer’s Authenticode signature
(app/util/updateSignature.js, app/util/authenticode.js). The check runs inside the app and
starts no other program: behaviour engines such as Bitdefender’s blocked the PowerShell process it
used before. It refuses:
- an unsigned installer;
- an installer signed by anyone but
CN=Shirow; - a
CN=Shirowinstaller whose certificate thumbprint is not pinned; - a file modified after it was signed;
- a signature that does not verify against the pinned key. The certificate block of any release is public, so the app verifies each signature layer against its certificate and recomputes the PE hash it covers, rather than trusting a block that only names the signer.
The SHA-512 in latest.yml is not a substitute: it comes from the same release as the installer,
so whoever can replace one can replace the other. A common name is no proof either, since anyone
can issue themselves a CN=Shirow certificate. The thumbprint is what ties an update to the
project’s own key.
If the installer cannot be read or is not a Windows executable, the update is refused and the message points to the GitHub releases page for a manual download. A check that cannot answer never counts as a pass.
The check runs again when the update finishes downloading and just before the installer starts, on the same file with its SHA-512 compared between the two. electron-updater reuses an installer cached by a previous launch without verifying its signature, so a cached file that fails is deleted.
Certificates, and what happens if one is lost
Two certificates are pinned:
File (in app/build/signing/, never committed) |
Role | Thumbprint |
|---|---|---|
Shirow.pfx + .password |
Signs every release, valid until 2031-08-04 | 2E581B204231D7EED9E33E798B5E0C503AD8FEDC |
Shirow-standby.pfx + .password-Shirow-standby |
Kept offline, never used until a rotation | F64838216091CCC975320E8A2D50F4F403837667 |
Back up the whole app/build/signing/ folder somewhere offline. If both private keys are lost,
every installed copy refuses every later update, and users can only move forward by downloading an
installer by hand.
Rotation, when Shirow.pfx expires, leaks or is lost:
- Promote the standby: rename
Shirow-standby.pfxtoShirow.pfxand.password-Shirow-standbyto.password. - Create a new standby:
powershell -ExecutionPolicy Bypass -File build/signing/create-self-signed-cert.ps1 -FileName Shirow-standby -Years 10. - In
PINNED_THUMBPRINTS, keep the promoted thumbprint, add the new standby, and remove the old release thumbprint only if it leaked. - Release. Clients that already trust the promoted certificate take this update and learn the new standby from it.
npm run build refuses to finish when the installer’s certificate is not pinned, and warns loudly
when there is no certificate at all.
Portable ZIP
The portable build has no app-update.yml, so its update checks used to fail with an error balloon.
It now reads the same release feed and, when a new version is out, offers the release page in the
update window: extracting the new archive over the folder keeps the data folder. It never
downloads or runs the installer.
With no installer to compress it, the extracted folder is compressed by the Watchdog half a minute
after the first start (watchdog/util/installCompression.js, the same request as the plug-in), and
again after the files are replaced by a newer archive. The data folder is left alone.
Testing without touching the real install
The window alone, without building an installer: cd native/setup-ui, then
cargo run --release --example preview -- install 1036 (also upgrade, update, uninstall,
error; the number is a Windows language id). It drives the DLL the way NSIS does, with a
simulated install.
A whole installer: build a renamed copy so it cannot collide with the installed app. From app/,
after node ../native/setup-ui/build.js:
node node_modules/electron-builder/cli.js --config electron-builder.yml --publish never --win nsis `
-c.appId=io.github.shirowwww.awprobe -c.productName="AW Update Probe" `
-c.executableName="AW Update Probe" -c.win.executableName="AW Update Probe" `
'-c.win.artifactName=AW.Probe.Setup.${version}.${ext}' -c.nsis.shortcutName="AW Update Probe" `
-c.nsis.uninstallDisplayName="AW Update Probe" -c.nsis.differentialPackage=false `
-c.directories.output=dist-probe -c.extraMetadata.name=aw-update-probe
then restore app/package.json, which electron-builder rewrites. Adding
--prepackaged <copy of dist\win-unpacked with the exe renamed to AW Update Probe.exe> builds only
the installer, in seconds. Install it, run <setup>.exe --updated --force-run to exercise the
update path (-c.extraMetadata.version= gives a second build to update to), and uninstall it.
With AW_SETUP_TRACE set to a file path, each step of a run is written there with its time.
The probe still shares two things with the real install, because they are fixed names rather than
derived from the app id: the %APPDATA%\Achievement Watcher Next folder (never tick the delete-data
option when uninstalling the probe) and the achievement-watcher protocol key (removed by the
probe’s uninstall, registered again by the next launch of the real app). A launched probe also
rewrites the “Start with Windows” value to its own path. To test deleting data or the language
setting, build the probe from a copy of build/installer.nsh with Achievement Watcher Next and
Achievement Watcher 3.0 replaced by throwaway names.
The update path end to end: point the probe at a local feed (a copy of electron-builder.yml whose
publish is provider: generic with url: http://127.0.0.1:<port>/), sign it with the release
certificate (CSC_LINK, CSC_KEY_PASSWORD), serve the next version’s latest.yml, installer and
blockmap over HTTP with byte ranges, and start the installed probe with
--user-data-dir=<throwaway folder> so it does not hand over to the running app. An unsigned build
on the same feed exercises the refusal.
A sandboxed or elevated shell may not see the registry the installer writes:
start installers through explorer.exe and read their keys from a process started the same way.