From e88c297d70f6e19baa86f611130f9be06b12763f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:22:21 -0700 Subject: [PATCH 01/63] chore(deps): bump actions/deploy-pages from 4 to 5 (#1038) Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5. - [Release notes](https://github.com/actions/deploy-pages/releases) - [Commits](https://github.com/actions/deploy-pages/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/deploy-pages dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jehonathan Thomas --- .github/workflows/deploy-website.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index fb8b6a281..6daa39fd8 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -134,4 +134,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 From 074e6b313b2678e1a0bd70c57746af37cb1dc5db Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:22:26 -0700 Subject: [PATCH 02/63] chore(deps): bump actions/setup-node from 4 to 7 (#1040) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 7. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jehonathan Thomas --- .github/workflows/deploy-website.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 6daa39fd8..f89208856 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -38,7 +38,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: '22' cache: npm From 476c5931f61530d4b7a1b2dff9134840e92297b3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:42:07 -0700 Subject: [PATCH 03/63] chore(deps): bump azure/login from 2 to 3 (#1039) Bumps [azure/login](https://github.com/azure/login) from 2 to 3. - [Release notes](https://github.com/azure/login/releases) - [Commits](https://github.com/azure/login/compare/v2...v3) --- updated-dependencies: - dependency-name: azure/login dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jehonathan Thomas --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d90401edd..59f9eeefc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -211,7 +211,7 @@ jobs: ./tools/packaging/bundle-http3-native.ps1 -Rid $rid -PublishDir $out - name: Azure login (OIDC) if: matrix.rid == 'win-x64' - uses: azure/login@v2 + uses: azure/login@v3 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -398,7 +398,7 @@ jobs: } - name: Azure login (OIDC) if: matrix.rid == 'win-x64' - uses: azure/login@v2 + uses: azure/login@v3 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} From d1ef4bdbcfe3e271d85c5e4df6cf04dbe03d13cb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:42:11 -0700 Subject: [PATCH 04/63] chore(deps): bump actions/download-artifact from 4 to 8 (#1041) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v4...v8) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jehonathan Thomas --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59f9eeefc..2d2c87103 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -526,7 +526,7 @@ jobs: - rid: linux-musl-x64 container: alpine:3.24 steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: cli-${{ matrix.rid }} path: dist @@ -624,7 +624,7 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: path: artifacts - name: Attach MIT NuGet SBOM when present From b40ad85b18afb527287c6b8c05973e977a894090 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:42:15 -0700 Subject: [PATCH 05/63] chore(deps): bump actions/cache from 4 to 6 (#1042) Bumps [actions/cache](https://github.com/actions/cache) from 4 to 6. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jehonathan Thomas --- .github/workflows/release.yml | 4 ++-- .github/workflows/rps-saturation.yml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d2c87103..297dd648a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -187,7 +187,7 @@ jobs: - uses: actions/setup-dotnet@v5 with: dotnet-version: '10.0.x' - - uses: actions/cache@v4 + - uses: actions/cache@v6 with: path: tools/packaging/.cache/http3-natives key: http3-natives-${{ hashFiles('tools/packaging/http3-native.lock.json') }}-${{ matrix.rid }} @@ -367,7 +367,7 @@ jobs: - uses: actions/setup-dotnet@v5 with: dotnet-version: '10.0.x' - - uses: actions/cache@v4 + - uses: actions/cache@v6 with: path: tools/packaging/.cache/http3-natives key: http3-natives-${{ hashFiles('tools/packaging/http3-native.lock.json') }}-${{ matrix.rid }} diff --git a/.github/workflows/rps-saturation.yml b/.github/workflows/rps-saturation.yml index a3d2e37c5..a2d3ea610 100644 --- a/.github/workflows/rps-saturation.yml +++ b/.github/workflows/rps-saturation.yml @@ -1,4 +1,4 @@ -# Manual saturation RPS lab on GitHub-hosted VMs (not a job container). +# Manual saturation RPS lab on GitHub-hosted VMs (not a job container). # Maintainers run this, then paste median numbers into wiki/Performance.md. # Not a per-PR gate; not comparable to dedicated-server blog posts. # @@ -380,7 +380,7 @@ jobs: # macOS uses Homebrew (typically native USE_QUIC) with a 3.2 osx source fallback. - name: Cache HAProxy QUIC prefix if: runner.os == 'Linux' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ${{ runner.temp }}/haproxy-quic @@ -470,7 +470,7 @@ jobs: - name: Cache HAProxy QUIC prefix (macOS) if: runner.os == 'macOS' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ runner.temp }}/haproxy-quic key: haproxy-3.2.23-quic-osx-x64 From ab6fe3443f54513d07e1e1df80efe535564432cf Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Tue, 15 Sep 2026 17:39:26 -0700 Subject: [PATCH 06/63] fix(inspector): accept * and localhost binds; recover after invalid address. A bad bind left Start stuck because parse ran outside the start-busy finally. Treat * as all IPv4 adapters to match the CLI. --- .../ViewModels/MainWindowViewModel.Trust.cs | 2 +- .../ViewModels/MainWindowViewModel.cs | 44 +++++++---- src/Titanium.Inspector/Views/MainWindow.axaml | 1 + .../BindEndpointUxTests.cs | 78 +++++++++++++++++++ .../InspectorCommandCoverageTests.cs | 9 +++ website/docs/configuration.md | 2 +- website/docs/inspector.md | 13 ++++ 7 files changed, 132 insertions(+), 17 deletions(-) diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Trust.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Trust.cs index 2984ef1b4..0913ef724 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Trust.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Trust.cs @@ -859,7 +859,7 @@ private async Task DeviceCaSetupAsync() "2. Install the exported .cer (or .pem) on the device as a trusted CA.\n" + $"3. Set the device HTTP proxy to this PC's LAN IP on port {BindPort} " + $"(current bind is {BindAddress}:{BindPort}).\n\n" + - "Use Bind address 0.0.0.0 so other devices can reach the proxy."; + "Use Bind address 0.0.0.0 or * so other devices can reach the proxy."; var owner = TryGetMainWindow(); if (await AwaitDialogAsync(_dialogs.ShowDeviceCaSetupAsync(owner, message))) diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs index 252f710cf..fe15ac8f3 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs @@ -2893,20 +2893,30 @@ private async Task StartCaptureAsync() using var scope = InspectorUxTrace.Scope("StartCapture", $"{BindAddress}:{BindPort}"); InspectorUxTrace.Event("UxTrace.Path", InspectorUxTrace.LogFilePath); _startBusy = true; - var address = ParseBindAddress(BindAddress); - PersistSettings(); - _interception.BreakpointOnResponse = BreakpointOnResponse; - _interception.ScriptOnRequest = ScriptOnRequest; - _interception.ScriptOnResponse = ScriptOnResponse; - _interception.IgnoreServerCertificateErrors = _settings.Current.IgnoreServerCertificateErrors; - _interception.AddViaHeader = _settings.Current.AddViaHeader; - _interception.DecryptHttps = _decryptHttps; - _interception.ConfigureLogging(_settings.Current); - SetStatus("Starting proxy…", StatusSeverity.Busy); - var token = StatusCancelToken; - var port = BindPort; try { + IPAddress address; + try + { + address = ParseBindAddress(BindAddress); + } + catch (Exception ex) when (ex is FormatException or ArgumentException) + { + SetOutcomeStatus(InvalidBindAddressMessage(BindAddress), StatusSeverity.Error, toastImportant: true); + return; + } + + PersistSettings(); + _interception.BreakpointOnResponse = BreakpointOnResponse; + _interception.ScriptOnRequest = ScriptOnRequest; + _interception.ScriptOnResponse = ScriptOnResponse; + _interception.IgnoreServerCertificateErrors = _settings.Current.IgnoreServerCertificateErrors; + _interception.AddViaHeader = _settings.Current.AddViaHeader; + _interception.DecryptHttps = _decryptHttps; + _interception.ConfigureLogging(_settings.Current); + SetStatus("Starting proxy…", StatusSeverity.Busy); + var token = StatusCancelToken; + var port = BindPort; // Listener start + first Root-store trust refresh can stall Crypt32 — keep off UI. // Use async Task.Run (not GetResult) to avoid sync-over-async deadlocks on a sync context. await Task.Run( @@ -2973,19 +2983,23 @@ private void RefreshEndpointAndBindUi() private static IPAddress ParseBindAddress(string bindAddress) { - if (string.IsNullOrWhiteSpace(bindAddress) || bindAddress == "0.0.0.0") + var host = (bindAddress ?? string.Empty).Trim(); + if (host.Length == 0 || host is "0.0.0.0" or "*") { return IPAddress.Any; } - if (bindAddress == "127.0.0.1") + if (host == "127.0.0.1" || host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) { return IPAddress.Loopback; } - return IPAddress.Parse(bindAddress); + return IPAddress.Parse(host); } + internal static string InvalidBindAddressMessage(string bindAddress) => + $"Invalid bind address '{bindAddress}'. Use 127.0.0.1 or localhost for this PC only, or 0.0.0.0 or * for all network adapters."; + diff --git a/src/Titanium.Inspector/Views/MainWindow.axaml b/src/Titanium.Inspector/Views/MainWindow.axaml index 72f1fbc9d..eb3cb3503 100644 --- a/src/Titanium.Inspector/Views/MainWindow.axaml +++ b/src/Titanium.Inspector/Views/MainWindow.axaml @@ -187,6 +187,7 @@ vm.StatusText.Contains("Invalid bind address", StringComparison.Ordinal)); + Assert.IsFalse(interception.IsRunning); + StringAssert.Contains(vm.StatusText, MainWindowViewModel.InvalidBindAddressMessage("::::")); + + vm.BindAddress = "*"; + vm.StartCaptureCommand.Execute(null); + await WaitUntil(() => interception.IsRunning && + vm.EndpointStatusText.StartsWith("Proxy running", StringComparison.Ordinal)); + Assert.AreEqual($"Proxy running on 0.0.0.0:{vm.BindPort}", vm.EndpointStatusText); + + vm.EnsureShutdown(); + } + finally + { + TryDelete(path); + } + } + + [TestMethod] + public async Task LocalhostAlias_StartsLoopbackListener() + { + var path = TempSettingsPath(); + try + { + var settings = new SettingsService(path); + settings.Current.AutoStartCapture = false; + settings.Current.AutoSystemProxyOnStart = false; + settings.Save(); + + var recorder = new RecordingSystemProxyController(); + using var interception = new InterceptionService(recorder) { UseInMemoryTrustState = true }; + var registry = new SessionRegistry(); + var vm = new MainWindowViewModel( + new SessionStreamBuffer(registry), + registry, + new UpdateService(settings), + settings, + interception); + + vm.BindPort = 0; + vm.BindAddress = "localhost"; + vm.StartCaptureCommand.Execute(null); + await WaitUntil(() => interception.IsRunning && + vm.EndpointStatusText.StartsWith("Proxy running", StringComparison.Ordinal)); + Assert.AreEqual($"Proxy running on localhost:{vm.BindPort}", vm.EndpointStatusText); + + vm.EnsureShutdown(); + } + finally + { + TryDelete(path); + } + } + [TestMethod] public async Task ManualStart_WithAutoSystemProxyOnStart_EnablesSystemProxy() { diff --git a/tests/Titanium.Inspector.Tests/InspectorCommandCoverageTests.cs b/tests/Titanium.Inspector.Tests/InspectorCommandCoverageTests.cs index 95c2d0092..20571c68f 100644 --- a/tests/Titanium.Inspector.Tests/InspectorCommandCoverageTests.cs +++ b/tests/Titanium.Inspector.Tests/InspectorCommandCoverageTests.cs @@ -237,10 +237,16 @@ public async Task RemainingCommands_CopyDiffComposerAutoResponderMapRemoteAndTog var flagsVm = BindingFlags.NonPublic | BindingFlags.Static; Assert.AreEqual(IPAddress.Any, (IPAddress)typeof(MainWindowViewModel).GetMethod("ParseBindAddress", flagsVm)!.Invoke(null, ["0.0.0.0"])!); + Assert.AreEqual(IPAddress.Any, + (IPAddress)typeof(MainWindowViewModel).GetMethod("ParseBindAddress", flagsVm)!.Invoke(null, ["*"])!); Assert.AreEqual(IPAddress.Any, (IPAddress)typeof(MainWindowViewModel).GetMethod("ParseBindAddress", flagsVm)!.Invoke(null, [" "])!); Assert.AreEqual(IPAddress.Loopback, (IPAddress)typeof(MainWindowViewModel).GetMethod("ParseBindAddress", flagsVm)!.Invoke(null, ["127.0.0.1"])!); + Assert.AreEqual(IPAddress.Loopback, + (IPAddress)typeof(MainWindowViewModel).GetMethod("ParseBindAddress", flagsVm)!.Invoke(null, ["localhost"])!); + Assert.AreEqual(IPAddress.Loopback, + (IPAddress)typeof(MainWindowViewModel).GetMethod("ParseBindAddress", flagsVm)!.Invoke(null, ["LocalHost"])!); Assert.AreEqual("h.test", (string?)typeof(MainWindowViewModel).GetMethod("TryHost", flagsVm)!.Invoke(null, ["https://h.test/x"])); Assert.IsNull(typeof(MainWindowViewModel).GetMethod("TryHost", flagsVm)!.Invoke(null, ["not-a-url"])); @@ -514,6 +520,9 @@ public void SelectedInspectFormatters_AndBindDebugHelpers_CoverBranches() BindPort = 8888, BindAddress = "0.0.0.0", }; + Assert.AreEqual("0.0.0.0", + (string)vmType.GetMethod("FormatBindDisplay", flags)!.Invoke(vm, null)!); + vm.BindAddress = "*"; Assert.AreEqual("0.0.0.0", (string)vmType.GetMethod("FormatBindDisplay", flags)!.Invoke(vm, null)!); vm.BindAddress = "127.0.0.1"; diff --git a/website/docs/configuration.md b/website/docs/configuration.md index 019a5a0de..d93245c8c 100644 --- a/website/docs/configuration.md +++ b/website/docs/configuration.md @@ -43,7 +43,7 @@ Engine knobs live under `server:` ([reference](#server-reference)). Plus feature | Field | Type | Notes | |-------|------|-------| -| `host` | string | Default `0.0.0.0` | +| `host` | string | Bind address. Default `0.0.0.0` (all IPv4 interfaces). `*` is the same as `0.0.0.0`. Use `127.0.0.1` for localhost only. | | `port` | int | Default `8000` | | `decryptSsl` | bool | Terminate TLS / decrypt HTTPS (man-in-the-middle when used for MITM) | | `type` | string? | `explicit`, `transparent`, `socks`, or `quic` (null uses ForwardHost heuristics) | diff --git a/website/docs/inspector.md b/website/docs/inspector.md index 3740a77bc..2aa79ba29 100644 --- a/website/docs/inspector.md +++ b/website/docs/inspector.md @@ -13,6 +13,19 @@ Desktop debugger for HTTP and HTTPS traffic. Decrypt HTTPS (man-in-the-middle / HTTPS stays encrypted (opaque tunnels) until **Decrypt HTTPS** is on. +### Bind address + +Toolbar **Bind address** is an IP (or alias), not a hostname except `localhost`. Edit it only while the proxy is stopped. + +| Value | Listens on | +| --- | --- | +| `127.0.0.1` or `localhost` | This PC only | +| `0.0.0.0` or `*` | All IPv4 adapters, including localhost | +| A NIC IP (for example `192.168.1.10`) | That interface only | +| `::` | All IPv6 adapters | + +`*` matches the CLI listener `host` alias for all IPv4 interfaces. Binding on all adapters means other machines on the network can reach the proxy — use that only on a network you trust. For a phone or other device, bind `0.0.0.0` or `*` and point the device at this PC's LAN IP (see **Capture → device CA setup**). + Capture menu options (**Capturing**, **Decrypt HTTPS**, **System proxy**, **Capture local traffic**, auto-start prefs) show a check when on. **Allow Store apps…** (Windows) sits with System proxy. Preferences such as **Session retention…**, **Excluded hosts…**, **Ignore insecure server certificates**, and **Logging…** live under **Options**. ## Install From ff2c344bdc79bcbbf5ba93643f1108815cea890b Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Tue, 15 Sep 2026 18:19:38 -0700 Subject: [PATCH 07/63] fix(inspector): treat port and AutoResponder status as text, not int binds. Empty fields no longer raise conversion exceptions; * picks an OS port, and invalid input shows a short message. --- .../MainWindowViewModel.Sessions.cs | 27 ++- .../ViewModels/MainWindowViewModel.cs | 207 +++++++++++++++++- src/Titanium.Inspector/Views/MainWindow.axaml | 8 +- .../Views/SessionRetentionWindow.axaml.cs | 37 +++- .../AutoResponderAndSelectionGuardTests.cs | 69 ++++++ .../BindEndpointUxTests.cs | 110 ++++++++++ .../CaptureSettingsParityTests.cs | 6 + website/docs/configuration.md | 2 +- website/docs/inspector.md | 14 +- 9 files changed, 461 insertions(+), 19 deletions(-) diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs index 86076e892..bb90056aa 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs @@ -434,6 +434,11 @@ private List ResolveCopyUrls() => .ToList(); private Task AddAutoResponderRuleAsync() { + if (!TryGetAutoResponderStatus(out var status)) + { + return Task.CompletedTask; + } + if (string.IsNullOrWhiteSpace(AutoResponderLocalFilePath) && AutoResponderBody.Length > InspectorBodyLimits.MaxInlineToolBodyChars) { @@ -444,7 +449,7 @@ private Task AddAutoResponderRuleAsync() AutoResponder.Rules.Add(new AutoResponderRule { MatchUrl = AutoResponderMatch, - StatusCode = AutoResponderStatus, + StatusCode = status, Body = AutoResponderBody, ContentType = AutoResponderContentType, LocalFilePath = AutoResponderLocalFilePath, @@ -477,6 +482,11 @@ private Task UpdateAutoResponderRuleAsync() return Task.CompletedTask; } + if (!TryGetAutoResponderStatus(out var status)) + { + return Task.CompletedTask; + } + if (string.IsNullOrWhiteSpace(AutoResponderLocalFilePath) && AutoResponderBody.Length > InspectorBodyLimits.MaxInlineToolBodyChars) { @@ -486,7 +496,7 @@ private Task UpdateAutoResponderRuleAsync() var rule = AutoResponder.SelectedRule; rule.MatchUrl = AutoResponderMatch; - rule.StatusCode = AutoResponderStatus; + rule.StatusCode = status; rule.Body = AutoResponderBody; rule.ContentType = AutoResponderContentType; rule.LocalFilePath = AutoResponderLocalFilePath; @@ -495,6 +505,19 @@ private Task UpdateAutoResponderRuleAsync() StatusText = "AutoResponder rule updated"; return Task.CompletedTask; } + + private bool TryGetAutoResponderStatus(out int status) + { + if (TryParseHttpStatus(AutoResponderStatusText, out status)) + { + AutoResponderStatus = status; + return true; + } + + SetOutcomeStatus(InvalidHttpStatusMessage(AutoResponderStatusText), StatusSeverity.Error, toastImportant: true); + status = 0; + return false; + } private async Task BrowseAutoResponderLocalFileAsync() { var path = await _pathPicker.PickOpenPathAsync( diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs index fe15ac8f3..5edf14dc2 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs @@ -1,5 +1,7 @@ +using System.Collections; using System.Collections.ObjectModel; using System.ComponentModel; +using System.Globalization; using System.Net; using System.Runtime.CompilerServices; using System.Text; @@ -16,7 +18,7 @@ namespace Titanium.Inspector.ViewModels; -public sealed partial class MainWindowViewModel : INotifyPropertyChanged +public sealed partial class MainWindowViewModel : INotifyPropertyChanged, INotifyDataErrorInfo { private const string ZipFileFilter = "*.zip"; @@ -89,6 +91,7 @@ public sealed partial class MainWindowViewModel : INotifyPropertyChanged private string _autoResponderContentType = "text/plain"; private string _autoResponderLocalFilePath = string.Empty; private int _autoResponderStatus = 200; + private string _autoResponderStatusText = "200"; private string _mapRemoteMatch = "*"; private string _mapRemoteTarget = "http://127.0.0.1/"; private string _mapRemoteGraphQlOperation = string.Empty; @@ -96,6 +99,7 @@ public sealed partial class MainWindowViewModel : INotifyPropertyChanged private string _plusPanelsSummary = ""; private string _bindAddress = "127.0.0.1"; private int _bindPort = 8866; + private string _bindPortText = "8866"; 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. @@ -1359,9 +1363,95 @@ public string BindAddress public int BindPort { get => _bindPort; - set => SetField(ref _bindPort, value); + set + { + SetField(ref _bindPort, value); + if (!TryParseBindPort(_bindPortText, out var represented) || represented != value) + { + _bindPortText = FormatBindPortText(value); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BindPortText))); + NotifyBindPortErrors(); + } + } } + /// Toolbar port box. Empty or * is an OS-chosen port; keep this a string so typing is not an int conversion error. + public string BindPortText + { + get => _bindPortText; + set + { + var text = value ?? string.Empty; + if (!SetField(ref _bindPortText, text)) + { + return; + } + + if (TryParseBindPort(text, out var port)) + { + _bindPort = port; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BindPort))); + } + + NotifyBindPortErrors(); + } + } + + public bool HasErrors => + !TryParseBindPort(_bindPortText, out _) || + !TryParseHttpStatus(_autoResponderStatusText, out _); + + public event EventHandler? ErrorsChanged; + + public IEnumerable GetErrors(string? propertyName) + { + if (string.IsNullOrEmpty(propertyName)) + { + return ConcatErrors(BindPortErrors(), AutoResponderStatusErrors()); + } + + if (propertyName == nameof(BindPortText)) + { + return BindPortErrors(); + } + + if (propertyName == nameof(AutoResponderStatusText)) + { + return AutoResponderStatusErrors(); + } + + return Array.Empty(); + } + + private IEnumerable BindPortErrors() => + TryParseBindPort(_bindPortText, out _) + ? Array.Empty() + : new object[] { InvalidBindPortMessage(_bindPortText) }; + + private IEnumerable AutoResponderStatusErrors() => + TryParseHttpStatus(_autoResponderStatusText, out _) + ? Array.Empty() + : new object[] { InvalidHttpStatusMessage(_autoResponderStatusText) }; + + private static IEnumerable ConcatErrors(IEnumerable first, IEnumerable second) + { + foreach (var item in first) + { + yield return item; + } + + foreach (var item in second) + { + yield return item; + } + } + + private void NotifyBindPortErrors() => + ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(BindPortText))); + + private void NotifyAutoResponderStatusErrors() => + ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(AutoResponderStatusText))); + /// Bind address/port are start-time config; editable only while the proxy is stopped. public bool BindFieldsEnabled => !_interception.IsRunning; @@ -1513,7 +1603,38 @@ public string AutoResponderGraphQlOperation public int AutoResponderStatus { get => _autoResponderStatus; - set => SetField(ref _autoResponderStatus, value); + set + { + SetField(ref _autoResponderStatus, value); + if (!TryParseHttpStatus(_autoResponderStatusText, out var represented) || represented != value) + { + _autoResponderStatusText = FormatHttpStatusText(value); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(AutoResponderStatusText))); + NotifyAutoResponderStatusErrors(); + } + } + } + + /// AutoResponder status box. Empty means 200; keep this a string so typing is not an int conversion error. + public string AutoResponderStatusText + { + get => _autoResponderStatusText; + set + { + var text = value ?? string.Empty; + if (!SetField(ref _autoResponderStatusText, text)) + { + return; + } + + if (TryParseHttpStatus(text, out var status)) + { + _autoResponderStatus = status; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(AutoResponderStatus))); + } + + NotifyAutoResponderStatusErrors(); + } } public string PlusPanelsSummary @@ -2396,7 +2517,7 @@ private void LoadFromSettings() { var s = _settings.Current; BindAddress = s.BindAddress; - BindPort = s.BindPort is > 0 and < 65536 ? s.BindPort : 8866; + BindPort = s.BindPort is >= 0 and <= 65535 ? s.BindPort : 8866; _launchAutoStartCapture = _autoStartCapture = s.AutoStartCapture; _launchAutoSystemProxyOnStart = _autoSystemProxyOnStart = s.AutoSystemProxyOnStart; _decryptHttps = s.DecryptHttps; @@ -2432,6 +2553,7 @@ private void NotifySettingsUiChanged() { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BindAddress))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BindPort))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BindPortText))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(AutoStartCapture))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(AutoSystemProxyOnStart))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DecryptHttps))); @@ -2890,7 +3012,7 @@ private async Task StartCaptureAsync() return; } - using var scope = InspectorUxTrace.Scope("StartCapture", $"{BindAddress}:{BindPort}"); + using var scope = InspectorUxTrace.Scope("StartCapture", $"{BindAddress}:{BindPortText}"); InspectorUxTrace.Event("UxTrace.Path", InspectorUxTrace.LogFilePath); _startBusy = true; try @@ -2906,6 +3028,18 @@ private async Task StartCaptureAsync() return; } + int port; + try + { + port = ParseBindPort(BindPortText); + } + catch (FormatException) + { + SetOutcomeStatus(InvalidBindPortMessage(BindPortText), StatusSeverity.Error, toastImportant: true); + return; + } + + BindPort = port; PersistSettings(); _interception.BreakpointOnResponse = BreakpointOnResponse; _interception.ScriptOnRequest = ScriptOnRequest; @@ -2916,7 +3050,6 @@ private async Task StartCaptureAsync() _interception.ConfigureLogging(_settings.Current); SetStatus("Starting proxy…", StatusSeverity.Busy); var token = StatusCancelToken; - var port = BindPort; // Listener start + first Root-store trust refresh can stall Crypt32 — keep off UI. // Use async Task.Run (not GetResult) to avoid sync-over-async deadlocks on a sync context. await Task.Run( @@ -3000,6 +3133,68 @@ private static IPAddress ParseBindAddress(string bindAddress) internal static string InvalidBindAddressMessage(string bindAddress) => $"Invalid bind address '{bindAddress}'. Use 127.0.0.1 or localhost for this PC only, or 0.0.0.0 or * for all network adapters."; + internal static bool TryParseBindPort(string? text, out int port) + { + var raw = (text ?? string.Empty).Trim(); + if (raw.Length == 0 || raw == "*") + { + port = 0; + return true; + } + + if (int.TryParse(raw, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) + && parsed is >= 0 and <= 65535) + { + port = parsed; + return true; + } + + port = 0; + return false; + } + + internal static int ParseBindPort(string text) + { + if (TryParseBindPort(text, out var port)) + { + return port; + } + + throw new FormatException(InvalidBindPortMessage(text)); + } + + internal static string FormatBindPortText(int port) => + port == 0 ? "*" : port.ToString(CultureInfo.InvariantCulture); + + internal static string InvalidBindPortMessage(string text) => + $"Invalid port '{text}'. Use 1–65535, or * (or empty) for an OS-chosen port."; + + internal static bool TryParseHttpStatus(string? text, out int status) + { + var raw = (text ?? string.Empty).Trim(); + if (raw.Length == 0) + { + status = 200; + return true; + } + + if (int.TryParse(raw, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) + && parsed is >= 100 and <= 599) + { + status = parsed; + return true; + } + + status = 0; + return false; + } + + internal static string FormatHttpStatusText(int status) => + status.ToString(CultureInfo.InvariantCulture); + + internal static string InvalidHttpStatusMessage(string text) => + $"Invalid status '{text}'. Use an HTTP status 100–599, or leave empty for 200."; + diff --git a/src/Titanium.Inspector/Views/MainWindow.axaml b/src/Titanium.Inspector/Views/MainWindow.axaml index eb3cb3503..a198193fe 100644 --- a/src/Titanium.Inspector/Views/MainWindow.axaml +++ b/src/Titanium.Inspector/Views/MainWindow.axaml @@ -184,14 +184,15 @@ - -