Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 `<ApplicationManifest>` 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)
Expand Down
5 changes: 5 additions & 0 deletions src/CodeShellManager/CodeShellManager.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
every release up to and including v0.5.0. -->
<Version>0.8.0</Version>
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
<!-- Per-monitor DPI (v2). See the comment in app.manifest and "DPI awareness" in CLAUDE.md. -->
<ApplicationManifest>app.manifest</ApplicationManifest>
<!-- WFO0003 says to move DPI settings to Application.SetHighDpiMode — WinForms' entry
point. This is a WPF app (WinForms is referenced only for the tray NotifyIcon), so that API never runs; the manifest is the WPF mechanism. -->
<NoWarn>$(NoWarn);WFO0003</NoWarn>
</PropertyGroup>

<!-- Hot Reload (Microsoft.Extensions.DotNetDeltaApplier) crashes the app with
Expand Down
62 changes: 47 additions & 15 deletions src/CodeShellManager/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -392,8 +392,20 @@ private async void OnLoaded(object sender, RoutedEventArgs e)
catch (Exception ex)
{
Log($"Restore FAILED for '{s.Name}': {ex}");
if (IsWebView2AccessDenied(ex))
if (IsWebView2AccessDenied(ex) || Terminal.WebView2StartupError.IsIncompatibleCopy(ex))
{
webView2AccessDenied.Add(s.Name);
// The batched dialog below tells the user to reopen these from the
// sidebar, so make sure there is a row to reopen: put the session to
// sleep, as Wake and Restart already do when a launch fails.
if (!_sessionUi.ContainsKey(s.Id))
{
_launchingSidebarItems.Remove(s.Id);
s.IsDormant = true;
AddDormantSidebarItem(s);
RebuildSidebarOrder();
}
}
else
MessageBox.Show($"Failed to restore '{s.Name}': {ex.Message}",
"Restore Error", MessageBoxButton.OK, MessageBoxImage.Warning);
Expand Down Expand Up @@ -426,10 +438,11 @@ private async void OnLoaded(object sender, RoutedEventArgs e)
MessageBox.Show(
$"Could not initialize WebView2 for {webView2AccessDenied.Count} session(s):\n\n" +
string.Join("\n", webView2AccessDenied.Select(n => " • " + n)) +
"\n\nThis usually means another CodeShellManager instance is running, " +
"or a previous instance didn't shut down cleanly. Close any other " +
"instances (or wait a few seconds for the WebView2 user-data folder " +
"to unlock) and reopen the affected sessions from the sidebar.",
"\n\nThis usually means another copy of CodeShellManager is running — " +
"a different version, a portable copy or a dev build — or a previous " +
"one didn't shut down cleanly. Close the other copy (or wait a few " +
"seconds for the WebView2 data folder to unlock), then click the " +
"sleeping sessions in the sidebar to wake them.",
"WebView2 unavailable", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
Expand Down Expand Up @@ -1449,20 +1462,15 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal
var alertDetector = new AlertDetector(session.Id, vm.DisplayName);
vm.AlertDetector = alertDetector;

// Create WebView2 — each instance needs its own user data folder;
// WebView2 locks the folder exclusively and throws E_ACCESSDENIED otherwise.
string wv2DataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"CodeShellManager", "webview2", session.Id);
// No CreationProperties: TerminalBridge.InitializeAsync passes an explicit
// environment, and that wins. A per-session UserDataFolder used to be set here and
// was never used — no such folder was ever created on disk. All terminals share
// one folder; see WebView2DataFolder.
var webView = new WebView2
{
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
DefaultBackgroundColor = System.Drawing.Color.FromArgb(30, 30, 46),
CreationProperties = new Microsoft.Web.WebView2.Wpf.CoreWebView2CreationProperties
{
UserDataFolder = wv2DataDir
}
};

// Build the persistent wrapper NOW (webView has no parent yet — this is safe).
Expand Down Expand Up @@ -1518,7 +1526,31 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal
_ => $"Starting {(string.IsNullOrWhiteSpace(session.Command) ? "session" : session.Command)}…",
};
bridge.SetBootContext(bootLabel, GetAccentForSession(session));
await bridge.InitializeAsync(htmlPath);
try
{
await bridge.InitializeAsync(htmlPath);
}
catch (Exception ex)
{
// Any WebView2 start failure — an incompatible copy of the app on the same data
// folder (0x8007139F) or the folder access-denied the restore loop batches. Same
// teardown as the PTY-failure catch below. Before this the exception escaped a
// launch nobody awaited: it reached crash.log only 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, invisible.
Log($"LaunchSession WebView2 FAILED: {ex}");
TerminalGrid.Children.Remove(terminalWrapper);
vm.Dispose();
if (_launchingSidebarItems.Remove(session.Id))
RebuildSidebarOrder();
// Restore, wake and restart each turn the throw into a sleeping row that can be
// woken once the other copy is closed, and say so in their own words.
if (restoring) throw;
MessageBox.Show(this, Terminal.WebView2StartupError.Describe(ex), "Launch Error",
MessageBoxButton.OK, MessageBoxImage.Error);
if (removeOnFailure) _sessionManager.RemoveSession(session.Id);
return;
}
bridge.ApplyFontSettings(_vm.Settings);
bridge.ApplyProfileOverrides(session);

Expand Down
5 changes: 3 additions & 2 deletions src/CodeShellManager/Terminal/TerminalBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,10 @@ public async Task InitializeAsync(string htmlPath)

// Use AppData for the WebView2 user-data folder so the app works when
// installed under Program Files (which is not writable by the user process).
string wv2DataDir = System.IO.Path.Combine(
// --clean gets its own folder — see WebView2DataFolder for why sharing can hang.
string wv2DataDir = WebView2DataFolder.Resolve(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
"CodeShellManager", "WebView2");
App.CleanStart);
System.IO.Directory.CreateDirectory(wv2DataDir);
var env = await CoreWebView2Environment.CreateAsync(null, wv2DataDir);
await _webView.EnsureCoreWebView2Async(env);
Expand Down
46 changes: 46 additions & 0 deletions src/CodeShellManager/Terminal/WebView2Startup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using System;
using System.IO;
using System.Runtime.InteropServices;

namespace CodeShellManager.Terminal;

/// <summary>
/// Where the terminals' WebView2 keeps its user data. See "WebView2 data folder" in CLAUDE.md.
/// </summary>
/// <remarks>
/// Every <see cref="TerminalBridge"/> in a process uses one folder, and WebView2 runs one
/// browser process per folder — shared by every app process that points at it. Two copies of
/// CodeShellManager that create WebView2 differently (DPI awareness above all) cannot share
/// that browser process; the second copy's start fails with <c>0x8007139F</c>. A
/// <c>--clean</c> run is by definition a second copy next to the user's real one, and usually
/// a different build, so it gets its own folder.
/// </remarks>
public static class WebView2DataFolder
{
public const string Normal = "WebView2";
public const string Clean = "WebView2-clean";

public static string Resolve(string appDataRoot, bool cleanStart) =>
Path.Combine(appDataRoot, "CodeShellManager", cleanStart ? Clean : Normal);
}

/// <summary>Turns a WebView2 start failure into something a user can act on.</summary>
public static class WebView2StartupError
{
/// <summary>
/// HRESULT_FROM_WIN32(ERROR_INVALID_STATE): "The group or resource is not in the correct
/// state…". Observed when a per-monitor-DPI build started WebView2 on a data folder whose
/// browser process belonged to a system-DPI copy of the app.
/// </summary>
public const int IncompatibleBrowserProcess = unchecked((int)0x8007139F);

public static bool IsIncompatibleCopy(Exception ex) =>
ex is COMException { HResult: IncompatibleBrowserProcess };

public static string Describe(Exception ex) => IsIncompatibleCopy(ex)
? "The terminal renderer (WebView2) could not start, because another copy of " +
"CodeShellManager — a different version, a portable copy or a dev build — is " +
"already using its WebView2 data folder with different settings. Close the other " +
"copy and try again."
: $"The terminal renderer (WebView2) could not start: {ex.Message}";
}
34 changes: 34 additions & 0 deletions src/CodeShellManager/app.manifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="CodeShellManager.app"/>

<!--
Per-monitor DPI awareness (v2). See "DPI awareness" in CLAUDE.md.

Without this the app was *system* DPI aware: it fixed its scale to the primary
monitor's DPI at the moment the process started, and Windows bitmap-stretched the
whole window whenever that stopped matching — moved to another monitor, docked or
undocked, scale changed in Settings, RDP from a different screen. A process started
at 150% then stayed at 150% (blurry, shrunk) for its whole lifetime, and because the
WebView2 browser process renders the terminals at its *own* scale, the terminal and
the WPF chrome around it could end up at different sizes.

PerMonitorV2 makes WPF re-layout at the new DPI on every monitor change and lets the
WebView2 control pass the real monitor scale to the renderer.
-->
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- Windows 10 1703+: honoured first. -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
<!-- Older Windows fallback (ignored when dpiAwareness is understood). -->
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
</windowsSettings>
</application>

<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 / 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
</assembly>
75 changes: 75 additions & 0 deletions tests/CodeShellManager.Tests/DpiAndWebView2FolderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using CodeShellManager.Terminal;
using Xunit;

namespace CodeShellManager.Tests;

/// <summary>
/// See "DPI awareness" and "WebView2 data folder" in CLAUDE.md.
/// </summary>
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 <ApplicationManifest> 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);
}
}