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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Titanium.Inspector/Views/HttpsDecryptHostsWindow.axaml.cs b/src/Titanium.Inspector/Views/HttpsDecryptHostsWindow.axaml.cs
new file mode 100644
index 000000000..805e49b7a
--- /dev/null
+++ b/src/Titanium.Inspector/Views/HttpsDecryptHostsWindow.axaml.cs
@@ -0,0 +1,74 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Titanium.Inspector.Services;
+
+namespace Titanium.Inspector.Views;
+
+public partial class HttpsDecryptHostsWindow : Window
+{
+ private readonly SettingsService _settings;
+ private readonly Action? _onSaved;
+ private bool _saved;
+
+ public HttpsDecryptHostsWindow() : this(SettingsService.Load(), null)
+ {
+ }
+
+ public HttpsDecryptHostsWindow(SettingsService settings, Action? onSaved)
+ {
+ _settings = settings;
+ _onSaved = onSaved;
+ InitializeComponent();
+ LoadFromSettings();
+ SaveButton.Click += OnSave;
+ CancelButton.Click += (_, _) => Close();
+ }
+
+ public bool Saved => _saved;
+
+ public static async Task ShowAsync(Window owner, SettingsService settings, Action? onSaved)
+ {
+ var w = new HttpsDecryptHostsWindow(settings, onSaved);
+ await w.ShowDialog(owner);
+ return w.Saved;
+ }
+
+ private void LoadFromSettings()
+ {
+ var s = _settings.Current;
+ SkipHostsBox.Text = HostListFormat.Join(s.DecryptSkipHosts);
+ OnlyHostsBox.Text = HostListFormat.Join(s.DecryptOnlyHosts);
+ }
+
+ private void OnSave(object? sender, RoutedEventArgs e)
+ {
+ var s = _settings.Current;
+ s.DecryptSkipHosts = HostListFormat.Parse(SkipHostsBox.Text);
+ s.DecryptOnlyHosts = HostListFormat.Parse(OnlyHostsBox.Text);
+ _settings.Save();
+ _onSaved?.Invoke();
+ _saved = true;
+ Close();
+ }
+}
+
+/// Newline-separated host pattern helpers for settings UI.
+public static class HostListFormat
+{
+ public static string Join(IEnumerable? hosts) =>
+ hosts is null ? "" : string.Join(Environment.NewLine, hosts.Where(h => !string.IsNullOrWhiteSpace(h)));
+
+ public static List Parse(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return [];
+ }
+
+ return text
+ .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Where(h => h.Length > 0 && !h.StartsWith('#'))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+}
diff --git a/src/Titanium.Inspector/Views/LoggingSettingsWindow.axaml b/src/Titanium.Inspector/Views/LoggingSettingsWindow.axaml
new file mode 100644
index 000000000..56e921db2
--- /dev/null
+++ b/src/Titanium.Inspector/Views/LoggingSettingsWindow.axaml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Titanium.Inspector/Views/LoggingSettingsWindow.axaml.cs b/src/Titanium.Inspector/Views/LoggingSettingsWindow.axaml.cs
new file mode 100644
index 000000000..3e4392fb0
--- /dev/null
+++ b/src/Titanium.Inspector/Views/LoggingSettingsWindow.axaml.cs
@@ -0,0 +1,94 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using Titanium.Inspector.Services;
+
+namespace Titanium.Inspector.Views;
+
+public partial class LoggingSettingsWindow : Window
+{
+ private static readonly string[] Levels = ["Error", "Warning", "Information", "Debug"];
+
+ private readonly SettingsService _settings;
+ private readonly Action? _applyLogging;
+ private bool _saved;
+
+ public LoggingSettingsWindow() : this(SettingsService.Load(), null)
+ {
+ }
+
+ public LoggingSettingsWindow(SettingsService settings, Action? applyLogging)
+ {
+ _settings = settings;
+ _applyLogging = applyLogging;
+ InitializeComponent();
+ LevelCombo.ItemsSource = Levels;
+ LoadFromSettings();
+ SaveButton.Click += OnSave;
+ CancelButton.Click += (_, _) => Close();
+ BrowseButton.Click += OnBrowse;
+ }
+
+ public bool Saved => _saved;
+
+ public static async Task ShowAsync(
+ Window owner,
+ SettingsService settings,
+ Action? applyLogging)
+ {
+ var w = new LoggingSettingsWindow(settings, applyLogging);
+ await w.ShowDialog(owner);
+ return w.Saved;
+ }
+
+ private void LoadFromSettings()
+ {
+ var s = _settings.Current;
+ EnableLoggingCheck.IsChecked = s.LoggingEnabled;
+ WriteFileCheck.IsChecked = s.LoggingEnableFile;
+ var level = Levels.FirstOrDefault(l =>
+ string.Equals(l, s.LoggingMinimumLevel, StringComparison.OrdinalIgnoreCase)) ?? "Error";
+ LevelCombo.SelectedItem = level;
+ PathBox.Text = string.IsNullOrWhiteSpace(s.LoggingFilePath)
+ ? DefaultLogPath()
+ : s.LoggingFilePath;
+ }
+
+ private async void OnBrowse(object? sender, RoutedEventArgs e)
+ {
+ var folders = StorageProvider;
+ var file = await folders.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "Log file",
+ SuggestedFileName = "titanium-inspector.log",
+ FileTypeChoices =
+ [
+ new FilePickerFileType("Log") { Patterns = ["*.log"] },
+ new FilePickerFileType("All") { Patterns = ["*.*"] },
+ ],
+ });
+ if (file?.TryGetLocalPath() is { } path)
+ {
+ PathBox.Text = path;
+ }
+ }
+
+ private void OnSave(object? sender, RoutedEventArgs e)
+ {
+ var s = _settings.Current;
+ s.LoggingEnabled = EnableLoggingCheck.IsChecked == true;
+ s.LoggingEnableFile = WriteFileCheck.IsChecked == true;
+ s.LoggingMinimumLevel = LevelCombo.SelectedItem as string ?? "Error";
+ var path = PathBox.Text?.Trim();
+ s.LoggingFilePath = string.IsNullOrWhiteSpace(path) ? DefaultLogPath() : path;
+ _settings.Save();
+ _applyLogging?.Invoke(s);
+ _saved = true;
+ Close();
+ }
+
+ public static string DefaultLogPath() =>
+ Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "TitaniumInspector", "logs", "titanium-inspector.log");
+}
diff --git a/src/Titanium.Inspector/Views/LoopbackExemptWindow.axaml b/src/Titanium.Inspector/Views/LoopbackExemptWindow.axaml
index 9c8d89077..4e81a338c 100644
--- a/src/Titanium.Inspector/Views/LoopbackExemptWindow.axaml
+++ b/src/Titanium.Inspector/Views/LoopbackExemptWindow.axaml
@@ -2,20 +2,20 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:svc="using:Titanium.Inspector.Services"
x:Class="Titanium.Inspector.Views.LoopbackExemptWindow" AutomationProperties.AutomationId="LoopbackExemptWindow"
- Title="Loopback for sandboxed apps"
+ Title="Allow Store apps to use this proxy"
Width="640" Height="520"
WindowStartupLocation="CenterOwner">
+ Text="Windows Store / UWP apps are blocked from localhost by default. Check apps that should use Inspector, then Apply. Clear all removes every allow entry." />
-
-
+
+
@@ -29,7 +29,7 @@
GridLinesVisibility="Horizontal"
AutoGenerateColumns="False">
-
+
i.IsExempt);
if (string.IsNullOrEmpty(query))
- StatusText.Text = $"{_items.Count} AppContainers; {exemptCount} currently exempt.";
+ StatusText.Text = $"{_items.Count} apps; {exemptCount} currently allowed.";
else
- StatusText.Text = $"Showing {filtered.Count} of {_items.Count}; {exemptCount} currently exempt.";
+ StatusText.Text = $"Showing {filtered.Count} of {_items.Count}; {exemptCount} currently allowed.";
}
private void SetGridItems(IReadOnlyList items)
diff --git a/src/Titanium.Inspector/Views/MainWindow.axaml b/src/Titanium.Inspector/Views/MainWindow.axaml
index 384d84c23..59e881780 100644
--- a/src/Titanium.Inspector/Views/MainWindow.axaml
+++ b/src/Titanium.Inspector/Views/MainWindow.axaml
@@ -18,8 +18,8 @@
-
-
@@ -66,6 +60,22 @@
+
+
+
+
+
+
+
+
+
@@ -79,10 +89,12 @@
-
-
+
-
diff --git a/src/Titanium.Inspector/Views/MainWindow.axaml.cs b/src/Titanium.Inspector/Views/MainWindow.axaml.cs
index bf88261fc..cc05b5b00 100644
--- a/src/Titanium.Inspector/Views/MainWindow.axaml.cs
+++ b/src/Titanium.Inspector/Views/MainWindow.axaml.cs
@@ -400,7 +400,8 @@ private void ApplySessionColumnHeaderTips()
var tip = SessionGridLayout.GetColumnKey(header.Content) switch
{
"Duration" => "Total request time from session start to complete (milliseconds).",
- "TTFB" => "Time to first byte — wait from request sent until response headers arrive (milliseconds).",
+ "TTFB" => "Time until first response byte (TTFB), in milliseconds.",
+ "Protocol" => "HTTP/1.1, HTTP/2, … between client and proxy.",
"Size" => "Response body size (B below 1 KB, otherwise KB / MB).",
_ => null,
};
diff --git a/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml b/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml
new file mode 100644
index 000000000..88fce63fb
--- /dev/null
+++ b/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml.cs b/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml.cs
new file mode 100644
index 000000000..8471e3faf
--- /dev/null
+++ b/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml.cs
@@ -0,0 +1,93 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Titanium.Inspector.Services;
+
+namespace Titanium.Inspector.Views;
+
+public partial class SessionRetentionWindow : Window
+{
+ private readonly SettingsService _settings;
+ private bool _saved;
+
+ public SessionRetentionWindow() : this(SettingsService.Load())
+ {
+ }
+
+ public SessionRetentionWindow(SettingsService settings)
+ {
+ _settings = settings;
+ InitializeComponent();
+ LoadFromSettings();
+ SpillBodiesCheck.IsCheckedChanged += (_, _) => SyncDiskFieldsEnabled();
+ SyncDiskFieldsEnabled();
+ SaveButton.Click += OnSave;
+ CancelButton.Click += (_, _) => Close();
+ }
+
+ public bool Saved => _saved;
+
+ public static async Task ShowAsync(Window owner, SettingsService settings)
+ {
+ var w = new SessionRetentionWindow(settings);
+ await w.ShowDialog(owner);
+ return w.Saved;
+ }
+
+ private void LoadFromSettings()
+ {
+ var s = _settings.Current;
+ SpillBodiesCheck.IsChecked = s.SpillBodiesToDisk;
+ DiskCacheMaxMbBox.Text = BytesToMb(s.DiskCacheMaxBytes).ToString();
+ DiskCacheMaxAgeDaysBox.Text = s.DiskCacheMaxAgeDays.ToString();
+ MaxSessionsBox.Text = s.MaxSessionsInMemory.ToString();
+ HotBodySessionsBox.Text = s.HotBodySessions.ToString();
+ MaxBodyRamMbBox.Text = BytesToMb(s.MaxCaptureBytesInMemory).ToString();
+ }
+
+ private void SyncDiskFieldsEnabled()
+ {
+ var on = SpillBodiesCheck.IsChecked == true;
+ DiskFieldsPanel.IsEnabled = on;
+ DiskFieldsPanel.Opacity = on ? 1 : 0.5;
+ }
+
+ private void OnSave(object? sender, RoutedEventArgs e)
+ {
+ if (!TryParsePositiveInt(MaxSessionsBox.Text, out var maxSessions) ||
+ !TryParsePositiveInt(HotBodySessionsBox.Text, out var hotBodies) ||
+ !TryParsePositiveInt(DiskCacheMaxAgeDaysBox.Text, out var maxAgeDays) ||
+ !TryParsePositiveLong(DiskCacheMaxMbBox.Text, out var diskMb) ||
+ !TryParsePositiveLong(MaxBodyRamMbBox.Text, out var ramMb))
+ {
+ StatusText.Text = "Enter positive numbers for all fields.";
+ return;
+ }
+
+ var s = _settings.Current;
+ s.SpillBodiesToDisk = SpillBodiesCheck.IsChecked == true;
+ s.DiskCacheMaxBytes = MbToBytes(diskMb);
+ s.DiskCacheMaxAgeDays = maxAgeDays;
+ s.MaxSessionsInMemory = maxSessions;
+ s.HotBodySessions = hotBodies;
+ s.MaxCaptureBytesInMemory = MbToBytes(ramMb);
+ _settings.Save();
+ _saved = true;
+ Close();
+ }
+
+ public static long BytesToMb(long bytes) => Math.Max(1, bytes / (1024L * 1024L));
+
+ public static long MbToBytes(long mb) => mb * 1024L * 1024L;
+
+ public static bool TryParsePositiveInt(string? text, out int value)
+ {
+ value = 0;
+ return int.TryParse(text?.Trim(), out value) && value > 0;
+ }
+
+ public static bool TryParsePositiveLong(string? text, out long value)
+ {
+ value = 0;
+ return long.TryParse(text?.Trim(), out value) && value > 0;
+ }
+}
diff --git a/src/Titanium.Plus/PlusInspectorViewProvider.cs b/src/Titanium.Plus/PlusInspectorViewProvider.cs
index d9a54e62d..52f54535b 100644
--- a/src/Titanium.Plus/PlusInspectorViewProvider.cs
+++ b/src/Titanium.Plus/PlusInspectorViewProvider.cs
@@ -5,7 +5,7 @@ namespace Titanium.Plus;
/// Plus Inspector panels — view provider only (Inspector never calls Apply).
public sealed class PlusInspectorViewProvider : IPlusInspectorViewProvider
{
- public Version RequiredAbstractionsVersion { get; } = new(7, 0, 0);
+ public Version RequiredAbstractionsVersion { get; } = new(7, 0, 1);
public IReadOnlyList
public sealed class TitaniumPlusModule : ITitaniumPlusModule
{
- public Version RequiredAbstractionsVersion { get; } = new(7, 0, 0);
+ public Version RequiredAbstractionsVersion { get; } = new(7, 0, 1);
public void Apply(PlusActivationContext context)
{
diff --git a/src/Titanium.Web.Proxy.Abstractions/Titanium.Web.Proxy.Abstractions.csproj b/src/Titanium.Web.Proxy.Abstractions/Titanium.Web.Proxy.Abstractions.csproj
index bc4c5d0ce..b8d8560ed 100644
--- a/src/Titanium.Web.Proxy.Abstractions/Titanium.Web.Proxy.Abstractions.csproj
+++ b/src/Titanium.Web.Proxy.Abstractions/Titanium.Web.Proxy.Abstractions.csproj
@@ -7,7 +7,7 @@
enable
True
StrongNameKey.snk
- 7.0.0
+ 7.0.1
Jehonathan Thomas
Shared contracts for Titanium Web Proxy routing, clusters, middleware, and plugins.
MIT
diff --git a/src/Titanium.Web.Proxy.Configuration/Titanium.Web.Proxy.Configuration.csproj b/src/Titanium.Web.Proxy.Configuration/Titanium.Web.Proxy.Configuration.csproj
index fc57a8ff6..f092aa7fd 100644
--- a/src/Titanium.Web.Proxy.Configuration/Titanium.Web.Proxy.Configuration.csproj
+++ b/src/Titanium.Web.Proxy.Configuration/Titanium.Web.Proxy.Configuration.csproj
@@ -7,7 +7,7 @@
enable
True
StrongNameKey.snk
- 7.0.0
+ 7.0.1
Jehonathan Thomas
YAML/JSON configuration binding for Titanium Web Proxy CLI and reverse-proxy documents.
MIT
diff --git a/src/Titanium.Web.Proxy/Certificates/Cache/DefaultCertificateDiskCache.cs b/src/Titanium.Web.Proxy/Certificates/Cache/DefaultCertificateDiskCache.cs
index 3cc638605..4e963b2b9 100644
--- a/src/Titanium.Web.Proxy/Certificates/Cache/DefaultCertificateDiskCache.cs
+++ b/src/Titanium.Web.Proxy/Certificates/Cache/DefaultCertificateDiskCache.cs
@@ -23,17 +23,40 @@ public sealed class DefaultCertificateDiskCache : ICertificateCache
private static bool orphanedLegacyRootNoticeLogged;
+ ///
+ /// When an absolute root PFX path has been used, leaf crts/ lives beside that file
+ /// instead of under the shared directory.
+ ///
+ private string? absoluteLeafBaseDirectory;
+
private string? rootCertificatePath;
+ ///
+ /// Shared default leaf-cache directory (%LocalAppData%/Titanium.Web.Proxy/crts on Windows).
+ /// Used by Inspector to prune legacy leaves after migrating to an absolute root path.
+ ///
+ public static string GetSharedLeafCertificateDirectory()
+ {
+ string basePath;
+ if (RunTime.IsWindows)
+ basePath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ else
+ basePath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+
+ return Path.Combine(basePath, AppDirectoryName, DefaultCertificateDirectoryName);
+ }
+
public X509Certificate2? LoadRootCertificate(string pathOrName, string password, X509KeyStorageFlags storageFlags)
{
var path = GetRootCertificatePath(pathOrName);
+ RememberAbsoluteLeafBase(pathOrName, path);
return LoadCertificate(path, password, storageFlags);
}
public void SaveRootCertificate(string pathOrName, string password, X509Certificate2 certificate)
{
var path = GetRootCertificatePath(pathOrName);
+ RememberAbsoluteLeafBase(pathOrName, path);
var exported = certificate.Export(X509ContentType.Pkcs12, password);
WriteFileAtomic(path, exported);
}
@@ -167,13 +190,22 @@ private string GetRootCertificatePath(string pathOrName)
{
if (Path.IsPathRooted(pathOrName)) return pathOrName;
- return Path.Combine(GetRootCertificateDirectory(),
+ return Path.Combine(GetSharedRootCertificateDirectory(),
string.IsNullOrEmpty(pathOrName) ? DefaultRootCertificateFileName : pathOrName);
}
+ private void RememberAbsoluteLeafBase(string pathOrName, string resolvedRootPath)
+ {
+ if (!Path.IsPathRooted(pathOrName)) return;
+
+ var dir = Path.GetDirectoryName(resolvedRootPath);
+ if (!string.IsNullOrEmpty(dir))
+ absoluteLeafBaseDirectory = dir;
+ }
+
private string GetCertificatePath(bool create)
{
- var path = GetRootCertificateDirectory();
+ var path = absoluteLeafBaseDirectory ?? GetSharedRootCertificateDirectory();
var certPath = Path.Combine(path, DefaultCertificateDirectoryName);
if (create && !Directory.Exists(certPath)) Directory.CreateDirectory(certPath);
@@ -189,7 +221,7 @@ private string GetCertificatePath(bool create)
/// location" for a file holding the root CA's private key. 5.0.0 is unreleased, so a clean move
/// is preferred over a dual-path migration that would have to keep checking the old spot forever.
///
- private string GetRootCertificateDirectory()
+ private string GetSharedRootCertificateDirectory()
{
if (rootCertificatePath == null)
{
diff --git a/src/Titanium.Web.Proxy/Certificates/CertificateManager.cs b/src/Titanium.Web.Proxy/Certificates/CertificateManager.cs
index d31572700..6c2cb7396 100644
--- a/src/Titanium.Web.Proxy/Certificates/CertificateManager.cs
+++ b/src/Titanium.Web.Proxy/Certificates/CertificateManager.cs
@@ -249,8 +249,27 @@ private void EnforceCertificateCacheBound()
///
private void EvictCertificate(string certificateName)
{
- if (cachedCertificates.TryRemove(certificateName, out var removed))
- pendingDisposals.Enqueue(new PendingCertificateDisposal(removed.Certificate, DateTime.UtcNow));
+ if (!cachedCertificates.TryRemove(certificateName, out var removed))
+ return;
+
+ // Drop any SslStreamCertificateContext keyed by this leaf before the deferred dispose.
+ // With SaveFakeCertificates, the next visit reloads the same PKCS#12 (same thumbprint)
+ // leaving the context cached would hand SslStream a disposed SafeCertContext
+ // ("m_safeCertContext is an invalid handle") and permanently break MITM for that host.
+ InvalidateSslCertificateContext(removed.Certificate);
+ pendingDisposals.Enqueue(new PendingCertificateDisposal(removed.Certificate, DateTime.UtcNow));
+ }
+
+ ///
+ /// Removes a cached for
+ /// so a later handshake cannot reuse a context whose underlying
+ /// has been (or is about to be) disposed.
+ ///
+ private void InvalidateSslCertificateContext(X509Certificate2 leaf)
+ {
+ var thumbprint = leaf.Thumbprint;
+ if (thumbprint != null)
+ sslCertificateContexts.TryRemove(thumbprint, out _);
}
///
@@ -265,8 +284,13 @@ private void DisposePendingEvictions()
var cutoff = DateTime.UtcNow.AddMinutes(-1);
while (pendingDisposals.TryPeek(out var pending) && pending.EvictedAtUtc <= cutoff)
{
- if (pendingDisposals.TryDequeue(out pending))
- try { pending.Certificate.Dispose(); } catch { /* best effort */ }
+ if (!pendingDisposals.TryDequeue(out pending))
+ continue;
+
+ // Belt-and-suspenders: eviction already invalidated, but expired-cache and other
+ // paths may enqueue without going through EvictCertificate.
+ InvalidateSslCertificateContext(pending.Certificate);
+ try { pending.Certificate.Dispose(); } catch { /* best effort */ }
}
}
@@ -594,16 +618,95 @@ private static X509Certificate2Collection FindCertificates(StoreName storeName,
}
///
- /// Make current machine trust the Root Certificate used by this proxy
+ /// Returns when has the expected common name
+ /// and, when is set, a different thumbprint (an orphan).
+ /// When is , any matching CN is selected
+ /// (used when removing all same-name roots).
///
- ///
- ///
- private void InstallCertificate(StoreName storeName, StoreLocation storeLocation)
+ internal static bool IsSameCommonNameStoreCandidate(
+ X509Certificate2 candidate, string expectedCommonName, string? keepThumbprint)
+ {
+ if (string.IsNullOrEmpty(expectedCommonName) || candidate.Subject.Length == 0)
+ return false;
+
+ // Subject is typically "CN=Titanium Root Certificate Authority" (plus optional other RDNs).
+ var cnPrefix = "CN=" + expectedCommonName;
+ if (!candidate.Subject.Contains(cnPrefix, StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(candidate.GetNameInfo(X509NameType.SimpleName, false), expectedCommonName,
+ StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ if (keepThumbprint == null)
+ return true;
+
+ return !string.Equals(candidate.Thumbprint, keepThumbprint, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Removes Root/My store certificates that share .
+ /// When is , the current
+ /// thumbprint is preserved (orphan cleanup after a
+ /// new Root install — not on re-trust of an already-present thumbprint).
+ /// When , every matching CN is removed (Remove CA / Rotate).
+ ///
+ private void RemoveOrphanedSameCommonNameCertificates(StoreLocation storeLocation, bool keepCurrentThumbprint)
+ {
+ var expectedCn = RootCertificateName;
+ var keepThumb = keepCurrentThumbprint ? RootCertificate?.Thumbprint : null;
+ RemoveMatchingCertificates(StoreName.Root, storeLocation, expectedCn, keepThumb);
+ RemoveMatchingCertificates(StoreName.My, storeLocation, expectedCn, keepThumb);
+ }
+
+ private void RemoveMatchingCertificates(
+ StoreName storeName, StoreLocation storeLocation, string expectedCn, string? keepThumbprint)
+ {
+ try
+ {
+ using var store = new X509Store(storeName, storeLocation);
+ store.Open(OpenFlags.ReadWrite);
+ var toRemove = store.Certificates
+ .Cast()
+ .Where(cert => IsSameCommonNameStoreCandidate(cert, expectedCn, keepThumbprint))
+ .ToList();
+
+ foreach (var cert in toRemove)
+ {
+ try
+ {
+ store.Remove(cert);
+ }
+ catch (Exception e)
+ {
+ OnException(new Exception(
+ $"Failed to remove same-CN certificate '{cert.Thumbprint}' from {storeName}\\{storeLocation}.",
+ e));
+ }
+ finally
+ {
+ cert.Dispose();
+ }
+ }
+ }
+ catch (Exception e)
+ {
+ OnException(new Exception(
+ $"Failed to open {storeName}\\{storeLocation} for same-CN root cleanup.", e));
+ }
+ }
+
+ ///
+ /// Make current machine trust the Root Certificate used by this proxy.
+ ///
+ ///
+ /// when the certificate was newly added;
+ /// when it was already present or the install failed.
+ ///
+ private bool InstallCertificate(StoreName storeName, StoreLocation storeLocation)
{
var certificate = RootCertificate;
if (certificate == null) throw new InvalidOperationException("Could not install certificate as it is null or empty.");
- if (FindCertificates(storeName, storeLocation, certificate.Thumbprint).Count > 0) return;
+ if (FindCertificates(storeName, storeLocation, certificate.Thumbprint).Count > 0) return false;
var x509Store = new X509Store(storeName, storeLocation);
@@ -611,6 +714,7 @@ private void InstallCertificate(StoreName storeName, StoreLocation storeLocation
{
x509Store.Open(OpenFlags.ReadWrite);
x509Store.Add(certificate);
+ return true;
}
catch (Exception e)
{
@@ -618,6 +722,7 @@ private void InstallCertificate(StoreName storeName, StoreLocation storeLocation
new Exception("Failed to make system trust root certificate "
+ $" for {storeName}\\{storeLocation} store location. You may need admin rights.",
e));
+ return false;
}
finally
{
@@ -787,7 +892,12 @@ private bool TryGetValidCachedCertificate(string certificateName, out X509Certif
var now = DateTime.Now;
if (cached.Certificate.NotAfter <= now || cached.Certificate.NotBefore > now)
{
- if (cachedCertificates.TryRemove(certificateName, out var removed)) removed.Certificate.Dispose();
+ if (cachedCertificates.TryRemove(certificateName, out var removed))
+ {
+ InvalidateSslCertificateContext(removed.Certificate);
+ removed.Certificate.Dispose();
+ }
+
return false;
}
@@ -998,14 +1108,23 @@ internal System.Net.Security.SslStreamCertificateContext CreateSslCertificateCon
{
var key = leaf.Thumbprint;
if (key != null && sslCertificateContexts.TryGetValue(key, out var cached))
- return cached;
+ {
+ // Same thumbprint can be a freshly loaded PKCS#12 after the previous X509Certificate2
+ // was disposed (idle/bound eviction + SaveFakeCertificates). SslStreamCertificateContext
+ // pins the original SafeCertContext — never reuse a context built for a different instance.
+ if (ReferenceEquals(cached.TargetCertificate, leaf))
+ return cached;
+
+ sslCertificateContexts.TryRemove(key, out _);
+ }
var created = BuildSslCertificateContext(leaf);
if (key == null)
return created;
- return sslCertificateContexts.GetOrAdd(key, created);
+ return sslCertificateContexts.AddOrUpdate(key, created, (_, existing) =>
+ ReferenceEquals(existing.TargetCertificate, leaf) ? existing : created);
}
private System.Net.Security.SslStreamCertificateContext BuildSslCertificateContext(X509Certificate2 leaf)
@@ -1219,15 +1338,21 @@ public void TrustRootCertificate(bool machineTrusted = false)
{
// currentUser\personal
InstallCertificate(StoreName.My, StoreLocation.CurrentUser);
- // currentUser\Root
- InstallCertificate(StoreName.Root, StoreLocation.CurrentUser);
+ // currentUser\Root — Windows may show a Trusted Root yes/no security dialog on Add.
+ var rootAdded = InstallCertificate(StoreName.Root, StoreLocation.CurrentUser);
+ // Orphan Remove also prompts; only prune when we just installed this thumbprint so
+ // re-trust / Install CA when already present does not open Root ReadWrite for cleanup.
+ if (rootAdded)
+ RemoveOrphanedSameCommonNameCertificates(StoreLocation.CurrentUser, keepCurrentThumbprint: true);
if (machineTrusted)
{
// localMachine\personal
InstallCertificate(StoreName.My, StoreLocation.LocalMachine);
// localMachine\Root
- InstallCertificate(StoreName.Root, StoreLocation.LocalMachine);
+ var machineRootAdded = InstallCertificate(StoreName.Root, StoreLocation.LocalMachine);
+ if (machineRootAdded)
+ RemoveOrphanedSameCommonNameCertificates(StoreLocation.LocalMachine, keepCurrentThumbprint: true);
}
// On macOS/Linux, also trust for SSL in Keychain / NSS so browsers accept MITM.
@@ -1251,7 +1376,9 @@ public bool TrustRootCertificateAsAdmin(bool machineTrusted = false)
// currentUser\Personal + currentUser\Root (machine elevation is only needed for LocalMachine).
InstallCertificate(StoreName.My, StoreLocation.CurrentUser);
- InstallCertificate(StoreName.Root, StoreLocation.CurrentUser);
+ var rootAdded = InstallCertificate(StoreName.Root, StoreLocation.CurrentUser);
+ if (rootAdded)
+ RemoveOrphanedSameCommonNameCertificates(StoreLocation.CurrentUser, keepCurrentThumbprint: true);
if (!RunTime.IsWindows)
{
@@ -1378,18 +1505,11 @@ public bool IsRootCertificateMachineTrusted()
///
public void RemoveTrustedRootCertificate(bool machineTrusted = false)
{
- // currentUser\personal
- UninstallCertificate(StoreName.My, StoreLocation.CurrentUser, RootCertificate);
- // currentUser\Root
- UninstallCertificate(StoreName.Root, StoreLocation.CurrentUser, RootCertificate);
+ // Drop every same-CN Titanium root (current + orphans) so Remove CA leaves a clean store.
+ RemoveOrphanedSameCommonNameCertificates(StoreLocation.CurrentUser, keepCurrentThumbprint: false);
if (machineTrusted)
- {
- // localMachine\personal
- UninstallCertificate(StoreName.My, StoreLocation.LocalMachine, RootCertificate);
- // localMachine\Root
- UninstallCertificate(StoreName.Root, StoreLocation.LocalMachine, RootCertificate);
- }
+ RemoveOrphanedSameCommonNameCertificates(StoreLocation.LocalMachine, keepCurrentThumbprint: false);
if (!RunTime.IsWindows && RootCertificate != null)
Helpers.UnixCertificateTrust.UntrustUserSsl(RootCertificate, RootCertificateName);
@@ -1401,9 +1521,8 @@ public void RemoveTrustedRootCertificate(bool machineTrusted = false)
/// Should also remove from machine store?
public bool RemoveTrustedRootCertificateAsAdmin(bool machineTrusted = false)
{
- // currentUser\Personal + currentUser\Root
- UninstallCertificate(StoreName.My, StoreLocation.CurrentUser, RootCertificate);
- UninstallCertificate(StoreName.Root, StoreLocation.CurrentUser, RootCertificate);
+ // Current-user: remove all same-CN entries (current + orphans) without elevation.
+ RemoveOrphanedSameCommonNameCertificates(StoreLocation.CurrentUser, keepCurrentThumbprint: false);
if (!RunTime.IsWindows)
{
diff --git a/src/Titanium.Web.Proxy/Handlers/Http3DiscoveryHandler.cs b/src/Titanium.Web.Proxy/Handlers/Http3DiscoveryHandler.cs
index 6f428c3c7..063191b42 100644
--- a/src/Titanium.Web.Proxy/Handlers/Http3DiscoveryHandler.cs
+++ b/src/Titanium.Web.Proxy/Handlers/Http3DiscoveryHandler.cs
@@ -3,6 +3,7 @@
using System.Threading.Tasks;
using Titanium.Web.Proxy.EventArguments;
using Titanium.Web.Proxy.Http3;
+using Titanium.Web.Proxy.Logging;
using Titanium.Web.Proxy.Models;
namespace Titanium.Web.Proxy;
@@ -16,6 +17,21 @@ public partial class ProxyServer
/// is in effect).
///
private void TryUpdateHttp3CapabilityFromResponse(SessionEventArgs args)
+ {
+ try
+ {
+ TryUpdateHttp3CapabilityFromResponseCore(args);
+ }
+ catch (Exception ex) when (ex is UriFormatException or FormatException)
+ {
+ // Alt-Svc bookkeeping must never fail AfterResponse (e.g. path-only H2 stream
+ // with no :authority / Host). Logged as benign — not ProxyHttpException.
+ ProxyDiagnostics.ReportBenign(logger,
+ "HTTP/3 Alt-Svc capability update skipped due to unparseable request origin.", ex);
+ }
+ }
+
+ private void TryUpdateHttp3CapabilityFromResponseCore(SessionEventArgs args)
{
if (!EnableHttp3) return;
@@ -26,17 +42,26 @@ private void TryUpdateHttp3CapabilityFromResponse(SessionEventArgs args)
if (string.IsNullOrEmpty(altSvc) || altSvc == "clear")
{
if (altSvc == "clear")
- {
- var (clearHost, clearPort) = args.HttpClient.Request.GetOriginHostPort(443);
- var clearKey = $"{clearHost}:{clearPort}";
- Http3OriginCapabilityCache.Evict(clearKey);
- // Prevent a late background SVCB completion from undoing the clear.
- _svcbDiscoveryCoordinator?.Invalidate(clearKey);
- }
-
+ ClearHttp3Capability(args);
return;
}
+ CacheHttp3CapabilityFromAltSvc(args, altSvc);
+ }
+
+ private void ClearHttp3Capability(SessionEventArgs args)
+ {
+ var (clearHost, clearPort) = args.HttpClient.Request.GetOriginHostPort(443);
+ if (string.IsNullOrEmpty(clearHost)) return;
+
+ var clearKey = $"{clearHost}:{clearPort}";
+ Http3OriginCapabilityCache.Evict(clearKey);
+ // Prevent a late background SVCB completion from undoing the clear.
+ _svcbDiscoveryCoordinator?.Invalidate(clearKey);
+ }
+
+ private void CacheHttp3CapabilityFromAltSvc(SessionEventArgs args, string altSvc)
+ {
var entries = AltSvcParser.Parse(altSvc);
if (entries.Count == 0) return;
diff --git a/src/Titanium.Web.Proxy/Handlers/TransparentClientHandler.cs b/src/Titanium.Web.Proxy/Handlers/TransparentClientHandler.cs
index 690c00ec7..3cb16b5d6 100644
--- a/src/Titanium.Web.Proxy/Handlers/TransparentClientHandler.cs
+++ b/src/Titanium.Web.Proxy/Handlers/TransparentClientHandler.cs
@@ -570,11 +570,11 @@ await HandleInboundHttp2CleartextAsync(endPoint, clientStream, clientConnection,
return;
}
}
- else if (!isHttps && EnableHttp2)
+ else if (!isHttps)
{
- // Transparent reverse cleartext: detect prior-knowledge h2c before HTTP/1 parsing.
- // Skip the peek entirely when HTTP/2 is disabled — StartReverseHttp1 / plain H1 reverse
- // paid GetMethod on every new client connection for nothing.
+ // Always detect prior-knowledge h2c before HTTP/1 parsing. When EnableHttp2 is false,
+ // HandleInboundHttp2CleartextAsync rejects the preface (closes the client) instead of
+ // hanging in H1 request parsing on "PRI * HTTP/2.0…".
var method = await HttpHelper.GetMethod(clientStream, BufferPool, cancellationToken);
if (method == KnownMethod.Pri)
{
diff --git a/src/Titanium.Web.Proxy/Http/Request.cs b/src/Titanium.Web.Proxy/Http/Request.cs
index e8464c38e..bd70fa11d 100644
--- a/src/Titanium.Web.Proxy/Http/Request.cs
+++ b/src/Titanium.Web.Proxy/Http/Request.cs
@@ -119,6 +119,7 @@ public override bool HasBody
///
/// Origin host/port from or the Host header — no alloc.
/// Falls back to only for absolute-form targets with neither field set.
+ /// Never throws: malformed / empty authority yields ("", defaultPort).
///
internal (string Host, int Port) GetOriginHostPort(int defaultPort)
{
@@ -131,8 +132,18 @@ public override bool HasBody
AuthorityParser.TryParse(header, defaultPort, out host, out port))
return (host, port);
- var uri = RequestUri;
- return (uri.Host, uri.Port > 0 ? uri.Port : defaultPort);
+ try
+ {
+ var uri = RequestUri;
+ if (!string.IsNullOrEmpty(uri.Host))
+ return (uri.Host, uri.Port > 0 ? uri.Port : defaultPort);
+ }
+ catch (UriFormatException)
+ {
+ // Relative URL / empty authority — callers treat empty host as a no-op.
+ }
+
+ return (string.Empty, defaultPort);
}
///
diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.cs
index e3bb80f45..85e2fb653 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2Helper.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.cs
@@ -2719,9 +2719,13 @@ await lockedOwnLegWrite(() => SendRstStreamAsync(
if (bodyBudgetBreached && bodyBudgetMode == PolicyMode.Enforce)
{
- ReportException(logger, new ProxyHttpException(
+ // Intentional policy enforcement, not a proxy defect — Debug only.
+ ProxyDiagnostics.ReportBenign(logger,
$"HTTP/2 {(isClient ? "request" : "response")} body exceeded the configured " +
- $"buffering limit of {maxBufferedBodyBytes:N0} bytes.", null, args));
+ $"buffering limit of {maxBufferedBodyBytes:N0} bytes.",
+ new ProxyHttpException(
+ $"HTTP/2 {(isClient ? "request" : "response")} body exceeded the configured " +
+ $"buffering limit of {maxBufferedBodyBytes:N0} bytes.", null, args));
var sizeLimitException = new BodySizeLimitExceededException(
$"HTTP/2 body byte count {data.Length + length:N0} exceeds the limit of {maxBufferedBodyBytes:N0}.");
@@ -3354,12 +3358,16 @@ await lockedOwnLegWrite(async () =>
// NO_ERROR (0) from the origin is a normal post-response cleanup; CANCEL is the usual
// client abort. REFUSED_STREAM is also expected under origin load-shedding / GOAWAY
- // races (observed live from github.com/Fastly both direct and via this proxy) - the
- // RST is still forwarded to the peer so browsers/HttpClient can retry, but it must
- // not flood server logs as a proxy defect.
+ // races (observed live from github.com/Fastly both direct and via this proxy).
+ // STREAM_CLOSED is the peer saying the stream is already done (half-close races).
+ // PROTOCOL_ERROR on a received RST is the peer's assessment — our own framing
+ // defects are already ReportException'd at the detection site before we send RST.
+ // Forward the RST either way; do not flood Error logs for peer-initiated codes.
if (errorCode != (int)Http2ErrorCode.NoError &&
errorCode != (int)Http2ErrorCode.Cancel &&
- errorCode != (int)Http2ErrorCode.RefusedStream)
+ errorCode != (int)Http2ErrorCode.RefusedStream &&
+ errorCode != (int)Http2ErrorCode.StreamClosed &&
+ errorCode != (int)Http2ErrorCode.ProtocolError)
{
var direction = isClient ? "client→proxy" : "origin→proxy";
var requestUrl = args?.HttpClient.Request.Url ?? "(unknown)";
@@ -3367,6 +3375,18 @@ await lockedOwnLegWrite(async () =>
$"HTTP/2 stream error. Error code: {errorCode}; direction: {direction}; " +
$"stream: {streamId}; request: {requestUrl}", null, args));
}
+ else if (logger.IsEnabled(LogLevel.Debug) &&
+ errorCode != (int)Http2ErrorCode.NoError &&
+ errorCode != (int)Http2ErrorCode.Cancel)
+ {
+ var direction = isClient ? "client→proxy" : "origin→proxy";
+ var requestUrl = args?.HttpClient.Request.Url ?? "(unknown)";
+ ProxyDiagnostics.ReportBenign(logger,
+ $"HTTP/2 peer RST_STREAM. Error code: {errorCode}; direction: {direction}; " +
+ $"stream: {streamId}; request: {requestUrl}",
+ new ProxyHttpException(
+ $"HTTP/2 peer stream reset code {errorCode}", null, args));
+ }
}
if (endStream && rr == null)
diff --git a/src/Titanium.Web.Proxy/Network/Streams/LimitedStream.cs b/src/Titanium.Web.Proxy/Network/Streams/LimitedStream.cs
index f96e3197d..08a641b59 100644
--- a/src/Titanium.Web.Proxy/Network/Streams/LimitedStream.cs
+++ b/src/Titanium.Web.Proxy/Network/Streams/LimitedStream.cs
@@ -71,7 +71,9 @@ private async Task GetNextChunkAsync()
readChunkTrail = true;
var chunkHead = await baseReader.ReadLineAsync();
- if (chunkHead == null)
+ // null = EOF; empty = blank line (half-closed / framing glitch). Either way there is no
+ // more chunk payload — treat as end rather than PROTOCOL_ERROR via Invalid chunk length: ''.
+ if (string.IsNullOrEmpty(chunkHead))
{
bytesRemaining = -1;
return;
diff --git a/src/Titanium.Web.Proxy/Network/TcpConnection/AlpnNegotiation.cs b/src/Titanium.Web.Proxy/Network/TcpConnection/AlpnNegotiation.cs
new file mode 100644
index 000000000..42b155ed6
--- /dev/null
+++ b/src/Titanium.Web.Proxy/Network/TcpConnection/AlpnNegotiation.cs
@@ -0,0 +1,54 @@
+using System;
+using System.ComponentModel;
+using System.Linq;
+using System.Security.Authentication;
+
+namespace Titanium.Web.Proxy.Network.Tcp;
+
+///
+/// Detects TLS ALPN negotiation failures so they are not mistaken for TLS-version problems
+/// that warrant a protocol downgrade retry.
+///
+internal static class AlpnNegotiation
+{
+ /// SEC_E_NO_APPLICATION_PROTOCOL — no common ALPN between client and server.
+ internal const int SecENoApplicationProtocol = unchecked((int)0x80090367);
+
+ ///
+ /// Returns when (or any inner exception)
+ /// indicates ALPN application-protocol negotiation failed.
+ ///
+ internal static bool IsAlpnNegotiationFailure(Exception? error)
+ {
+ for (Exception? e = error; e != null; e = e.InnerException)
+ {
+ if (MatchesAlpnFailure(e))
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool MatchesAlpnFailure(Exception e)
+ {
+ if (e is AggregateException aggregate)
+ return aggregate.InnerExceptions.Any(IsAlpnNegotiationFailure);
+
+ if (e is Win32Exception win32 && win32.NativeErrorCode == SecENoApplicationProtocol)
+ return true;
+
+ // Some runtimes surface the Win32 code only on HResult.
+ if (e.HResult == SecENoApplicationProtocol)
+ return true;
+
+ return e.Message.Contains("No common application protocol", StringComparison.OrdinalIgnoreCase)
+ || e.Message.Contains("Application protocol negotiation failed", StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// True when an should attempt a TLS-version downgrade
+ /// retry (legacy gate). ALPN mismatches must not enter that path.
+ ///
+ internal static bool ShouldAttemptTlsVersionDowngrade(AuthenticationException ex) =>
+ !IsAlpnNegotiationFailure(ex);
+}
diff --git a/src/Titanium.Web.Proxy/Network/TcpConnection/Ipv6UnreachableSoftSkip.cs b/src/Titanium.Web.Proxy/Network/TcpConnection/Ipv6UnreachableSoftSkip.cs
index 3468a89a4..b077bff0a 100644
--- a/src/Titanium.Web.Proxy/Network/TcpConnection/Ipv6UnreachableSoftSkip.cs
+++ b/src/Titanium.Web.Proxy/Network/TcpConnection/Ipv6UnreachableSoftSkip.cs
@@ -13,7 +13,7 @@ namespace Titanium.Web.Proxy.Network.Tcp;
internal static class Ipv6UnreachableSoftSkip
{
internal const int DefaultStrikeThreshold = 1;
- internal static readonly TimeSpan DefaultTtl = TimeSpan.FromSeconds(30);
+ internal static readonly TimeSpan DefaultTtl = TimeSpan.FromMinutes(5);
private static int consecutiveIpv6Unreachable;
private static long skipUntilUnixMs; // 0 = not skipping
diff --git a/src/Titanium.Web.Proxy/Network/TcpConnection/TcpConnectionFactory.cs b/src/Titanium.Web.Proxy/Network/TcpConnection/TcpConnectionFactory.cs
index 26a0d7a2b..bb96a12f1 100644
--- a/src/Titanium.Web.Proxy/Network/TcpConnection/TcpConnectionFactory.cs
+++ b/src/Titanium.Web.Proxy/Network/TcpConnection/TcpConnectionFactory.cs
@@ -1126,7 +1126,8 @@ internal bool TryRentPooled(ProxyServer proxyServer, string cacheKey,
goto retry; // NOSONAR S907 -- TLS compatibility fallback must restart the complete connection attempt.
}
catch (AuthenticationException ex) when (ex.HResult == unchecked((int)0x80131501) && retry &&
- enabledSslProtocols >= SslProtocols.Tls11) // NOSONAR S4423 - legacy fallback gate
+ enabledSslProtocols >= SslProtocols.Tls11 && // NOSONAR S4423 - legacy fallback gate
+ AlpnNegotiation.ShouldAttemptTlsVersionDowngrade(ex))
{
if (stream != null) await stream.DisposeAsync();
tcpServerSocket?.Close();
@@ -1146,6 +1147,16 @@ internal bool TryRentPooled(ProxyServer proxyServer, string cacheKey,
ProxyMetrics.PoolDowngraded();
goto retry; // NOSONAR S907 -- TLS compatibility fallback must restart the complete connection attempt.
}
+ catch (AuthenticationException ex) when (AlpnNegotiation.IsAlpnNegotiationFailure(ex))
+ {
+ // h2-only (or other ALPN) mismatch is not a TLS-version problem — fail fast so
+ // NegotiateHttp2Async can treat the probe as "no HTTP/2" without multi-second thrash.
+ if (stream != null) await stream.DisposeAsync();
+ tcpServerSocket?.Close();
+ ProxyDiagnostics.ReportBenign(proxyServer.Logger,
+ "TcpConnectionFactory ALPN negotiation rejected by origin; rethrowing without TLS downgrade", ex);
+ throw;
+ }
#pragma warning restore SYSLIB0039
catch (Exception ex)
{
diff --git a/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs b/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs
index 570d2a549..fc825435a 100644
--- a/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs
+++ b/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs
@@ -65,5 +65,5 @@
// file-properties version disagreed with the package it was published in. Keep both of the values
// below equal to (as Major.Minor.Build.0) whenever that property changes.
-[assembly: AssemblyVersion("7.0.0.0")]
-[assembly: AssemblyFileVersion("7.0.0.0")]
+[assembly: AssemblyVersion("7.0.1.0")]
+[assembly: AssemblyFileVersion("7.0.1.0")]
diff --git a/src/Titanium.Web.Proxy/ProxyServer.cs b/src/Titanium.Web.Proxy/ProxyServer.cs
index 223f29c08..670c57022 100644
--- a/src/Titanium.Web.Proxy/ProxyServer.cs
+++ b/src/Titanium.Web.Proxy/ProxyServer.cs
@@ -386,7 +386,9 @@ internal void TrimOriginCapabilityCaches()
///
/// Requires MsQuic native library and a supported operating-system version
/// (). Setting to with
- /// no inbound HTTP/3 endpoint configured emits a warning and skips QUIC initialization.
+ /// no inbound HTTP/3 endpoint is fine when an explicit/SOCKS/transparent TCP endpoint is
+ /// present (origin-side QUIC only). A warning is emitted only when EnableHttp3 is set with
+ /// no client-facing endpoints at all.
/// Default: (opt-in).
///
/// Experimental: HTTP/3 support has not yet completed the full interop/soak/fuzz gate
@@ -1681,10 +1683,19 @@ private void ClearEndpointSystemProxyFlags(ProxyProtocolType protocolType)
quicListenerCts = new CancellationTokenSource();
else if (EnableHttp3)
{
- Logger.LogWarning(
- "EnableHttp3 is true but no inbound HTTP/3 endpoint is registered. " +
- "Add a TransparentQuicProxyEndPoint, or a TransparentProxyEndPoint with EnableHttp3, " +
- "before calling Start().");
+ // Explicit/SOCKS endpoints speak TCP to the client; EnableHttp3 still correctly
+ // arms origin-side QUIC (Alt-Svc / H2↔H3 bridge). That is the Inspector/CLI happy
+ // path — do not warn. Warn only when nothing can use either inbound or origin H3
+ // (no client-facing TCP endpoints), which usually means a misconfigured Start().
+ var hasTcpClientFacingEndpoint = ProxyEndPoints.Any(e =>
+ e is ExplicitProxyEndPoint or SocksProxyEndPoint or TransparentProxyEndPoint);
+ if (!hasTcpClientFacingEndpoint)
+ {
+ Logger.LogWarning(
+ "EnableHttp3 is true but no inbound HTTP/3 endpoint is registered. " +
+ "Add a TransparentQuicProxyEndPoint, or a TransparentProxyEndPoint with EnableHttp3, " +
+ "before calling Start().");
+ }
}
// UDP-only transparent QUIC first (no TCP on that port).
diff --git a/src/Titanium.Web.Proxy/PublicAPI.Unshipped.txt b/src/Titanium.Web.Proxy/PublicAPI.Unshipped.txt
index 43373b98a..02dc461dd 100644
--- a/src/Titanium.Web.Proxy/PublicAPI.Unshipped.txt
+++ b/src/Titanium.Web.Proxy/PublicAPI.Unshipped.txt
@@ -22,6 +22,7 @@ Titanium.Web.Proxy.Network.CertificateKeyAlgorithm.Rsa2048 = 0 -> Titanium.Web.P
Titanium.Web.Proxy.Network.CertificateManager.LeafCertificateKeyAlgorithm.get -> Titanium.Web.Proxy.Network.CertificateKeyAlgorithm
Titanium.Web.Proxy.Network.CertificateManager.LeafCertificateKeyAlgorithm.set -> void
Titanium.Web.Proxy.Network.CertificateManager.ApplyFastColdStartLeafSettings() -> void
+static Titanium.Web.Proxy.Network.DefaultCertificateDiskCache.GetSharedLeafCertificateDirectory() -> string!
static Titanium.Web.Proxy.Network.CertificateManager.LeafRsaKeyPairBufferSize.get -> int
static Titanium.Web.Proxy.Network.CertificateManager.LeafRsaKeyPairBufferSize.set -> void
Titanium.Web.Proxy.Options.ProxyResourceLimits.MaxCertificateDiskCacheEntries.get -> int?
diff --git a/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj b/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj
index 02d1d27f8..357563c37 100644
--- a/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj
+++ b/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj
@@ -13,7 +13,7 @@
- 7.0.0
+ 7.0.1