Skip to content

Design Decisions

This page captures non-obvious decisions about how the launcher works, with the reasoning behind each. Future maintainers should consult this before "fixing" something that looks wrong.

File comparison: length + timestamp, not hash

The launcher's hot-path comparison checks length and timestamp only: 1. Length differs → copy. 2. Source timestamp newer than dest by more than 1 second → copy. 3. Otherwise → skip.

It does not read file contents or compute hashes during normal operation.

Why not always hash?

Hashing every file on every launch would mean reading every byte over the network, even for files that haven't changed since the last sync. With a typical install of 150-300 files totaling tens of megabytes, that's seconds of unnecessary I/O on every launch.

The length-and-timestamp check catches every realistic update scenario: - Real updates almost always change the file length, the timestamp, or both. - A rebuild that produces byte-identical output with the same length and an earlier timestamp is so vanishingly rare in practice that paying network cost for it on every launch is the wrong tradeoff.

For the rare case where it matters, the /verify flag exists.

The 1-second timestamp tolerance

AreFilesDifferent only treats source as "newer" if it beats destination by more than 1 second. Without this tolerance, byte-identical files would appear different and recopy on every launch.

Why this is necessary

Filesystems disagree on timestamp precision: - NTFS stores ~100ns resolution. - SMB sometimes rounds. - FAT was 2-second resolution.

After File.Copy, the destination's LastWriteTime can drift a few milliseconds from the source even though the file is byte-identical. Without tolerance, the next launch sees "source is newer" and recopies — every file, every launch.

We picked 1 second because: - It's longer than any realistic filesystem rounding (millisecond-scale). - It's shorter than any realistic real-world update (humans don't replace files within 1 second of each other in production deployments).

If you ever see spurious recopies in the log, this tolerance might need adjusting — but more likely something else is wrong.

Per-file failures don't throw

When a copy fails (file locked, AV interference, etc.), ClientUpdater records the failure into result.Failures and continues processing the remaining files.

Why not throw on first failure?

Original behavior was throw-on-first-failure, which created two problems: 1. The user can't tell what's broken. A single throw means they know one thing failed but nothing about what else was tried. 2. Retry costs increase. If 5 files are locked and you fail on the first, the user has to re-run the launcher to discover the second, etc.

Current design: - Each file is attempted independently. Other files don't depend on its success. - All failures are aggregated into a single dialog at the end. - The user closes the offending app once and clicks Retry, and the entire retry runs against the still-failed set.

The trade-off: slightly more code complexity in ProcessFileCopies for far better UX in failure cases.

Cancel does NOT launch the application

When sync (or clean) fails and the user clicks Cancel on the failure dialog, the launcher exits without starting Pulse Dashboard.

Why this is the right default

The whole point of the launcher is to keep the local install in sync. If sync didn't complete: - Some files may be the new version, others may be the old version. - Mixed versions almost always cause subtle bugs that look like application issues. - The user is better served by a clear "something's wrong, fix it before proceeding" than by a half-broken application.

If the user really needs to launch despite sync failure, they can close the launcher, manually run the local Pulse Dashboard.exe, and skip the sync entirely. We don't make that easy because it's almost never the right thing to do.

Exempt subfolders: populate-once, then sacred

Two subfolders are special: Settings and Pulse Logs. The launcher: - Allows initial population if the folder doesn't exist locally yet — on a fresh install, the server's contents seed the local copy. - Never overwrites local files in these folders once they exist. - Never deletes files from them during cleanup. - Won't recurse into them looking for orphan files.

Why these specific folders?

  • Settings\ — holds the SQL connection configuration that links the installation to a specific database. The server provides defaults so a first-time user has a working connection out of the box without manual configuration. After first launch, the local copy may be edited by the user, IT, or the dashboard application itself, and the launcher must not overwrite those local changes on subsequent syncs. Note that user preferences (saved layouts, recent items, etc.) are not stored here — those live in the database the user connects to.
  • Pulse Logs\ — where the launcher's own log lives. If the launcher cleaned up its own log file, debugging would be much harder.

Why "populate-once" instead of "never touch"?

The exemption check in GetCopyExemptionReason is:

If IsInExemptSubfolder(destFile, exemptFolder) AndAlso File.Exists(destFile) Then
    Return $"in protected subfolder '{exemptFolder}'"
End If

The AndAlso File.Exists(destFile) means: only exempt if the destination already has the file. This permits initial population on a brand-new client folder, then locks the contents from that point forward. The result is a turnkey first-run experience plus full local control afterward.

Why not make exemptions configurable?

We considered an exemptions.txt file or similar. The current 2-folder exemption set is enough for the application's needs, and adding configurability introduces failure modes (typos, missing files, malformed config). When we need a third exemption, we'll add it — but we'll do it in code where it's visible and reviewable.

The launcher never copies itself

GetCopyExemptionReason returns a non-null reason for any file whose name matches the running launcher's filename. So the launcher never lands in the client folder.

Why not?

Two reasons: 1. It would lock itself. The launcher .exe is open while it's running. If it tried to copy a new version of itself over its own running instance, the copy would fail (Windows file lock). 2. It's pointless. The launcher is meant to run from the network share directly. The user always has access to the latest version because they run it from the share, not from their local install.

Cleanup also preserves the running launcher

If a stale copy of the launcher already exists in the client folder (from a previous design where it was copied), cleanup will delete it — unless it's the file the launcher is currently running from. The "currently running" check uses full-path comparison, not just filename.

Why we use EnumerateFiles(AllDirectories) not recursive walk

Originally:

For Each f In Directory.GetFiles(folder)
For Each sub In Directory.GetDirectories(folder)
    [recurse into sub]

Now:

For Each f In Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories)

Why the change

Directory.GetFiles and Directory.GetDirectories each issue an SMB query per call. The recursive version means 2 round trips per folder in the tree. For a folder with deep nesting and many subfolders, that adds up fast.

EnumerateFiles(AllDirectories) lets the OS handle traversal. Internally it can stream entries from the server in larger batches, with fewer round-trips.

In testing, this change roughly halved the scan-phase time on a moderate network share.

The splash form is a child of Main (sort of)

Main is the application's main form (so the message loop runs through it) but it's hidden (Opacity 0, minimized, not in taskbar). The visible UI during sync is MainSplash, which is shown as a top-level form.

Why this dance

WinForms requires a "main form" to anchor the message loop. If we used MainSplash as the main form, the launcher would exit when the splash closed — but we want it to live until LaunchOrSkip completes (which happens after the splash is disposed).

So Main is the message-loop anchor (hidden, doing nothing), and MainSplash is shown/hidden as needed during the various phases. After everything is done, Main closes itself, which exits the launcher.

This is more complex than necessary in the abstract, but it's the standard pattern for "WinForms launcher with a splash" and it works reliably.

Why we don't use ClickOnce, MSIX, or Squirrel

Each was considered. The reasons against: - ClickOnce — requires per-user install, has compatibility quirks with network shares as installation sources, generally needs IT involvement to set up cleanly. The launcher's "one .exe, no installer needed" pattern is simpler for our scale. - MSIX — requires modern Windows and signing infrastructure. Overkill for an internal tool. - Squirrel — designed for consumer apps with internet-based update servers. Adds infrastructure we don't need.

The custom launcher gives us: - Zero install (just a shortcut to a network path). - Full visibility into what's happening (the log file). - No admin rights needed at any point. - Easy emergency recovery (just delete the local folder).

For an internal tool at this scale, the bespoke solution is the right solution. If the deployment ever scales to thousands of external users with strict compliance requirements, that's the time to migrate to MSIX.

Why per-second/per-file logging instead of summary-only

Every copy, every skip, every exemption is logged individually. For a healthy 154-file launch, that's roughly 30-40 log lines per session.

Why not just log totals?

Granular logging is the only way to investigate "why did this specific file get treated this way?" Without per-file lines, when a user reports "this file isn't updating," support has no way to see what the launcher actually did.

The cost is bounded — the log file is capped by retention (10 sessions × ~30 lines each ≈ 300 lines, well under any reasonable size limit).

Why we have a /yes flag and not a default-yes flag

/clean shows a confirmation dialog by default. /yes skips it.

Why not invert it?

Default-yes (with a /confirm flag to require confirmation) would mean a user who runs /clean without thinking would wipe their local install without warning. That's a lot of trust to extend by default.

The current design errs on the side of safety: by default, destructive operations require confirmation. The /yes flag is for scripted scenarios where there's no human to click, and the script author has consciously chosen to bypass the safety check.