From 55679c9c1ea60e9ab4b2597f24940eefb825dfa6 Mon Sep 17 00:00:00 2001 From: Martin Perreault Date: Thu, 2 Jul 2026 14:02:08 -0400 Subject: [PATCH 1/3] Fix multi-second first-click delay after boot (startup optimizations, v0.3.0.0) The first click on a pinned group after a reboot had to cold-start the tray-resident background host (~5s); every later click was instant. This release attacks that cold start on four fronts: - Fix Multicore JIT in the background client: SetProfileRoot was only ever called in the editor, making the existing StartProfile call a silent no-op on the hot path. Also profile the startup path itself. - Lazy group loading: startup now only indexes group folders; XML deserialization + icon decoding happen on demand at click time, with all groups pre-warmed on a background thread after tray init. - Defer jump-list construction (WindowsAPICodePack + shell COM) until after the popup is shown; the setGroupContextMenu registration path is preserved. - New "Start at login (instant first click)" toggle in the editor (default on, HKCU Run key, disabled in portable mode): the host is already resident before the first click, hiding any remaining cold start entirely. Launching the exe with no args already starts it resident, so no new argument was needed. Also fixes portable-mode toggle writing portableMode=true even when disabling (previously self-corrected only on next launch). Docs: OPTIMIZATION_PLAN.md (final plan), PLAN_REVIEW.md (independent verification), CHANGELOG.md, build.ps1 (reproducible Release build). Existing groups, pins, AppUserModelIDs, config layout, and the pinned .lnk command-line contract are unchanged; upgrading is running the new editor once (existing MD5 self-update mechanism). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 32 +++ OPTIMIZATION_PLAN.md | 278 ++++++++++++++++++++ PLAN_REVIEW.md | 109 ++++++++ backgroundClient/Classes/Settings.cs | 5 + backgroundClient/Forms/bkgProcess.cs | 46 +++- backgroundClient/Forms/frmMain.cs | 22 +- backgroundClient/Program.cs | 16 +- backgroundClient/Properties/AssemblyInfo.cs | 4 +- build.ps1 | 56 ++++ main/Classes/Settings.cs | 5 + main/Classes/StartupManager.cs | 71 +++++ main/Forms/frmClient.Designer.cs | 44 +++- main/Forms/frmClient.cs | 57 +++- main/Properties/AssemblyInfo.cs | 4 +- main/editorClient.csproj | 1 + 15 files changed, 731 insertions(+), 19 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 OPTIMIZATION_PLAN.md create mode 100644 PLAN_REVIEW.md create mode 100644 build.ps1 create mode 100644 main/Classes/StartupManager.cs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f644189 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +## v0.3.0.0 (2026-07-02) — Startup optimization release + +Fixes the multi-second delay on the **first** click of a pinned taskbar group after a reboot. +See `OPTIMIZATION_PLAN.md` (final plan) and `PLAN_REVIEW.md` (independent verification) for the full analysis. + +### Background client (`Taskbar Groups Background.exe` — the hot path) +- **Fixed broken Multicore JIT**: `ProfileOptimization.SetProfileRoot` was never called in the + background client, silently disabling the `StartProfile` call that already existed. The JIT + profile now records/replays across launches (`%LocalAppData%\...\JITComp\`). +- **Lazy group loading**: startup now only indexes group folders (cheap directory scan). + A group's XML + icons are deserialized/decoded on demand at click time, and all groups are + pre-warmed on a background thread after the tray host starts — nothing heavy runs on the + startup or first-paint path anymore. +- **Deferred jump-list construction**: the taskbar jump-list (WindowsAPICodePack + shell COM) + is now built after the popup is shown instead of before, keeping first paint fast. + The `setGroupContextMenu` registration path is unchanged. + +### Editor (`Taskbar Groups.exe`) +- **New: "Start at login (instant first click)" toggle** (default **on**, next to the + portability toggle). Registers the background host in `HKCU\...\CurrentVersion\Run` so it is + already tray-resident before your first click — this is what makes the first click after a + reboot instant. Disabled automatically in portable mode. The host also starts immediately + when the setting is applied, so no reboot is needed for it to take effect. +- Fixed: disabling portable mode now writes `portableMode=false` immediately instead of + relying on next-launch self-correction. + +### Compatibility +- Existing groups, pinned taskbar shortcuts, `Settings.xml`, AppUserModelIDs, and the + command-line contract are all unchanged — upgrading is running the new editor once + (it replaces the installed background exe automatically via the existing MD5 check). diff --git a/OPTIMIZATION_PLAN.md b/OPTIMIZATION_PLAN.md new file mode 100644 index 0000000..75c6c16 --- /dev/null +++ b/OPTIMIZATION_PLAN.md @@ -0,0 +1,278 @@ +# Taskbar Groups — Startup Optimization & Modernization Plan + +> **Status:** FINAL — decisions locked (§0), independent second-model review merged (see `PLAN_REVIEW.md` for the full audit trail). Ready to implement. +> **Audience:** The engineer/agent implementing the changes. +> **Repo:** Fork of `PikeNote/taskbar-groups-pike-beta`, cloned into `G:\CODING PROJECTS\PROJECTS\WEB_APPS\taskbargroups`. +> **Primary goal:** Kill the ~5-second delay on the *first* taskbar-group click after boot, apply every reasonable startup optimization, ship a new `.exe`, and **do not lose the user's existing groups** (ADOBE, TOOLS, TOPAZ). + +--- + +## 0. Decisions (LOCKED after two-model review) + +| # | Decision | **FINAL** | Rationale (see `PLAN_REVIEW.md`) | +|---|----------|-----------|----------------------------------| +| D1 | **Runtime** | **Stay on .NET Framework 4.7.2 for this release.** .NET 8 migration deferred to a follow-up release, gated on Phase-1 measurements. | Framework's runtime is NGen'd/warm; the JIT tax is limited to ~1.5 MB of app code. A 100 MB+ self-contained single-file exe must page in cold at boot and may *regress* first launch; the editor embedding the bg exe would double that. The editor also uses WinRT APIs (`PackageManager`, `Windows.Data.Json`) making the migration bigger than first scoped. NGen (option c) rejected: admin install steps not worth it when prewarm exists. | +| D2 | **Boot prewarm** | **YES — auto-start the background host at login (HKCU Run key), with a `launchAtStartup` setting (default on) managed by the editor.** This is *the* fix: it hides the cold start entirely, regardless of its cause. | **No new argument needed** — launching the bg exe with **no args** already starts it tray-resident without a popup (the editor does exactly this after every group save). | +| D3 | **Scope** | **Phase 1 in full** (1.1–1.4 below), in the reordered sequence of §8. Phase 2 (.NET 8) documented but deferred. | Prewarm first — highest certainty-to-effort ratio of any change. | + +--- + +## 1. How the app actually works (architecture) + +Two executables, both WinForms, currently **.NET Framework 4.7.2**: + +| Project | Assembly name | Role | +|---------|---------------|------| +| `main/` | `Taskbar Groups.exe` | The **editor** UI — create/edit/pin groups. Also embeds the background exe as a resource. | +| `backgroundClient/` | `Taskbar Groups Background.exe` | The **tray-resident, single-instance** process your pinned taskbar icons launch. Shows the fly-out popup of shortcuts. | + +### Launch flow when you click a pinned group +1. The pinned `.lnk` targets `Taskbar Groups Background.exe` with args `" shortcut"` and a per-group **AppUserModelID** `tjackenpacken.taskbarGroup.menu.`. +2. `Program.Main` → `SingleInstanceApp` (`Microsoft.VisualBasic.ApplicationServices.WindowsFormsApplicationBase`, `IsSingleInstance = true`). +3. **First launch after boot:** no resident instance exists → the process **cold-starts fully**, runs `bkgProcess` ctor (`backgroundClient/Forms/bkgProcess.cs`), then in `OnCreateMainForm` **spawns a second copy of itself** to trigger the single-instance handoff, which fires `OnStartupNextInstance` → `bkgProcess.showFormCat(...)` → `new frmMain(...).Show()`. +4. **Subsequent clicks:** the resident instance is already warm; the new launch just signals it via `OnStartupNextInstance` → popup appears instantly. + +**That asymmetry — cold start once, warm forever after — is exactly the symptom the user reports.** + +### Where data lives (all survive an exe swap) +| Data | Path | +|------|------| +| Groups (per-group folder w/ `ObjectData.xml`, `GroupIcon.ico`, `Icons\*.png`) | `%AppData%\Jack Schierbeck\taskbar-groups\config\\` | +| Pinned shortcut `.lnk` files | `%AppData%\Jack Schierbeck\taskbar-groups\Shortcuts\` | +| Settings (portableMode) | `%AppData%\Jack Schierbeck\taskbar-groups\Settings.xml` | +| Installed background exe | `%LocalAppData%\Jack Schierbeck\taskbar-groups\Taskbar Groups Background.exe` | +| Multicore-JIT profile root | `%LocalAppData%\Jack Schierbeck\taskbar-groups\JITComp\` | +| Editor shortcut (for jumplist "Edit Group") | `%LocalAppData%\Jack Schierbeck\taskbar-groups\Taskbar Group Editor.lnk` | +| Windows taskbar pins (OS-managed, point at the above) | `%AppData%\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\` | + +The editor **re-extracts the background exe from its own embedded resource and overwrites the installed copy whenever the MD5 differs** (`main/Classes/Paths.cs → setupBackgroundApplication()`). So a clean upgrade = "run the new editor once." Pins keep working **iff** we preserve: the AppData layout, the exe filename, the AppUserModelID scheme, and the command-line argument contract. + +--- + +## 2. Root-cause diagnosis (what the 5 seconds is spent on) + +Ranked by likely contribution to the cold start: + +1. **Cold disk I/O + app-code JIT.** *(Corrected by review:)* .NET Framework's own assemblies are NGen'd system-wide and the CLR is typically already warm on a Windows box, so the JIT tax is limited to the app's own code (~1.5 MB) plus the byte-array-loaded WindowsAPICodePack DLLs (which can never use native images). The dominant cold cost is paging the exe, DLLs, config XML, and icon files in from disk after a boot. *(Addressed decisively by D2 prewarm; partially by 1.1/1.2.)* +2. **Multicore JIT is broken in the background client.** `System.Runtime.ProfileOptimization.StartProfile("frmMain.Profile")` is called in `backgroundClient/Forms/frmMain.cs:85`, but `ProfileOptimization.SetProfileRoot(...)` is **only** called in the editor (`main/editorClient.cs:84`) — **never** in `backgroundClient/Program.cs`. Without a profile root, `StartProfile` is a **silent no-op**. The empty `JITComp\` folder on disk confirms the background client never records/uses a profile. *(Free, high-value fix.)* +3. **Eager load of ALL groups on startup.** `bkgProcess` ctor (`bkgProcess.cs:74-81`) enumerates every group dir and constructs a `Category` for each. `Category(string)` (`backgroundClient/Classes/Category.cs`) deserializes XML, **decodes every shortcut icon PNG via `Image.FromStream`**, and reads each `GroupIcon.ico` — for *every* group, when the click needs only **one**. +4. **Assemblies loaded from embedded byte arrays.** `bkgProcess.cs:57-68` hooks `AssemblyResolve` and does `Assembly.Load(byte[])` for `WindowsAPICodePack(.Shell)`. Byte-array loads can't be memory-mapped/NGen'd and add decompress+load latency on the cold path. +5. **WPF dragged in for the jump-list.** `backgroundClient/Classes/Jumplist.cs` uses `Microsoft.WindowsAPICodePack.Taskbar`, and the csproj references `PresentationFramework`. WPF assemblies are heavy to load cold. The jump-list is rebuilt on **every** popup (`frmMain` ctor line ~133-134). +6. **Double process spawn on first launch** (`Program.cs:81-95`) — a second copy of the exe is started just to hand off to the single-instance host. Two process creations on the cold path. + +--- + +## 3. The optimization plan + +### Phase 1 — Safe, high-value fixes (do these regardless of D1) + +**1.1 Fix Multicore JIT in the background client** *(do first — cheapest win)* +- In `backgroundClient/Program.cs → Program.Main`, **before** `new SingleInstanceApp().Run(...)`, add: + ```csharp + try { System.Runtime.ProfileOptimization.SetProfileRoot(backgroundClient.Classes.Paths.OptimizationProfilePath); } catch { } + ``` +- Keep the existing `StartProfile("frmMain.Profile")` in `frmMain.cs:85`. It will now actually record/replay a JIT profile, parallelizing JIT across cores on subsequent launches. +- **Note:** Multicore JIT only helps from the *second* launch onward (first run records the profile). It complements — does not replace — the prewarm (1.4) and R2R (Phase 2). + +**1.2 Lazy-load only the clicked group** +- In `bkgProcess.cs` ctor, **stop building the full `loadedCategories` dictionary eagerly.** Replace the "decode everything" loop with a lightweight index of *available* group names (directory + `ObjectData.xml` existence check only — no XML deserialize, no image decode). +- Load the actual `Category` (XML + icons) **on demand** inside `showFormCat(category, ...)` (and in the `OnStartupNextInstance`/`OnCreateMainForm` validation paths, which currently call `loadedCategories.ContainsKey(...)` — swap for a cheap "does this group dir exist" check). +- Optionally, after the popup is shown, warm the remaining groups on a background thread (so later clicks stay instant) — but never on the UI/cold path. +- **Watch:** `Category` static color (`bkgProcess.SystemColors`) and the `"sys"` color substitution must still be resolved before a Category is used for display. Keep `updateColor()` early but cheap. + +**1.3 Defer the jump-list / avoid WPF on the cold path** +- The jump-list (`Jumplist.buildJumplist`) is not needed for the popup to *appear*. Build it **after** the form is shown (e.g., end of `frmMain_Load`, or on a low-priority background task), so first paint doesn't wait on `WindowsAPICodePack.Taskbar` + WPF load. +- Investigate whether the jump-list truly needs `PresentationFramework`. If it's only the WindowsAPICodePack transitive requirement, deferring the call is enough. If feasible, replace the WindowsAPICodePack jump-list with the native `ICustomDestinationList` COM API to drop the WPF/WindowsAPICodePack dependency entirely (larger change — treat as optional stretch). + +**1.4 Prewarm the background host at login (D2)** *(THE fix — do this first)* +- **No new argument or code path is needed on the hot side** *(review simplification)*: launching `Taskbar Groups Background.exe` with **no arguments** already starts it tray-resident with no popup (`Program.cs` only spawns the popup hand-off when `arguments.Length > 1`; the editor already launches it arg-less after every group save, `main/Classes/Category.cs:150-152`). +- Mechanism: **Registry Run key** `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, value `TaskbarGroupsBackground` → quoted path to the installed background exe (`Paths.BackgroundApplication`). Managed by the **editor**. +- **Toggle:** extend `Setting` (both `Settings.cs` copies) with `bool launchAtStartup` (default **on**; old `Settings.xml` without the element deserializes to the default). Editor on launch and on toggle: create/remove the Run key accordingly, and if enabled + no bg process running, start it immediately so the upgrade takes effect without a reboot. +- After tray init, warm the group index (and optionally decode group data) on a **background thread** so even the popup-build work is done before the first click (pairs with 1.2). +- **Cost:** one lightweight always-resident tray process — which is already what exists after the first click today. + +**1.5 Ship WindowsAPICodePack DLLs as normal files (if staying on Framework)** +- If **not** migrating to .NET 8, drop the `Assembly.Load(byte[])` embedded-resource trick in `bkgProcess.cs:57-68` and instead ship `Microsoft.WindowsAPICodePack*.dll` next to the exe (or fold into a single-file publish under .NET 8). Enables OS image caching / NGen. +- **Caveat:** this changes the "one loose exe" distribution. Only do it under Framework if pairing with NGen (D1 option c) or a folder-based install. Under .NET 8 single-file (recommended), this is handled by the publish pipeline instead. + +### Phase 2 — Runtime modernization (.NET 8) — **DEFERRED to a follow-up release** + +> **Review outcome:** do NOT do this in the same release as Phase 1. Ship Phase 1, measure the cold start, and only migrate if numbers justify it — the migration is modernization (Framework 4.7.2 is a dead end), not the startup cure. Key caveats added by review: +> - **Drop `EnableCompressionInSingleFile`** for the background client — decompression on the cold path directly hurts startup latency. +> - A self-contained single-file exe (~80–150 MB) must page in cold at boot; net cold-start effect vs. today's 1.5 MB exe + NGen'd Framework is **not guaranteed positive**. Consider **framework-dependent** deployment (needs .NET 8 Desktop Runtime once) or shipping the bg exe beside the editor instead of embedded (self-contained embedding ⇒ ~250 MB editor + MD5 hash of it on every editor launch). +> - **WinRT migration work the original plan missed:** the editor uses `Windows.Management.Deployment.PackageManager` (`handleWindowsApp.cs`), `Windows.ApplicationModel` (`ucTabControl.cs`), and `Windows.Data.Json` (`frmClient.cs`) via legacy `Windows.winmd`. On .NET 8: TFM `net8.0-windows10.0.17763.0`+ for CsWinRT projections; replace `Windows.Data.Json` with `System.Text.Json`. +> - Verify `WindowsFormsApplicationBase.IsSingleInstance` behavior on .NET 8 or replace with the Mutex+pipe pattern below. + +**2.1 Migrate both projects to SDK-style .NET 8 WinForms** +- Convert `main/editorClient.csproj` and `backgroundClient/backgroundClient.csproj` to SDK-style projects: + ```xml + net8.0-windows + true + true + ``` +- Replace legacy references with NuGet packages: + - `Microsoft.VisualBasic` → built into .NET (namespace still available; `WindowsFormsApplicationBase` single-instance still works, or replace with a `Mutex` + named-pipe/`WM_COPYDATA` single-instance implementation — cleaner on .NET 8). + - `Microsoft-WindowsAPICodePack-Shell` / `-Core` → use the maintained NuGet packages, or replace jump-list with native COM (see 1.3). + - COM refs `IWshRuntimeLibrary` (`WshShell`) and `Shell32` → still usable via COM interop on Windows; keep or replace `.lnk` creation with `IShellLinkW` (already present in `ShellLink.cs`). + - `TxFileManager`, `KaitaiStruct.Runtime` → available on NuGet; keep. +- Remove ClickOnce/bootstrapper cruft from the csproj (`BootstrapperPackage`, `PublishUrl`, etc.). +- Fix any API differences (e.g., default nullability, `Image`/`Icon` disposal, DPI: set app manifest `PerMonitorV2` for crisp popups — the code already does manual eDPI math in `frmMain`). + +**2.2 Publish with ReadyToRun, single-file, self-contained** +- Background client (the hot path) publish profile: + ``` + dotnet publish backgroundClient/backgroundClient.csproj -c Release ^ + -r win-x64 --self-contained true ^ + -p:PublishReadyToRun=true ^ + -p:PublishSingleFile=true ^ + -p:IncludeNativeLibrariesForSelfExtract=true ^ + -p:EnableCompressionInSingleFile=true + ``` + - **ReadyToRun** precompiles IL → native at build time, eliminating most first-run JIT (the #1 cost). Tiered compilation still re-optimizes hot methods later. + - **Self-contained** removes any dependency on a machine-installed runtime → the "just an exe" model is preserved. + - Keep `AssemblyName = Taskbar Groups Background.exe` **unchanged**. +- Editor publish similarly (single-file self-contained is fine; startup latency matters less for the editor). +- **Size note:** self-contained single-file ≈ 80-150 MB per exe. If size is a concern, add `-p:PublishTrimmed=true` **cautiously** — WinForms + COM interop + reflection (XML serializer!) are trim-hostile; if trimming, use trimmer roots/`TrimmerRootDescriptor` and test every feature. Safer default: **no trimming**. +- Consider `-p:TieredCompilationQuickJit=true` (default) and evaluate `DOTNET_TieredPGO` — R2R already covers the cold path. + +**2.3 Keep the embed-and-self-update mechanism working** +- The editor embeds the background exe as `Resources\Taskbar Groups Background.exe` and overwrites the installed copy on MD5 mismatch. After migration: + - Ensure the build **copies the newly published single-file background exe into `main/Resources\`** before building the editor, so the editor embeds the *new* one. Add this as a pre-build step or a `build.ps1` script (see §5). + - The MD5 self-update logic in `Paths.cs` needs no change — it will detect the new hash and overwrite on first editor run. + +### Phase 2-alt — If staying on .NET Framework (D1 = a or c) +- Do all of Phase 1. +- **(c) NGen:** at install, run `ngen install "Taskbar Groups Background.exe"` (+ its dependent DLLs shipped as loose files per 1.5). Requires an elevated install step. Add uninstall `ngen uninstall`. This removes JIT cost similar to R2R but needs admin and loose DLLs. +- Set `true` (Release already does) and confirm Release config is what ships (Debug currently outputs to `..\main\Resources\` — see csproj `OutputPath`). + +--- + +## 4. Data-safety & upgrade procedure (do NOT lose ADOBE / TOOLS / TOPAZ) + +### 4.1 Backup first (mandatory, before any install) +Create a timestamped backup of everything user-owned: +``` +%AppData%\Jack Schierbeck\taskbar-groups\ (config, Shortcuts, Settings.xml) +%LocalAppData%\Jack Schierbeck\taskbar-groups\ (installed bg exe, editor .lnk, JITComp) +``` +Also export the current taskbar pins folder for good measure: +``` +%AppData%\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\ +``` +Copy these to a dated folder (e.g. `Backup_TaskbarGroups_\`). A restore = copy back. + +### 4.2 Invariants the new build MUST preserve (or pins/groups break) +- **AppData layout** `Jack Schierbeck\taskbar-groups\{config,Shortcuts,Settings.xml}` — unchanged. +- **Background exe filename** `Taskbar Groups Background.exe` at the same LocalAppData path — unchanged. +- **AppUserModelID** scheme `tjackenpacken.taskbarGroup.menu.` — unchanged (`main/Classes/Category.cs:128`, `main/editorClient.cs:123`, `backgroundClient/Program.cs:50`). +- **Command-line contract** *(CORRECTED by review — `ShellLink.InstallShortcut(exePath, appId, desc, wkDirec, iconLocation, saveLocation, arguments)`; the `" shortcut"` string is the .lnk **description**, not its arguments)*: + | Invocation | Args | + |------------|------| + | Pinned taskbar click | `` (bare group name — this is why `loadedCategories.ContainsKey(args[1])` works) | + | Jump-list "Edit Group" | `editingGroupMode ` | + | Jump-list "Open all" | ` tskBaropen_allGroup` | + | Editor after group save | ` setGroupContextMenu` (registers jump-list; popup opens+closes immediately) | + | Exit signal | `exitApplicationModeReserved` | + + Also preserve: the pinned `.lnk`'s **working directory** (= the group's config folder), `Directory.SetCurrentDirectory(exe dir)` in `Program.cs:75`, and the `.lnk` filename transform `Regex.Replace(Name, @"(_)+", " ")` (underscores→spaces). +- **Single-instance identity across versions** *(added by review)*: `WindowsFormsApplicationBase.IsSingleInstance` keys off assembly identity, so an old resident instance may not hand off to a new exe. **The upgrade must kill the resident background process before/when swapping** — the editor's `closeBackgroundApp()` already does this when it rewrites the exe; verify it still runs. +- **Config schema** (`Category` XML shape) — keep backward-compatible; the XML serializer must still deserialize existing `ObjectData.xml`. If fields are added, make them optional with defaults. + +### 4.3 Upgrade steps (what the user actually does) +1. Close any running Taskbar Groups (tray → Exit, or the editor closes the bg app on save). +2. Run backup (4.1). +3. Replace the editor exe with the new build (any location; the pins don't point at the editor). +4. **Launch the new editor once.** It MD5-checks and overwrites the installed background exe with the new embedded one automatically. Existing pins/groups are untouched. +5. If prewarm/auto-start (D2) is enabled, the editor writes the startup entry; reboot (or manually launch the bg host once) to make the *next* first-click instant. +6. Verify each group (ADOBE/TOOLS/TOPAZ) opens; verify jump-lists ("Edit Group") still work. + +### 4.4 If a pin ever breaks (fallback) +The `.lnk` files are regenerated by the editor when a group is saved. Worst case: open the editor, re-save each group, re-pin from the fly-out (right-click the highlighted shortcut → "Pin to taskbar"). The `Shortcuts\*.lnk` are also preserved by the backup. + +--- + +## 5. Build & release + +### Tooling present on this machine (verified) +- **.NET 8 SDK 8.0.400** (`C:\Program Files\dotnet`) +- **Visual Studio 2022 Community** + **VS 2022 BuildTools** (MSBuild available via the VS Developer shell) + +### Build order (matters because the editor embeds the bg exe) +1. Build/publish **`backgroundClient`** → produces `Taskbar Groups Background.exe`. +2. Copy that exe into `main/Resources/Taskbar Groups Background.exe` (overwrite). +3. Build/publish **`main`** → produces `Taskbar Groups.exe` (now embedding the fresh bg exe). +4. Ship `Taskbar Groups.exe` (self-contained single-file). The user runs it once; it deploys the bg exe. + +Provide a **`build.ps1`** that does steps 1-3 deterministically (Release, `win-x64`), so releases are reproducible. For the current Framework build, the same ordering applies via MSBuild; note the Debug config's odd `OutputPath=..\main\Resources\` already wires the bg exe into the editor's resources — replicate that intent cleanly in the script rather than relying on Debug output paths. + +### Versioning +- Bump the assembly/product version (currently v0.2.2.0 upstream). Update `AssemblyInfo.cs` in both projects. +- Keep a short CHANGELOG noting: startup optimizations, runtime migration, prewarm option. + +--- + +## 6. Verification checklist (definition of done) + +Cold-start (the whole point): +- [ ] Reboot. Time the **first** click on a pinned group. Target: **≤1s** (from ~5s). Measure before/after. +- [ ] Confirm the tray host is resident at login (if D2 on) — Task Manager shows `Taskbar Groups Background` after boot, before any click. +- [ ] Second and subsequent clicks remain instant. +- [ ] `JITComp\` folder is now populated for the background client (proves Multicore JIT active) — only relevant on Framework/if R2R not used. + +Correctness / no regressions: +- [ ] ADOBE, TOOLS, TOPAZ each open with correct icons, colors, layout, DPI on all monitors. +- [ ] Clicking a shortcut launches the right app with correct args/working dir. +- [ ] Jump-list "Edit Group" and "Open all shortcuts" (if enabled) work. +- [ ] Popup positions correctly for taskbar on bottom/top/left/right and auto-hide. +- [ ] Popup closes on focus loss; keyboard number shortcuts work. +- [ ] Editor: create a new group, pin it, confirm it opens. Edit an existing group, confirm changes persist. +- [ ] Existing pins survive the upgrade without re-pinning. +- [ ] Portable mode still resolves paths correctly (if used). + +Instrumentation aid (temporary): add `Stopwatch` logging around ctor / category load / first paint (write to a temp log), compare Framework-baseline vs. optimized build, then remove. + +--- + +## 7. Risks & mitigations + +| Risk | Mitigation | +|------|-----------| +| .NET 8 migration breaks COM interop (`WshShell`, `Shell32`) or XML serialization | Migrate incrementally; keep XML `Category` schema stable; test each COM call; XML serializer is trim-hostile → don't trim, or root it. | +| Single-file self-contained exe is large (~100MB) | Acceptable for a local tool; offer `PublishTrimmed` only if tested feature-by-feature. | +| Pins break due to changed exe path/name/AppUserModelID | Treat §4.2 invariants as hard constraints; verify in §6. | +| Prewarm adds a startup process the user dislikes | D2 toggle, default-on with a one-time notice; easy off. | +| Multicore JIT change has no visible effect under R2R | Expected — R2R supersedes it on the cold path; keep the fix anyway for non-R2R builds and warm re-JIT. | +| Lazy-loading changes `loadedCategories.ContainsKey` validation semantics | Replace with equivalent "group dir + ObjectData.xml exists" checks in all 3 call sites (`Program.cs` ×2, `bkgProcess.cs`). | + +--- + +## 8. Implementation order (FINAL, per review) + +1. **Phase 1.4** (prewarm: arg-less Run key + `launchAtStartup` setting + editor management) — THE fix, do first. +2. **Phase 1.1** (Multicore JIT root, one line + `StartProfile` for the startup path) — verify `JITComp\` populates. +3. **Phase 1.2** (lazy per-group load + background warm-all after tray init). Call sites to change: `Program.cs:38`, `:56`, `:79` (`ContainsKey` → group-dir existence check), `bkgProcess.cs:113` (dictionary access → load-on-demand). Guard shared state with a lock; note `showFormCat`'s bare `catch {}` will hide lazy-load bugs — log during development. +4. **Phase 1.3** (defer jump-list to after first paint via the form's `Shown` event; the `setGroupContextMenu` path must still build the jump-list before closing). +5. Bump versions, wire up **`build.ps1`** (build order §5), CHANGELOG, produce final `Taskbar Groups.exe`. +6. Measure cold start after a reboot (§6). **Phase 2 (.NET 8) only as a follow-up release, gated on those numbers.** + +--- + +## Appendix A — Key files & line references + +- `backgroundClient/Program.cs` — single-instance host, launch/handoff, AppUserModelID (`:50`), double-spawn (`:81-95`). **Add `SetProfileRoot` + `prewarm` branch here.** +- `backgroundClient/Forms/bkgProcess.cs` — ctor eager-loads all categories (`:74-81`), embedded `Assembly.Load(byte[])` (`:57-68`), `showFormCat` (`:109-116`). **Lazy-load target.** +- `backgroundClient/Forms/frmMain.cs` — popup build; `StartProfile` (`:85`); jump-list build (`:133-134`). **Defer jump-list.** +- `backgroundClient/Classes/Category.cs` — XML deserialize + per-shortcut `Image.FromStream` decode (`:34-69`). **Cold-path cost.** +- `backgroundClient/Classes/Jumplist.cs` — WindowsAPICodePack + WPF pull-in. +- `backgroundClient/Classes/Paths.cs` — `OptimizationProfilePath` (`:62`), config/portable paths. +- `main/Classes/Paths.cs` — `setupBackgroundApplication()` MD5 self-update (`:106-156`); `BackgroundApplication` path; `OptimizationProfilePath` (`:93`). +- `main/Classes/Category.cs` — pin `.lnk` creation + AppUserModelID (`:126-141`). +- `main/editorClient.cs` — editor single-instance, `SetProfileRoot` (`:84`), AppUserModelID (`:123`). +- `main/Classes/Settings.cs` / `backgroundClient/Classes/Settings.cs` — `Setting` model (add `launchAtStartup`). + +## Appendix B — Verified environment facts +- User's existing groups: **ADOBE, TOOLS, TOPAZ** (in `%AppData%\Jack Schierbeck\taskbar-groups\config`). +- Installed bg exe present at `%LocalAppData%\Jack Schierbeck\taskbar-groups\Taskbar Groups Background.exe` (1.49 MB, Framework build). +- `JITComp\` exists but is effectively unused by the background client (confirms diagnosis #2). +- Build tooling: .NET 8 SDK 8.0.400, VS 2022 Community, VS 2022 BuildTools — all present. +- Current target: .NET Framework **4.7.2**, WinForms, AnyCPU (64-bit capable). diff --git a/PLAN_REVIEW.md b/PLAN_REVIEW.md new file mode 100644 index 0000000..c1278ed --- /dev/null +++ b/PLAN_REVIEW.md @@ -0,0 +1,109 @@ +# Independent Review of OPTIMIZATION_PLAN.md + +> **Reviewer:** Second-model pass (Fable), verifying against the actual source, not the plan's summary. +> **Verdict up front:** **The diagnosis is substantially correct and the plan is sound.** I concur with the overall direction, with **one reprioritization** (prewarm is the fix; .NET 8 is modernization, not the startup cure), **two factual corrections** in the hard-invariants section, and **several implementation-level findings** Fable must know before coding. + +--- + +## 1. Root-cause verification (file-by-file) + +I re-read every file cited in Appendix A. Claim-by-claim: + +| # | Plan's claim | Verdict | Notes | +|---|--------------|---------|-------| +| 2 | Multicore JIT broken: `StartProfile` called in `backgroundClient/Forms/frmMain.cs:85` but `SetProfileRoot` only in `main/editorClient.cs:84`, never in `backgroundClient/Program.cs` | ✅ **Confirmed** | Grep across repo: the only `SetProfileRoot` call is editor-side. Without a root, `StartProfile` is a documented no-op. Empty `JITComp\` on the user's machine corroborates. | +| 3 | Eager load of ALL groups in `bkgProcess` ctor (`bkgProcess.cs:74-81`), each `Category` ctor deserializes XML + decodes every icon PNG + reads `GroupIcon.ico` | ✅ **Confirmed** | `Category(string)` in `backgroundClient/Classes/Category.cs:34-69` does exactly this, incl. `Image.FromStream` per shortcut. | +| 4 | Assemblies loaded from embedded byte arrays defeat OS caching | ✅ **Confirmed** | `bkgProcess.cs:57-68` (and the editor does the same in `editorClient.cs:54-65`). `Assembly.Load(byte[])` can't be memory-mapped or use native images. | +| 5 | WPF pulled in for the jump-list | ⚠️ **Partially confirmed — don't overclaim** | `PresentationFramework` is *referenced* (csproj:71) and `Jumplist.cs` uses WindowsAPICodePack.Taskbar. But the CLR loads assemblies lazily — whether WPF actually loads at runtime (and how much it costs) is **unmeasured**. Deferring the jump-list off the cold path (Plan 1.3) is right regardless; just don't expect a guaranteed win from dependency surgery. Measure first. | +| 6 | Double process spawn on first launch | ✅ **Confirmed** | `Program.cs:81-95`: first instance spawns a second copy of itself purely to trigger the single-instance handoff → `OnStartupNextInstance` → popup. Two process creations + one full arg round-trip on the cold path. | +| 1 | ".NET Framework CLR load + **full JIT** of everything" is the biggest cost | ⚠️ **Overstated** | .NET **Framework**'s own assemblies (mscorlib, System.Windows.Forms, etc.) are NGen'd system-wide — they load as native images, and the CLR itself is usually already warm on a Windows box. What actually gets JIT'd is the app code (~1.5 MB) plus the byte-array-loaded WindowsAPICodePack DLLs. The dominant cold cost after reboot is more likely **cold disk I/O** (exe, DLL extraction, config XML, icon PNGs, .ico files) **plus** app-code JIT — not "full JIT of everything." This matters because it reweights the remedies (see §3/D1 below). | + +**Bottom line:** the *mechanism* (cold-start of the tray-resident single-instance host on first click, warm thereafter) is exactly right, and every code-level finding checks out. Only the cost attribution in #1 needs nuance — which changes which fix you should trust most. + +--- + +## 2. Hard invariants (§4.2) — corrections ⚠️ + +This is the section protecting ADOBE/TOOLS/TOPAZ, so I checked it hardest. Two errors, one omission: + +### 2.1 ❌ The command-line contract is misquoted (important for Fable) +The plan says the pinned `.lnk` passes `" shortcut"`. **Wrong.** `ShellLink.InstallShortcut`'s signature (`main/Classes/ShellLink.cs:9`) is: + +``` +InstallShortcut(exePath, appId, desc, wkDirec, iconLocation, saveLocation, arguments) +``` + +and the call in `main/Classes/Category.cs:126-134` passes: +- `desc` = `path + " shortcut"` ← this is the **description** field, not args +- `wkDirec` = `path` (the group's config folder) +- **`arguments` = `this.Name`** ← the pinned `.lnk` passes **just the bare group name** + +That's why `Program.cs` can do `loadedCategories.ContainsKey(arguments[1])` — the key *is* the group name. The **real contract to preserve**: + +| Invocation | Args | +|------------|------| +| Pinned taskbar click | `` | +| Jump-list "Edit Group" | `editingGroupMode ` | +| Jump-list "Open all" | ` tskBaropen_allGroup` | +| Editor after group save | ` setGroupContextMenu` ← **missing from the plan** (see `Category.cs:155-158` / `frmMain.cs:136-139`; opens+immediately closes a popup to register the jump-list) | +| Exit signal | `exitApplicationModeReserved` | + +Also preserve: the `.lnk` **working directory** = the group's config folder, and `Directory.SetCurrentDirectory(exe dir)` in `Program.cs:75` (relative-path behavior other code may rely on). + +### 2.2 ✅ The rest of §4.2 verified correct +- AppUserModelID scheme `tjackenpacken.taskbarGroup.menu.` — confirmed in all three places, and confirmed it's stamped **into the `.lnk`'s property store** (`ShellLink.cs:22-26`, `PROPERTYKEY.AppUserModel_ID`). Windows' pinned copy of the `.lnk` carries target path + AUMID, so same exe path + same AUMID ⇒ pins survive an exe swap. The reasoning holds. +- AppData layout, exe filename/path, XML schema backward-compat — all correct as stated. +- One cosmetic addition: `.lnk` filenames apply `Regex.Replace(Name, @"(_)+", " ")` (underscores→spaces). Keep identical or shortcut regeneration will duplicate files. + +### 2.3 ➕ Missing invariant: single-instance identity across versions +`WindowsFormsApplicationBase.IsSingleInstance` derives its channel identity from assembly identity. During an upgrade, an **old** resident instance may not receive the handoff from a **new** exe (or vice versa). Mitigation is already implicitly in the plan's upgrade steps ("close the tray app first") — make it an explicit invariant: **the upgrade procedure must kill the resident background process before swapping** (the editor's `closeBackgroundApp()` already does this when it rewrites the exe — verify it still runs in the new build). + +--- + +## 3. The three open decisions — my independent recommendation + +### D2 (prewarm at login): **YES — and promote it to THE fix, not a fix.** +This is where I differ most from the plan's framing. Prewarm at login makes the cold start happen during login, invisibly, **regardless of what the cold start costs or why**. It's immune to diagnosis error — even if the 5 s were 100% disk I/O or 100% JIT, prewarm hides all of it. It's ~30 lines of work (startup entry + settings toggle). Nothing else in the plan has this certainty-to-effort ratio. + +**Simplification the plan missed:** no new `prewarm` argument is needed. `Program.cs` already does the right thing when launched with **no arguments**: `OnCreateMainForm` builds the tray host and — because `arguments.Length == 1` — never spawns the popup process. The editor already launches it exactly this way after every group save (`Category.cs:150-152`). So the startup entry is just a plain launch of the existing exe. Zero new code paths on the hot path; only the toggle UI + entry management is new. (A no-op arg like `prewarm` is fine too, but it's optional polish, not a requirement.) + +### D1 (runtime): **Do Phase 1 + prewarm first, measure, and treat .NET 8 as modernization — not as the startup cure.** +The plan recommends .NET 8 + R2R self-contained single-file *for cold start*. I partially disagree with the rationale: + +- Per §1 claim-1 above, Framework's runtime is NGen'd and typically warm; the JIT tax is limited to app code. R2R's win here is real but smaller than "eliminates the #1 cost" suggests. +- A **self-contained single-file exe is ~80–150 MB**. On a cold boot, that file must be read from disk before anything runs — trading "JIT a 1.5 MB app" for "page in a 100 MB bundle." The net cold-start effect is **not obviously positive** and could regress. +- `EnableCompressionInSingleFile=true` (in the plan's publish command, §2.2) is **counterproductive for startup latency** — it adds decompression on the cold path. If you migrate, turn it **off** for the background client. +- The embed-and-self-update mechanism **doubles the size problem**: the editor embeds the background exe as a resource, so a self-contained editor embedding a self-contained bg exe ≈ 250 MB editor, and `setupBackgroundApplication()` MD5-hashes that embedded resource on **every editor launch**. Consider framework-dependent .NET 8 (needs the .NET 8 Desktop Runtime installed — one-time) or shipping the bg exe beside the editor instead of embedded. +- **Migration cost is understated in one spot:** the editor uses **WinRT APIs** — `Windows.Management.Deployment.PackageManager` (`handleWindowsApp.cs`), `Windows.ApplicationModel` (`ucTabControl.cs`), `Windows.Data.Json` (`frmClient.cs`) — via the legacy `Windows.winmd` reference. On .NET 8 this requires TFM `net8.0-windows10.0.17763.0`+ (CsWinRT projections); `Windows.Data.Json` should be replaced with `System.Text.Json`. Also verify `WindowsFormsApplicationBase.IsSingleInstance` behavior on .NET 8 or replace with the Mutex+pipe pattern the plan already suggests. + +**So:** still migrate eventually (Framework 4.7.2 is a dead end; .NET 8 is right for maintainability), but **gate it on Phase 1 measurements**, and if the numbers after prewarm + lazy-load are already ≤1 s, ship that and do the migration as its own release. Skip NGen (option c) entirely — admin-elevated install steps aren't worth it when prewarm exists. + +### D3 (scope): **Full pass — but reorder.** +Implement in this order: **1.4 (prewarm) → 1.1 (JIT root, one line) → 1.2 (lazy load) → measure → 1.3 (defer jump-list) → measure → decide on Phase 2.** The plan's §8 puts 1.1 first; fine either way, but 1.4 is the one the user will feel. + +--- + +## 4. Additional implementation notes for Fable (beyond the plan) + +1. **Lazy-load call-site inventory (Plan 1.2):** the `loadedCategories.ContainsKey` checks to replace are at `Program.cs:38`, `Program.cs:56`, `Program.cs:79`; the dictionary access at `bkgProcess.cs:113`. Replace with a "config dir + `ObjectData.xml` exists" check and construct `Category` on demand in `showFormCat`. Note `showFormCat`'s bare `catch {}` will silently swallow any lazy-load bug — add at least a debug log there while developing. +2. **`Category` ctor XmlSerializer cost:** first-use of `XmlSerializer(typeof(Category))` generates+loads a temp serialization assembly at runtime — a known Framework cold-start hit. Mitigations: pre-generate with `sgen` (Framework), or it becomes cheaper under .NET 8, or warm it on a background thread right after the tray host starts (pairs perfectly with prewarm). +3. **Prewarm should pre-build the jump-list registration?** No — leave jump-lists alone at prewarm (they're per-window). But *do* warm the group index + optionally decode icons on a background thread after tray init, so even the popup-build work is done before the first click. +4. **Measure before/after properly:** reproduce "first click after boot" without rebooting by clearing the standby cache is unreliable — just reboot. Add temporary `Stopwatch` logging (plan §6 already says this) at: process start → `bkgProcess` ctor done → `frmMain` ctor done → first paint. That will settle the JIT-vs-I/O attribution question definitively and decide Phase 2. +5. **The editor also byte-array-loads its embedded DLLs** (`editorClient.cs:54-65`) — same fix class as 1.5 if it ever matters, but editor startup is not the complaint; don't spend time there. +6. **Backup script (§4.1) — small addition:** also back up `%LocalAppData%\...\Taskbar Group Editor.lnk` (it's in the LocalAppData folder already listed, fine) and note that `Settings.xml` at the repo-adjacent portable location doesn't exist for this user (confirmed non-portable install: `Settings.xml` in Roaming, `portableMode` default false). + +--- + +## 5. Final verdict + +| Area | Assessment | +|------|-----------| +| Architecture & launch-flow analysis | ✅ Correct, verified line-by-line | +| Root cause (cold single-instance start) | ✅ Correct | +| Cost attribution ("full JIT" as #1) | ⚠️ Overstated; likely cold I/O + app-code JIT. Doesn't change the fix list, does change D1's justification | +| Fix list (Phase 1) | ✅ All four fixes valid; prewarm needs **no new argument** (no-arg launch already prewarrms) | +| .NET 8 + R2R single-file (Phase 2) | ⚠️ Worth doing for modernization; **not** guaranteed to improve cold start; drop single-file **compression**; mind the 100 MB+ size, the ~250 MB embedded-editor consequence, and the **WinRT migration work the plan missed** | +| §4.2 invariants | ⚠️ One factual error (args contract — it's ``, not `" shortcut"`), one missing arg (`setGroupContextMenu`), one missing invariant (kill resident instance before swap). Otherwise correct | +| Data safety / upgrade / verification sections | ✅ Sound | + +**Concur with the plan overall.** Corrections above should be merged into `OPTIMIZATION_PLAN.md` before handing to implementation — the args-contract fix (§2.1) and the prewarm simplification (§3/D2) are the two that would otherwise cause wrong code to be written. diff --git a/backgroundClient/Classes/Settings.cs b/backgroundClient/Classes/Settings.cs index eb21885..06a5269 100644 --- a/backgroundClient/Classes/Settings.cs +++ b/backgroundClient/Classes/Settings.cs @@ -76,5 +76,10 @@ public class Setting { [XmlElement] public bool portableMode { get; set; } = false; + + // Start the background host at login so the first taskbar-group click is instant. + // Managed by the editor; mirrored here so both clients share the same XML schema. + [XmlElement] + public bool launchAtStartup { get; set; } = true; } } diff --git a/backgroundClient/Forms/bkgProcess.cs b/backgroundClient/Forms/bkgProcess.cs index d3b2672..937a187 100644 --- a/backgroundClient/Forms/bkgProcess.cs +++ b/backgroundClient/Forms/bkgProcess.cs @@ -14,7 +14,13 @@ namespace backgroundClient { public partial class bkgProcess : Form { + // Lightweight index of available groups (name -> config folder path). + // Built at startup with a cheap directory scan only; the expensive Category + // construction (XML deserialize + icon decode) happens lazily via getCategory(). + public static Dictionary availableGroups = new Dictionary(); + public static Dictionary loadedCategories = new Dictionary(); + private static readonly object categoryLock = new object(); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr handle, int flags); @@ -71,17 +77,18 @@ public bkgProcess() updateColor(); this.Hide(); + // Only index the groups here (cheap); do NOT construct Category objects + // on the startup path — that used to deserialize every group's XML and + // decode every icon before the first popup could appear. string[] folders = Directory.GetDirectories(Paths.ConfigPath); foreach (string folderName in folders) { if (System.IO.File.Exists(Path.Combine(folderName, "ObjectData.xml"))) { - loadedCategories.Add(new DirectoryInfo(folderName).Name, new Category(folderName)); + availableGroups[new DirectoryInfo(folderName).Name] = folderName; } } - - notifyIcon1.Visible = true; notifyIcon1.Icon = backgroundClient.Properties.Resources.Icon; @@ -91,6 +98,37 @@ public bkgProcess() Application.Exit(); }); notifyIcon1.ContextMenu = trayContext; + + // Warm all groups on a background thread so that by the time the user + // first clicks a pinned group, its data is already in memory. + // Runs off the startup/UI path; failures are ignored (surfaced on demand instead). + System.Threading.Tasks.Task.Run(() => + { + foreach (string groupName in new List(availableGroups.Keys)) + { + try { getCategory(groupName); } catch { } + } + }); + } + + public static bool groupExists(string category) + { + return availableGroups.ContainsKey(category); + } + + // Loads (and caches) a single group on demand. + public static Category getCategory(string category) + { + lock (categoryLock) + { + Category cat; + if (!loadedCategories.TryGetValue(category, out cat)) + { + cat = new Category(availableGroups[category]); + loadedCategories[category] = cat; + } + return cat; + } } private void updateColor() @@ -110,7 +148,7 @@ public static void showFormCat( string category, string arguments) { try { - new frmMain(loadedCategories[category], arguments.Split(' ')).Show(); + new frmMain(getCategory(category), arguments.Split(' ')).Show(); } catch { } } diff --git a/backgroundClient/Forms/frmMain.cs b/backgroundClient/Forms/frmMain.cs index 8e67896..32d5d09 100644 --- a/backgroundClient/Forms/frmMain.cs +++ b/backgroundClient/Forms/frmMain.cs @@ -130,13 +130,29 @@ public frmMain(Category category, string[] arguments) HoverColor = ColorTranslator.FromHtml(category.HoverColor); } - jumpList = new Jumplist(this.Handle); - jumpList.buildJumplist(category.allowOpenAll, category.Name); - if (arguments[0] == "setGroupContextMenu") { + // This invocation exists solely to register the jump-list (fired by the + // editor after a group save), so it must be built before closing. + buildJumpList(); this.Close(); } + else + { + // Defer jump-list construction (WindowsAPICodePack + shell COM) off the + // cold path — build it only after the popup has been shown to the user. + this.Shown += (s, e) => buildJumpList(); + } + } + + private void buildJumpList() + { + try + { + jumpList = new Jumplist(this.Handle); + jumpList.buildJumplist(loadedCat.allowOpenAll, loadedCat.Name); + } + catch { } } // eDpi Calculations Below ----------------- diff --git a/backgroundClient/Program.cs b/backgroundClient/Program.cs index dbfcea7..d8d9223 100644 --- a/backgroundClient/Program.cs +++ b/backgroundClient/Program.cs @@ -35,7 +35,7 @@ protected override void OnStartupNextInstance( if (secondInstanceArgumens.Length > 1) // Checks for additional arguments; opens either main application or taskbar drawer application { - if (bkgProcess.loadedCategories.ContainsKey(secondInstanceArgumens[1])) + if (bkgProcess.groupExists(secondInstanceArgumens[1])) { String group = secondInstanceArgumens[1]; String argument = ""; @@ -53,7 +53,7 @@ protected override void OnStartupNextInstance( } else if (secondInstanceArgumens[1] == "editingGroupMode") { - if (bkgProcess.loadedCategories.ContainsKey(secondInstanceArgumens[2])) + if (bkgProcess.groupExists(secondInstanceArgumens[2])) { bkgProcess.openEditor("editingGroupMode" + " " + secondInstanceArgumens[2]); } @@ -76,7 +76,7 @@ protected override void OnCreateMainForm() if (arguments.Length > 1) // Checks for additional arguments; opens either main application or taskbar drawer application { - if (bkgProcess.loadedCategories.ContainsKey(arguments[1]) || arguments[1] == "editingGroupMode") + if (bkgProcess.groupExists(arguments[1]) || arguments[1] == "editingGroupMode") { Process p = new Process(); p.StartInfo.FileName = Paths.exeString; @@ -108,6 +108,16 @@ static class Program [STAThread] static void Main() { + // Enable Multicore JIT for the startup path. + // SetProfileRoot was previously only ever called in the editor, which made the + // StartProfile call in frmMain a silent no-op for this process. + try + { + System.Runtime.ProfileOptimization.SetProfileRoot(Paths.OptimizationProfilePath); + System.Runtime.ProfileOptimization.StartProfile("bkgStartup.Profile"); + } + catch { } + Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); diff --git a/backgroundClient/Properties/AssemblyInfo.cs b/backgroundClient/Properties/AssemblyInfo.cs index b75e72c..1f767b0 100644 --- a/backgroundClient/Properties/AssemblyInfo.cs +++ b/backgroundClient/Properties/AssemblyInfo.cs @@ -33,6 +33,6 @@ // You can specify all the values or you can default the Build and Revision Numbers // by using the '*' as shown below: // [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("0.2.2.0")] -[assembly: AssemblyFileVersion("0.2.2.0")] +[assembly: AssemblyVersion("0.3.0.0")] +[assembly: AssemblyFileVersion("0.3.0.0")] [assembly: NeutralResourcesLanguage("en")] diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..afe4d2c --- /dev/null +++ b/build.ps1 @@ -0,0 +1,56 @@ +# Taskbar Groups — reproducible Release build +# Produces: main\bin\Release\Taskbar Groups.exe (the only file you need to ship; +# it embeds the background exe and installs/updates it automatically on first run) +# +# Build order matters: the editor EMBEDS the background exe from main\Resources\, +# so backgroundClient must be built first (its Release OutputPath is main\Resources\). +# +# Requires: Visual Studio 2022 (Community or BuildTools) with .NET Framework 4.7.2 targeting pack. + +$ErrorActionPreference = "Stop" +$root = $PSScriptRoot + +# --- Locate MSBuild --- +$msbCandidates = @( + "C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", + "C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", + "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", + "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe" +) +$msb = $msbCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $msb) { throw "MSBuild.exe not found. Install VS 2022 or Build Tools." } +Write-Host "Using MSBuild: $msb" + +# --- Restore NuGet package layout from checked-in lib DLLs (offline restore) --- +# The csproj HintPaths point at ..\packages\...; the identical assemblies are +# committed under main\lib\, so we materialize the expected layout from them. +$pkgMap = @{ + "main\lib\ChinhDo.Transactions.FileManager.dll" = "packages\TxFileManager.1.5.0.1\lib\netstandard2.0" + "main\lib\Kaitai.Struct.Runtime.dll" = "packages\KaitaiStruct.Runtime.CSharp.0.10.0\lib\net45" + "main\lib\Microsoft.WindowsAPICodePack.dll" = "packages\Microsoft-WindowsAPICodePack-Core.1.1.4\lib\net472" + "main\lib\Microsoft.WindowsAPICodePack.Shell.dll" = "packages\Microsoft-WindowsAPICodePack-Shell.1.1.4\lib\net472" +} +foreach ($src in $pkgMap.Keys) { + $destDir = Join-Path $root $pkgMap[$src] + New-Item -ItemType Directory -Force $destDir | Out-Null + Copy-Item (Join-Path $root $src) $destDir -Force +} + +# --- Generate COM interop assemblies (IWshRuntimeLibrary, Shell32) --- +# backgroundClient references main\obj\Debug\Interop.IWshRuntimeLibrary.dll, +# which only exists after the editor project has resolved its COM references in Debug. +& $msb "$root\main\editorClient.csproj" /t:ResolveComReferences /p:Configuration=Debug /p:Platform=AnyCPU /v:minimal /nologo +if ($LASTEXITCODE -ne 0) { throw "COM interop generation failed." } + +# --- 1) Background client (hot path) -> main\Resources\Taskbar Groups Background.exe --- +& $msb "$root\backgroundClient\backgroundClient.csproj" /t:Rebuild /p:Configuration=Release /p:Platform=AnyCPU /v:minimal /nologo +if ($LASTEXITCODE -ne 0) { throw "backgroundClient build failed." } + +# --- 2) Editor (embeds the fresh background exe) -> main\bin\Release\Taskbar Groups.exe --- +& $msb "$root\main\editorClient.csproj" /t:Rebuild /p:Configuration=Release /p:Platform=AnyCPU /v:minimal /nologo +if ($LASTEXITCODE -ne 0) { throw "editor build failed." } + +$out = Join-Path $root "main\bin\Release\Taskbar Groups.exe" +Write-Host "" +Write-Host "Build complete: $out" +Write-Host ("Version: " + (Get-Item $out).VersionInfo.FileVersion) diff --git a/main/Classes/Settings.cs b/main/Classes/Settings.cs index f5f3c19..8e0b801 100644 --- a/main/Classes/Settings.cs +++ b/main/Classes/Settings.cs @@ -72,5 +72,10 @@ public class Setting { [XmlElement] public bool portableMode { get; set; } = false; + + // Start the background host at login so the first taskbar-group click is instant. + // Old Settings.xml files without this element deserialize to the default (true). + [XmlElement] + public bool launchAtStartup { get; set; } = true; } } diff --git a/main/Classes/StartupManager.cs b/main/Classes/StartupManager.cs new file mode 100644 index 0000000..bf5afa8 --- /dev/null +++ b/main/Classes/StartupManager.cs @@ -0,0 +1,71 @@ +using Microsoft.Win32; +using System; +using System.Diagnostics; + +namespace client.Classes +{ + // Manages the "launch background host at login" behavior (HKCU Run key). + // Prewarming the tray-resident background process at login is what makes the + // FIRST click on a pinned taskbar group instant after a reboot — without it, + // that click has to cold-start the whole process. + static class StartupManager + { + private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run"; + private const string RunValueName = "TaskbarGroupsBackground"; + + // Creates or removes the HKCU Run entry to match the current setting, + // and starts the background host immediately when enabling so the change + // takes effect without waiting for the next login. + // In portable mode no Run entry is written (the exe may live on removable media). + public static void ApplyStartupSetting() + { + try + { + if (Settings.settingInfo.launchAtStartup && !Settings.settingInfo.portableMode) + { + using (RegistryKey key = Registry.CurrentUser.CreateSubKey(RunKeyPath)) + { + key.SetValue(RunValueName, "\"" + Paths.BackgroundApplication + "\""); + } + StartBackgroundIfNotRunning(); + } + else + { + RemoveStartupEntry(); + } + } + catch { } + } + + public static void RemoveStartupEntry() + { + try + { + using (RegistryKey key = Registry.CurrentUser.OpenSubKey(RunKeyPath, true)) + { + if (key != null && key.GetValue(RunValueName) != null) + { + key.DeleteValue(RunValueName); + } + } + } + catch { } + } + + // Launching the background exe with no arguments starts it tray-resident + // without opening any popup (same call the editor makes after a group save). + public static void StartBackgroundIfNotRunning() + { + try + { + if (Process.GetProcessesByName("Taskbar Groups Background").Length == 0) + { + Process p = new Process(); + p.StartInfo.FileName = Paths.BackgroundApplication; + p.Start(); + } + } + catch { } + } + } +} diff --git a/main/Forms/frmClient.Designer.cs b/main/Forms/frmClient.Designer.cs index 99386a1..bbcf231 100644 --- a/main/Forms/frmClient.Designer.cs +++ b/main/Forms/frmClient.Designer.cs @@ -41,6 +41,8 @@ private void InitializeComponent() this.label4 = new System.Windows.Forms.Label(); this.panel2 = new System.Windows.Forms.Panel(); this.portabilityButton = new System.Windows.Forms.Button(); + this.startupButton = new System.Windows.Forms.Button(); + this.lblStartup = new System.Windows.Forms.Label(); this.label8 = new System.Windows.Forms.Label(); this.label7 = new System.Windows.Forms.Label(); this.label11 = new System.Windows.Forms.Label(); @@ -200,13 +202,15 @@ private void InitializeComponent() // // panel2 // + this.panel2.Controls.Add(this.startupButton); + this.panel2.Controls.Add(this.lblStartup); this.panel2.Controls.Add(this.portabilityButton); this.panel2.Controls.Add(this.label8); this.panel2.Controls.Add(this.label7); this.panel2.Controls.Add(this.label11); this.panel2.Location = new System.Drawing.Point(3, 285); this.panel2.Name = "panel2"; - this.panel2.Size = new System.Drawing.Size(300, 179); + this.panel2.Size = new System.Drawing.Size(300, 235); this.panel2.TabIndex = 20; // // portabilityButton @@ -228,9 +232,41 @@ private void InitializeComponent() this.portabilityButton.Tag = "n"; this.portabilityButton.UseVisualStyleBackColor = false; this.portabilityButton.Click += new System.EventHandler(this.button1_Click); - // + // + // lblStartup + // + this.lblStartup.BackColor = System.Drawing.Color.Transparent; + this.lblStartup.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.lblStartup.ForeColor = System.Drawing.Color.Transparent; + this.lblStartup.Location = new System.Drawing.Point(35, 182); + this.lblStartup.Name = "lblStartup"; + this.lblStartup.Size = new System.Drawing.Size(218, 23); + this.lblStartup.TabIndex = 22; + this.lblStartup.Text = "Start at login (instant first click)"; + this.lblStartup.TextAlign = System.Drawing.ContentAlignment.TopCenter; + // + // startupButton + // + this.startupButton.BackColor = System.Drawing.Color.Transparent; + this.startupButton.BackgroundImage = global::client.Properties.Resources.toggleOff; + this.startupButton.BackgroundImageLayout = System.Windows.Forms.ImageLayout.Center; + this.startupButton.Cursor = System.Windows.Forms.Cursors.Hand; + this.startupButton.FlatAppearance.BorderSize = 0; + this.startupButton.FlatAppearance.MouseDownBackColor = System.Drawing.Color.Transparent; + this.startupButton.FlatAppearance.MouseOverBackColor = System.Drawing.Color.Transparent; + this.startupButton.FlatStyle = System.Windows.Forms.FlatStyle.Flat; + this.startupButton.ForeColor = System.Drawing.Color.Transparent; + this.startupButton.Location = new System.Drawing.Point(111, 197); + this.startupButton.Name = "startupButton"; + this.startupButton.Size = new System.Drawing.Size(66, 31); + this.startupButton.TabIndex = 23; + this.startupButton.TabStop = false; + this.startupButton.Tag = "n"; + this.startupButton.UseVisualStyleBackColor = false; + this.startupButton.Click += new System.EventHandler(this.startupButton_Click); + // // label8 - // + // this.label8.BackColor = System.Drawing.Color.Transparent; this.label8.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); this.label8.ForeColor = System.Drawing.Color.Transparent; @@ -475,6 +511,8 @@ private void InitializeComponent() private System.Windows.Forms.Label label8; private System.Windows.Forms.Label label7; private System.Windows.Forms.Button portabilityButton; + private System.Windows.Forms.Button startupButton; + private System.Windows.Forms.Label lblStartup; private System.Windows.Forms.Label label5; private System.Windows.Forms.TableLayoutPanel pnlExistingShortcuts; private System.Windows.Forms.TableLayoutPanel tableLayoutPanel2; diff --git a/main/Forms/frmClient.cs b/main/Forms/frmClient.cs index 8d94294..534a2eb 100644 --- a/main/Forms/frmClient.cs +++ b/main/Forms/frmClient.cs @@ -76,6 +76,20 @@ public frmClient(List arguments) portabilityButton.Image = Properties.Resources.toggleOff; } + // Reflect the launch-at-startup setting on its toggle and enforce it + // (creates/removes the HKCU Run entry; starts the background host now if enabled) + if (Settings.settingInfo.launchAtStartup && !Settings.settingInfo.portableMode) + { + startupButton.Tag = "y"; + startupButton.BackgroundImage = Properties.Resources.toggleOn; + } + else + { + startupButton.Tag = "n"; + startupButton.BackgroundImage = Properties.Resources.toggleOff; + } + StartupManager.ApplyStartupSetting(); + if (Paths.justWritten) { changeAllShortcuts(); @@ -254,6 +268,29 @@ private void button1_Click(object sender, EventArgs e) } } + private void startupButton_Click(object sender, EventArgs e) + { + if ((string)startupButton.Tag == "y") + { + Settings.settingInfo.launchAtStartup = false; + startupButton.Tag = "n"; + startupButton.BackgroundImage = Properties.Resources.toggleOff; + } + else + { + if (Settings.settingInfo.portableMode) + { + MessageBox.Show("Launch at startup is not available in portable mode."); + return; + } + Settings.settingInfo.launchAtStartup = true; + startupButton.Tag = "y"; + startupButton.BackgroundImage = Properties.Resources.toggleOn; + } + Settings.writeXML(); + StartupManager.ApplyStartupSetting(); + } + public static void changeAllShortcuts() { string[] files = System.IO.Directory.GetFiles(Paths.ShortcutsPath, "*.lnk"); @@ -301,7 +338,10 @@ private void portibleModeToggle(int mode) using (TransactionScope scope1 = new TransactionScope()) { - Settings.settingInfo.portableMode = true; + // Mode 1 = turning portable mode ON, mode 0 = turning it OFF + // (was previously hardcoded to true and relied on the next-launch + // self-correction in the Settings static ctor) + Settings.settingInfo.portableMode = (mode == 1); Settings.writeXML(); for (int i = 0; i < folderArray.Length/2; i++) @@ -361,7 +401,20 @@ private void portibleModeToggle(int mode) changeAllShortcuts(); - + // Portable mode relocates the background exe, so recompute the + // launch-at-startup Run entry (removed while portable, restored when not) + StartupManager.ApplyStartupSetting(); + if (Settings.settingInfo.portableMode) + { + startupButton.Tag = "n"; + startupButton.BackgroundImage = Properties.Resources.toggleOff; + } + else if (Settings.settingInfo.launchAtStartup) + { + startupButton.Tag = "y"; + startupButton.BackgroundImage = Properties.Resources.toggleOn; + } + scope1.Complete(); MessageBox.Show("File moving done!"); diff --git a/main/Properties/AssemblyInfo.cs b/main/Properties/AssemblyInfo.cs index 2c3a648..53c55cb 100644 --- a/main/Properties/AssemblyInfo.cs +++ b/main/Properties/AssemblyInfo.cs @@ -33,6 +33,6 @@ // You can specify all the values or you can default the Build and Revision Numbers // by using the '*' as shown below: // [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("0.2.2.1")] -[assembly: AssemblyFileVersion("0.2.2.1")] +[assembly: AssemblyVersion("0.3.0.0")] +[assembly: AssemblyFileVersion("0.3.0.0")] [assembly: NeutralResourcesLanguage("en")] diff --git a/main/editorClient.csproj b/main/editorClient.csproj index 2ad1726..c18a00e 100644 --- a/main/editorClient.csproj +++ b/main/editorClient.csproj @@ -111,6 +111,7 @@ + Form From 7ece5be8c57ee97b70d1cacaaa72f8947698b2de Mon Sep 17 00:00:00 2001 From: Martin Perreault Date: Thu, 2 Jul 2026 14:14:55 -0400 Subject: [PATCH 2/3] Fix editor crash when upgrading while background host is resident closeBackgroundApp launched a messenger copy of the background exe to deliver the exit signal but only waited on the resident process; the messenger also holds a lock on the same exe file, so the immediate WriteAllBytes in setupBackgroundApplication raced it and threw an unhandled IOException from inside the Paths static initializer, killing the editor mid-upgrade (found live during the v0.3.0.0 rollout; a resident host is the normal case now that launch-at-login exists). - closeBackgroundApp: wait on the messenger process and on every remaining instance, including after Kill() (which is async). - setupBackgroundApplication: retry the exe write briefly on IOException; on persistent failure keep the previous helper and inform the user instead of crashing (mismatch retries next launch). Co-Authored-By: Claude Fable 5 --- main/Classes/Category.cs | 36 +++++++++++++++++++++++++++--------- main/Classes/Paths.cs | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/main/Classes/Category.cs b/main/Classes/Category.cs index f3be803..d6c729e 100644 --- a/main/Classes/Category.cs +++ b/main/Classes/Category.cs @@ -377,23 +377,41 @@ public static void closeBackgroundApp(string path = "") Process[] pname = Process.GetProcessesByName(Path.GetFileNameWithoutExtension("Taskbar Groups Background")); if (pname.Length != 0) { - Process bkg = pname[0]; - - Process p = new Process(); if (path == "") { path = Paths.BackgroundApplication; } - p.StartInfo.FileName = path; - p.StartInfo.Arguments = "exitApplicationModeReserved"; - p.Start(); - if(!bkg.WaitForExit(2000)) + // Ask the resident instance to exit gracefully via the single-instance channel. + // The messenger process spawned here is ALSO backed by the same exe file, so it + // must be waited on too — otherwise overwriting the exe right after this call + // races its file lock (this crashed the editor with an IOException when + // upgrading while the background host was resident). + try + { + Process p = new Process(); + p.StartInfo.FileName = path; + p.StartInfo.Arguments = "exitApplicationModeReserved"; + p.Start(); + p.WaitForExit(3000); + } + catch { } + + // Wait for every remaining instance to actually terminate (Kill() is async) + foreach (Process bkg in Process.GetProcessesByName(Path.GetFileNameWithoutExtension("Taskbar Groups Background"))) { - bkg.Kill(); + try + { + if (!bkg.WaitForExit(2000)) + { + bkg.Kill(); + bkg.WaitForExit(2000); + } + } + catch { } } } - + } } } diff --git a/main/Classes/Paths.cs b/main/Classes/Paths.cs index 28f06f7..aac900a 100644 --- a/main/Classes/Paths.cs +++ b/main/Classes/Paths.cs @@ -146,9 +146,37 @@ private static string setupBackgroundApplication() if (fileHash.SequenceEqual(localHash) == false) { Category.closeBackgroundApp(filePath); - System.IO.File.WriteAllBytes(filePath, Resources.Taskbar_Groups_Background); - justWritten = true; + // The just-closed instance may take a moment to release its file lock; + // retry briefly instead of letting an IOException kill the editor + // from inside this static initializer. + bool written = false; + for (int attempt = 0; attempt < 10; attempt++) + { + try + { + System.IO.File.WriteAllBytes(filePath, Resources.Taskbar_Groups_Background); + written = true; + break; + } + catch (IOException) + { + System.Threading.Thread.Sleep(300); + } + } + + if (written) + { + justWritten = true; + } + else + { + // Keep running with the existing background exe; the hash mismatch + // will trigger another update attempt on the next editor launch. + System.Windows.Forms.MessageBox.Show( + "Taskbar Groups could not update its background helper because the file is in use.\r\n\r\n" + + "Your groups will keep working with the previous version. Close any open group popups and restart the editor to retry."); + } } } From b9de4d5e2d95953dfaee83978861c29fb4eda755 Mon Sep 17 00:00:00 2001 From: Martin Perreault Date: Thu, 2 Jul 2026 14:18:29 -0400 Subject: [PATCH 3/3] Add FINDINGS.md; document upgrade-race fix in CHANGELOG Condensed record of the investigation: root causes with proof, the cost-attribution correction, the latent upgrade-race bug found during rollout, verification performed, and the compatibility invariants any future change must preserve. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 ++++ FINDINGS.md | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 FINDINGS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f644189..c127fcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,13 @@ See `OPTIMIZATION_PLAN.md` (final plan) and `PLAN_REVIEW.md` (independent verifi - Fixed: disabling portable mode now writes `portableMode=false` immediately instead of relying on next-launch self-correction. +### Reliability +- **Fixed editor crash when upgrading while the background host is running** (latent upstream + bug, surfaced during this release's rollout): `closeBackgroundApp` now waits for the exit + messenger process and every remaining instance to terminate, and the background-exe update + retries briefly on `IOException` instead of crashing the editor from a static initializer. + With launch-at-login, a resident host during upgrades is the normal case, so this mattered. + ### Compatibility - Existing groups, pinned taskbar shortcuts, `Settings.xml`, AppUserModelIDs, and the command-line contract are all unchanged — upgrading is running the new editor once diff --git a/FINDINGS.md b/FINDINGS.md new file mode 100644 index 0000000..2a29ccc --- /dev/null +++ b/FINDINGS.md @@ -0,0 +1,105 @@ +# Findings — First-Click Startup Delay Investigation & Fix (v0.3.0.0) + +> Companion docs: `OPTIMIZATION_PLAN.md` (the implementation plan, final), +> `PLAN_REVIEW.md` (independent second-model verification of the diagnosis), +> `CHANGELOG.md` (release notes). This file is the condensed record of *what +> was found and proven*, for future contributors. + +## The symptom + +On Windows 11, the **first** click on a pinned taskbar group after a reboot +took ~5 seconds to open the popup. Every subsequent click on any group was +instant until the next reboot. + +## Architecture (why the symptom has that shape) + +Two executables: + +- **`Taskbar Groups.exe`** (project `main/`) — the editor. Embeds the + background exe as a resource and installs/updates it at + `%LocalAppData%\Jack Schierbeck\taskbar-groups\` via an MD5 comparison on + every editor launch. +- **`Taskbar Groups Background.exe`** (project `backgroundClient/`) — a + **tray-resident, single-instance** process. Pinned taskbar `.lnk` files + target this exe with the **bare group name as the only argument** + (note: `ShellLink.InstallShortcut`'s `" shortcut"` string is the + shortcut *description*, not its arguments — an earlier draft of the plan + got this wrong; see `PLAN_REVIEW.md` §2.1). + +First click after boot = full cold start of the background process, which +then stays resident; later clicks only deliver an argument to the warm +instance over the single-instance channel. That asymmetry **is** the bug +surface. + +## Root causes found (all verified against source, then fixed) + +| # | Finding | Location | Fix | +|---|---------|----------|-----| +| 1 | **Multicore JIT was silently disabled** on the hot path: `ProfileOptimization.StartProfile` was called in `frmMain`, but `SetProfileRoot` only ever ran in the editor — without a root, `StartProfile` is a documented no-op. Corroborated on a real install: `JITComp\` contained only editor profiles. | `backgroundClient/Program.cs`, `frmMain.cs:85` | `SetProfileRoot` + a startup profile added to `Program.Main`. Proven: `bkgStartup.Profile` and `frmMain.Profile` now appear in `JITComp\`. | +| 2 | **Every group was fully loaded at startup** — XML deserialization + decoding of every shortcut icon PNG + every `GroupIcon.ico`, before the first popup could show, even though a click needs one group. | `bkgProcess.cs` ctor | Startup now only indexes group folders (cheap scan). `Category` loads on demand at click; all groups pre-warm on a background thread after tray init. | +| 3 | **Jump-list built on the popup's critical path** (WindowsAPICodePack + shell COM, and the project references WPF assemblies). | `frmMain.cs` ctor | Deferred to the form's `Shown` event; the `setGroupContextMenu` registration path still builds it before closing. | +| 4 | **Nothing kept the process warm across reboots** — the cold start was simply paid on the first click. | — | New **"Start at login (instant first click)"** toggle (default on) in the editor: HKCU `Run` key pointing at the background exe. Launching it with **no arguments** already starts it tray-resident with no popup, so no new hot-path code was needed. Disabled in portable mode. | + +### Cost-attribution correction (matters for future work) + +An earlier hypothesis blamed ".NET Framework JIT of everything." Wrong +emphasis: Framework's own assemblies are NGen'd and its CLR is typically +warm; the JIT tax is limited to the app's ~1.5 MB plus the byte-array-loaded +WindowsAPICodePack DLLs (`Assembly.Load(byte[])` can never use native +images). The dominant cold cost is **disk I/O after boot** plus that +app-code JIT. This is why the login prewarm (#4) is the decisive fix — it +hides the cold start regardless of its exact composition — and why a .NET 8 +self-contained single-file migration (~100 MB to page in cold) is **not** +automatically a startup win. See `OPTIMIZATION_PLAN.md` Phase 2 (deferred). + +## Bug found *during* rollout (latent upstream bug, now fixed) + +Upgrading while the background host was resident **crashed the editor** +(unhandled `IOException` from the `Paths` static initializer; confirmed via +Windows Event Log). Cause: `closeBackgroundApp()` spawns a *messenger copy* +of the background exe to deliver the exit signal but only waited on the +resident process — the messenger itself holds a lock on the same exe file, +and `Process.Kill()` is asynchronous — so the immediate `File.WriteAllBytes` +raced the lock. This was rare before (helper seldom resident during an +upgrade) but becomes the **normal case** with launch-at-login. + +Fix: wait on the messenger and on every remaining instance (including after +`Kill()`), and retry the exe write briefly on `IOException`; on persistent +failure keep the previous helper and inform the user instead of crashing. + +Also fixed while in the area: the portable-mode toggle wrote +`portableMode=true` even when *disabling* portable mode (previously +self-corrected only on the next launch). + +## Verification performed + +- Both projects build clean (only pre-existing CS0168 warnings); `build.ps1` + reproduces the Release build end-to-end, including offline NuGet layout + restore and COM interop generation. +- New background exe: no-arg launch goes tray-resident with no popup; + group-name launch hands off to the resident instance and shows the popup; + `exitApplicationModeReserved` cleanly terminates it; JIT profiles are + written. +- Live upgrade on a real install (3 groups: ADOBE/TOOLS/TOPAZ): helper + replaced in place, Run key registered, helper resident immediately, popup + opens through the same code path a taskbar click uses. Existing pins and + groups untouched (AppUserModelIDs, exe path/name, config layout, and the + `.lnk` argument contract are all preserved — see `OPTIMIZATION_PLAN.md` + §4.2 for the invariants). +- Full data backup taken before any change (`Backup_TaskbarGroups_*`, + gitignored). + +## Compatibility invariants for future changes + +Preserve these or existing pinned groups break (details in +`OPTIMIZATION_PLAN.md` §4.2): + +1. AppData layout `Jack Schierbeck\taskbar-groups\{config,Shortcuts,Settings.xml}`. +2. Background exe name and `%LocalAppData%` path. +3. AppUserModelID scheme `tjackenpacken.taskbarGroup.menu.`. +4. `.lnk` argument contract (bare group name; plus `editingGroupMode`, + `tskBaropen_allGroup`, `setGroupContextMenu`, `exitApplicationModeReserved`). +5. `Category` XML schema backward-compatibility (new fields optional with + defaults — `launchAtStartup` in `Settings.xml` follows this rule). +6. Kill the resident background process before swapping its exe (now + handled robustly by `closeBackgroundApp`).