diff --git a/CLAUDE.md b/CLAUDE.md index 05afdc7..47e9134 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,8 @@ dotnet run --project src/CodeShellManager/CodeShellManager.csproj - `MainWindow.OnLoaded` skips the restore loop and clears the in-memory `SessionManager` — both sessions AND groups — so any new work in the run starts from a blank slate. - `MainViewModel.SaveStateAsync` short-circuits — **nothing is written to `state.json`** for the entire run. Window bounds, layout changes, settings tweaks, and any sessions created during the clean run are all discarded on exit. - The user's prior `state.json` survives the run untouched, so this is the safe way to test from a blank slate. +- The terminals use their own WebView2 data folder (`WebView2-clean`), so a clean run — usually a dev build next to the installed app — never shares the installed copy's browser process. See "DPI awareness and the WebView2 data folder". +- **Not isolated:** `output.db` (search index, notes, session history) and `crash.log` are shared with any other copy that is running, so test-session output shows up in the real copy's search. ## Architecture @@ -382,6 +384,53 @@ The wait is bounded (1.5s) so a wedged renderer cannot block a launch; the `resi still corrects the size whenever it arrives. It traces `RESIZE cols= rows= token= released=` under `DebugTerminalTrace` — the *absence* of that line is the signature of this bug. +## DPI awareness and the WebView2 data folder + +**The app is per-monitor DPI aware (v2)**, declared in `app.manifest`. Before that it had no +manifest and WPF defaulted to *system* DPI awareness: a process fixed its scale to the primary +monitor's DPI **at the moment it started** and kept it for life. Whenever that stopped +matching — another monitor, docking or undocking, a scale change in Settings, RDP from a +different screen — Windows bitmap-stretched the whole window (soft, wrongly sized). + +The terminals made it worse, because they are not drawn by the app's process. WebView2 runs +**one browser process per user-data folder**, shared by every app process pointing at that +folder, and that process renders at its own scale. Observed: the installed app had started at +150% and stayed there after the monitors went to 100%; a dev build started afterwards came up +at the correct 100%, but its panes were rendered by the installed copy's browser process at +150% — so the WPF chrome was right and the terminal text 1.5× too big, inside one window. + +**All terminals share one data folder** — `%AppData%\CodeShellManager\WebView2`, resolved by +`Terminal/WebView2DataFolder`. `MainWindow` used to set a per-session `UserDataFolder` through +`CreationProperties`, with a comment saying each instance needs its own; it was dead code — +`TerminalBridge.InitializeAsync` passes an explicit environment, which wins, and no +per-session folder was ever created on disk. Don't rename `WebView2`: that orphans every +user's existing profile. + +**Two copies that create WebView2 differently cannot share that folder.** A per-monitor build +starting next to a still-running system-DPI copy fails with `COMException 0x8007139F` ("The +group or resource is not in the correct state…") the moment it calls `EnsureCoreWebView2Async`. +`--clean` therefore uses its own `WebView2-clean` folder. A *normal* run of a different build +next to the installed one (a portable ZIP, F5 without `--clean`) can still hit it — and so can +the first launch of *this* release if a pre-manifest copy is somehow still running, since it +is the first version on the other side of the DPI change. Closing the other copy ends its +browser process and clears it. That case now fails cleanly instead of silently: + +- `LaunchSessionAsync` catches any `InitializeAsync` failure and tears down exactly like the + PTY-failure path. Before, the exception escaped a launch nobody awaited, so it only reached + `crash.log` later from the finalizer as an unobserved `TASK:` line; the pane kept its + spinner forever; and a new session stayed in `SessionManager` with no row — persisted to + `state.json`, invisible. That looked exactly like a hang, and was first diagnosed as one. +- A new session shows `WebView2StartupError.Describe`'s message ("close the other copy"); + restore, wake and restart rethrow, and each ends with the session **asleep** in the + sidebar. The restore loop batches 0x8007139F with the existing folder access-denied case + into one dialog. + +Covered by `DpiAndWebView2FolderTests`, including that the *built* `CodeShellManager.exe` +contains `PerMonitorV2`: removing `` from the csproj would leave +`app.manifest` on disk and silently bring the old behaviour back. The `WFO0003` analyzer +warning is suppressed in the csproj deliberately — it recommends WinForms' +`Application.SetHighDpiMode`, which a WPF app never calls. + ## Session Lifecycle 1. User clicks **+ New Session** → `NewSessionDialog` modal (Local, Remote SSH, or WSL) diff --git a/src/CodeShellManager/CodeShellManager.csproj b/src/CodeShellManager/CodeShellManager.csproj index 1e1f665..c48b41a 100644 --- a/src/CodeShellManager/CodeShellManager.csproj +++ b/src/CodeShellManager/CodeShellManager.csproj @@ -15,6 +15,11 @@ every release up to and including v0.5.0. --> 0.8.0 Assets\app.ico + + app.manifest + + $(NoWarn);WFO0003 + + + + PerMonitorV2, PerMonitor + + true/pm + + + + + + + + + + diff --git a/tests/CodeShellManager.Tests/DpiAndWebView2FolderTests.cs b/tests/CodeShellManager.Tests/DpiAndWebView2FolderTests.cs new file mode 100644 index 0000000..f151a0f --- /dev/null +++ b/tests/CodeShellManager.Tests/DpiAndWebView2FolderTests.cs @@ -0,0 +1,75 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using CodeShellManager.Terminal; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// See "DPI awareness" and "WebView2 data folder" in CLAUDE.md. +/// +public class DpiAndWebView2FolderTests +{ + [Fact] + public void Normal_run_keeps_the_existing_folder() + { + // Renaming this would silently orphan every user's existing WebView2 profile. + Assert.Equal(@"C:\AppData\CodeShellManager\WebView2", + WebView2DataFolder.Resolve(@"C:\AppData", cleanStart: false)); + } + + [Fact] + public void Clean_run_never_shares_the_normal_folder() + { + // Sharing is what broke a --clean dev build next to the installed app: one browser + // process per folder, and the two copies could not both attach to it. + string normal = WebView2DataFolder.Resolve(@"C:\AppData", cleanStart: false); + string clean = WebView2DataFolder.Resolve(@"C:\AppData", cleanStart: true); + Assert.NotEqual(normal, clean, StringComparer.OrdinalIgnoreCase); + // Siblings, not nested, so neither copy's folder can contain the other's. + Assert.False(clean.StartsWith(normal + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Incompatible_copy_is_recognised_from_the_hresult_actually_observed() + { + // The exact exception crash.log recorded for a per-monitor build on a folder whose + // browser process belonged to the system-DPI installed app. + var observed = new COMException( + "The group or resource is not in the correct state to perform the requested operation. (0x8007139F)", + unchecked((int)0x8007139F)); + + Assert.True(WebView2StartupError.IsIncompatibleCopy(observed)); + string text = WebView2StartupError.Describe(observed); + Assert.Contains("another copy", text); + Assert.Contains("Close the other copy", text); + } + + [Fact] + public void Other_failures_are_not_misdiagnosed_as_another_copy() + { + Assert.False(WebView2StartupError.IsIncompatibleCopy(new COMException("x", unchecked((int)0x80070005)))); + Assert.False(WebView2StartupError.IsIncompatibleCopy(new UnauthorizedAccessException())); + Assert.False(WebView2StartupError.IsIncompatibleCopy(new InvalidOperationException())); + + string text = WebView2StartupError.Describe(new InvalidOperationException("boom")); + Assert.Contains("boom", text); + Assert.DoesNotContain("another copy", text); + } + + [Fact] + public void Built_executable_declares_per_monitor_v2() + { + // Pins the manifest into the binary that actually ships, not just the source file: + // dropping from the csproj would leave app.manifest on disk and + // quietly revert the app to system-DPI scaling. + string dir = Path.GetDirectoryName(typeof(App).Assembly.Location)!; + string exe = Path.Combine(dir, "CodeShellManager.exe"); + Assert.True(File.Exists(exe), $"apphost not found next to the app assembly: {exe}"); + + string bytes = Encoding.ASCII.GetString(File.ReadAllBytes(exe)); + Assert.Contains("PerMonitorV2", bytes); + } +}