diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c127fcd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,39 @@ +# 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. + +### 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 + (it replaces the installed background exe automatically via the existing MD5 check). 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`). 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/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."); + } } } 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