diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 15b920976..8bd42bf75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,7 +60,6 @@ jobs: - uses: actions/setup-dotnet@v5 with: dotnet-version: '10.0.x' - cache: true - name: Test run: | dotnet test src/Titanium.Web.Proxy.sln -c Release --nologo --filter "TestCategory!=Slow&TestCategory!=E2E-UI-Window" @@ -98,7 +97,6 @@ jobs: - uses: actions/setup-dotnet@v5 with: dotnet-version: '10.0.x' - cache: true - uses: actions/cache@v4 with: path: tools/packaging/.cache/http3-natives @@ -138,7 +136,6 @@ jobs: - uses: actions/setup-dotnet@v5 with: dotnet-version: '10.0.x' - cache: true - name: Build Plus env: RELEASE_TAG: ${{ needs.resolve-version.outputs.release_tag }} @@ -185,7 +182,6 @@ jobs: - uses: actions/setup-dotnet@v5 with: dotnet-version: '10.0.x' - cache: true - uses: actions/cache@v4 with: path: tools/packaging/.cache/http3-natives diff --git a/README.md b/README.md index 5ced78d48..6ad31fbe5 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,10 @@ A lightweight, high-performance HTTP(S) proxy — reverse / edge CLI, desktop In | Product | What it is | How you get it | |---------|------------|----------------| -| **Titanium.Cli** (`titanium` / `twp`) | Standalone reverse / edge proxy for any stack: `run`, `test`, `version`, `update` | GitHub Releases, winget, or `dotnet tool` | -| **Titanium Inspector** | Desktop MITM debugger (session grid, inspectors, AutoResponder, breakpoints, HAR) | Windows MSI, winget, or GitHub Releases | -| **Titanium.Plus** | Optional advanced features: control plane, ops, observability, and dashboard | `titanium update --plus` | -| **Titanium.Web.Proxy** | Core library. Embed a MITM and/or reverse proxy in a .NET app | NuGet | +| **Titanium.Cli** (`titanium` / `twp`) | Standalone reverse / edge proxy for any stack: `run`, `test`, `version`, `update` | [Download (Windows, Linux & Mac)](https://titaniumproxy.com/download#cli) | +| **Titanium Inspector** | Desktop MITM debugger (session grid, inspectors, AutoResponder, breakpoints, HAR) | [Download (Windows, Linux & Mac)](https://titaniumproxy.com/download#inspector) | +| **Titanium.Plus** | Optional advanced features: control plane, ops, observability, and dashboard | After installing CLI, run `titanium update --plus` | +| **Titanium.Web.Proxy** | Core library. Embed a MITM and/or reverse proxy in a .NET app | [NuGet](https://www.nuget.org/packages/Titanium.Web.Proxy/7.0.1-beta) (`dotnet add package Titanium.Web.Proxy --prerelease`) | CLI and Plus target reverse-proxy / edge workloads (routing, load balancing, health, discovery) on Windows, Linux, and macOS. Inspector is the MITM debugging product. The Core library is the embed path for .NET. Requires .NET 10 or later. @@ -67,7 +67,7 @@ On Windows, **winget is stable-only**: winget install justcoding121.TitaniumCli ``` -For **beta**, download self-contained zips from [Download](https://titaniumproxy.com/download) / [GitHub Releases](https://github.com/justcoding121/titanium-web-proxy/releases) when a product release includes `Titanium.Cli-*.zip` assets (e.g. `v7.0.0-beta`). Extract and run: +For **beta**, download self-contained zips from [Download](https://titaniumproxy.com/download) / [GitHub Releases](https://github.com/justcoding121/titanium-web-proxy/releases) when a product release includes `Titanium.Cli-*.zip` assets (e.g. `v7.0.1-beta`). Extract and run: ```shell titanium run -c twp.yaml @@ -82,7 +82,7 @@ Optional Plus: run `titanium update --plus` (add `--channel beta` for prerelease ### Titanium Inspector -Prefer [Download](https://titaniumproxy.com/download). On Windows, winget id `justcoding121.TitaniumInspector` is **stable-only**; MSI / portable zip for beta come from the product `v*` release (e.g. `v7.0.0-beta`). Start interception from the Capture menu, install the root CA, then toggle system proxy. +Prefer [Download](https://titaniumproxy.com/download). On Windows, winget id `justcoding121.TitaniumInspector` is **stable-only**; MSI / portable zip for beta come from the product `v*` release (e.g. `v7.0.1-beta`). Start interception from the Capture menu, install the root CA, then toggle system proxy. ## Quick start diff --git a/src/Titanium.Cli/Titanium.Cli.csproj b/src/Titanium.Cli/Titanium.Cli.csproj index c5910282c..3afa41f7d 100644 --- a/src/Titanium.Cli/Titanium.Cli.csproj +++ b/src/Titanium.Cli/Titanium.Cli.csproj @@ -7,7 +7,7 @@ latest enable false - 7.0.0 + 7.0.1 Jehonathan Thomas Titanium Web Proxy CLI (titanium / twp). MIT diff --git a/src/Titanium.Inspector/App.axaml.cs b/src/Titanium.Inspector/App.axaml.cs index 150511b0d..3b1c350a4 100644 --- a/src/Titanium.Inspector/App.axaml.cs +++ b/src/Titanium.Inspector/App.axaml.cs @@ -14,8 +14,8 @@ public partial class App : Application public override void OnFrameworkInitializationCompleted() { var settings = SettingsService.Load(); - var sessions = new SessionRegistry(); - var buffer = new SessionStreamBuffer(sessions); + var sessions = new SessionRegistry(SessionStoreOptions.FromSettings(settings.Current)); + var buffer = new SessionStreamBuffer(); var updates = new UpdateService(settings); PlusInspectorLoader.TryLoadPanels(out _); diff --git a/src/Titanium.Inspector/Services/IInspectorDialogs.cs b/src/Titanium.Inspector/Services/IInspectorDialogs.cs index 4d12817e5..e5a4fed0f 100644 --- a/src/Titanium.Inspector/Services/IInspectorDialogs.cs +++ b/src/Titanium.Inspector/Services/IInspectorDialogs.cs @@ -19,18 +19,27 @@ public interface IInspectorDialogs /// Show device CA setup steps. Returns true if the user chose Export CA; false on Close / no owner. /// Task ShowDeviceCaSetupAsync(Window? owner, string message); + + /// Ask to clear and reinstall (regenerate) the Titanium root CA. Returns true if confirmed. + Task ConfirmRotateRootCaAsync(Window? owner); + + /// + /// Confirm resetting Inspector preferences to factory defaults (not the root CA or sessions). + /// + Task ConfirmResetSettingsAsync(Window? owner); } /// Avalonia modal dialogs. public sealed class AvaloniaInspectorDialogs : IInspectorDialogs { + private const string CancelLabel = "Cancel"; public Task ConfirmInstallRootCaAsync(Window? owner) => SimpleConfirmDialog.ShowAsync( owner, "Install root CA", "Decrypt HTTPS requires trusting the Titanium Inspector root CA in your current-user certificate store (and Keychain/NSS on macOS/Linux). Install now?", accept: "Install", - cancel: "Cancel"); + cancel: CancelLabel); public Task ConfirmRemoveRootCaAsync(Window? owner) => SimpleConfirmDialog.ShowAsync( @@ -38,7 +47,7 @@ public Task ConfirmRemoveRootCaAsync(Window? owner) => "Remove root CA", "Remove the Titanium Inspector root CA from the current-user Trusted Root store? HTTPS decrypt will be turned off.", accept: "Remove", - cancel: "Cancel"); + cancel: CancelLabel); public Task ConfirmElevateRootCaAsync(Window? owner) => SimpleConfirmDialog.ShowAsync( @@ -46,7 +55,7 @@ public Task ConfirmElevateRootCaAsync(Window? owner) => "Install with administrator privileges", "User-level trust failed or was insufficient. Continue to show the OS admin prompt (UAC / macOS authentication / polkit)? Cancel leaves certificate settings unchanged.", accept: "Continue", - cancel: "Cancel"); + cancel: CancelLabel); public Task ShowDeviceCaSetupAsync(Window? owner, string message) => SimpleConfirmDialog.ShowAsync( @@ -56,6 +65,29 @@ public Task ShowDeviceCaSetupAsync(Window? owner, string message) => accept: "Export CA", cancel: "Close", height: 320); + + public Task ConfirmRotateRootCaAsync(Window? owner) => + SimpleConfirmDialog.ShowAsync( + owner, + "Clear and reinstall root CA", + "Clear the current Titanium Inspector root CA and create a new one?\n\n" + + "• All same-name Titanium roots are removed from the current-user Trusted Root store\n" + + "• Cached site certificates for this install are cleared\n" + + "• You will be asked to trust the new root CA again (or enable Decrypt HTTPS)\n\n" + + "Stop capture is recommended first.", + accept: "Clear and reinstall", + cancel: CancelLabel, + height: 320); + + public Task ConfirmResetSettingsAsync(Window? owner) => + SimpleConfirmDialog.ShowAsync( + owner, + "Reset Inspector settings", + "Restore bind address, menus, Tools (Composer/Breakpoints/AutoResponder/Scripts), retention, logging, HTTPS host lists, and layout to factory defaults?\n\n" + + "This does not remove the root CA, change OS trust, clear captured sessions, or delete the on-disk body cache. Restart Inspector afterward so retention limits fully apply.", + accept: "Reset settings", + cancel: CancelLabel, + height: 300); } /// Scripted answers for unit / E2E-UI tests (no real windows). @@ -65,10 +97,14 @@ public sealed class ScriptedInspectorDialogs : IInspectorDialogs public bool RemoveRootCaResult { get; set; } = true; public bool ElevateRootCaResult { get; set; } = true; public bool DeviceCaSetupResult { get; set; } + public bool ResetSettingsResult { get; set; } = true; + public bool RotateRootCaResult { get; set; } = true; public int InstallRootCaCalls { get; private set; } public int RemoveRootCaCalls { get; private set; } public int ElevateRootCaCalls { get; private set; } public int DeviceCaSetupCalls { get; private set; } + public int ResetSettingsCalls { get; private set; } + public int RotateRootCaCalls { get; private set; } public string? LastDeviceCaSetupMessage { get; private set; } public Task ConfirmInstallRootCaAsync(Window? owner) @@ -89,10 +125,22 @@ public Task ConfirmElevateRootCaAsync(Window? owner) return Task.FromResult(ElevateRootCaResult); } + public Task ConfirmRotateRootCaAsync(Window? owner) + { + RotateRootCaCalls++; + return Task.FromResult(RotateRootCaResult); + } + public Task ShowDeviceCaSetupAsync(Window? owner, string message) { DeviceCaSetupCalls++; LastDeviceCaSetupMessage = message; return Task.FromResult(DeviceCaSetupResult); } + + public Task ConfirmResetSettingsAsync(Window? owner) + { + ResetSettingsCalls++; + return Task.FromResult(ResetSettingsResult); + } } diff --git a/src/Titanium.Inspector/Services/InterceptionService.cs b/src/Titanium.Inspector/Services/InterceptionService.cs index 2b9383722..ae35142bf 100644 --- a/src/Titanium.Inspector/Services/InterceptionService.cs +++ b/src/Titanium.Inspector/Services/InterceptionService.cs @@ -49,6 +49,12 @@ public InterceptionService(ISystemProxyController? systemProxy = null) /// public bool DecryptHttps { get; set; } + /// Extra host patterns that skip HTTPS decryption (in addition to built-in bypasses). + public List DecryptSkipHosts { get; set; } = []; + + /// When non-empty, only these hosts are decrypted (built-in bypasses still never decrypt). + public List DecryptOnlyHosts { get; set; } = []; + /// True when the OS can host QUIC (MsQuic / QuicListener.IsSupported). public static bool IsHttp3Supported => System.Net.Quic.QuicListener.IsSupported; @@ -108,6 +114,10 @@ public async Task StartAsync(IPAddress address, int port, CancellationToken canc ApplyLoggingOptions(_loggingSettings); _proxy.EnableHttpInterception = true; _proxy.EnableRequestTimingCapture = true; + // Inspector eagerly buffers bodies for the session grid; 4 MiB trips too often on + // normal browsing (images, JS bundles) and RST'd the H2 stream. 32 MiB still bounds + // memory while covering typical inspected payloads. + _proxy.MaxBufferedBodyBytes = 32 * 1024 * 1024; ApplyHttpProtocols(); _proxy.BeforeRequest += OnBeforeRequest; _proxy.BeforeResponse += OnBeforeResponse; @@ -136,6 +146,8 @@ public async Task StartAsync(IPAddress address, int port, CancellationToken canc IsRootTrusted = UseInMemoryTrustState ? _inMemoryTrusted : IsRootPresentInStore(machineStore: false); + TryPruneLegacySharedCrtsOnce(); + if (AutoTrustRootOnStart) { InstallRootCertificate(machineStore: false); @@ -368,6 +380,14 @@ public bool InstallRootCertificate(bool machineStore) return true; } + // Already trusted: skip TrustRootCertificate so Windows does not show another + // Trusted Root security dialog (or orphan-removal prompt) on repeated Install CA. + if (IsRootPresentInStore(machineStore)) + { + IsRootTrusted = true; + return true; + } + _proxy.CertificateManager.TrustRootCertificate(machineStore); IsRootTrusted = IsRootPresentInStore(machineStore); return IsRootTrusted; @@ -415,6 +435,112 @@ public void UntrustRootCertificate(bool machineStore) IsRootTrusted = IsRootPresentInStore(machineStore); } + /// + /// Mint a new root CA: untrust same-CN store entries, delete Inspector PFX + local leaf cache, + /// recreate root. Always best-effort prunes the legacy shared Titanium.Web.Proxy/crts folder. + /// Does not install trust — caller should prompt Install CA. + /// + public bool RotateRootCertificate(bool machineStore) + { + if (_proxy is null) + return false; + + EnsureRootPfxPath(); + var mgr = _proxy.CertificateManager; + + if (!UseInMemoryTrustState) + mgr.RemoveTrustedRootCertificate(machineStore); + else + { + _inMemoryTrusted = false; + IsRootTrusted = false; + } + + mgr.ClearRootCertificate(); + + try + { + if (File.Exists(_rootPfxPath)) + File.Delete(_rootPfxPath); + } + catch + { + // best-effort + } + + try + { + var localCrts = Path.Combine(Path.GetDirectoryName(_rootPfxPath!)!, "crts"); + if (Directory.Exists(localCrts)) + Directory.Delete(localCrts, recursive: true); + } + catch + { + // best-effort + } + + mgr.PfxFilePath = _rootPfxPath!; + var ok = mgr.CreateRootCertificate(persistToFile: true); + IsRootTrusted = !UseInMemoryTrustState && IsRootPresentInStore(machineStore); + + PruneLegacySharedCrts(force: true); + return ok && mgr.RootCertificate != null; + } + + /// Test seam: override marker + shared-crts paths under a temp directory. + public string? LegacyCrtsTestRoot { get; set; } + + private string LegacySharedCrtsMarkerPath() + { + EnsureRootPfxPath(); + var dir = LegacyCrtsTestRoot ?? Path.GetDirectoryName(_rootPfxPath!)!; + return Path.Combine(dir, "legacy-shared-crts-cleared"); + } + + private string ResolveLegacySharedCrtsDirectory() + { + if (LegacyCrtsTestRoot != null) + return Path.Combine(LegacyCrtsTestRoot, "shared-crts"); + return Titanium.Web.Proxy.Network.DefaultCertificateDiskCache.GetSharedLeafCertificateDirectory(); + } + + private void TryPruneLegacySharedCrtsOnce() + { + var marker = LegacySharedCrtsMarkerPath(); + if (File.Exists(marker)) + return; + PruneLegacySharedCrts(force: false); + } + + /// + /// Best-effort delete of shared Titanium.Web.Proxy/crts (never the shared root PFX). + /// When is false, writes the one-time Start marker. + /// + public void PruneLegacySharedCrts(bool force) + { + _ = force; // Callers pass Start vs rotate; marker write is identical. + try + { + var sharedCrts = ResolveLegacySharedCrtsDirectory(); + if (Directory.Exists(sharedCrts)) + Directory.Delete(sharedCrts, recursive: true); + } + catch + { + // best-effort + } + + // Start (force=false) and rotate (force=true) both ensure the one-time marker exists. + try + { + File.WriteAllText(LegacySharedCrtsMarkerPath(), DateTime.UtcNow.ToString("O")); + } + catch + { + // best-effort + } + } + public bool RefreshTrustState(bool machineStore = false) { IsRootTrusted = UseInMemoryTrustState ? _inMemoryTrusted : IsRootPresentInStore(machineStore); @@ -482,7 +608,10 @@ private Task OnBeforeTunnelConnect(object sender, TunnelConnectSessionEventArgs { var host = e.HttpClient.Request.RequestUri?.Host ?? TryHost(e.HttpClient.Request); - e.DecryptSsl = DecryptHttps && !MitmBypass.ShouldDisableSslDecrypt(host); + e.DecryptSsl = DecryptHttps && !MitmBypass.ShouldDisableSslDecrypt( + host, + DecryptSkipHosts, + DecryptOnlyHosts); if (!Capturing) { @@ -571,7 +700,7 @@ private async Task OnBeforeRequest(object sender, SessionEventArgs e) { try { - if (e.HttpClient.Request.HasBody) + if (e.HttpClient.Request.HasBody && ShouldBufferBody(e.HttpClient.Request, e)) { e.HttpClient.Request.KeepBody = true; await e.GetRequestBody(); @@ -629,7 +758,7 @@ private async Task OnBeforeResponse(object sender, SessionEventArgs e) { try { - if (e.HttpClient.Response.HasBody) + if (e.HttpClient.Response.HasBody && ShouldBufferBody(e.HttpClient.Response, e)) { e.HttpClient.Response.KeepBody = true; await e.GetResponseBody(); @@ -841,6 +970,24 @@ private static string FormatHeaders(HeaderCollection headers) return sb.ToString(); } + /// + /// Whole-body buffering for the session grid must not run when Content-Length already + /// exceeds — that path RSTs HTTP/2 streams + /// with ENHANCE_YOUR_CALM and breaks the browser download. Unknown length still buffers + /// up to the limit (UI truncation via applies afterward). + /// + private bool ShouldBufferBody(RequestResponseBase message, SessionEventArgs session) + { + var limit = session.MaxBufferedBodyBytes ?? _proxy?.MaxBufferedBodyBytes ?? (4 * 1024 * 1024); + if (limit <= 0) + { + return true; + } + + var contentLength = message.ContentLength; + return contentLength < 0 || contentLength <= limit; + } + private static byte[]? TruncateBytes(byte[]? body) { if (body is null || body.Length == 0) diff --git a/src/Titanium.Inspector/Services/MitmBypass.cs b/src/Titanium.Inspector/Services/MitmBypass.cs index e1d0df903..cc67664fe 100644 --- a/src/Titanium.Inspector/Services/MitmBypass.cs +++ b/src/Titanium.Inspector/Services/MitmBypass.cs @@ -1,3 +1,4 @@ +using System.Linq; using Titanium.Web.Proxy; namespace Titanium.Inspector.Services; @@ -34,24 +35,54 @@ public static SystemProxySettings CreateSystemProxySettings(bool includeLoopback return settings; } - public static bool ShouldDisableSslDecrypt(string? hostname) + public static bool ShouldDisableSslDecrypt(string? hostname) => + ShouldDisableSslDecrypt(hostname, userSkipHosts: null, userOnlyHosts: null); + + /// + /// Returns true when TLS should stay opaque (no MITM decrypt). + /// Built-in SSO/pinning hosts always skip. User skip patterns add more. + /// When is non-empty, only matching hosts decrypt + /// (built-in bypass hosts still never decrypt). + /// + public static bool ShouldDisableSslDecrypt( + string? hostname, + IEnumerable? userSkipHosts, + IEnumerable? userOnlyHosts) { if (string.IsNullOrEmpty(hostname)) { return false; } - if (SystemProxyBypassRules.Any(rule => HostnameMatches(hostname, rule))) + if (IsBuiltInSslBypass(hostname)) { return true; } - return hostname.Contains("dropbox.com", StringComparison.OrdinalIgnoreCase) - || hostname.Contains("webex.com", StringComparison.OrdinalIgnoreCase); + if (MatchesAny(hostname, userSkipHosts)) + { + return true; + } + + var only = userOnlyHosts? + .Where(h => !string.IsNullOrWhiteSpace(h)) + .ToList(); + if (only is { Count: > 0 } && !MatchesAny(hostname, only)) + { + return true; + } + + return false; } - private static bool HostnameMatches(string hostname, string pattern) + public static bool HostnameMatches(string hostname, string pattern) { + if (string.IsNullOrWhiteSpace(pattern)) + { + return false; + } + + pattern = pattern.Trim(); if (pattern.StartsWith("*.", StringComparison.Ordinal)) { var suffix = pattern[1..]; @@ -59,6 +90,28 @@ private static bool HostnameMatches(string hostname, string pattern) || hostname.Equals(pattern[2..], StringComparison.OrdinalIgnoreCase); } - return hostname.Equals(pattern, StringComparison.OrdinalIgnoreCase); + return hostname.Equals(pattern, StringComparison.OrdinalIgnoreCase) + || hostname.EndsWith("." + pattern, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsBuiltInSslBypass(string hostname) + { + if (SystemProxyBypassRules.Any(rule => HostnameMatches(hostname, rule))) + { + return true; + } + + return hostname.Contains("dropbox.com", StringComparison.OrdinalIgnoreCase) + || hostname.Contains("webex.com", StringComparison.OrdinalIgnoreCase); + } + + private static bool MatchesAny(string hostname, IEnumerable? patterns) + { + if (patterns is null) + { + return false; + } + + return patterns.Any(pattern => HostnameMatches(hostname, pattern)); } } diff --git a/src/Titanium.Inspector/Services/SessionArchive.cs b/src/Titanium.Inspector/Services/SessionArchive.cs index 5fbec2a3b..34608348e 100644 --- a/src/Titanium.Inspector/Services/SessionArchive.cs +++ b/src/Titanium.Inspector/Services/SessionArchive.cs @@ -9,19 +9,23 @@ public static class SessionArchive { private static readonly JsonSerializerOptions HarJson = new() { WriteIndented = true }; - public static async Task ExportHarAsync(IEnumerable sessions, string path, CancellationToken ct = default) + public static Task ExportHarAsync(IEnumerable sessions, string path, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); var entries = sessions.Select(ToHarEntry).ToList(); var har = new { log = new { version = "1.2", - creator = new { name = "Titanium Inspector", version = "7.0.0" }, + creator = new { name = "Titanium Inspector", version = "7.0.1" }, entries, }, }; - await File.WriteAllTextAsync(path, JsonSerializer.Serialize(har, HarJson), ct); + // Sync write keeps headless RelayCommand StatusText updates on the same UI turn + // (async File.WriteAllTextAsync continuations were invisible to macOS WaitUntil pumps). + File.WriteAllText(path, JsonSerializer.Serialize(har, HarJson)); + return Task.CompletedTask; } public static async Task> ImportHarAsync(string path, CancellationToken ct = default) diff --git a/src/Titanium.Inspector/Services/SessionBodyDiskCache.cs b/src/Titanium.Inspector/Services/SessionBodyDiskCache.cs new file mode 100644 index 000000000..e2a603e59 --- /dev/null +++ b/src/Titanium.Inspector/Services/SessionBodyDiskCache.cs @@ -0,0 +1,334 @@ +using System.Text; + +namespace Titanium.Inspector.Services; + +/// +/// Binary spill of session body fields under a cache directory. +/// Format: magic "TSIB" + version int32 + four length-prefixed blobs +/// (request bytes, response bytes, request text UTF-8, response text UTF-8). +/// Length -1 means null; 0 means empty. +/// +public sealed class SessionBodyDiskCache : IDisposable +{ + private const string BodyFileSearchPattern = "*.bin"; + private const int Version = 1; + private static readonly byte[] Magic = "TSIB"u8.ToArray(); + + private readonly string _directory; + private readonly long _maxBytes; + private readonly TimeSpan _maxAge; + private readonly object _gate = new(); + private long _trackedBytes; + private bool _disposed; + + public SessionBodyDiskCache(string directory, long maxBytes, TimeSpan maxAge) + { + _directory = directory; + _maxBytes = maxBytes; + _maxAge = maxAge; + Directory.CreateDirectory(_directory); + PruneOnStartup(); + } + + public string DirectoryPath => _directory; + + public string PathFor(long sessionId) => Path.Combine(_directory, sessionId.ToString("D") + ".bin"); + + public void Write(SessionSnapshot snapshot) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var path = PathFor(snapshot.Id); + var tmp = path + ".tmp"; + using (var fs = new FileStream(tmp, FileMode.Create, FileAccess.Write, FileShare.None)) + using (var bw = new BinaryWriter(fs, Encoding.UTF8, leaveOpen: false)) + { + bw.Write(Magic); + bw.Write(Version); + WriteBytes(bw, snapshot.RequestBodyBytes); + WriteBytes(bw, snapshot.ResponseBodyBytes); + WriteString(bw, snapshot.RequestBodyText); + WriteString(bw, snapshot.ResponseBodyText); + } + + if (File.Exists(path)) + { + var oldLen = new FileInfo(path).Length; + File.Delete(path); + AdjustTracked(-oldLen); + } + + File.Move(tmp, path); + AdjustTracked(new FileInfo(path).Length); + PruneExpiredFiles(); + EnforceDiskBudget(); + } + + public bool TryLoad(SessionSnapshot snapshot) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var path = PathFor(snapshot.Id); + if (!File.Exists(path)) + { + return false; + } + + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + using var br = new BinaryReader(fs, Encoding.UTF8, leaveOpen: false); + var magic = br.ReadBytes(4); + if (magic.Length != 4 || magic[0] != Magic[0] || magic[1] != Magic[1] || magic[2] != Magic[2] || magic[3] != Magic[3]) + { + return false; + } + + var version = br.ReadInt32(); + if (version != Version) + { + return false; + } + + snapshot.RequestBodyBytes = ReadBytes(br); + snapshot.ResponseBodyBytes = ReadBytes(br); + snapshot.RequestBodyText = ReadString(br); + snapshot.ResponseBodyText = ReadString(br); + return true; + } + + public void Delete(long sessionId) + { + var path = PathFor(sessionId); + if (!File.Exists(path)) + { + return; + } + + try + { + var len = new FileInfo(path).Length; + File.Delete(path); + AdjustTracked(-len); + } + catch + { + // Best-effort cleanup. + } + } + + public void DeleteMany(IEnumerable sessionIds) + { + foreach (var id in sessionIds) + { + Delete(id); + } + } + + public void ClearAll() + { + if (!Directory.Exists(_directory)) + { + return; + } + + foreach (var file in Directory.EnumerateFiles(_directory, BodyFileSearchPattern)) + { + try + { + File.Delete(file); + } + catch + { + // Best-effort. + } + } + + lock (_gate) + { + _trackedBytes = 0; + } + } + + public void Dispose() + { + _disposed = true; + } + + private void PruneExpiredFiles() + { + if (_maxAge <= TimeSpan.Zero || !Directory.Exists(_directory)) + { + return; + } + + var cutoff = DateTime.UtcNow - _maxAge; + foreach (var path in Directory.EnumerateFiles(_directory, BodyFileSearchPattern)) + { + try + { + var info = new FileInfo(path); + if (info.LastWriteTimeUtc >= cutoff) + { + continue; + } + + var len = info.Length; + info.Delete(); + AdjustTracked(-len); + } + catch + { + // Best-effort. + } + } + } + + private void PruneOnStartup() + { + if (!Directory.Exists(_directory)) + { + return; + } + + PruneExpiredFiles(); + + long total = 0; + var files = new List(); + foreach (var path in Directory.EnumerateFiles(_directory, BodyFileSearchPattern)) + { + try + { + var info = new FileInfo(path); + files.Add(info); + total += info.Length; + } + catch + { + // Ignore unreadable entries. + } + } + + lock (_gate) + { + _trackedBytes = total; + } + + if (total > _maxBytes) + { + EnforceDiskBudget(files); + } + } + + private void EnforceDiskBudget(List? knownFiles = null) + { + long tracked; + lock (_gate) + { + tracked = _trackedBytes; + } + + if (tracked <= _maxBytes) + { + return; + } + + var files = knownFiles ?? Directory.EnumerateFiles(_directory, BodyFileSearchPattern) + .Select(p => + { + try + { + return new FileInfo(p); + } + catch + { + return null!; + } + }) + .Where(f => f is not null) + .ToList(); + + foreach (var file in files.OrderBy(f => f.LastWriteTimeUtc)) + { + if (tracked <= _maxBytes) + { + break; + } + + try + { + var len = file.Length; + file.Delete(); + tracked -= len; + AdjustTracked(-len); + } + catch + { + // Best-effort. + } + } + } + + private void AdjustTracked(long delta) + { + lock (_gate) + { + _trackedBytes = Math.Max(0, _trackedBytes + delta); + } + } + + private static void WriteBytes(BinaryWriter bw, byte[]? data) + { + if (data is null) + { + bw.Write(-1); + return; + } + + bw.Write(data.Length); + if (data.Length > 0) + { + bw.Write(data); + } + } + + private static void WriteString(BinaryWriter bw, string? text) + { + if (text is null) + { + bw.Write(-1); + return; + } + + var bytes = Encoding.UTF8.GetBytes(text); + bw.Write(bytes.Length); + if (bytes.Length > 0) + { + bw.Write(bytes); + } + } + + private static byte[]? ReadBytes(BinaryReader br) + { + var len = br.ReadInt32(); + if (len < 0) + { + return null; + } + + return len == 0 ? Array.Empty() : br.ReadBytes(len); + } + + private static string? ReadString(BinaryReader br) + { + var len = br.ReadInt32(); + if (len < 0) + { + return null; + } + + if (len == 0) + { + return string.Empty; + } + + var bytes = br.ReadBytes(len); + return Encoding.UTF8.GetString(bytes); + } +} diff --git a/src/Titanium.Inspector/Services/SessionGridLayout.cs b/src/Titanium.Inspector/Services/SessionGridLayout.cs index 452d8f7e6..fa715c120 100644 --- a/src/Titanium.Inspector/Services/SessionGridLayout.cs +++ b/src/Titanium.Inspector/Services/SessionGridLayout.cs @@ -43,6 +43,8 @@ public static class SessionGridLayout { "Duration (ms)" => "Duration", "TTFB (ms)" => "TTFB", + "Wait (ms)" => "TTFB", + "Client→Server" => "Protocol", _ => raw, }; } @@ -84,6 +86,11 @@ public static Dictionary IndexByKey( } map[column.Key] = column; + // Prefer plain "Protocol" key; keep legacy Client→Server layouts working. + if (string.Equals(column.Key, "Client→Server", StringComparison.Ordinal)) + { + map["Protocol"] = column; + } } return map; diff --git a/src/Titanium.Inspector/Services/SessionRegistry.cs b/src/Titanium.Inspector/Services/SessionRegistry.cs index f6617a5cc..81be80976 100644 --- a/src/Titanium.Inspector/Services/SessionRegistry.cs +++ b/src/Titanium.Inspector/Services/SessionRegistry.cs @@ -2,62 +2,38 @@ namespace Titanium.Inspector.Services; -/// Session registry with LRU eviction at 50k. -public sealed class SessionRegistry +/// +/// Thin facade over kept for existing wiring and tests. +/// Retention / eviction live in the store (not a separate 50k LRU). +/// +public sealed class SessionRegistry : IDisposable { - private const int LruLimit = 50_000; - private readonly object _gate = new(); - private readonly LinkedList _lru = new(); - private readonly Dictionary> _nodes = new(); - private readonly Dictionary> _weak = new(); - - public ObservableCollection VisibleSessions { get; } = new(); + /// + /// Test / headless default: no disk spill (avoids LocalAppData writers). + /// Desktop App constructs with . + /// + public SessionRegistry() + : this(new SessionStoreOptions { SpillBodiesToDisk = false }) + { + } - public void Add(SessionSnapshot snapshot) + public SessionRegistry(SessionStoreOptions? options, string? cacheDirectory = null) + : this(new SessionStore(options, cacheDirectory)) { - lock (_gate) - { - if (_nodes.TryGetValue(snapshot.Id, out var existing)) - { - _lru.Remove(existing); - _lru.AddFirst(existing); - } - else - { - var node = _lru.AddFirst(snapshot.Id); - _nodes[snapshot.Id] = node; - _weak[snapshot.Id] = new WeakReference(snapshot); - VisibleSessions.Add(snapshot); - } - - while (_lru.Count > LruLimit) - { - var last = _lru.Last!; - _lru.RemoveLast(); - _nodes.Remove(last.Value); - _weak.Remove(last.Value); - for (var i = VisibleSessions.Count - 1; i >= 0; i--) - { - if (VisibleSessions[i].Id == last.Value) - { - VisibleSessions.RemoveAt(i); - break; - } - } - } - } } - public SessionSnapshot? TryGet(long id) + public SessionRegistry(SessionStore store) { - lock (_gate) - { - if (_weak.TryGetValue(id, out var wr) && wr.TryGetTarget(out var snap)) - { - return snap; - } - - return null; - } + Store = store; } + + public SessionStore Store { get; } + + public ObservableCollection VisibleSessions => Store.Sessions; + + public void Add(SessionSnapshot snapshot) => Store.Add(snapshot); + + public SessionSnapshot? TryGet(long id) => Store.TryGet(id); + + public void Dispose() => Store.Dispose(); } diff --git a/src/Titanium.Inspector/Services/SessionSnapshot.cs b/src/Titanium.Inspector/Services/SessionSnapshot.cs index 2a7fbeb47..4391c811d 100644 --- a/src/Titanium.Inspector/Services/SessionSnapshot.cs +++ b/src/Titanium.Inspector/Services/SessionSnapshot.cs @@ -28,6 +28,12 @@ public sealed class SessionSnapshot : INotifyPropertyChanged public string Method { get; set; } = "GET"; public string Url { get; set; } = ""; public DateTimeOffset StartedUtc { get; set; } = DateTimeOffset.UtcNow; + + /// + /// When true, request/response body bytes and text were spilled to the session disk cache + /// and must be reloaded via . + /// + public bool BodiesOnDisk { get; set; } public bool IsWebSocket { get; set; } public bool IsGrpc { get; set; } public bool IsTunnel { get; set; } diff --git a/src/Titanium.Inspector/Services/SessionStore.cs b/src/Titanium.Inspector/Services/SessionStore.cs new file mode 100644 index 000000000..90a9f12d0 --- /dev/null +++ b/src/Titanium.Inspector/Services/SessionStore.cs @@ -0,0 +1,517 @@ +using System.Collections.ObjectModel; +using System.Threading.Channels; + +namespace Titanium.Inspector.Services; + +/// +/// Single source of truth for captured sessions: ordered list, byte budget, body spill, and hard eviction. +/// +public sealed class SessionStore : IDisposable +{ + private readonly object _gate = new(); + private readonly SessionStoreOptions _options; + private readonly SessionBodyDiskCache? _disk; + private readonly Dictionary _byId = new(); + private readonly Dictionary _bodyBytes = new(); + private readonly Channel? _spillChannel; + private readonly CancellationTokenSource? _spillCts; + private readonly Task? _spillLoop; + private int _pendingSpills; + private long _inMemoryBodyBytes; + private long? _pinnedSessionId; + private bool _disposed; + + public SessionStore(SessionStoreOptions? options = null, string? cacheDirectory = null) + { + _options = options ?? new SessionStoreOptions(); + if (_options.SpillBodiesToDisk) + { + var dir = cacheDirectory ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "TitaniumInspector", + "session-cache"); + _disk = new SessionBodyDiskCache( + dir, + _options.DiskCacheMaxBytes, + TimeSpan.FromDays(_options.DiskCacheMaxAgeDays)); + _spillChannel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + }); + _spillCts = new CancellationTokenSource(); + _spillLoop = Task.Run(() => SpillLoopAsync(_spillCts.Token), _spillCts.Token); + } + + Sessions = new ObservableCollection(); + } + + public ObservableCollection Sessions { get; } + + public SessionStoreOptions Options => _options; + + public int Count + { + get + { + lock (_gate) + { + return _byId.Count; + } + } + } + + public int SpilledCount + { + get + { + lock (_gate) + { + var n = 0; + foreach (var s in _byId.Values) + { + if (s.BodiesOnDisk) + { + n++; + } + } + + return n; + } + } + } + + public long InMemoryBodyBytes + { + get + { + lock (_gate) + { + return _inMemoryBodyBytes; + } + } + } + + /// Session id that must not be hard-evicted (typically the UI selection). + public long? PinnedSessionId + { + get + { + lock (_gate) + { + return _pinnedSessionId; + } + } + set + { + lock (_gate) + { + _pinnedSessionId = value; + } + } + } + + public event Action? SessionAdded; + public event Action>? SessionsRemoved; + + /// Insert a new session, or refresh body budget if the id already exists. + public void Add(SessionSnapshot snapshot) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var isNew = false; + List? removed = null; + lock (_gate) + { + if (_byId.ContainsKey(snapshot.Id)) + { + TouchBodyBudget(snapshot); + EnforceLimitsLocked(ref removed); + } + else + { + isNew = true; + _byId[snapshot.Id] = snapshot; + Sessions.Add(snapshot); + TouchBodyBudget(snapshot); + EnforceLimitsLocked(ref removed); + } + } + + if (isNew) + { + SessionAdded?.Invoke(snapshot); + } + + if (removed is { Count: > 0 }) + { + SessionsRemoved?.Invoke(removed); + } + } + + public SessionSnapshot? TryGet(long id) + { + lock (_gate) + { + return _byId.TryGetValue(id, out var snap) ? snap : null; + } + } + + public void NotifyUpdated(SessionSnapshot snapshot) + { + ObjectDisposedException.ThrowIf(_disposed, this); + List? removed = null; + lock (_gate) + { + if (!_byId.ContainsKey(snapshot.Id)) + { + return; + } + + TouchBodyBudget(snapshot); + EnforceLimitsLocked(ref removed); + } + + if (removed is { Count: > 0 }) + { + SessionsRemoved?.Invoke(removed); + } + } + + public void Remove(IEnumerable ids) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var idSet = ids as HashSet ?? ids.ToHashSet(); + if (idSet.Count == 0) + { + return; + } + + List removed; + lock (_gate) + { + removed = RemoveIdsLocked(idSet); + } + + if (removed.Count > 0) + { + SessionsRemoved?.Invoke(removed); + } + } + + public void Clear() + { + ObjectDisposedException.ThrowIf(_disposed, this); + List removed; + lock (_gate) + { + removed = _byId.Values.ToList(); + _byId.Clear(); + _bodyBytes.Clear(); + _inMemoryBodyBytes = 0; + Sessions.Clear(); + } + + _disk?.ClearAll(); + if (removed.Count > 0) + { + SessionsRemoved?.Invoke(removed); + } + } + + public async Task EnsureBodiesLoadedAsync(SessionSnapshot snapshot, CancellationToken ct = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!snapshot.BodiesOnDisk || _disk is null) + { + return; + } + + for (var attempt = 0; attempt < 40; attempt++) + { + ct.ThrowIfCancellationRequested(); + lock (_gate) + { + if (!snapshot.BodiesOnDisk) + { + return; + } + + if (_disk.TryLoad(snapshot)) + { + snapshot.BodiesOnDisk = false; + TouchBodyBudget(snapshot); + return; + } + } + + // Spill writer may still be flushing the file. + await Task.Delay(25, ct).ConfigureAwait(false); + } + } + + public async Task EnsureBodiesLoadedAsync(IEnumerable snapshots, CancellationToken ct = default) + { + foreach (var snap in snapshots) + { + ct.ThrowIfCancellationRequested(); + await EnsureBodiesLoadedAsync(snap, ct).ConfigureAwait(false); + } + } + + /// Flush pending spill writes (tests). + public async Task FlushSpillAsync(TimeSpan? timeout = null) + { + if (_spillChannel is null) + { + return; + } + + var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(5)); + while (Volatile.Read(ref _pendingSpills) > 0 && DateTime.UtcNow < deadline) + { + await Task.Delay(25, CancellationToken.None).ConfigureAwait(false); + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _spillCts?.Cancel(); + _spillChannel?.Writer.TryComplete(); + try + { + _spillLoop?.Wait(millisecondsTimeout: 2000, CancellationToken.None); + } + catch + { + // Ignore shutdown races. + } + + _spillCts?.Dispose(); + _disk?.Dispose(); + } + + internal static long EstimateInMemoryBodyBytes(SessionSnapshot s) + { + if (s.BodiesOnDisk) + { + return 0; + } + + long n = 0; + if (s.RequestBodyBytes is { } req) + { + n += req.Length; + } + + if (s.ResponseBodyBytes is { } resp) + { + n += resp.Length; + } + + if (s.RequestBodyText is { } reqText) + { + n += (long)reqText.Length * sizeof(char); + } + + if (s.ResponseBodyText is { } respText) + { + n += (long)respText.Length * sizeof(char); + } + + return n; + } + + private void TouchBodyBudget(SessionSnapshot snapshot) + { + var next = EstimateInMemoryBodyBytes(snapshot); + if (_bodyBytes.TryGetValue(snapshot.Id, out var prev)) + { + _inMemoryBodyBytes -= prev; + } + + _bodyBytes[snapshot.Id] = next; + _inMemoryBodyBytes += next; + if (_inMemoryBodyBytes < 0) + { + _inMemoryBodyBytes = 0; + } + } + + private void EnforceLimitsLocked(ref List? removed) + { + SpillColdBodiesLocked(); + + while (_byId.Count > _options.MaxSessionsInMemory || + _inMemoryBodyBytes > _options.MaxCaptureBytesInMemory) + { + if (!TryEvictOldestLocked(out var evicted)) + { + break; + } + + removed ??= new List(); + removed.Add(evicted); + SpillColdBodiesLocked(); + } + } + + private void SpillColdBodiesLocked() + { + if (!_options.SpillBodiesToDisk || _disk is null || _spillChannel is null) + return; + + var hot = _options.HotBodySessions; + if (Sessions.Count <= hot && _inMemoryBodyBytes <= _options.MaxCaptureBytesInMemory) + return; + + SpillOutsideHotWindowLocked(hot); + SpillUntilUnderBudgetLocked(); + } + + private void SpillOutsideHotWindowLocked(int hot) + { + var spillUntilIndex = Math.Max(0, Sessions.Count - hot); + for (var i = 0; i < spillUntilIndex; i++) + { + var snap = Sessions[i]; + if (snap.BodiesOnDisk || EstimateInMemoryBodyBytes(snap) == 0) + continue; + QueueSpillLocked(snap); + } + } + + private void SpillUntilUnderBudgetLocked() + { + if (_inMemoryBodyBytes <= _options.MaxCaptureBytesInMemory) + return; + + for (var i = 0; i < Sessions.Count && _inMemoryBodyBytes > _options.MaxCaptureBytesInMemory; i++) + { + var snap = Sessions[i]; + if (snap.BodiesOnDisk || EstimateInMemoryBodyBytes(snap) == 0) + continue; + if (_pinnedSessionId is long pin && snap.Id == pin) + continue; + QueueSpillLocked(snap); + } + } + + private void QueueSpillLocked(SessionSnapshot snap) + { + var copy = new SessionSnapshot + { + Id = snap.Id, + RequestBodyBytes = snap.RequestBodyBytes, + ResponseBodyBytes = snap.ResponseBodyBytes, + RequestBodyText = snap.RequestBodyText, + ResponseBodyText = snap.ResponseBodyText, + }; + + snap.RequestBodyBytes = null; + snap.ResponseBodyBytes = null; + snap.RequestBodyText = null; + snap.ResponseBodyText = null; + snap.BodiesOnDisk = true; + TouchBodyBudget(snap); + Interlocked.Increment(ref _pendingSpills); + if (!_spillChannel!.Writer.TryWrite(copy)) + { + Interlocked.Decrement(ref _pendingSpills); + } + } + + private bool TryEvictOldestLocked(out SessionSnapshot evicted) + { + for (var i = 0; i < Sessions.Count; i++) + { + var snap = Sessions[i]; + if (_pinnedSessionId is long pin && snap.Id == pin) + { + continue; + } + + Sessions.RemoveAt(i); + _byId.Remove(snap.Id); + if (_bodyBytes.TryGetValue(snap.Id, out var bytes)) + { + _inMemoryBodyBytes -= bytes; + _bodyBytes.Remove(snap.Id); + } + + _disk?.Delete(snap.Id); + evicted = snap; + return true; + } + + evicted = null!; + return false; + } + + private List RemoveIdsLocked(HashSet ids) + { + var removed = new List(); + for (var i = Sessions.Count - 1; i >= 0; i--) + { + var snap = Sessions[i]; + if (!ids.Contains(snap.Id)) + { + continue; + } + + Sessions.RemoveAt(i); + _byId.Remove(snap.Id); + if (_bodyBytes.TryGetValue(snap.Id, out var bytes)) + { + _inMemoryBodyBytes -= bytes; + _bodyBytes.Remove(snap.Id); + } + + _disk?.Delete(snap.Id); + removed.Add(snap); + } + + if (_inMemoryBodyBytes < 0) + { + _inMemoryBodyBytes = 0; + } + + return removed; + } + + private async Task SpillLoopAsync(CancellationToken ct) + { + if (_spillChannel is null || _disk is null) + { + return; + } + + try + { + await foreach (var snap in _spillChannel.Reader.ReadAllAsync(ct).ConfigureAwait(false)) + { + try + { + _disk.Write(snap); + } + catch + { + // Best-effort spill; session already marked BodiesOnDisk. + } + finally + { + Interlocked.Decrement(ref _pendingSpills); + } + } + } + catch (OperationCanceledException) + { + // Shutdown. + } + } +} diff --git a/src/Titanium.Inspector/Services/SessionStoreOptions.cs b/src/Titanium.Inspector/Services/SessionStoreOptions.cs new file mode 100644 index 000000000..3da8d9418 --- /dev/null +++ b/src/Titanium.Inspector/Services/SessionStoreOptions.cs @@ -0,0 +1,27 @@ +namespace Titanium.Inspector.Services; + +/// Retention knobs for long-running Inspector capture. +public sealed class SessionStoreOptions +{ + public int MaxSessionsInMemory { get; set; } = 10_000; + public long MaxCaptureBytesInMemory { get; set; } = 512L * 1024 * 1024; + public int HotBodySessions { get; set; } = 2_000; + public bool SpillBodiesToDisk { get; set; } = true; + public long DiskCacheMaxBytes { get; set; } = 2L * 1024 * 1024 * 1024; + public int DiskCacheMaxAgeDays { get; set; } = 7; + + public static SessionStoreOptions FromSettings(InspectorSettings settings) => + new() + { + MaxSessionsInMemory = settings.MaxSessionsInMemory > 0 ? settings.MaxSessionsInMemory : 10_000, + MaxCaptureBytesInMemory = settings.MaxCaptureBytesInMemory > 0 + ? settings.MaxCaptureBytesInMemory + : 512L * 1024 * 1024, + HotBodySessions = settings.HotBodySessions > 0 ? settings.HotBodySessions : 2_000, + SpillBodiesToDisk = settings.SpillBodiesToDisk, + DiskCacheMaxBytes = settings.DiskCacheMaxBytes > 0 + ? settings.DiskCacheMaxBytes + : 2L * 1024 * 1024 * 1024, + DiskCacheMaxAgeDays = settings.DiskCacheMaxAgeDays > 0 ? settings.DiskCacheMaxAgeDays : 7, + }; +} diff --git a/src/Titanium.Inspector/Services/SessionStreamBuffer.cs b/src/Titanium.Inspector/Services/SessionStreamBuffer.cs index 63d2f7702..c670b2aaf 100644 --- a/src/Titanium.Inspector/Services/SessionStreamBuffer.cs +++ b/src/Titanium.Inspector/Services/SessionStreamBuffer.cs @@ -6,12 +6,10 @@ namespace Titanium.Inspector.Services; public sealed class SessionStreamBuffer { private readonly Channel _channel; - private readonly SessionRegistry _registry; private long _nextId; - public SessionStreamBuffer(SessionRegistry registry, int capacity = 10_000) + public SessionStreamBuffer(int capacity = 10_000) { - _registry = registry; _channel = Channel.CreateBounded(new BoundedChannelOptions(capacity) { FullMode = BoundedChannelFullMode.DropOldest, @@ -21,6 +19,13 @@ public SessionStreamBuffer(SessionRegistry registry, int capacity = 10_000) _ = Task.Run(ReadLoopAsync); } + /// Compatibility ctor — registry retention is owned by the ViewModel / . + public SessionStreamBuffer(SessionRegistry registry, int capacity = 10_000) + : this(capacity) + { + _ = registry; + } + public event Action? SessionAdded; public void Publish(SessionSnapshot snapshot) @@ -40,7 +45,6 @@ private async Task ReadLoopAsync() { await foreach (var snapshot in _channel.Reader.ReadAllAsync()) { - _registry.Add(snapshot); SessionAdded?.Invoke(snapshot); } } diff --git a/src/Titanium.Inspector/Services/SettingsService.cs b/src/Titanium.Inspector/Services/SettingsService.cs index 7dce8de75..9b6c49227 100644 --- a/src/Titanium.Inspector/Services/SettingsService.cs +++ b/src/Titanium.Inspector/Services/SettingsService.cs @@ -41,6 +41,7 @@ public sealed class InspectorSettings #endif public string? LoggingFilePath { get; set; } + /// Accept upstream TLS that fails normal validation (lab / self-signed hosts). Off by default. public bool IgnoreServerCertificateErrors { get; set; } /// Start listener when the main window opens. @@ -54,6 +55,32 @@ public sealed class InspectorSettings /// Session grid column widths, order, and sort across launches. public SessionGridLayoutDto? SessionGridLayout { get; set; } + + /// Hard cap on sessions retained in the Inspector grid. + public int MaxSessionsInMemory { get; set; } = 10_000; + + /// Soft budget for in-RAM body bytes+text across retained sessions. + public long MaxCaptureBytesInMemory { get; set; } = 512L * 1024 * 1024; + + /// Newest N sessions keep bodies in RAM; older ones spill to disk. + public int HotBodySessions { get; set; } = 2_000; + + /// When true, cold session bodies are written under LocalAppData session-cache. + public bool SpillBodiesToDisk { get; set; } = true; + + /// Max size of the on-disk session body cache. + public long DiskCacheMaxBytes { get; set; } = 2L * 1024 * 1024 * 1024; + + /// Delete spill files older than this many days on startup. + public int DiskCacheMaxAgeDays { get; set; } = 7; + + /// Extra host patterns that skip HTTPS decryption (one pattern per entry; supports *.example.com). + public List DecryptSkipHosts { get; set; } = new(); + + /// + /// When non-empty, only these host patterns are decrypted (built-in bypass hosts still never decrypt). + /// + public List DecryptOnlyHosts { get; set; } = new(); } public sealed class SettingsService @@ -86,6 +113,16 @@ public void Save() File.WriteAllText(_path, JsonSerializer.Serialize(Current, JsonOptions)); } + /// + /// Replace preferences with factory defaults and write settings.json. + /// Does not touch the root CA, OS trust stores, or captured sessions / disk body cache. + /// + public void ResetToFactoryDefaults() + { + Current = new InspectorSettings(); + Save(); + } + private InspectorSettings LoadFromDisk() { if (!File.Exists(_path)) diff --git a/src/Titanium.Inspector/Titanium.Inspector.csproj b/src/Titanium.Inspector/Titanium.Inspector.csproj index 8d1903599..443b7bf6f 100644 --- a/src/Titanium.Inspector/Titanium.Inspector.csproj +++ b/src/Titanium.Inspector/Titanium.Inspector.csproj @@ -8,7 +8,7 @@ enable true false - 7.0.0 + 7.0.1 Jehonathan Thomas Titanium Inspector desktop traffic debugger (PolyForm Noncommercial). LICENSE diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs index e185e63ba..9bbedd49b 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs @@ -20,12 +20,13 @@ public sealed class MainWindowViewModel : INotifyPropertyChanged private readonly SessionStreamBuffer _buffer; private readonly SessionRegistry _registry; + private readonly SessionStore _store; private readonly UpdateService _updates; private readonly SettingsService _settings; private readonly InterceptionService _interception; private readonly IInspectorDialogs _dialogs; private readonly IInspectorPathPicker _pathPicker; - private readonly ObservableCollection _all = new(); + private readonly ObservableCollection _all; private readonly List _selectedSessions = new(); private string _statusText = "Ready"; private string _sessionCountText = "Sessions: 0"; @@ -52,8 +53,8 @@ public sealed class MainWindowViewModel : INotifyPropertyChanged private string _plusPanelsSummary = ""; private string _bindAddress = "127.0.0.1"; private int _bindPort = 8866; - private string _endpointStatusText = "Not listening"; - private string _interceptToggleText = "Start interception"; + private string _endpointStatusText = "Proxy stopped"; + private string _interceptToggleText = "Start proxy"; /// Sticky intent: re-enable system proxy on the next Start after a Stop that had it on. private bool _reenableSystemProxyOnStart; private bool _stopBusy; @@ -82,6 +83,8 @@ public MainWindowViewModel( { _buffer = buffer; _registry = registry; + _store = registry.Store; + _all = _store.Sessions; _updates = updates; _settings = settings; _interception = interception ?? new InterceptionService(); @@ -121,14 +124,24 @@ public MainWindowViewModel( DecryptHttps = !DecryptHttps; return Task.CompletedTask; }); + ToggleIgnoreServerCertificateErrorsCommand = new RelayCommand(() => + { + IgnoreServerCertificateErrors = !IgnoreServerCertificateErrors; + return Task.CompletedTask; + }); ClearSessionsCommand = new RelayCommand(ClearSessionsAsync); RemoveSelectedSessionsCommand = new RelayCommand(RemoveSelectedSessionsAsync); ToggleSystemProxyCommand = new RelayCommand(ToggleSystemProxyAsync); InstallCaCommand = new RelayCommand(InstallCaAsync); UntrustCaCommand = new RelayCommand(UntrustCaAsync); + RotateCaCommand = new RelayCommand(RotateCaAsync); ExportCaCommand = new RelayCommand(ExportCaAsync); DeviceCaSetupCommand = new RelayCommand(DeviceCaSetupAsync); OpenLoopbackExemptCommand = new RelayCommand(OpenLoopbackExemptAsync); + OpenSessionRetentionCommand = new RelayCommand(OpenSessionRetentionAsync); + OpenLoggingSettingsCommand = new RelayCommand(OpenLoggingSettingsAsync); + OpenHttpsDecryptHostsCommand = new RelayCommand(OpenHttpsDecryptHostsAsync); + ResetSettingsCommand = new RelayCommand(ResetSettingsAsync); ReplayCommand = new RelayCommand(async () => await ReplaySelectedAsync()); LoadFromSelectedCommand = new RelayCommand(LoadFromSelectedAsync); LoadIntoComposerCommand = new RelayCommand(LoadIntoComposerAsync); @@ -167,6 +180,7 @@ public MainWindowViewModel( _interception.ConfigureLogging(_settings.Current); _interception.IgnoreServerCertificateErrors = _settings.Current.IgnoreServerCertificateErrors; _interception.DecryptHttps = _decryptHttps; + ApplyDecryptHostListsFromSettings(); ShowLoopbackExemptMenu = AppContainerLoopback.IsSupported; } @@ -182,8 +196,8 @@ public MainWindowViewModel( /// Exposed for E2E / headless tests — seeds the in-memory capture list. public void SeedSession(SessionSnapshot snapshot) { - _registry.Add(snapshot); - OnSessionAdded(snapshot); + _store.Add(snapshot); + OnSessionAddedToFilter(snapshot); } /// Called from the session grid when Extended multi-select changes. @@ -223,13 +237,13 @@ public async Task TryAutoStartAsync() if (SystemProxy) { StatusText = - $"Listening on {FormatBindDisplay()}:{BindPort}; system proxy on. HTTPS shows as CONNECT until Decrypt HTTPS is enabled." + + $"Proxy running on {FormatBindDisplay()}:{BindPort}; system proxy on. HTTPS shown as encrypted tunnels until Decrypt HTTPS is enabled." + " Chrome/Edge: --disable-quic or HTTP/3 may bypass the proxy."; } else { StatusText = - $"Listening on {FormatBindDisplay()}:{BindPort}, but system proxy failed to enable — use the System proxy checkbox."; + $"Proxy running on {FormatBindDisplay()}:{BindPort}, but system proxy failed to enable — use the System proxy checkbox."; } } @@ -275,6 +289,7 @@ public void EnsureShutdown() _interception.EnsureShutdown(); SetSystemProxyCore(false); RefreshEndpointAndBindUi(); + _registry.Dispose(); } /// @@ -334,15 +349,17 @@ private void WireBreakpointHandlers() private void WireSessionPipelineHandlers() { - _buffer.SessionAdded += snapshot => MarshalToUi(() => OnSessionAdded(snapshot)); - _interception.SessionCaptured += (_, snap) => + _buffer.SessionAdded += snapshot => MarshalToUi(() => { - _registry.Add(snap); - _buffer.Publish(snap); - }; + _store.Add(snapshot); + OnSessionAddedToFilter(snapshot); + }); + _store.SessionsRemoved += removed => MarshalToUi(() => OnSessionsRemoved(removed)); + _interception.SessionCaptured += (_, snap) => _buffer.Publish(snap); _interception.SessionUpdated += (_, snap) => MarshalToUi(() => { + _store.NotifyUpdated(snap); if (ReferenceEquals(SelectedSession, snap)) { RefreshSelectedInspectors(); @@ -448,7 +465,7 @@ private Task ToggleCapturingAsync() private Task ClearSessionsAsync() { - _all.Clear(); + _store.Clear(); Sessions.Clear(); _selectedSessions.Clear(); SelectedSession = null; @@ -467,13 +484,7 @@ private Task RemoveSelectedSessionsAsync() } var ids = selected.Select(s => s.Id).ToHashSet(); - for (var i = _all.Count - 1; i >= 0; i--) - { - if (ids.Contains(_all[i].Id)) - { - _all.RemoveAt(i); - } - } + _store.Remove(ids); for (var i = Sessions.Count - 1; i >= 0; i--) { @@ -504,7 +515,7 @@ private async Task InstallCaAsync() { if (!_interception.IsRunning) { - StatusText = "Start interception first"; + StatusText = "Start the proxy first"; return; } @@ -531,7 +542,7 @@ private async Task UntrustCaAsync() { if (!_interception.IsRunning) { - StatusText = "Start interception first"; + StatusText = "Start the proxy first"; return; } @@ -553,10 +564,63 @@ private async Task UntrustCaAsync() : "Root CA removed from current user store; Decrypt HTTPS is off until you install the CA again"; } + private async Task RotateCaAsync() + { + if (!_interception.IsRunning) + { + StatusText = "Start the proxy first"; + return; + } + + var owner = TryGetMainWindow(); + if (!await _dialogs.ConfirmRotateRootCaAsync(owner)) + { + StatusText = "Clear and reinstall root CA cancelled"; + return; + } + + if (DecryptHttps) + SetDecryptHttpsCore(false); + + var oldThumb = _interception.RootCertificate?.Thumbprint; + var ok = _interception.RotateRootCertificate(machineStore: false); + if (!ok) + { + StatusText = "Clear and reinstall root CA failed — see logs"; + return; + } + + var newThumb = _interception.RootCertificate?.Thumbprint; + var changed = !string.IsNullOrEmpty(newThumb) && + !string.Equals(oldThumb, newThumb, StringComparison.OrdinalIgnoreCase); + + if (await _dialogs.ConfirmInstallRootCaAsync(owner)) + { + var trusted = _interception.InstallRootCertificate(machineStore: false); + if (!trusted && await _dialogs.ConfirmElevateRootCaAsync(owner)) + trusted = _interception.InstallRootCertificateAsAdmin(machineStore: false); + + StatusText = FormatRotateCaInstallStatus(trusted, changed); + return; + } + + StatusText = FormatRotateCaDeferredTrustStatus(changed); + } + + private static string FormatRotateCaInstallStatus(bool trusted, bool changed) + { + if (!trusted) + return "Root CA cleared but trust failed — use Install root CA or Export CA"; + return changed ? "Root CA cleared and reinstalled — enable Decrypt HTTPS when ready" : "Root CA recreate completed and trusted"; + } + + private static string FormatRotateCaDeferredTrustStatus(bool changed) => + changed ? "Root CA cleared — Install root CA (or enable Decrypt HTTPS) to trust the new certificate" : "Root CA recreate completed — Install root CA to trust"; + private Task ExportCaAsync() { var path = _interception.ExportRootCertificate(); - StatusText = path is null ? "No root certificate yet — start interception first" : "Exported CA: " + path; + StatusText = path is null ? "No root certificate yet — Start the proxy first" : "Exported CA: " + path; return Task.CompletedTask; } @@ -564,7 +628,7 @@ private async Task OpenLoopbackExemptAsync() { if (!AppContainerLoopback.IsSupported) { - StatusText = "Loopback exemptions require Windows 8 or later"; + StatusText = "Allowing Store apps requires Windows 8 or later"; return; } @@ -573,7 +637,7 @@ private async Task OpenLoopbackExemptAsync() { if (AppContainerLoopback.TryProbeApis(out var msg)) { - StatusText = "Loopback APIs OK (no UI owner): " + msg; + StatusText = "Store app allow-list OK (no UI owner): " + msg; } else { @@ -584,7 +648,94 @@ private async Task OpenLoopbackExemptAsync() } await LoopbackExemptWindow.ShowAsync(owner); - StatusText = "Loopback exemption dialog closed"; + StatusText = "Allow Store apps dialog closed"; + } + + private async Task OpenSessionRetentionAsync() + { + var owner = TryGetMainWindow(); + if (owner is null) + { + StatusText = "Session retention requires the main window"; + return; + } + + var saved = await SessionRetentionWindow.ShowAsync(owner, _settings); + StatusText = saved + ? "Session retention saved — restart Inspector to apply" + : "Session retention cancelled"; + } + + private async Task OpenLoggingSettingsAsync() + { + var owner = TryGetMainWindow(); + if (owner is null) + { + // Headless / unit tests: apply defaults path without UI. + StatusText = "Logging settings require the main window"; + return; + } + + var saved = await LoggingSettingsWindow.ShowAsync( + owner, + _settings, + s => + { + _interception.ConfigureLogging(s); + DebugFileLogging = IsDebugFileLoggingEnabled(s); + }); + if (saved) + { + var path = _settings.Current.LoggingFilePath ?? LoggingSettingsWindow.DefaultLogPath(); + StatusText = _settings.Current.LoggingEnableFile + ? $"Logging saved: {path}" + : "Logging saved (file logging off)"; + } + else + { + StatusText = "Logging settings cancelled"; + } + } + + private async Task OpenHttpsDecryptHostsAsync() + { + var owner = TryGetMainWindow(); + if (owner is null) + { + StatusText = "HTTPS sites to decrypt requires the main window"; + return; + } + + var saved = await HttpsDecryptHostsWindow.ShowAsync( + owner, + _settings, + ApplyDecryptHostListsFromSettings); + StatusText = saved + ? "HTTPS sites to decrypt saved (applies to new connections)" + : "HTTPS sites to decrypt cancelled"; + } + + private async Task ResetSettingsAsync() + { + var owner = TryGetMainWindow(); + if (!await _dialogs.ConfirmResetSettingsAsync(owner)) + { + StatusText = "Reset settings cancelled"; + return; + } + + _settings.ResetToFactoryDefaults(); + LoadFromSettings(); + NotifySettingsUiChanged(); + StatusText = + "Settings restored to defaults — restart Inspector so retention limits fully apply. Root CA and sessions were not changed."; + } + + private void ApplyDecryptHostListsFromSettings() + { + var s = _settings.Current; + _interception.DecryptSkipHosts = s.DecryptSkipHosts?.ToList() ?? []; + _interception.DecryptOnlyHosts = s.DecryptOnlyHosts?.ToList() ?? []; } private async Task DeviceCaSetupAsync() @@ -604,36 +755,45 @@ private async Task DeviceCaSetupAsync() } } - private Task LoadFromSelectedAsync() + private async Task LoadFromSelectedAsync() { - if (SelectedSession is null) + var selected = SelectedSession; + if (selected is null) { StatusText = "Select a session to load into Composer"; - return Task.CompletedTask; + return; } - ComposerMethod = SelectedSession.Method; - ComposerUrl = SelectedSession.Url; - ComposerHeaders = SelectedSession.RequestHeadersText ?? ""; - ComposerBody = SelectedSession.RequestBodyText ?? ""; - StatusText = "Composer loaded from selected session"; - return Task.CompletedTask; + await _store.EnsureBodiesLoadedAsync(selected).ConfigureAwait(false); + await MarshalToUiAsync(() => + { + ComposerMethod = selected.Method; + ComposerUrl = selected.Url; + ComposerHeaders = selected.RequestHeadersText ?? ""; + ComposerBody = selected.RequestBodyText ?? ""; + StatusText = "Composer loaded from selected session"; + }).ConfigureAwait(false); } - private Task LoadIntoComposerAsync() + private async Task LoadIntoComposerAsync() { - if (SelectedSession is null) + var selected = SelectedSession; + if (selected is null) { StatusText = "Select a session to load into Composer"; - return Task.CompletedTask; + return; } - ComposerMethod = SelectedSession.Method; - ComposerUrl = SelectedSession.Url; - ComposerHeaders = SelectedSession.RequestHeadersText ?? ""; - ComposerBody = SelectedSession.RequestBodyText ?? ""; - StatusText = "Composer loaded from selected session"; - return OpenToolsTabAsync(0); + await _store.EnsureBodiesLoadedAsync(selected).ConfigureAwait(false); + await MarshalToUiAsync(() => + { + ComposerMethod = selected.Method; + ComposerUrl = selected.Url; + ComposerHeaders = selected.RequestHeadersText ?? ""; + ComposerBody = selected.RequestBodyText ?? ""; + StatusText = "Composer loaded from selected session"; + }).ConfigureAwait(false); + await OpenToolsTabAsync(0).ConfigureAwait(false); } private async Task CopyUrlAsync() @@ -877,14 +1037,20 @@ private Task ApplyEditBodyAsync() public ICommand ToggleAutoStartCaptureCommand { get; } public ICommand ToggleAutoSystemProxyOnStartCommand { get; } public ICommand ToggleDecryptHttpsCommand { get; } + public ICommand ToggleIgnoreServerCertificateErrorsCommand { get; } public ICommand ClearSessionsCommand { get; } public ICommand RemoveSelectedSessionsCommand { get; } public ICommand ToggleSystemProxyCommand { get; } public ICommand InstallCaCommand { get; } public ICommand UntrustCaCommand { get; } + public ICommand RotateCaCommand { get; } public ICommand ExportCaCommand { get; } public ICommand DeviceCaSetupCommand { get; } public ICommand OpenLoopbackExemptCommand { get; } + public ICommand OpenSessionRetentionCommand { get; } + public ICommand OpenLoggingSettingsCommand { get; } + public ICommand OpenHttpsDecryptHostsCommand { get; } + public ICommand ResetSettingsCommand { get; } public ICommand ReplayCommand { get; } public ICommand LoadFromSelectedCommand { get; } public ICommand LoadIntoComposerCommand { get; } @@ -921,10 +1087,10 @@ public int BindPort /// Bind address/port are start-time config; editable only while the proxy is stopped. public bool BindFieldsEnabled => !_interception.IsRunning; - /// True while the proxy endpoint is listening (drives toolbar accent / live indicator). + /// True while the proxy endpoint is Proxy running on (drives toolbar accent / live indicator). public bool IsIntercepting => _interception.IsRunning; - /// Toolbar button label: Start or Stop interception. + /// Toolbar button label: Start or Stop proxy. public string InterceptToggleText { get => _interceptToggleText; @@ -1064,7 +1230,7 @@ public bool SystemProxy { if (!_interception.IsRunning) { - StatusText = "Start interception before enabling system proxy"; + StatusText = "Start the proxy before enabling system proxy"; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SystemProxy))); return; } @@ -1079,7 +1245,7 @@ public bool SystemProxy SetSystemProxyCore(true); StatusText = - "System proxy enabled (identity bypass). For Chrome: disable QUIC (--disable-quic) or H3 may bypass the proxy."; + "System proxy enabled. For Chrome: disable QUIC (--disable-quic) or H3 may bypass the proxy."; return; } @@ -1156,8 +1322,28 @@ public bool DecryptHttps else { SetDecryptHttpsCore(false); - StatusText = "Decrypt HTTPS off — HTTPS shown as CONNECT tunnels"; + StatusText = "Decrypt HTTPS off — HTTPS shown as encrypted tunnels (not decrypted)"; + } + } + } + + /// When true, accept upstream TLS certs that would otherwise fail validation. + public bool IgnoreServerCertificateErrors + { + get => _interception.IgnoreServerCertificateErrors; + set + { + if (_interception.IgnoreServerCertificateErrors == value) + { + return; } + + _interception.IgnoreServerCertificateErrors = value; + PersistSettings(); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IgnoreServerCertificateErrors))); + StatusText = value + ? "Ignoring insecure server certificates" + : "Validating server certificates"; } } @@ -1258,6 +1444,8 @@ public SessionSnapshot? SelectedSession return; } + _store.PinnedSessionId = value?.Id; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasSelectedSession))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowInspectEmpty))); NotifyFilterSelectionProperties(); @@ -1269,7 +1457,14 @@ public SessionSnapshot? SelectedSession } UpdateWsFramesVisibility(); - RefreshSelectedInspectors(); + if (value is { BodiesOnDisk: true }) + { + _ = LoadSelectedBodiesAsync(value); + } + else + { + RefreshSelectedInspectors(); + } } } @@ -1373,20 +1568,37 @@ private void LoadFromSettings() _launchAutoStartCapture = _autoStartCapture = s.AutoStartCapture; _launchAutoSystemProxyOnStart = _autoSystemProxyOnStart = s.AutoSystemProxyOnStart; _decryptHttps = s.DecryptHttps; - AutoResponder.Enabled = s.AutoResponderEnabled; - AutoResponder.LoadFromDtos(s.AutoResponderRules); - Breakpoints.Enabled = s.BreakpointEnabled; - Breakpoints.UrlFilter = string.IsNullOrEmpty(s.BreakpointUrlFilter) ? "*" : s.BreakpointUrlFilter; _breakpointOnResponse = s.BreakpointOnResponse; _scriptOnRequest = s.ScriptOnRequest; _scriptOnResponse = s.ScriptOnResponse; + // Apply interception flags before AutoResponder/Breakpoints mutations — those can PersistSettings. _interception.BreakpointOnResponse = _breakpointOnResponse; _interception.ScriptOnRequest = _scriptOnRequest; _interception.ScriptOnResponse = _scriptOnResponse; _interception.IgnoreServerCertificateErrors = s.IgnoreServerCertificateErrors; _interception.DecryptHttps = _decryptHttps; + ApplyDecryptHostListsFromSettings(); _debugFileLogging = IsDebugFileLoggingEnabled(s); _interception.ConfigureLogging(s); + + AutoResponder.Enabled = s.AutoResponderEnabled; + AutoResponder.LoadFromDtos(s.AutoResponderRules); + Breakpoints.Enabled = s.BreakpointEnabled; + Breakpoints.UrlFilter = string.IsNullOrEmpty(s.BreakpointUrlFilter) ? "*" : s.BreakpointUrlFilter; + } + + private void NotifySettingsUiChanged() + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BindAddress))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BindPort))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(AutoStartCapture))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(AutoSystemProxyOnStart))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DecryptHttps))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IgnoreServerCertificateErrors))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BreakpointOnResponse))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ScriptOnRequest))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ScriptOnResponse))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DebugFileLogging))); } private static bool IsDebugFileLoggingEnabled(InspectorSettings s) => @@ -1409,6 +1621,7 @@ private void PersistSettings() s.AutoStartCapture = AutoStartCapture; s.AutoSystemProxyOnStart = AutoSystemProxyOnStart; s.DecryptHttps = DecryptHttps; + s.IgnoreServerCertificateErrors = _interception.IgnoreServerCertificateErrors; s.AutoResponderEnabled = AutoResponder.Enabled; s.AutoResponderRules = AutoResponder.ToDtos(); s.BreakpointEnabled = Breakpoints.Enabled; @@ -1416,7 +1629,6 @@ private void PersistSettings() s.BreakpointOnResponse = BreakpointOnResponse; s.ScriptOnRequest = ScriptOnRequest; s.ScriptOnResponse = ScriptOnResponse; - s.IgnoreServerCertificateErrors = _interception.IgnoreServerCertificateErrors; s.LoggingEnabled = _settings.Current.LoggingEnabled; s.LoggingMinimumLevel = _settings.Current.LoggingMinimumLevel; s.LoggingEnableFile = _settings.Current.LoggingEnableFile; @@ -1457,7 +1669,7 @@ private async Task EnableDecryptHttpsAsync() { if (!_interception.IsRunning) { - StatusText = "Start interception before enabling Decrypt HTTPS"; + StatusText = "Start the proxy before enabling Decrypt HTTPS"; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DecryptHttps))); return; } @@ -1483,7 +1695,7 @@ private async Task EnableDecryptHttpsAsync() } SetDecryptHttpsCore(true); - StatusText = "Decrypt HTTPS on — MITM decrypting TLS"; + StatusText = "Decrypting HTTPS"; } finally { @@ -1511,6 +1723,13 @@ private string FormatBindDisplay() private Task ToggleDebugLoggingAsync() { + // Kept for tests that invoke ToggleDebugLoggingCommand; opens Logging… when UI is available, + // otherwise toggles the previous Debug-file latch in settings. + if (TryGetMainWindow() is not null) + { + return OpenLoggingSettingsAsync(); + } + var s = _settings.Current; var enable = !IsDebugFileLoggingEnabled(s); s.LoggingEnabled = true; @@ -1518,9 +1737,7 @@ private Task ToggleDebugLoggingAsync() s.LoggingMinimumLevel = enable ? "Debug" : "Error"; if (string.IsNullOrWhiteSpace(s.LoggingFilePath)) { - s.LoggingFilePath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "TitaniumInspector", "logs", "titanium-inspector.log"); + s.LoggingFilePath = LoggingSettingsWindow.DefaultLogPath(); } _interception.ConfigureLogging(s); @@ -1528,7 +1745,7 @@ private Task ToggleDebugLoggingAsync() DebugFileLogging = enable; StatusText = enable ? $"Debug file logging on: {s.LoggingFilePath}" - : "Debug file logging off (Error level, file sink disabled)"; + : "Debug file logging off (Error level, file logging off)"; return Task.CompletedTask; } @@ -1601,11 +1818,9 @@ private void RefreshSelectedInspectors() } } - private void OnSessionAdded(SessionSnapshot snapshot) + private void OnSessionAddedToFilter(SessionSnapshot snapshot) { - _all.Add(snapshot); - // Append in place — never Clear/rebuild here or DataGrid multi-select (Ctrl+A) is wiped - // every time a new session arrives. + // Store already holds the row — append to the filtered grid in place. if (SessionSearch.Matches(snapshot, SearchQuery)) { Sessions.Add(snapshot); @@ -1614,10 +1829,72 @@ private void OnSessionAdded(SessionSnapshot snapshot) RefreshSessionCountText(); } - private void RefreshSessionCountText() => + private void OnSessionsRemoved(IReadOnlyList removed) + { + if (removed.Count == 0) + { + return; + } + + var ids = removed.Select(s => s.Id).ToHashSet(); + for (var i = Sessions.Count - 1; i >= 0; i--) + { + if (ids.Contains(Sessions[i].Id)) + { + Sessions.RemoveAt(i); + } + } + + _selectedSessions.RemoveAll(s => ids.Contains(s.Id)); + if (SelectedSession is not null && ids.Contains(SelectedSession.Id)) + { + SelectedSession = null; + } + + RefreshSessionCountText(); + if (removed.Count == 1) + { + StatusText = "Removed 1 oldest session to stay under limits"; + } + else + { + StatusText = $"Removed {removed.Count} oldest sessions to stay under limits"; + } + } + + private async Task LoadSelectedBodiesAsync(SessionSnapshot snap) + { + try + { + await _store.EnsureBodiesLoadedAsync(snap).ConfigureAwait(false); + await MarshalToUiAsync(() => + { + if (ReferenceEquals(_selected, snap)) + { + RefreshSelectedInspectors(); + } + }).ConfigureAwait(false); + } + catch + { + await MarshalToUiAsync(() => + { + if (ReferenceEquals(_selected, snap)) + { + RefreshSelectedInspectors(); + } + }).ConfigureAwait(false); + } + } + + private void RefreshSessionCountText() + { + var spilled = _store.SpilledCount; + var spilledSuffix = spilled > 0 ? $" ({spilled} bodies on disk)" : ""; SessionCountText = string.IsNullOrWhiteSpace(SearchQuery) - ? $"Sessions: {_all.Count}" - : $"Sessions: {Sessions.Count} / {_all.Count}"; + ? $"Sessions: {_all.Count}{spilledSuffix}" + : $"Sessions: {Sessions.Count} / {_all.Count}{spilledSuffix}"; + } private void NotifyQuickFilterProperties() { @@ -1679,31 +1956,31 @@ private async Task StartCaptureAsync() { SetDecryptHttpsCore(false); StatusText = SystemProxy - ? $"Listening on {FormatBindDisplay()}:{BindPort}; system proxy on — Decrypt HTTPS off (root CA not trusted). Install CA or enable Decrypt HTTPS." - : $"Listening on {FormatBindDisplay()}:{BindPort} — Decrypt HTTPS off (root CA not trusted). Install CA or enable Decrypt HTTPS."; + ? $"Proxy running on {FormatBindDisplay()}:{BindPort}; system proxy on — Decrypt HTTPS off (root CA not trusted). Install CA or enable Decrypt HTTPS." + : $"Proxy running on {FormatBindDisplay()}:{BindPort} — Decrypt HTTPS off (root CA not trusted). Install CA or enable Decrypt HTTPS."; return; } if (SystemProxy) { StatusText = _decryptHttps - ? $"Listening on {FormatBindDisplay()}:{BindPort}; system proxy on. Decrypt HTTPS on. Chrome: --disable-quic or H3 may bypass." - : $"Listening on {FormatBindDisplay()}:{BindPort}; system proxy on. HTTPS shows as CONNECT until Decrypt HTTPS is enabled." + + ? $"Proxy running on {FormatBindDisplay()}:{BindPort}; system proxy on. Decrypt HTTPS on. Chrome: --disable-quic or H3 may bypass." + : $"Proxy running on {FormatBindDisplay()}:{BindPort}; system proxy on. HTTPS shown as encrypted tunnels until Decrypt HTTPS is enabled." + " Chrome/Edge: --disable-quic or HTTP/3 may bypass the proxy."; return; } StatusText = _decryptHttps - ? $"Listening on {FormatBindDisplay()}:{BindPort} — Decrypt HTTPS on. Enable System proxy if needed. Chrome: --disable-quic or H3 may bypass." - : $"Listening on {FormatBindDisplay()}:{BindPort} — HTTPS as CONNECT until Decrypt HTTPS is enabled. Enable System proxy if needed."; + ? $"Proxy running on {FormatBindDisplay()}:{BindPort} — Decrypt HTTPS on. Enable System proxy if needed. Chrome: --disable-quic or H3 may bypass." + : $"Proxy running on {FormatBindDisplay()}:{BindPort} — HTTPS shown as encrypted tunnels until Decrypt HTTPS is enabled. Enable System proxy if needed."; } private void RefreshEndpointAndBindUi() { EndpointStatusText = _interception.IsRunning - ? $"Listening {FormatBindDisplay()}:{BindPort}" - : "Not listening"; - InterceptToggleText = _interception.IsRunning ? "Stop interception" : "Start interception"; + ? $"Proxy running on {FormatBindDisplay()}:{BindPort}" + : "Proxy stopped"; + InterceptToggleText = _interception.IsRunning ? "Stop proxy" : "Start proxy"; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BindFieldsEnabled))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsIntercepting))); } @@ -1732,12 +2009,16 @@ private async Task ReplaySelectedAsync() } StatusText = "Replaying…"; + await _store.EnsureBodiesLoadedAsync(SelectedSession).ConfigureAwait(false); var result = await ReplayService.ReplayAsync( SelectedSession, - ignoreServerCertificateErrors: _interception.IgnoreServerCertificateErrors); - StatusText = result.Ok - ? $"Replay → HTTP {result.StatusCode}: {Truncate(result.Message, 120)}" - : "Replay failed: " + result.Message; + ignoreServerCertificateErrors: _interception.IgnoreServerCertificateErrors).ConfigureAwait(false); + await MarshalToUiAsync(() => + { + StatusText = result.Ok + ? $"Replay → HTTP {result.StatusCode}: {Truncate(result.Message, 120)}" + : "Replay failed: " + result.Message; + }).ConfigureAwait(false); } private async Task SendComposerAsync() @@ -1789,8 +2070,7 @@ private async Task SendComposerAsync() Protocol = "Composer", }; - _registry.Add(snap); - _all.Add(snap); + _store.Add(snap); ApplyFilter(); RefreshSessionCountText(); SelectedSession = snap; @@ -1832,12 +2112,17 @@ private async Task ExportHarAsync() try { - await SessionArchive.ExportHarAsync(_all, path); - await MarshalToUiAsync(() => StatusText = $"Exported {_all.Count} sessions to {path}"); + var sessions = _all.ToList(); + // Stay on the UI sync context (RelayCommand). ConfigureAwait(false) + StatusText update + // raced with headless WaitUntil pumps on macOS (file written, StatusText stayed Ready). + StatusText = "Exporting HAR…"; + await _store.EnsureBodiesLoadedAsync(sessions); + await SessionArchive.ExportHarAsync(sessions, path); + StatusText = $"Exported {sessions.Count} sessions to {path}"; } catch (Exception ex) { - await MarshalToUiAsync(() => StatusText = "Export HAR failed: " + Truncate(ex.Message, 160)); + StatusText = "Export HAR failed: " + Truncate(ex.Message, 160); } } @@ -1859,12 +2144,14 @@ private async Task ExportSelectedHarAsync() try { + StatusText = "Exporting HAR…"; + await _store.EnsureBodiesLoadedAsync(sessions); await SessionArchive.ExportHarAsync(sessions, path); - await MarshalToUiAsync(() => StatusText = $"Exported {sessions.Count} sessions to {path}"); + StatusText = $"Exported {sessions.Count} sessions to {path}"; } catch (Exception ex) { - await MarshalToUiAsync(() => StatusText = "Export HAR failed: " + Truncate(ex.Message, 160)); + StatusText = "Export HAR failed: " + Truncate(ex.Message, 160); } } @@ -1889,8 +2176,7 @@ private async Task ImportHarAsync() foreach (var snap in imported) { - _registry.Add(snap); - _all.Add(snap); + _store.Add(snap); } ApplyFilter(); @@ -1915,9 +2201,10 @@ private async Task ExportArchiveAsync() try { - // SessionArchive runs zip IO on the thread pool; resume here on the UI sync context. - await SessionArchive.ExportNativeArchiveAsync(_all, path); - StatusText = $"Exported {_all.Count} sessions to {path}"; + var sessions = _all.ToList(); + await _store.EnsureBodiesLoadedAsync(sessions).ConfigureAwait(false); + await SessionArchive.ExportNativeArchiveAsync(sessions, path).ConfigureAwait(false); + StatusText = $"Exported {sessions.Count} sessions to {path}"; } catch (Exception ex) { @@ -1943,7 +2230,8 @@ private async Task ExportSelectedArchiveAsync() try { - await SessionArchive.ExportNativeArchiveAsync(sessions, path); + await _store.EnsureBodiesLoadedAsync(sessions).ConfigureAwait(false); + await SessionArchive.ExportNativeArchiveAsync(sessions, path).ConfigureAwait(false); StatusText = $"Exported {sessions.Count} sessions to {path}"; } catch (Exception ex) @@ -1970,8 +2258,7 @@ await MarshalToUiAsync(() => { foreach (var snap in imported) { - _registry.Add(snap); - _all.Add(snap); + _store.Add(snap); } ApplyFilter(); diff --git a/src/Titanium.Inspector/Views/HttpsDecryptHostsWindow.axaml b/src/Titanium.Inspector/Views/HttpsDecryptHostsWindow.axaml new file mode 100644 index 000000000..c97539738 --- /dev/null +++ b/src/Titanium.Inspector/Views/HttpsDecryptHostsWindow.axaml @@ -0,0 +1,30 @@ + + + + +