diff --git a/src/Titanium.Cli/Titanium.Cli.csproj b/src/Titanium.Cli/Titanium.Cli.csproj
index c271e6f1f..fda9e12cd 100644
--- a/src/Titanium.Cli/Titanium.Cli.csproj
+++ b/src/Titanium.Cli/Titanium.Cli.csproj
@@ -7,7 +7,7 @@
latest
enable
false
- 7.0.19
+ 7.0.20
Jehonathan Thomas
Titanium Web Proxy CLI (titanium / twp).
MIT
diff --git a/src/Titanium.Inspector/Services/SessionBodyDiskCache.cs b/src/Titanium.Inspector/Services/SessionBodyDiskCache.cs
index fc7179bc4..7f3a6cd5f 100644
--- a/src/Titanium.Inspector/Services/SessionBodyDiskCache.cs
+++ b/src/Titanium.Inspector/Services/SessionBodyDiskCache.cs
@@ -3,11 +3,15 @@
namespace Titanium.Inspector.Services;
+/// Tracked HAR files under the session cache, for the retention window.
+public readonly record struct SessionCacheStats(int RunCount, long TotalBytes);
+
///
/// On-disk session cache under a size budget. Each Inspector process run writes into a
/// timestamped subfolder under the cache root ({root}/{yyyyMMdd-HHmmss-fff}/{id}.har)
-/// so restarted session ids never overwrite another run. Disk budget counts all runs and
-/// deletes oldest HAR files first (across folders).
+/// so restarted session ids never overwrite another run. The run folder is created on the
+/// first write. Disk budget counts all runs and deletes oldest HAR files first (across folders).
+/// Empty run folders other than the current run are removed.
///
public sealed class SessionBodyDiskCache : IDisposable
{
@@ -28,6 +32,8 @@ public sealed class SessionBodyDiskCache : IDisposable
StringComparer.OrdinalIgnoreCase);
private long _trackedBytes;
private bool _disposed;
+ private static readonly object RunPathReserveGate = new();
+ private static readonly HashSet ReservedRunPaths = new(StringComparer.OrdinalIgnoreCase);
public SessionBodyDiskCache(string rootDirectory, long maxBytes, TimeSpan maxAge)
: this(rootDirectory, maxBytes, maxAge, runStartedUtc: DateTimeOffset.UtcNow)
@@ -44,6 +50,7 @@ internal SessionBodyDiskCache(
Directory.CreateDirectory(_rootDirectory);
_runDirectory = CreateRunDirectory(_rootDirectory, runStartedUtc);
RebuildIndexAndEnforceBudget();
+ SweepEmptyRunFolders();
}
/// Updates disk budget. Index updates return immediately; file deletes run in the background.
@@ -98,6 +105,63 @@ public static string GetDefaultDirectory() =>
/// Alias for (UI / options).
public string DirectoryPath => _rootDirectory;
+ ///
+ /// Run count and tracked bytes. Run count includes indexed parents and on-disk run
+ /// folders that still contain a HAR. Bytes come from the size index.
+ ///
+ public SessionCacheStats GetCacheStats()
+ {
+ HashSet runs;
+ long bytes;
+ lock (_gate)
+ {
+ bytes = _trackedBytes;
+ runs = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var path in _index.Keys)
+ {
+ var dir = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(dir))
+ {
+ runs.Add(Path.GetFullPath(dir));
+ }
+ }
+ }
+
+ if (!Directory.Exists(_rootDirectory))
+ {
+ return new SessionCacheStats(runs.Count, bytes);
+ }
+
+ try
+ {
+ var rootFull = Path.GetFullPath(_rootDirectory);
+ if (Directory.EnumerateFiles(_rootDirectory, SessionFileSearchPattern).Any())
+ {
+ runs.Add(rootFull);
+ }
+
+ foreach (var sub in Directory.EnumerateDirectories(_rootDirectory))
+ {
+ var full = Path.GetFullPath(sub);
+ if (runs.Contains(full))
+ {
+ continue;
+ }
+
+ if (Directory.EnumerateFiles(sub, SessionFileSearchPattern).Any())
+ {
+ runs.Add(full);
+ }
+ }
+ }
+ catch
+ {
+ // Best-effort stats while a delete is in progress.
+ }
+
+ return new SessionCacheStats(runs.Count, bytes);
+ }
+
public string PathFor(long sessionId) =>
Path.Combine(_runDirectory, sessionId.ToString("D", CultureInfo.InvariantCulture) + ".har");
@@ -250,25 +314,29 @@ public void DeleteMany(IEnumerable sessionIds)
}
///
- /// Drops the current run folder from the index and moves it aside. New writes use a fresh folder.
- /// The moved folder is deleted on a background thread. Other run folders are left in place.
+ /// Drops the current run folder from the index and moves it aside. New writes use a fresh
+ /// folder path, created on the next write. The moved folder is deleted on a background thread.
+ /// Other run folders are left in place. Returns tracked bytes queued for deletion.
///
- public void AbandonCurrentRun()
+ public long AbandonCurrentRun()
{
string old;
+ long freed;
lock (_writeGate)
{
lock (_gate)
{
old = _runDirectory;
Interlocked.Increment(ref _runGeneration);
+ var before = _trackedBytes;
RemoveIndexEntriesUnder(old);
+ freed = Math.Max(0, before - _trackedBytes);
_runDirectory = CreateRunDirectory(_rootDirectory, DateTimeOffset.UtcNow);
}
if (!Directory.Exists(old))
{
- return;
+ return freed;
}
var trash = Path.Combine(Path.GetTempPath(), "ti-discard-" + Guid.NewGuid().ToString("N"));
@@ -282,6 +350,32 @@ public void AbandonCurrentRun()
QueueCleanup([old]);
}
}
+
+ return freed;
+ }
+
+ ///
+ /// Rotates the current run so in-flight spills are rejected, then deletes every run folder
+ /// on the cleanup queue. The replacement folder is not created until the next write.
+ /// Returns tracked bytes queued for deletion. Does not enumerate the cache on the caller thread.
+ ///
+ public long ScheduleClearAllRuns()
+ {
+ long freed;
+ lock (_writeGate)
+ {
+ lock (_gate)
+ {
+ freed = _trackedBytes;
+ Interlocked.Increment(ref _runGeneration);
+ _index.Clear();
+ _trackedBytes = 0;
+ _runDirectory = CreateRunDirectory(_rootDirectory, DateTimeOffset.UtcNow);
+ }
+ }
+
+ QueueFullWipe();
+ return freed;
}
/// Removes session files from the size index immediately and deletes them in the background.
@@ -406,7 +500,6 @@ public void ClearAllRuns()
}
}
- Directory.CreateDirectory(_runDirectory);
lock (_gate)
{
_index.Clear();
@@ -636,16 +729,22 @@ private void TryDeleteEmptyRunFolder(string? folder)
return;
}
- try
+ for (var attempt = 0; attempt < 8; attempt++)
{
- if (Directory.Exists(full) && IsEmptyDirectory(full))
+ try
{
+ if (!Directory.Exists(full) || !IsEmptyDirectory(full))
+ {
+ return;
+ }
+
Directory.Delete(full, recursive: false);
+ return;
+ }
+ catch
+ {
+ Thread.Sleep(25 * (attempt + 1));
}
- }
- catch
- {
- // Best-effort.
}
}
@@ -736,6 +835,103 @@ private void DrainCleanupBatch()
{
TryDeletePathWithRetry(path);
}
+
+ SweepEmptyRunFolders();
+ }
+
+ private void QueueFullWipe()
+ {
+ lock (_cleanupGate)
+ {
+ _cleanupTask = _cleanupTask.ContinueWith(
+ _ => RunFullWipe(),
+ CancellationToken.None,
+ TaskContinuationOptions.RunContinuationsAsynchronously,
+ TaskScheduler.Default);
+ }
+ }
+
+ ///
+ /// Deletes every run folder except whichever path is current at the moment of each delete,
+ /// so a second clear cannot lose the newest run to an older wipe.
+ ///
+ private void RunFullWipe()
+ {
+ LastCleanupThreadId = Environment.CurrentManagedThreadId;
+ if (!Directory.Exists(_rootDirectory))
+ {
+ return;
+ }
+
+ List dirs;
+ try
+ {
+ dirs = Directory.EnumerateDirectories(_rootDirectory).ToList();
+ }
+ catch
+ {
+ return;
+ }
+
+ foreach (var sub in dirs)
+ {
+ if (IsCurrentRunDirectory(sub))
+ {
+ continue;
+ }
+
+ TryDeletePathWithRetry(sub);
+ }
+
+ try
+ {
+ foreach (var file in Directory.EnumerateFiles(_rootDirectory).ToList())
+ {
+ TryDeletePathWithRetry(file);
+ }
+ }
+ catch
+ {
+ // Best-effort.
+ }
+ }
+
+ private bool IsCurrentRunDirectory(string path)
+ {
+ lock (_writeGate)
+ {
+ return string.Equals(
+ Path.GetFullPath(path),
+ Path.GetFullPath(_runDirectory),
+ StringComparison.OrdinalIgnoreCase);
+ }
+ }
+
+ /// Deletes empty subfolders except the current run and the cache root.
+ private void SweepEmptyRunFolders()
+ {
+ lock (_writeGate)
+ {
+ if (!Directory.Exists(_rootDirectory))
+ {
+ return;
+ }
+
+ List subs;
+ try
+ {
+ subs = Directory.EnumerateDirectories(_rootDirectory).ToList();
+ }
+ catch
+ {
+ return;
+ }
+
+ foreach (var sub in subs)
+ {
+ TryDeleteEmptyRunFolder(sub);
+ }
+ }
}
private static void TryDeletePathWithRetry(string path)
@@ -783,17 +979,40 @@ private void RemoveIndexEntriesUnder(string directory)
}
}
+ ///
+ /// Picks a unique run-folder path. The directory is created on the first write so a run
+ /// that captures nothing does not leave an empty folder. The name is reserved in-process
+ /// so two caches started in the same millisecond do not share a path before either exists.
+ ///
internal static string CreateRunDirectory(string root, DateTimeOffset startedUtc)
{
var stamp = startedUtc.UtcDateTime.ToString("yyyyMMdd-HHmmss-fff", CultureInfo.InvariantCulture);
- var path = Path.Combine(root, stamp);
- // Extremely unlikely collision within the same millisecond.
- if (Directory.Exists(path))
+ lock (RunPathReserveGate)
{
- path = Path.Combine(root, stamp + "-" + Guid.NewGuid().ToString("N")[..6]);
+ var path = Path.Combine(root, stamp);
+ if (TryReserveRunPath(path))
+ {
+ return path;
+ }
+
+ for (var i = 0; i < 5; i++)
+ {
+ path = Path.Combine(root, stamp + "-" + Guid.NewGuid().ToString("N")[..6]);
+ if (TryReserveRunPath(path))
+ {
+ return path;
+ }
+ }
+
+ path = Path.Combine(root, stamp + "-" + Guid.NewGuid().ToString("N"));
+ TryReserveRunPath(path);
+ return path;
}
+ }
- Directory.CreateDirectory(path);
- return path;
+ private static bool TryReserveRunPath(string path)
+ {
+ var full = Path.GetFullPath(path);
+ return !Directory.Exists(full) && ReservedRunPaths.Add(full);
}
}
diff --git a/src/Titanium.Inspector/Services/SessionStore.cs b/src/Titanium.Inspector/Services/SessionStore.cs
index ffa5f7f6c..18a194068 100644
--- a/src/Titanium.Inspector/Services/SessionStore.cs
+++ b/src/Titanium.Inspector/Services/SessionStore.cs
@@ -7,6 +7,8 @@ namespace Titanium.Inspector.Services;
/// Captured sessions: in-memory list (headers/metadata; bodies unloaded), HAR archive on disk
/// (per-run subfolders under the cache root), and two independent limits — MaxSessionsInMemory
/// (drop rows from the list) and DiskCacheMaxBytes (delete oldest HAR files across runs).
+/// Empty run folders other than the current run are removed. The current run folder is created
+/// on the first spill.
///
public sealed class SessionStore : IDisposable
{
@@ -265,11 +267,13 @@ public void Remove(IEnumerable ids)
}
///
- /// Drops every in-memory session and rotates the current-run HAR folder.
- /// Does not raise — the caller clears the grid itself.
+ /// Drops every in-memory session and deletes the current-run HAR folder, including rows
+ /// already evicted from the list. Earlier runs stay on disk. Does not raise
+ /// — the caller clears the grid itself.
/// Disk deletion continues in the background ().
+ /// Returns tracked bytes queued for deletion.
///
- public void Clear()
+ public long Clear()
{
ObjectDisposedException.ThrowIf(_disposed, this);
lock (_gate)
@@ -281,9 +285,46 @@ public void Clear()
Interlocked.Increment(ref _spillEpoch);
}
- _disk?.AbandonCurrentRun();
+ return _disk?.AbandonCurrentRun() ?? 0;
}
+ ///
+ /// Deletes every saved run, including the current one. In-flight spills are rejected via
+ /// the run generation. Rows stay in the list; spilled bodies are marked missing.
+ /// Deletion runs in the background ().
+ /// Returns tracked bytes queued for deletion.
+ ///
+ public long ClearAllSavedRuns()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (_disk is null)
+ {
+ return 0;
+ }
+
+ long freed;
+ List spilled;
+ lock (_gate)
+ {
+ spilled = new List();
+ foreach (var snap in _byId.Values)
+ {
+ if (snap.BodiesOnDisk)
+ {
+ spilled.Add(snap.Id);
+ }
+ }
+
+ freed = _disk.ScheduleClearAllRuns();
+ }
+
+ MarkBodiesMissing(spilled);
+ return freed;
+ }
+
+ /// Run count and tracked bytes for the retention window. Zero when spill is off.
+ public SessionCacheStats GetCacheStats() => _disk?.GetCacheStats() ?? default;
+
///
/// Inserts many sessions under one lock and enforces the memory cap once.
/// Spill writes are queued; HAR files are not written on the caller thread.
diff --git a/src/Titanium.Inspector/Titanium.Inspector.csproj b/src/Titanium.Inspector/Titanium.Inspector.csproj
index 95c3d84f2..cb1ef6f59 100644
--- a/src/Titanium.Inspector/Titanium.Inspector.csproj
+++ b/src/Titanium.Inspector/Titanium.Inspector.csproj
@@ -8,7 +8,7 @@
enable
true
false
- 7.0.19
+ 7.0.20
Jehonathan Thomas
Titanium Inspector desktop traffic debugger (PolyForm Noncommercial).
LICENSE
diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs
index 1977ddda0..d60a41122 100644
--- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs
+++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs
@@ -41,10 +41,11 @@ private Task ClearSessionsAsync()
_userRemovalDepth++;
_suppressOpenSessionDetails = true;
+ long freed;
try
{
// Store.Clear does not raise SessionsRemoved. One grid Clear is a single Reset.
- _store.Clear();
+ freed = _store.Clear();
Sessions.Clear();
}
finally
@@ -57,7 +58,10 @@ private Task ClearSessionsAsync()
_interception.ResetSessionIdSequence();
RefreshSessionCountText();
NotifyFilterSelectionProperties();
- SetOutcomeStatus("Sessions cleared", StatusSeverity.Success, toastImportant: true);
+ SetOutcomeStatus(
+ SessionRetentionWindow.FormatSessionsClearedStatus(freed),
+ StatusSeverity.Success,
+ toastImportant: true);
return Task.CompletedTask;
}
private Task RemoveSelectedSessionsAsync()
diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs
index 1ccd10ea1..a63ad2b40 100644
--- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs
+++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs
@@ -1030,7 +1030,7 @@ private async Task OpenSessionRetentionAsync()
return;
}
- var saved = await AwaitCancellableAsync(SessionRetentionWindow.ShowAsync(owner, _settings));
+ var saved = await AwaitCancellableAsync(SessionRetentionWindow.ShowAsync(owner, _settings, _store));
if (!saved)
{
StatusText = "Session retention cancelled";
diff --git a/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml b/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml
index f148332a9..94a714750 100644
--- a/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml
+++ b/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml
@@ -3,7 +3,7 @@
x:Class="Titanium.Inspector.Views.SessionRetentionWindow"
AutomationProperties.AutomationId="SessionRetentionWindow"
Title="Session retention"
- Width="520" Height="400"
+ Width="520" Height="460"
WindowStartupLocation="CenterOwner"
CanResize="False">
@@ -18,7 +18,7 @@
+ Text="How many requests stay in the session list. When over this, the oldest rows leave the list but remain on disk until Clear sessions or the disk space limit removes them." />
@@ -31,12 +31,20 @@
AcceptsReturn="True"
Opacity="0.85"
AutomationProperties.AutomationId="RetentionCacheFolderPath" />
-
+
+
+
+
+
+ Text="Maximum space for all saved sessions on disk (all Inspector runs). Each run uses a timestamped subfolder; oldest HAR files are deleted first when over this. Empty run folders are removed." />
diff --git a/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml.cs b/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml.cs
index 7cdd657bf..fd6379e1d 100644
--- a/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml.cs
+++ b/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Titanium.Inspector.Services;
@@ -7,37 +8,125 @@ namespace Titanium.Inspector.Views;
public partial class SessionRetentionWindow : Window
{
private readonly SettingsService _settings;
+ private readonly SessionStore? _store;
private bool _saved;
public SessionRetentionWindow() : this(SettingsService.Load())
{
}
- public SessionRetentionWindow(SettingsService settings)
+ public SessionRetentionWindow(SettingsService settings, SessionStore? store = null)
{
_settings = settings;
+ _store = store;
InitializeComponent();
LoadFromSettings();
SaveButton.Click += OnSave;
CancelButton.Click += (_, _) => Close();
OpenCacheFolderButton.Click += OnOpenCacheFolder;
+ ClearSavedCacheButton.Click += OnClearSavedCache;
}
public bool Saved => _saved;
- public static async Task ShowAsync(Window owner, SettingsService settings)
+ public static async Task ShowAsync(Window owner, SettingsService settings, SessionStore? store = null)
{
- var w = new SessionRetentionWindow(settings);
+ var w = new SessionRetentionWindow(settings, store);
await w.ShowDialog(owner);
return w.Saved;
}
+ public static string FormatCacheUsage(SessionCacheStats stats)
+ {
+ if (stats.RunCount <= 0 && stats.TotalBytes <= 0)
+ {
+ return "No saved sessions on disk.";
+ }
+
+ return DescribeRuns(stats.RunCount) + ", " + SessionDisplayFormat.FormatByteSize(stats.TotalBytes) + " on disk.";
+ }
+
+ public static string FormatClearCacheConfirm(SessionCacheStats stats)
+ {
+ var size = SessionDisplayFormat.FormatByteSize(stats.TotalBytes);
+ return "Delete " + DescribeRuns(stats.RunCount) + " (" + size + ")? "
+ + "This removes saved session files from every run, including the current one. "
+ + "Sessions stay in the list, but their saved bodies are removed.";
+ }
+
+ public static string FormatSessionsClearedStatus(long freedBytes) =>
+ freedBytes > 0
+ ? "Sessions cleared (freed " + SessionDisplayFormat.FormatByteSize(freedBytes) + ")"
+ : "Sessions cleared";
+
+ private static string DescribeRuns(int runCount) =>
+ runCount == 1
+ ? "1 saved run"
+ : runCount.ToString(CultureInfo.InvariantCulture) + " saved runs";
+
private void LoadFromSettings()
{
var s = _settings.Current;
DiskCacheMaxMbBox.Text = BytesToMb(s.DiskCacheMaxBytes).ToString();
MaxSessionsBox.Text = s.MaxSessionsInMemory.ToString();
CacheFolderPathBox.Text = SessionBodyDiskCache.GetDefaultDirectory();
+ RefreshCacheUsage();
+ }
+
+ private void RefreshCacheUsage()
+ {
+ CacheUsageText.Text = _store is null
+ ? "Saved session cache is not available."
+ : FormatCacheUsage(_store.GetCacheStats());
+ }
+
+ private async void OnClearSavedCache(object? sender, RoutedEventArgs e)
+ {
+ try
+ {
+ await ClearSavedCacheAsync();
+ }
+ catch (Exception ex)
+ {
+ StatusText.Text = "Could not clear saved session cache: " + ex.Message;
+ }
+ }
+
+ private async Task ClearSavedCacheAsync()
+ {
+ StatusText.Text = string.Empty;
+ if (_store is null)
+ {
+ StatusText.Text = "Saved session cache is not available.";
+ return;
+ }
+
+ var stats = _store.GetCacheStats();
+ if (stats.RunCount <= 0 && stats.TotalBytes <= 0)
+ {
+ StatusText.Text = "No saved sessions on disk.";
+ RefreshCacheUsage();
+ return;
+ }
+
+ var accepted = await SimpleConfirmDialog.ShowAsync(
+ this,
+ "Clear saved session cache",
+ FormatClearCacheConfirm(stats),
+ "Delete",
+ "Cancel",
+ height: 240);
+ if (!accepted)
+ {
+ StatusText.Text = "Clear saved session cache cancelled";
+ return;
+ }
+
+ var freed = _store.ClearAllSavedRuns();
+ StatusText.Text = freed > 0
+ ? "Cleared saved session cache (freed " + SessionDisplayFormat.FormatByteSize(freed) + ")"
+ : "Cleared saved session cache";
+ RefreshCacheUsage();
}
private void OnOpenCacheFolder(object? sender, RoutedEventArgs e)
diff --git a/src/Titanium.Plus/Titanium.Plus.csproj b/src/Titanium.Plus/Titanium.Plus.csproj
index 15571b0bf..50c472e85 100644
--- a/src/Titanium.Plus/Titanium.Plus.csproj
+++ b/src/Titanium.Plus/Titanium.Plus.csproj
@@ -7,7 +7,7 @@
enable
True
StrongNameKey.snk
- 7.0.19
+ 7.0.20
Jehonathan Thomas
Titanium Web Proxy Plus advanced features plugin (PolyForm Noncommercial).
LICENSE
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 a1591ec10..3165978bb 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.19
+ 7.0.20
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 5c531fbc0..a093073dd 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.19
+ 7.0.20
Jehonathan Thomas
YAML/JSON configuration binding for Titanium Web Proxy CLI and reverse-proxy documents.
MIT
diff --git a/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs b/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs
index 010078847..3c35305cc 100644
--- a/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs
+++ b/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs
@@ -77,5 +77,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.19.0")]
-[assembly: AssemblyFileVersion("7.0.19.0")]
+[assembly: AssemblyVersion("7.0.20.0")]
+[assembly: AssemblyFileVersion("7.0.20.0")]
diff --git a/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj b/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj
index 1a55fe166..9b00bf878 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.19
+ 7.0.20