diff --git a/.cursor/rules/re-measure-wording.mdc b/.cursor/rules/re-measure-wording.mdc deleted file mode 100644 index 75334321f..000000000 --- a/.cursor/rules/re-measure-wording.mdc +++ /dev/null @@ -1,10 +0,0 @@ ---- -description: Use hyphenated re-measure; never remasure or remeasure -alwaysApply: true ---- - -# Wording: re-measure - -When writing about measuring again (RPS, memory, UI layout), use the hyphenated form **re-measure** (and **re-measurement**). - -Never write `remasure` or `remeasure` in code, comments, docs, wiki, the website, commit messages, or plans. diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index 7f77d39b9..aa92927c6 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -78,7 +78,9 @@ jobs: run: dotnet build src/Titanium.Web.Proxy.sln --configuration Release --no-restore --warnaserror - name: Begin SonarCloud analysis + id: sonar_begin if: env.SONAR_TOKEN != '' + continue-on-error: true shell: pwsh run: > .\.sonar\scanner\dotnet-sonarscanner begin @@ -87,10 +89,10 @@ jobs: /d:sonar.token="$env:SONAR_TOKEN" /d:sonar.cs.vscoveragexml.reportsPaths="coverage/coverage.xml" /d:sonar.exclusions="**/docs/**,**/examples/**,**/benchmarks/**,**/tools/**,**/*.axaml,**/website/**,**/.github/**" - /d:sonar.coverage.exclusions="**/examples/**,**/benchmarks/**,**/docs/**,**/Http3/Http3OriginBridge.cs,**/Http3/Http3OriginClientSession.cs,**/Titanium.Plus/Dashboard/**,**/Titanium.Inspector/Views/**,**/Titanium.Inspector/Services/AppContainerLoopback.cs,**/Titanium.Inspector/App.axaml.cs,**/Titanium.Inspector/Program.cs,**/Titanium.Inspector/InspectorAppFactory.cs,**/Titanium.Inspector/Services/UpdateService.cs,**/Titanium.Cli/Program.cs,**/Titanium.Cli/AsyncConsole.cs,**/Titanium.Cli/Http3/Http3DepsCommand.cs,**/Titanium.Cli/Updates/VersionAndUpdateCommands.cs,**/Titanium.Cli/Certificates/CertificateBootstrap.cs,**/Titanium.Plus/Discovery/**,**/Titanium.Plus/Security/**,**/Titanium.Plus/State/**,**/Titanium.Plus/Resilience/**,**/Titanium.Plus/PlusLog.cs,**/Titanium.Web.Proxy/Helpers/LinuxSystemProxyBackend.cs,**/Titanium.Web.Proxy/Helpers/MacOsSystemProxyBackend.cs,**/Titanium.Web.Proxy/Helpers/IElevationPrompt.cs,**/Titanium.Web.Proxy/Helpers/IProcessRunner.cs" + /d:sonar.coverage.exclusions="**/examples/**,**/benchmarks/**,**/docs/**,**/Http3/Http3OriginBridge.cs,**/Http3/Http3OriginClientSession.cs,**/Handlers/Http11ToHttp2BridgeHandler.cs,**/Handlers/H1TerminateFastForward.cs,**/Titanium.Plus/Dashboard/**,**/Titanium.Inspector/Views/**,**/Titanium.Inspector/ViewModels/MainWindowViewModel.Trust.cs,**/Titanium.Inspector/Services/AppContainerLoopback.cs,**/Titanium.Inspector/Services/AvaloniaStatusNotifier.cs,**/Titanium.Inspector/Services/DesktopShell.cs,**/Titanium.Inspector/App.axaml.cs,**/Titanium.Inspector/Program.cs,**/Titanium.Inspector/InspectorAppFactory.cs,**/Titanium.Inspector/Services/UpdateService.cs,**/Titanium.Cli/Program.cs,**/Titanium.Cli/AsyncConsole.cs,**/Titanium.Cli/Http3/Http3DepsCommand.cs,**/Titanium.Cli/Updates/VersionAndUpdateCommands.cs,**/Titanium.Cli/Certificates/CertificateBootstrap.cs,**/Titanium.Plus/Discovery/**,**/Titanium.Plus/Security/**,**/Titanium.Plus/State/**,**/Titanium.Plus/Resilience/**,**/Titanium.Plus/PlusLog.cs,**/Titanium.Web.Proxy/Helpers/LinuxSystemProxyBackend.cs,**/Titanium.Web.Proxy/Helpers/MacOsSystemProxyBackend.cs,**/Titanium.Web.Proxy/Helpers/IElevationPrompt.cs,**/Titanium.Web.Proxy/Helpers/IProcessRunner.cs,**/Titanium.Web.Proxy/Certificates/FirefoxCertificateTrust.cs" - name: Build for SonarCloud analysis - if: env.SONAR_TOKEN != '' + if: env.SONAR_TOKEN != '' && steps.sonar_begin.outcome == 'success' run: dotnet build src/Titanium.Web.Proxy.sln --configuration Release --no-restore --no-incremental --disable-build-servers - name: Test @@ -108,7 +110,8 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet test tests/Titanium.Cli.Tests/Titanium.Cli.Tests.csproj --configuration Release --no-build --no-restore --collect:"Code Coverage" --results-directory coverage/cli if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - dotnet test tests/Titanium.E2E.Tests/Titanium.E2E.Tests.csproj --configuration Release --no-build --no-restore --filter "TestCategory=E2E|TestCategory=E2E-UI" --collect:"Code Coverage" --results-directory coverage/e2e + # CLI process E2E (TestCategory=E2E) runs on the cli-e2e OS matrix — avoid doubling Windows. + dotnet test tests/Titanium.E2E.Tests/Titanium.E2E.Tests.csproj --configuration Release --no-build --no-restore --filter "TestCategory=E2E-UI" --collect:"Code Coverage" --results-directory coverage/e2e if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # The integration suite spins up real listening sockets and TLS handshakes, which @@ -130,12 +133,13 @@ jobs: run: .\.coverage\tools\dotnet-coverage merge "coverage\**\*.coverage" --output coverage/coverage.xml --output-format xml - name: Complete SonarCloud analysis - if: env.SONAR_TOKEN != '' + if: env.SONAR_TOKEN != '' && steps.sonar_begin.outcome == 'success' + continue-on-error: true shell: pwsh run: .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.token="$env:SONAR_TOKEN" # DocFX output races other develop pushes (incl. concurrent "Update documentation"). - # Sync to origin/develop, regenerate, commit docs-only, retry push — never fail the + # Sync to origin/develop, regenerate, commit docs-only, retry push — never fail the # job on stash/rebase conflicts from EndBug/add-and-commit autostash. - name: Publish Documentation if: github.ref == 'refs/heads/develop' @@ -172,7 +176,7 @@ jobs: throw "Failed to publish documentation after retries" # Cross-OS Inspector + Plus dashboard UI gates (Headless / Visual / Playwright). - # Inspector unit suite stays on Windows `build` only except Inspector-Stress (below). + # Inspector unit suite stays on Windows `build` only except Inspector-Stress / Inspector-Trust-Decision (below). ui-portable: runs-on: ${{ matrix.os }} timeout-minutes: 35 @@ -215,7 +219,7 @@ jobs: shell: pwsh run: | $ErrorActionPreference = 'Stop' - # Intel macOS bottles are sparse; do not force-upgrade openssl (no bottle → job fail). + # Intel macOS bottles are sparse; do not force-upgrade openssl (no bottle → job fail). $env:HOMEBREW_NO_AUTO_UPDATE = '1' $env:HOMEBREW_NO_INSTALL_UPGRADE = '1' brew install openssl@3 libmsquic @@ -240,7 +244,7 @@ jobs: } if (-not (Test-QuicSupported)) { - Write-Host 'QuicListener.IsSupported still false after brew; trying Microsoft libmsquic drop…' + Write-Host 'QuicListener.IsSupported still false after brew; trying Microsoft libmsquic drop…' $arch = (& uname -m).Trim() $rid = if ($arch -eq 'arm64') { 'osx-arm64' } else { 'osx-x64' } $dest = Join-Path $env:RUNNER_TEMP 'msquic-osx' @@ -259,7 +263,7 @@ jobs: Select-Object -First 1 if ($found) { $extra = $found.Directory.FullName - # ${extra} — bare $extra: is parsed as a PowerShell drive-qualified variable. + # ${extra} — bare $extra: is parsed as a PowerShell drive-qualified variable. $dyld2 = "${extra}:${dyld}" Add-Content -Path $env:GITHUB_ENV -Value "DYLD_LIBRARY_PATH=$dyld2" Add-Content -Path $env:GITHUB_ENV -Value "DYLD_FALLBACK_LIBRARY_PATH=$dyld2" @@ -300,7 +304,7 @@ jobs: dotnet test tests/Titanium.E2E.Tests/Titanium.E2E.Tests.csproj --configuration Release --no-build --no-restore --filter "TestCategory=E2E-UI-Headless|TestCategory=E2E-UI-Visual|TestCategory=E2E-UI-Plus-Dashboard" - name: Inspector retention stress (spill + H3) run: | - dotnet test tests/Titanium.Inspector.Tests/Titanium.Inspector.Tests.csproj --configuration Release --no-restore --filter "TestCategory=Inspector-Stress" + dotnet test tests/Titanium.Inspector.Tests/Titanium.Inspector.Tests.csproj --configuration Release --no-restore --filter "TestCategory=Inspector-Stress|TestCategory=Inspector-Trust-Decision" - name: OS proxy-backend filters run: | dotnet test tests/Titanium.Web.Proxy.UnitTests/Titanium.Web.Proxy.UnitTests.csproj --configuration Release --no-build --no-restore --filter "FullyQualifiedName~UnixProxyBypassMapperTests|FullyQualifiedName~MacOsSystemProxyBackendTests|FullyQualifiedName~LinuxSystemProxyBackendTests|FullyQualifiedName~ElevationPromptCancelTests|FullyQualifiedName~SystemProxyBackendFactoryPlatformTests" @@ -327,8 +331,70 @@ jobs: **/playwright-report/** if-no-files-found: ignore - # Tiered RPS gates for beta/stable publish (parallel — wall clock ~max of the two). - # Editions: CLI/Plus tax vs Core. Peer: Core reverse vs YARP (+ MITM÷Reverse) so a + # Full CLI + CLI Plus process E2E on all three OS (command tree, services, control plane). + # Does not pre-install libmsquic so http3-deps install can exercise the real leaf when Quic is false. + cli-e2e: + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v6 + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 10.0.x + - name: Linux Playwright OS deps + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y fonts-liberation libnss3 libatk-bridge2.0-0 libdrm2 libxkbcommon0 libgbm1 libasound2t64 || sudo apt-get install -y fonts-liberation libnss3 libatk-bridge2.0-0 libdrm2 libxkbcommon0 libgbm1 libasound2 + - name: Restore + run: dotnet restore src/Titanium.Web.Proxy.sln + - name: Build CLI + Plus + E2E + run: | + dotnet build src/Titanium.Cli/Titanium.Cli.csproj --configuration Release --no-restore + dotnet build src/Titanium.Plus/Titanium.Plus.csproj --configuration Release --no-restore + dotnet build tests/Titanium.E2E.Tests/Titanium.E2E.Tests.csproj --configuration Release --no-restore + - name: Install Playwright Chromium + shell: pwsh + run: | + $pw = Join-Path (Resolve-Path "tests/Titanium.E2E.Tests/bin/Release/net10.0") "playwright.ps1" + if (-not (Test-Path $pw)) { + throw "playwright.ps1 missing at $pw" + } + & $pw install chromium + - name: CLI process E2E (all leaves) + shell: pwsh + run: | + if ($IsLinux -or $IsMacOS) { + sudo -n true 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Warning "sudo -n not available; machine service lifecycle may Inconclusive" + } + } + dotnet test tests/Titanium.E2E.Tests/Titanium.E2E.Tests.csproj ` + --configuration Release --no-build --no-restore ` + --filter "TestCategory=E2E" ` + --logger "trx;LogFileName=cli-e2e.trx" ` + --results-directory "artifacts/cli-e2e-${{ matrix.os }}" + - name: Upload CLI E2E artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: cli-e2e-${{ matrix.os }} + path: | + artifacts/cli-e2e-${{ matrix.os }}/** + tests/Titanium.E2E.Tests/TestResults/** + if-no-files-found: ignore + + # Tiered RPS gates for beta/stable publish (parallel) — wall clock ~max of the two). + # Editions: CLI/Plus tax vs Core. Peer: Core reverse vs YARP (+ MITM÷Reverse) so a # uniform Core slowdown cannot hide behind green edition ratios. rps-publish-gate: if: github.event_name == 'push' && (github.ref == 'refs/heads/beta' || github.ref == 'refs/heads/stable') @@ -388,7 +454,7 @@ jobs: sudo apt-get update sudo apt-get install -y libmsquic pwsh -NoProfile -Command 'if (-not [System.Net.Quic.QuicListener]::IsSupported) { throw "QuicListener.IsSupported is false after libmsquic install" }; Write-Host "QuicListener.IsSupported=$([System.Net.Quic.QuicListener]::IsSupported)"' - - name: compare-spot (Core÷YARP + MITM÷Reverse) + - name: compare-spot (Core÷YARP + MITM÷Reverse) shell: pwsh run: | pwsh tools/RpsLoadProbe/run-spot-matrix.ps1 @@ -447,7 +513,7 @@ jobs: # Product zips stay on release.yml (v* tags). After beta/stable merge, create/move the # version tag and dispatch release.yml (GITHUB_TOKEN tag pushes do not re-trigger workflows). # release.yml also packs/pushes Chocolatey (titanium-cli / titanium-inspector) after the - # GitHub Release exists — no separate chocolatey step here. + # GitHub Release exists — no separate chocolatey step here. cut-product-tag: if: github.event_name == 'push' && (github.ref == 'refs/heads/beta' || github.ref == 'refs/heads/stable') needs: [build, ui-portable, rps-publish-gate, rps-peer-gate] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6149d3f5c..a4e409243 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,8 +83,8 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } - Write-Host "=== E2E / E2E-UI ===" - dotnet test tests/Titanium.E2E.Tests/Titanium.E2E.Tests.csproj -c Release --no-build --no-restore --nologo --filter "TestCategory=E2E|TestCategory=E2E-UI" + Write-Host "=== E2E-UI (CLI process E2E is cli-e2e matrix) ===" + dotnet test tests/Titanium.E2E.Tests/Titanium.E2E.Tests.csproj -c Release --no-build --no-restore --nologo --filter "TestCategory=E2E-UI" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $maxAttempts = 2 @@ -106,8 +106,43 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cli-e2e: + needs: resolve-version + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + - name: Linux Playwright OS deps + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y fonts-liberation libnss3 libatk-bridge2.0-0 libdrm2 libxkbcommon0 libgbm1 libasound2t64 || sudo apt-get install -y fonts-liberation libnss3 libatk-bridge2.0-0 libdrm2 libxkbcommon0 libgbm1 libasound2 + - name: Restore and build + run: | + dotnet restore src/Titanium.Web.Proxy.sln + dotnet build src/Titanium.Cli/Titanium.Cli.csproj -c Release --no-restore + dotnet build src/Titanium.Plus/Titanium.Plus.csproj -c Release --no-restore + dotnet build tests/Titanium.E2E.Tests/Titanium.E2E.Tests.csproj -c Release --no-restore + - name: Install Playwright Chromium + shell: pwsh + run: | + $pw = Join-Path (Resolve-Path "tests/Titanium.E2E.Tests/bin/Release/net10.0") "playwright.ps1" + & $pw install chromium + - name: CLI process E2E + run: | + dotnet test tests/Titanium.E2E.Tests/Titanium.E2E.Tests.csproj -c Release --no-build --no-restore --nologo --filter "TestCategory=E2E" + build-cli: - needs: [resolve-version, test] + needs: [resolve-version, test, cli-e2e] runs-on: ${{ matrix.os }} # win-x64 uses environment "signing" for Azure OIDC (federated cred). environment: ${{ matrix.rid == 'win-x64' && 'signing' || '' }} diff --git a/.gitignore b/.gitignore index 747b0dd3e..f59d4ca55 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,8 @@ ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. -# Local Cursor config (plans, caches). Shared project rules are tracked. -.cursor/* -!.cursor/rules/ -!.cursor/rules/** +# Local Cursor agent rules / config (not shared) +.cursor/ # User-specific files *.suo diff --git a/README.md b/README.md index f8586664f..832865f7d 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,13 @@ Requires .NET 10 or later for the library. CLI and Inspector downloads are self- ## What you can do -- Run a reverse / edge proxy in front of any backend, or inspect and modify HTTP(S) traffic in the desktop Inspector -- Explicit, transparent, and SOCKS4/5 endpoints; decrypt HTTPS when you trust a local root certificate -- Stream bodies across HTTP/1.x, HTTP/2, and HTTP/3; upstream proxies, auth, and mutual TLS +- Decrypt and inspect HTTPS in a native desktop Inspector on Windows, macOS, and Linux — AutoResponder, Map Local/Remote, breakpoints, Composer, HAR, curl/fetch +- Keep sign-in working: SSO hosts stay on OS bypass; hosts that reject MITM auto-tunnel +- Speak modern HTTP — HTTP/2 by default, optional HTTP/3, WebSocket, gRPC, SSE, GraphQL rules in the same grid +- Run the same engine as a reverse / edge proxy from the CLI (YAML, load balancing, ACME, live reload) +- Embed in .NET via NuGet (MIT); Inspector is free for personal and education use -Protocol coverage: [protocol support matrix](https://github.com/justcoding121/titanium-web-proxy/wiki/Protocol-Support). HTTP/3 packaging: [HTTP/3 wiki](https://github.com/justcoding121/titanium-web-proxy/wiki/HTTP-3). +Full catalog: [Features](https://titaniumproxy.com/docs/features). Protocol coverage: [protocol support matrix](https://github.com/justcoding121/titanium-web-proxy/wiki/Protocol-Support). HTTP/3 packaging: [HTTP/3 wiki](https://github.com/justcoding121/titanium-web-proxy/wiki/HTTP-3). ## Performance @@ -108,8 +110,8 @@ Point your client at `127.0.0.1:8000` as its HTTP and HTTPS proxy. Trusting a ge ## Examples and documentation -- **[Website](https://titaniumproxy.com)** — product docs, [download](https://titaniumproxy.com/download), [getting started](https://titaniumproxy.com/docs/getting-started), [release notes](https://titaniumproxy.com/releases) -- **[Wiki](https://github.com/justcoding121/titanium-web-proxy/wiki)** — deeper guides (performance, streaming bodies, HTTP/3, protocol support) +- **[Website](https://titaniumproxy.com)** — product docs, [download](https://titaniumproxy.com/download), [getting started](https://titaniumproxy.com/docs/getting-started), [features](https://titaniumproxy.com/docs/features), [release notes](https://titaniumproxy.com/releases) +- **[Wiki](https://github.com/justcoding121/titanium-web-proxy/wiki)** — deeper guides (performance, streaming bodies, HTTP/3, protocol support) and a short [Features](https://github.com/justcoding121/titanium-web-proxy/wiki/Features) pointer - [Basic console proxy](examples/Titanium.Web.Proxy.Examples.Basic) - [WPF desktop example](examples/Titanium.Web.Proxy.Examples.Wpf) - [Windows service example](examples/Titanium.Web.Proxy.Examples.WindowsService) diff --git a/docs/api/Titanium.Web.Proxy.Http.Request.html b/docs/api/Titanium.Web.Proxy.Http.Request.html index c3b780634..dc1ffb9bd 100644 --- a/docs/api/Titanium.Web.Proxy.Http.Request.html +++ b/docs/api/Titanium.Web.Proxy.Http.Request.html @@ -168,7 +168,7 @@

Properties Edit this page - View Source + View Source

ExpectContinue

@@ -199,7 +199,7 @@
Property Value
Edit this page - View Source + View Source

ExpectationFailed

@@ -230,7 +230,7 @@
Property Value
Edit this page - View Source + View Source

ExpectationSucceeded

@@ -261,7 +261,7 @@
Property Value
Edit this page - View Source + View Source

ExtendedConnectProtocol

@@ -295,7 +295,7 @@
Property Value
Edit this page - View Source + View Source

HasBody

@@ -328,7 +328,7 @@
Overrides
Edit this page - View Source + View Source

HeaderText

@@ -361,7 +361,7 @@
Overrides
Edit this page - View Source + View Source

Host

@@ -394,7 +394,7 @@
Property Value
Edit this page - View Source + View Source

IsHttps

@@ -425,7 +425,7 @@
Property Value
Edit this page - View Source + View Source

IsMultipartFormData

@@ -456,7 +456,7 @@
Property Value
Edit this page - View Source + View Source

Method

@@ -487,7 +487,7 @@
Property Value
Edit this page - View Source + View Source

RequestUri

@@ -518,7 +518,7 @@
Property Value
Edit this page - View Source + View Source

RequestUriString

@@ -549,7 +549,7 @@
Property Value
Edit this page - View Source + View Source

UpgradeToWebSocket

@@ -582,7 +582,7 @@
Property Value
Edit this page - View Source + View Source

Url

@@ -620,7 +620,7 @@
Property Value
Edit this page
  • - View Source + View Source
  • diff --git a/docs/api/Titanium.Web.Proxy.Models.UpstreamHttpProtocol.html b/docs/api/Titanium.Web.Proxy.Models.UpstreamHttpProtocol.html index b8b8a6378..85a8af15c 100644 --- a/docs/api/Titanium.Web.Proxy.Models.UpstreamHttpProtocol.html +++ b/docs/api/Titanium.Web.Proxy.Models.UpstreamHttpProtocol.html @@ -114,9 +114,9 @@

    Fields the origin has also been confirmed (via a fresh probe or a cached prior result) to support HTTP/2, and the origin connection then uses whatever protocol the client ends up negotiating. When EnableHttp3 is true, a cached Alt-Svc / HTTPS/SVCB -result in Titanium.Web.Proxy.Http3.Http3OriginCapabilityCache only arms background QUIC warm-up; -outbound HTTP/3 is used once that origin is warm, otherwise the request stays on HTTP/2 or -HTTP/1.1. This is the default.

    +result in Titanium.Web.Proxy.Http3.Http3OriginCapabilityCache selects outbound HTTP/3 on the next +CONNECT or new HTTP/1.1 request (background QUIC warm-up starts when the cache is filled). +An already-open H2↔H2 MITM session is not upgraded mid-connection. This is the default.

    @@ -153,7 +153,7 @@

    Fields

    Honored from connection-level events and from UpstreamHttpProtocol in BeforeRequest. - Forced Http3 skips Auto-mode warm-up gating and fails closed with no TCP fallback. + Forced Http3 does not require an Alt-Svc / SVCB cache entry and fails closed with no TCP fallback.

    diff --git a/docs/api/Titanium.Web.Proxy.Network.CertificateManager.html b/docs/api/Titanium.Web.Proxy.Network.CertificateManager.html index b88c5e6f8..1ec960fb9 100644 --- a/docs/api/Titanium.Web.Proxy.Network.CertificateManager.html +++ b/docs/api/Titanium.Web.Proxy.Network.CertificateManager.html @@ -402,7 +402,7 @@

    Property Value
    Edit this page - View Source + View Source

    LastOsTrustResult

    @@ -829,12 +829,62 @@
    Declaration
    public void ApplyFastColdStartLeafSettings()
    + + | + Edit this page + + + View Source + + +

    ApplyUnixSslTrustAfterStoreInstall(bool)

    +

    macOS/Linux SSL trust (Keychain / NSS). May show auth UI — keep on a pumping thread. +No-op on Windows (Root store presence is trust).

    +
    +
    +
    Declaration
    +
    +
    public void ApplyUnixSslTrustAfterStoreInstall(bool machineTrusted = false)
    +
    +
    Parameters
    + + + + + + + + + + + + + + + +
    TypeNameDescription
    boolmachineTrusted
    + + | + Edit this page + + + View Source + + +

    ApplyUnixSslUntrust()

    +

    macOS/Linux Keychain/NSS untrust. May show auth UI — keep on a pumping thread.

    +
    +
    +
    Declaration
    +
    +
    public void ApplyUnixSslUntrust()
    +
    | Edit this page - View Source + View Source

    ClearRootCertificate()

    @@ -850,7 +900,7 @@
    Declaration
    Edit this page - View Source + View Source

    CreateRootCertificate(bool)

    @@ -900,7 +950,7 @@
    Returns
    Edit this page - View Source + View Source

    CreateServerCertificate(string)

    @@ -964,7 +1014,7 @@
    Declaration
    Edit this page - View Source + View Source

    EnsureRootCertificate()

    @@ -981,7 +1031,7 @@
    Declaration
    Edit this page - View Source + View Source

    EnsureRootCertificate(bool, bool, bool)

    @@ -1029,7 +1079,7 @@
    Parameters
    Edit this page - View Source + View Source

    InstallNssCertutilAndRetryUserTrust()

    @@ -1055,12 +1105,63 @@
    Returns
    + + | + Edit this page + + + View Source + + +

    InstallRootIntoCertificateStores(bool)

    +

    Installs the root into Personal + Trusted Root stores only (Windows CryptUI Yes/No on Root Add). +Does not prune orphans or run Unix Keychain/NSS trust — UI callers should finish those +off the dispatcher after CryptUI returns so Avalonia does not show Not Responding.

    +
    +
    +
    Declaration
    +
    +
    public bool InstallRootIntoCertificateStores(bool machineTrusted = false)
    +
    +
    Parameters
    + + + + + + + + + + + + + + + +
    TypeNameDescription
    boolmachineTrusted
    +
    Returns
    + + + + + + + + + + + + + +
    TypeDescription
    bool

    True when the user Root store entry was newly added.

    +
    | Edit this page - View Source + View Source

    IsOsRootStillPresent()

    @@ -1091,7 +1192,7 @@
    Returns
    Edit this page - View Source + View Source

    IsRootCertificateMachineTrusted()

    @@ -1122,7 +1223,7 @@
    Returns
    Edit this page - View Source + View Source

    IsRootCertificateUserTrusted()

    @@ -1153,7 +1254,7 @@
    Returns
    Edit this page - View Source + View Source

    IsRootInLoginKeychain()

    @@ -1180,12 +1281,67 @@
    Returns
    + + | + Edit this page + + + View Source + + +

    ListSameCommonNameRootThumbprints(StoreLocation, string?)

    +

    Read-only: Root-store thumbprints matching RootCertificateName. +Uses FindBySubjectName (not a full store enumeration) so interactive Clear/reinstall +does not sit on Busy for tens of seconds on large Windows Root stores.

    +
    +
    +
    Declaration
    +
    +
    public IReadOnlyList<string> ListSameCommonNameRootThumbprints(StoreLocation storeLocation, string? keepThumbprint = null)
    +
    +
    Parameters
    + + + + + + + + + + + + + + + + + + + + +
    TypeNameDescription
    StoreLocationstoreLocation
    stringkeepThumbprint
    +
    Returns
    + + + + + + + + + + + + + +
    TypeDescription
    IReadOnlyList<string>
    | Edit this page - View Source + View Source

    LoadRootCertificate()

    @@ -1217,7 +1373,7 @@
    Returns
    Edit this page - View Source + View Source

    LoadRootCertificate(string, string, bool, X509KeyStorageFlags)

    @@ -1286,7 +1442,7 @@
    Returns
    Edit this page - View Source + View Source

    OpenMacKeychainGuidance()

    @@ -1312,12 +1468,147 @@
    Returns
    + + | + Edit this page + + + View Source + + +

    PruneOrphanedPersonalCertificates(StoreLocation, bool)

    +

    Personal (My) store same-CN cleanup only — typically no CryptUI. Safe off the UI thread.

    +
    +
    +
    Declaration
    +
    +
    public void PruneOrphanedPersonalCertificates(StoreLocation storeLocation, bool keepCurrentThumbprint)
    +
    +
    Parameters
    + + + + + + + + + + + + + + + + + + + + +
    TypeNameDescription
    StoreLocationstoreLocation
    boolkeepCurrentThumbprint
    + + | + Edit this page + + + View Source + + +

    PruneOrphanedSameCommonNameCertificates(bool, bool)

    +

    Removes same-CN Root/My entries (may show Windows Root Delete CryptUI). Prefer a pumping +UI thread when interactive.

    +
    +
    +
    Declaration
    +
    +
    public void PruneOrphanedSameCommonNameCertificates(bool machineTrusted, bool keepCurrentThumbprint)
    +
    +
    Parameters
    + + + + + + + + + + + + + + + + + + + + +
    TypeNameDescription
    boolmachineTrusted
    boolkeepCurrentThumbprint
    + + | + Edit this page + + + View Source + + +

    RemoveCertificateByThumbprint(StoreName, StoreLocation, string)

    +

    Removes one certificate by thumbprint. Root Remove may show Windows CryptUI — UI thread.

    +
    +
    +
    Declaration
    +
    +
    public bool RemoveCertificateByThumbprint(StoreName storeName, StoreLocation storeLocation, string thumbprint)
    +
    +
    Parameters
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    TypeNameDescription
    StoreNamestoreName
    StoreLocationstoreLocation
    stringthumbprint
    +
    Returns
    + + + + + + + + + + + + + +
    TypeDescription
    bool
    | Edit this page - View Source + View Source

    RemoveTrustedRootCertificate(bool)

    @@ -1353,7 +1644,7 @@
    Parameters
    Edit this page - View Source + View Source

    RemoveTrustedRootCertificateAsAdmin(bool)

    @@ -1402,7 +1693,7 @@
    Returns
    Edit this page - View Source + View Source

    TrustRootCertificate(bool)

    @@ -1440,7 +1731,7 @@
    Parameters
    Edit this page - View Source + View Source

    TrustRootCertificateAsAdmin(bool)

    @@ -1492,7 +1783,7 @@
    Returns
    Edit this page - View Source + View Source

    VerifyOsUserSslTrust()

    diff --git a/docs/api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html b/docs/api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html index e85f5bd37..6c32a8e30 100644 --- a/docs/api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html +++ b/docs/api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html @@ -122,14 +122,84 @@
    Syntax
    public static class FirefoxCertificateTrust
    +

    Properties +

    + + | + Edit this page + + + View Source + + +

    LastEnterpriseRootsStep

    +

    Last step tag from TryEnableWindowsEnterpriseRoots() / +TryEnableEnterpriseRootsUserPref() for Inspector ux-trace +(e.g. HkcuOk, UserJsSkippedFirefoxRunning, UserJsOk, Failed).

    +
    +
    +
    Declaration
    +
    +
    public static string? LastEnterpriseRootsStep { get; }
    +
    +
    Property Value
    + + + + + + + + + + + + + +
    TypeDescription
    string

    Methods

    + + | + Edit this page + + + View Source + + +

    ClearRootTrustBestEffort(string?)

    +

    Clears enterprise-roots policy/prefs (HKCU / user.js). Does not run NSS +certutil — that can hang for minutes on a locked Firefox profile and blocked +Clear/reinstall on "Finishing root CA removal…". Use Trust/Untrust Firefox for NSS.

    +
    +
    +
    Declaration
    +
    +
    public static void ClearRootTrustBestEffort(string? friendlyName)
    +
    +
    Parameters
    + + + + + + + + + + + + + + + +
    TypeNameDescription
    stringfriendlyName
    | Edit this page - View Source + View Source

    IsFirefoxProcessRunning()

    @@ -160,7 +230,7 @@
    Returns
    Edit this page - View Source + View Source

    IsFirefoxProfilePresent()

    @@ -191,7 +261,7 @@
    Returns
    Edit this page - View Source + View Source

    TrustDefaultProfile(X509Certificate2, string)

    @@ -245,7 +315,7 @@
    Returns
    Edit this page - View Source + View Source

    TryClearWindowsEnterpriseRoots()

    @@ -276,7 +346,7 @@
    Returns
    Edit this page - View Source + View Source

    TryEnableEnterpriseRootsUserPref()

    @@ -309,7 +379,7 @@
    Returns
    Edit this page - View Source + View Source

    TryEnableWindowsEnterpriseRoots()

    @@ -342,7 +412,7 @@
    Returns
    Edit this page - View Source + View Source

    TryRequestFirefoxQuit(TimeSpan?)

    @@ -391,7 +461,7 @@
    Returns
    Edit this page - View Source + View Source

    TryResolveDefaultProfileDirectory(out string, out string?)

    @@ -444,7 +514,7 @@
    Returns
    Edit this page - View Source + View Source

    UntrustDefaultProfile(string)

    diff --git a/docs/api/Titanium.Web.Proxy.ProxyServer.html b/docs/api/Titanium.Web.Proxy.ProxyServer.html index 5d791f4a5..f9e92e576 100644 --- a/docs/api/Titanium.Web.Proxy.ProxyServer.html +++ b/docs/api/Titanium.Web.Proxy.ProxyServer.html @@ -259,7 +259,7 @@

    Fields Edit this page - View Source + View Source

    DefaultViaHeaderPseudonym

    Default Via header pseudonym (RFC 9110 §7.6.3). Used by ViaHeaderPseudonym @@ -292,7 +292,7 @@

    Properties Edit this page - View Source + View Source

    AdmittedClientConnectionCount

    @@ -326,7 +326,7 @@
    Property Value
    Edit this page - View Source + View Source

    BlockPrivateNetworkDestinations

    @@ -378,7 +378,7 @@
    Property Value
    Edit this page - View Source + View Source

    BufferPool

    @@ -412,7 +412,7 @@
    Property Value
    Edit this page - View Source + View Source

    CertificateManager

    @@ -443,7 +443,7 @@
    Property Value
    Edit this page - View Source + View Source

    CheckCertificateRevocation

    @@ -475,7 +475,7 @@
    Property Value
    Edit this page - View Source + View Source

    ClientConnectionCount

    @@ -507,7 +507,7 @@
    Property Value
    Edit this page - View Source + View Source

    ClientHeaderTimeoutSeconds

    @@ -547,7 +547,7 @@
    Property Value
    Edit this page - View Source + View Source

    CompatibilityMode100Continue

    @@ -585,7 +585,7 @@
    Property Value
    Edit this page - View Source + View Source

    ConnectTimeOutSeconds

    @@ -617,7 +617,7 @@
    Property Value
    Edit this page - View Source + View Source

    ConnectionTimeOutSeconds

    @@ -650,7 +650,7 @@
    Property Value
    Edit this page - View Source + View Source

    CustomUpStreamProxyFailureFunc

    @@ -682,7 +682,7 @@
    Property Value
    Edit this page - View Source + View Source

    DecryptFailureBypassMaxEntries

    @@ -713,7 +713,7 @@
    Property Value
    Edit this page - View Source + View Source

    DecryptFailureBypassThreshold

    @@ -746,7 +746,7 @@
    Property Value
    Edit this page - View Source + View Source

    DecryptFailureBypassTtl

    @@ -777,7 +777,7 @@
    Property Value
    Edit this page - View Source + View Source

    DnsServerEndPoint

    @@ -817,7 +817,7 @@
    Property Value
    Edit this page - View Source + View Source

    Enable100ContinueBehaviour

    @@ -850,7 +850,7 @@
    Property Value
    Edit this page - View Source + View Source

    EnableConnectionPool

    @@ -892,7 +892,7 @@
    Property Value
    Edit this page - View Source + View Source

    EnableDecryptFailureBypass

    @@ -927,7 +927,7 @@
    Property Value
    Edit this page - View Source + View Source

    EnableHttp2

    @@ -971,7 +971,7 @@
    Property Value
    Edit this page - View Source + View Source

    EnableHttp3

    @@ -984,10 +984,10 @@

    Auto (default), a cached Alt-Svc / HTTPS/SVCB - capability only arms background QUIC warm-up. Outbound HTTP/3 is used once that origin - is warm; until then the request stays on HTTP/2 or HTTP/1.1. Forced - Http3 skips warm-up gating and fails closed with no - TCP fallback. + capability selects outbound HTTP/3 on the next CONNECT or new HTTP/1.1 request. + Background QUIC warm-up starts when the cache is filled so that handshake is often + already done. An already-open H2↔H2 MITM session is not upgraded mid-connection. + Forced Http3 fails closed with no TCP fallback.

    Requires MsQuic native library and a supported operating-system version (IsSupported). Setting to true with @@ -1027,7 +1027,7 @@

    Property Value
    Edit this page - View Source + View Source

    EnableHttpInterception

    @@ -1060,7 +1060,7 @@
    Property Value
    Edit this page - View Source + View Source

    EnableHttpsSvcbDnsDiscovery

    @@ -1102,7 +1102,7 @@
    Property Value
    Edit this page - View Source + View Source

    EnableIpv6UnreachableSoftSkip

    @@ -1137,7 +1137,7 @@
    Property Value
    Edit this page - View Source + View Source

    EnableQpackDynamicTable

    @@ -1172,7 +1172,7 @@
    Property Value
    Edit this page - View Source + View Source

    EnableRequestTimingCapture

    @@ -1219,7 +1219,7 @@
    Property Value
    Edit this page - View Source + View Source

    EnableRfc8441

    @@ -1265,7 +1265,7 @@
    Property Value
    Edit this page - View Source + View Source

    EnableTcpKeepAlive

    @@ -1297,7 +1297,7 @@
    Property Value
    Edit this page - View Source + View Source

    EnableTcpServerConnectionPrefetch

    @@ -1335,7 +1335,7 @@
    Property Value
    Edit this page - View Source + View Source

    EnableWinAuth

    @@ -1368,7 +1368,7 @@
    Property Value
    Edit this page - View Source + View Source

    EndpointAdmissionRejectionCount

    @@ -1400,7 +1400,7 @@
    Property Value
    Edit this page - View Source + View Source

    ForwardToUpstreamGateway

    @@ -1432,7 +1432,7 @@
    Property Value
    Edit this page - View Source + View Source

    GetCustomUpStreamProxyFunc

    @@ -1464,7 +1464,7 @@
    Property Value
    Edit this page - View Source + View Source

    GlobalAdmissionRejectionCount

    @@ -1496,7 +1496,7 @@
    Property Value
    Edit this page - View Source + View Source

    Http3ClientConnectionCount

    @@ -1527,7 +1527,7 @@
    Property Value
    Edit this page - View Source + View Source

    Http3ServerConnectionCount

    @@ -1559,7 +1559,7 @@
    Property Value
    Edit this page - View Source + View Source

    IdleReadTimeoutSeconds

    @@ -1592,7 +1592,7 @@
    Property Value
    Edit this page - View Source + View Source

    IdleWriteTimeoutSeconds

    @@ -1625,7 +1625,7 @@
    Property Value
    Edit this page - View Source + View Source

    IgnoreServerCertificateErrors

    @@ -1659,7 +1659,7 @@
    Property Value
    Edit this page - View Source + View Source

    ListenerBackLog

    @@ -1690,7 +1690,7 @@
    Property Value
    Edit this page - View Source + View Source

    Logger

    @@ -1722,7 +1722,7 @@
    Property Value
    Edit this page - View Source + View Source

    Logging

    @@ -1763,7 +1763,7 @@
    Property Value
    Edit this page - View Source + View Source

    MaxBufferedBodyBytes

    @@ -1798,7 +1798,7 @@
    Property Value
    Edit this page - View Source + View Source

    MaxCachedConnections

    @@ -1852,7 +1852,7 @@
    Exceptions
    Edit this page - View Source + View Source

    MaxConcurrentClientConnections

    @@ -1892,7 +1892,7 @@
    Property Value
    Edit this page - View Source + View Source

    MaxConcurrentHttp11HttpsOriginCreates

    @@ -1943,7 +1943,7 @@
    Exceptions
    Edit this page - View Source + View Source

    MaxDecodedHeaderListBytes

    @@ -1978,7 +1978,7 @@
    Property Value
    Edit this page - View Source + View Source

    MaxWebSocketFramePayloadBytes

    @@ -2015,7 +2015,7 @@
    Property Value
    Edit this page - View Source + View Source

    NetworkFailureRetryAttempts

    @@ -2046,7 +2046,7 @@
    Property Value
    Edit this page - View Source + View Source

    NoDelay

    @@ -2078,7 +2078,7 @@
    Property Value
    Edit this page - View Source + View Source

    OriginHttpVersionPolicy

    @@ -2117,7 +2117,7 @@
    Property Value
    Edit this page - View Source + View Source

    PolicyModes

    @@ -2160,7 +2160,7 @@
    Property Value
    Edit this page - View Source + View Source

    Profile

    @@ -2206,7 +2206,7 @@
    Property Value
    Edit this page - View Source + View Source

    ProxyAuthenticationRealm

    @@ -2237,7 +2237,7 @@
    Property Value
    Edit this page - View Source + View Source

    ProxyAuthenticationSchemes

    @@ -2270,7 +2270,7 @@
    Property Value
    Edit this page - View Source + View Source

    ProxyBasicAuthenticateFunc

    @@ -2303,7 +2303,7 @@
    Property Value
    Edit this page - View Source + View Source

    ProxyEndPoints

    @@ -2334,7 +2334,7 @@
    Property Value
    Edit this page - View Source + View Source

    ProxyRunning

    @@ -2365,7 +2365,7 @@
    Property Value
    Edit this page - View Source + View Source

    ProxySchemeAuthenticateFunc

    @@ -2399,7 +2399,7 @@
    Property Value
    Edit this page - View Source + View Source

    RequestTimeoutSeconds

    @@ -2433,7 +2433,7 @@
    Property Value
    Edit this page - View Source + View Source

    ResourceLimits

    @@ -2474,7 +2474,7 @@
    Property Value
    Edit this page - View Source + View Source

    ResponseHeaderTimeoutSeconds

    @@ -2514,7 +2514,7 @@
    Property Value
    Edit this page - View Source + View Source

    ReuseSocket

    @@ -2547,7 +2547,7 @@
    Property Value
    Edit this page - View Source + View Source

    ReverseProxy

    @@ -2579,7 +2579,7 @@
    Property Value
    Edit this page - View Source + View Source

    ServerConnectionCount

    @@ -2611,7 +2611,7 @@
    Property Value
    Edit this page - View Source + View Source

    ShouldInterceptHttp

    @@ -2645,7 +2645,7 @@
    Property Value
    Edit this page - View Source + View Source

    SupportedServerSslProtocols

    @@ -2686,7 +2686,7 @@
    Property Value
    Edit this page - View Source + View Source

    SupportedSslProtocols

    @@ -2724,7 +2724,7 @@
    Property Value
    Edit this page - View Source + View Source

    TcpTimeWaitSeconds

    @@ -2760,7 +2760,7 @@
    Property Value
    Edit this page - View Source + View Source

    ThreadPoolWorkerThread

    @@ -2793,7 +2793,7 @@
    Property Value
    Edit this page - View Source + View Source

    UpStreamEndPoint

    @@ -2828,7 +2828,7 @@
    Property Value
    Edit this page - View Source + View Source

    UpStreamEndPointIPv4

    @@ -2860,7 +2860,7 @@
    Property Value
    Edit this page - View Source + View Source

    UpStreamEndPointIPv6

    @@ -2892,7 +2892,7 @@
    Property Value
    Edit this page - View Source + View Source

    UpStreamHttpProxy

    @@ -2923,7 +2923,7 @@
    Property Value
    Edit this page - View Source + View Source

    UpStreamHttpsProxy

    @@ -2954,7 +2954,7 @@
    Property Value
    Edit this page - View Source + View Source

    UpstreamProxyConfigurationScript

    @@ -2985,7 +2985,7 @@
    Property Value
    Edit this page - View Source + View Source

    ViaHeaderPseudonym

    @@ -3019,7 +3019,7 @@
    Property Value
    Edit this page - View Source + View Source

    WinAuthCredentialsProvider

    @@ -3089,7 +3089,7 @@
    Parameters
    Edit this page - View Source + View Source

    ApplyLoggingConfiguration()

    @@ -3110,7 +3110,7 @@
    Declaration
    Edit this page - View Source + View Source

    ClearDecryptFailureBypass()

    @@ -3251,12 +3251,61 @@
    Parameters
    + + | + Edit this page + + + View Source + + +

    ForceDecryptFailureBypass(string)

    +

    Marks host as actively bypassed (same as a forced learn after origin TLS failure). +Raises DecryptFailureBypassChanged when bypass newly becomes active.

    +
    +
    +
    Declaration
    +
    +
    public bool ForceDecryptFailureBypass(string host)
    +
    +
    Parameters
    + + + + + + + + + + + + + + + +
    TypeNameDescription
    stringhost
    +
    Returns
    + + + + + + + + + + + + + +
    TypeDescription
    bool
    | Edit this page - View Source + View Source

    GetDecryptFailureBypassEntries()

    @@ -3287,7 +3336,7 @@
    Returns
    Edit this page - View Source + View Source

    RemoveDecryptFailureBypass(string)

    @@ -3620,7 +3669,7 @@
    Parameters
    Edit this page - View Source + View Source

    SetHttp3Enabled(bool)

    @@ -3672,7 +3721,7 @@
    Returns
    Edit this page - View Source + View Source

    ShouldBypassDecryptForLearnedHost(string?)

    @@ -3913,7 +3962,7 @@
    Returns
    Edit this page - View Source + View Source

    TryEnableHttp3IfSupported()

    @@ -4039,7 +4088,7 @@

    Events Edit this page - View Source + View Source

    AfterResponse

    Intercept after response event from server.

    @@ -4069,7 +4118,7 @@
    Event Type
    Edit this page - View Source + View Source

    BeforeRequest

    Intercept request event to server.

    @@ -4099,7 +4148,7 @@
    Event Type
    Edit this page - View Source + View Source

    BeforeResponse

    Intercept response event from server.

    @@ -4129,7 +4178,7 @@
    Event Type
    Edit this page - View Source + View Source

    BeforeUpStreamConnectRequest

    Intercept connect request sent to upstream proxy.

    @@ -4159,7 +4208,7 @@
    Event Type
    Edit this page - View Source + View Source

    ClientCertificateSelectionCallback

    Event to override client certificate selection during mutual SSL authentication.

    @@ -4189,7 +4238,7 @@
    Event Type
    Edit this page - View Source + View Source

    ClientConnectionCountChanged

    Event occurs when client connection count changed.

    @@ -4219,7 +4268,7 @@
    Event Type
    Edit this page - View Source + View Source

    DecryptFailureBypassChanged

    Raised when a host becomes actively bypassed (threshold reached or same-CONNECT mark). @@ -4250,7 +4299,7 @@

    Event Type
    Edit this page - View Source + View Source

    Http3ClientConnectionCountChanged

    Event occurs when inbound HTTP/3 client connection count changed.

    @@ -4280,7 +4329,7 @@
    Event Type
    Edit this page - View Source + View Source

    Http3ServerConnectionCountChanged

    Event occurs when upstream HTTP/3 server connection count changed.

    @@ -4310,7 +4359,7 @@
    Event Type
    Edit this page - View Source + View Source

    OnClientConnectionCreate

    Customize TcpClient used for client connection upon create.

    @@ -4340,7 +4389,7 @@
    Event Type
    Edit this page - View Source + View Source

    OnRequestBodyWrite

    Intercept request body send event to server. @@ -4372,7 +4421,7 @@

    Event Type
    Edit this page - View Source + View Source

    OnResponseBodyWrite

    Intercept response body send event to client. @@ -4404,7 +4453,7 @@

    Event Type
    Edit this page - View Source + View Source

    OnServerConnectionCreate

    Customize TcpClient used for server connection upon create.

    @@ -4434,7 +4483,7 @@
    Event Type
    Edit this page - View Source + View Source

    ServerCertificateValidationCallback

    Event to override the default verification logic of remote SSL certificate received during authentication.

    @@ -4464,7 +4513,7 @@
    Event Type
    Edit this page - View Source + View Source

    ServerConnectionCountChanged

    Event occurs when server connection count changed.

    diff --git a/docs/index.json b/docs/index.json index 037cae34e..309459583 100644 --- a/docs/index.json +++ b/docs/index.json @@ -432,7 +432,7 @@ "api/Titanium.Web.Proxy.Models.UpstreamHttpProtocol.html": { "href": "api/Titanium.Web.Proxy.Models.UpstreamHttpProtocol.html", "title": "Enum UpstreamHttpProtocol | Titanium Web Proxy", - "summary": "Enum UpstreamHttpProtocol Controls which HTTP version the proxy uses on its own connection to the origin server, independent of which HTTP version the client used to talk to the proxy. Set a connection-level default on UpstreamHttpProtocol (during BeforeTunnelConnectRequest), UpstreamHttpProtocol (during BeforeSslAuthenticate), or UpstreamHttpProtocol (during BeforeQuicAuthenticate). Per-request overrides are available via UpstreamHttpProtocol in BeforeRequest. Namespace: Titanium.Web.Proxy.Models Assembly: Titanium.Web.Proxy.dll Syntax public enum UpstreamHttpProtocol Fields Name Description Auto Couple the origin protocol to the client protocol: HTTP/2 is only ever offered to the client when the origin has also been confirmed (via a fresh probe or a cached prior result) to support HTTP/2, and the origin connection then uses whatever protocol the client ends up negotiating. When EnableHttp3 is true, a cached Alt-Svc / HTTPS/SVCB result in Titanium.Web.Proxy.Http3.Http3OriginCapabilityCache only arms background QUIC warm-up; outbound HTTP/3 is used once that origin is warm, otherwise the request stays on HTTP/2 or HTTP/1.1. This is the default. Http11 Always use HTTP/1.1 on the connection to the origin, regardless of what the client negotiates with the proxy. When AllowHttpProtocolTranslation/ AllowHttpProtocolTranslation is left at its default of false, the client is simply never offered \"h2\" via ALPN either, so it transparently negotiates HTTP/1.1 too and no translation is ever required. Setting it to true instead allows the client to negotiate HTTP/2 while the origin connection stays HTTP/1.1, which requires bridging client h2 streams onto HTTP/1.1 origin requests. Http2 Always use HTTP/2 on the connection to the origin. Without ForwardCleartext, the origin must negotiate h2 via TLS ALPN. With ForwardCleartext, the origin connection is cleartext HTTP/2 prior-knowledge (h2c) instead. A translation bridge cannot fabricate HTTP/2 at an origin that lacks it. When the client itself does not negotiate HTTP/2, reconciling that with a confirmed HTTP/2 origin connection requires AllowHttpProtocolTranslation/ AllowHttpProtocolTranslation to bridge HTTP/1.1 client requests onto the HTTP/2 origin connection. Http3 Always use HTTP/3 (QUIC) on the connection to the origin. Fails the stream with ProxyConnectException if HTTP/3 cannot be established — no fallback to HTTP/2 or HTTP/1.1. Symmetric with Http2: origin must support QUIC/h3 or the request fails. When AllowHttpProtocolTranslation is true, a non-H3 inbound client connection may still be bridged onto the H3 origin stream. Honored from connection-level events and from UpstreamHttpProtocol in BeforeRequest. Forced Http3 skips Auto-mode warm-up gating and fails closed with no TCP fallback." + "summary": "Enum UpstreamHttpProtocol Controls which HTTP version the proxy uses on its own connection to the origin server, independent of which HTTP version the client used to talk to the proxy. Set a connection-level default on UpstreamHttpProtocol (during BeforeTunnelConnectRequest), UpstreamHttpProtocol (during BeforeSslAuthenticate), or UpstreamHttpProtocol (during BeforeQuicAuthenticate). Per-request overrides are available via UpstreamHttpProtocol in BeforeRequest. Namespace: Titanium.Web.Proxy.Models Assembly: Titanium.Web.Proxy.dll Syntax public enum UpstreamHttpProtocol Fields Name Description Auto Couple the origin protocol to the client protocol: HTTP/2 is only ever offered to the client when the origin has also been confirmed (via a fresh probe or a cached prior result) to support HTTP/2, and the origin connection then uses whatever protocol the client ends up negotiating. When EnableHttp3 is true, a cached Alt-Svc / HTTPS/SVCB result in Titanium.Web.Proxy.Http3.Http3OriginCapabilityCache selects outbound HTTP/3 on the next CONNECT or new HTTP/1.1 request (background QUIC warm-up starts when the cache is filled). An already-open H2↔H2 MITM session is not upgraded mid-connection. This is the default. Http11 Always use HTTP/1.1 on the connection to the origin, regardless of what the client negotiates with the proxy. When AllowHttpProtocolTranslation/ AllowHttpProtocolTranslation is left at its default of false, the client is simply never offered \"h2\" via ALPN either, so it transparently negotiates HTTP/1.1 too and no translation is ever required. Setting it to true instead allows the client to negotiate HTTP/2 while the origin connection stays HTTP/1.1, which requires bridging client h2 streams onto HTTP/1.1 origin requests. Http2 Always use HTTP/2 on the connection to the origin. Without ForwardCleartext, the origin must negotiate h2 via TLS ALPN. With ForwardCleartext, the origin connection is cleartext HTTP/2 prior-knowledge (h2c) instead. A translation bridge cannot fabricate HTTP/2 at an origin that lacks it. When the client itself does not negotiate HTTP/2, reconciling that with a confirmed HTTP/2 origin connection requires AllowHttpProtocolTranslation/ AllowHttpProtocolTranslation to bridge HTTP/1.1 client requests onto the HTTP/2 origin connection. Http3 Always use HTTP/3 (QUIC) on the connection to the origin. Fails the stream with ProxyConnectException if HTTP/3 cannot be established — no fallback to HTTP/2 or HTTP/1.1. Symmetric with Http2: origin must support QUIC/h3 or the request fails. When AllowHttpProtocolTranslation is true, a non-H3 inbound client connection may still be bridged onto the H3 origin stream. Honored from connection-level events and from UpstreamHttpProtocol in BeforeRequest. Forced Http3 does not require an Alt-Svc / SVCB cache entry and fails closed with no TCP fallback." }, "api/Titanium.Web.Proxy.Models.WinAuthCredentials.html": { "href": "api/Titanium.Web.Proxy.Models.WinAuthCredentials.html", @@ -457,7 +457,7 @@ "api/Titanium.Web.Proxy.Network.CertificateManager.html": { "href": "api/Titanium.Web.Proxy.Network.CertificateManager.html", "title": "Class CertificateManager | Titanium Web Proxy", - "summary": "Class CertificateManager A class to manage SSL certificates used by this proxy server. Inheritance object CertificateManager Implements IDisposable Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Network Assembly: Titanium.Web.Proxy.dll Syntax public sealed class CertificateManager : IDisposable Properties | Edit this page View Source AreInteractiveRootStoreMutationsSuppressed True when Root-store Add/Remove should be skipped to avoid modal CryptUI prompts (static flag, CI env, or TITANIUM_SKIP_ROOT_STORE_UI=1). Declaration public static bool AreInteractiveRootStoreMutationsSuppressed { get; } Property Value Type Description bool | Edit this page View Source CertificateCacheTimeOutMinutes Minutes certificates should be kept in cache when not used. Declaration public int CertificateCacheTimeOutMinutes { get; set; } Property Value Type Description int | Edit this page View Source CertificateEngine Selects the certificate generation engine. Default is BouncyCastle on all platforms. On non-Windows runtimes, DefaultWindows is coerced to BouncyCastle; both BouncyCastle engines are supported. Declaration public CertificateEngine CertificateEngine { get; set; } Property Value Type Description CertificateEngine | Edit this page View Source CertificateGraceDays Number of days by which the certificate's NotBefore timestamp is backdated relative to the current UTC time. A small backdate (the default is 2 days) compensates for minor clock-skew between the proxy machine and clients; it is not necessary to backdate by a year. The total certificate lifetime is CertificateValidDays + CertificateGraceDays. Chrome 70+ and iOS 14+ cap this at 398 days for TLS leaf certificates. Declaration public int CertificateGraceDays { get; set; } Property Value Type Description int | Edit this page View Source CertificateStorage The fake certificate cache storage. The default implementation stores leaf certificates in a crts subdirectory of the per-user Titanium.Web.Proxy directory (%LocalAppData% on Windows, ApplicationData on Linux/macOS). Implement ICertificateCache and assign a concrete class here to customize. Declaration public ICertificateCache CertificateStorage { get; set; } Property Value Type Description ICertificateCache | Edit this page View Source CertificateValidDays Number of days generated HTTPS leaf certificates are valid for, measured forward from the moment of creation. The certificate's NotBefore is set to UtcNow - CertificateGraceDays, so the effective total validity window (NotAfter − NotBefore) equals CertificateValidDays + CertificateGraceDays. Chrome 70+ and iOS 14+ reject certificates whose total validity window exceeds 398 days. To stay within that limit, keep CertificateValidDays + CertificateGraceDays <= 398. The default value of 396, combined with the default grace of 2, equals exactly 398 days total. Declaration public int CertificateValidDays { get; set; } Property Value Type Description int | Edit this page View Source DisableWildCardCertificates When true, issue per-host certificates instead of *.parent.tld wildcards. Default false (wildcards enabled where applicable). Declaration public bool DisableWildCardCertificates { get; set; } Property Value Type Description bool | Edit this page View Source IntermediateCertificates Additional certificates to send to clients as part of the TLS certificate chain. Use this when RootCertificate is an intermediate CA rather than the trust anchor: set this to the ordered list of intermediate certificates between the signing certificate and the client-trusted root so that clients can build a complete verified chain. When RootCertificate is not self-signed it is automatically included in the chain even if this collection is empty; any certificates in this collection are appended after it. Declaration public X509Certificate2Collection? IntermediateCertificates { get; set; } Property Value Type Description X509Certificate2Collection | Edit this page View Source LastOsTrustResult Last OS/browser trust outcome from TrustRootCertificate(bool) / related helpers. Declaration public CertificateOsTrustResult? LastOsTrustResult { get; } Property Value Type Description CertificateOsTrustResult | Edit this page View Source LeafCertificateKeyAlgorithm Key algorithm for generated leaf certificates. Honoured by the BouncyCastle engines; the Windows engine always issues RSA. Defaults to Rsa2048. Switching to EcdsaP256 makes generating a certificate for a not-yet-seen host roughly fifty times cheaper, which is the single largest cost the proxy adds to a first visit. Only clients that accept ECDSA server certificates can be intercepted afterwards. Declaration public CertificateKeyAlgorithm LeafCertificateKeyAlgorithm { get; set; } Property Value Type Description CertificateKeyAlgorithm | Edit this page View Source LeafRsaKeyPairBufferSize How many RSA-2048 leaf private keys to keep ready in a background-refilled buffer so first visits do not pay key-generation cost on the CONNECT that needs the certificate. Defaults to 8. Set to 0 to disable buffering (keys are generated on demand). Only applies when LeafCertificateKeyAlgorithm is Rsa2048. ECDSA P-256 keys are cheap enough that they are always generated inline. The buffer is process-wide and shared by every CertificateManager instance. Declaration public static int LeafRsaKeyPairBufferSize { get; set; } Property Value Type Description int | Edit this page View Source OverwritePfxFile Overwrite Root certificate file. true : replace an existing .pfx file if password is incorrect or if RootCertificate = null. Declaration public bool OverwritePfxFile { get; set; } Property Value Type Description bool | Edit this page View Source PfxFilePath Name(path) of the Root certificate file. Set the name or path of the .pfx file. When empty, the file is named rootCert.pfx. Relative or empty values are resolved under the per-user Titanium.Web.Proxy directory (%LocalAppData% on Windows, ApplicationData on Linux/macOS). Absolute paths are honored as-is. Declaration public string PfxFilePath { get; set; } Property Value Type Description string | Edit this page View Source PfxPassword Password of the Root certificate file. Set a password for the .pfx file Declaration public string PfxPassword { get; set; } Property Value Type Description string | Edit this page View Source RootCertificate The root certificate. Declaration public X509Certificate2? RootCertificate { get; set; } Property Value Type Description X509Certificate2 | Edit this page View Source RootCertificateIssuerName Name of the root certificate issuer. (This is valid only when RootCertificate property is not set.) Declaration public string RootCertificateIssuerName { get; set; } Property Value Type Description string | Edit this page View Source RootCertificateName Subject/CN name used when generating a root certificate. (This is valid only when RootCertificate property is not set.) If no certificate is provided then a default root certificate will be created and used. Persistence uses PfxFilePath / CertificateStorage under the per-user Titanium.Web.Proxy directory (not the process executable directory). Declaration public string RootCertificateName { get; set; } Property Value Type Description string | Edit this page View Source SaveFakeCertificates When true, persist generated leaf certificates via CertificateStorage so subsequent runs can reload them instead of regenerating. Declaration public bool SaveFakeCertificates { get; set; } Property Value Type Description bool | Edit this page View Source StorageFlag Adjust behaviour when certificates are saved to filesystem. Declaration public X509KeyStorageFlags StorageFlag { get; set; } Property Value Type Description X509KeyStorageFlags | Edit this page View Source SuppressInteractiveRootStoreMutations When true, skip Root Add/Remove that trigger Windows CryptUI \"Root Certificate Store\" Yes/No dialogs (which hang headless CI and unattended dotnet test). Personal (My) mutations still run. Also treated as true when CI, GITHUB_ACTIONS, TF_BUILD, or TITANIUM_SKIP_ROOT_STORE_UI=1 is set. Opt back in for intentional interactive Install CA (e.g. local E2E-Slow Chrome) by setting this to false in a process that does not set those env vars. Declaration public static bool SuppressInteractiveRootStoreMutations { get; set; } Property Value Type Description bool Methods | Edit this page View Source ApplyFastColdStartLeafSettings() Fast first-visit MITM for modern TLS clients (browsers, current HttpClient): BouncyCastleFast, ECDSA P-256 leaves, and SaveFakeCertificates enabled. The root CA stays RSA. Library Balanced still defaults to RSA-2048 leaves for widest compatibility. Call this from Inspector, CLI, and desktop MITM hosts where clients are known to accept ECDSA server certificates — RSA leaf generation is the dominant cold-start cost when a page hits many not-yet-seen hosts (often ~1 s per host). Declaration public void ApplyFastColdStartLeafSettings() | Edit this page View Source ClearRootCertificate() Clear the root certificate and cache. Declaration public void ClearRootCertificate() | Edit this page View Source CreateRootCertificate(bool) Attempts to create a RootCertificate. Declaration public bool CreateRootCertificate(bool persistToFile = true) Parameters Type Name Description bool persistToFile if set to true try to load/save the certificate from rootCert.pfx. Returns Type Description bool true if succeeded, else false. | Edit this page View Source CreateServerCertificate(string) Creates a server certificate signed by the root certificate. Declaration public Task CreateServerCertificate(string certificateName) Parameters Type Name Description string certificateName Returns Type Description Task | Edit this page View Source Dispose() Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. Declaration public void Dispose() | Edit this page View Source EnsureRootCertificate() Ensure certificates are setup (creates root if required). Also makes root certificate trusted based on initial setup from proxy constructor for user/machine trust. Declaration public void EnsureRootCertificate() | Edit this page View Source EnsureRootCertificate(bool, bool, bool) Ensure certificates are setup (creates root if required). Also makes root certificate trusted based on provided parameters. Declaration public void EnsureRootCertificate(bool userTrustRootCertificate, bool machineTrustRootCertificate, bool trustRootCertificateAsAdmin = false) Parameters Type Name Description bool userTrustRootCertificate Trust in the current-user stores. Prefer true for interactive MITM; false for fully opt-in trust. bool machineTrustRootCertificate Also trust in local-machine stores (needs elevation). Implies user trust. Prefer false unless installing for a service / all users. bool trustRootCertificateAsAdmin Elevate via UAC when installing (Windows only). Defaults to false. | Edit this page View Source InstallNssCertutilAndRetryUserTrust() Installs NSS certutil (Linux package or macOS Homebrew) after user consent, then retries user SSL trust. Declaration public CertificateOsTrustResult InstallNssCertutilAndRetryUserTrust() Returns Type Description CertificateOsTrustResult | Edit this page View Source IsOsRootStillPresent() True when a Titanium root (current hash or known CN) remains in login or System keychain. Declaration public bool IsOsRootStillPresent() Returns Type Description bool | Edit this page View Source IsRootCertificateMachineTrusted() Determines whether the root certificate is machine trusted. Declaration public bool IsRootCertificateMachineTrusted() Returns Type Description bool | Edit this page View Source IsRootCertificateUserTrusted() Determines whether the root certificate is trusted. Declaration public bool IsRootCertificateUserTrusted() Returns Type Description bool | Edit this page View Source IsRootInLoginKeychain() Best-effort: true when the current root appears in the macOS login keychain. Does not imply SSL Always Trust — use VerifyOsUserSslTrust(). Declaration public bool IsRootInLoginKeychain() Returns Type Description bool | Edit this page View Source LoadRootCertificate() Loads the root certificate via CertificateStorage (default: per-user Titanium.Web.Proxy directory, file name from PfxFilePath or rootCert.pfx). Declaration public X509Certificate2? LoadRootCertificate() Returns Type Description X509Certificate2 | Edit this page View Source LoadRootCertificate(string, string, bool, X509KeyStorageFlags) Manually load a Root certificate file from give path (.pfx file). Declaration public bool LoadRootCertificate(string pfxFilePath, string password, bool overwritePfXFile = true, X509KeyStorageFlags storageFlag = X509KeyStorageFlags.Exportable) Parameters Type Name Description string pfxFilePath Set the name or path of the .pfx file. When empty, the file is named rootCert.pfx under the per-user Titanium.Web.Proxy directory. Absolute paths are honored as-is. string password Set a password for the .pfx file. bool overwritePfXFile true : replace an existing .pfx file if password is incorrect or if RootCertificate==null. X509KeyStorageFlags storageFlag Returns Type Description bool true if succeeded, else false. | Edit this page View Source OpenMacKeychainGuidance() Opens Keychain Access (and a temp .cer) for manual Always Trust on macOS. Declaration public string? OpenMacKeychainGuidance() Returns Type Description string | Edit this page View Source RemoveTrustedRootCertificate(bool) Removes the trusted certificates from the current-user Personal and Trusted Root stores, and optionally also from the local-machine Personal and Trusted Root stores. Declaration public void RemoveTrustedRootCertificate(bool machineTrusted = false) Parameters Type Name Description bool machineTrusted When true, also remove from local-machine stores (needs elevation; fails silently otherwise). Pass the same value used when trusting. | Edit this page View Source RemoveTrustedRootCertificateAsAdmin(bool) Removes the trusted certificates from user store, optionally also from machine store Declaration public bool RemoveTrustedRootCertificateAsAdmin(bool machineTrusted = false) Parameters Type Name Description bool machineTrusted Returns Type Description bool Should also remove from machine store? | Edit this page View Source TrustRootCertificate(bool) Trusts the root certificate in the current-user Personal and Trusted Root stores, and optionally also in the local-machine Personal and Trusted Root stores. Declaration public void TrustRootCertificate(bool machineTrusted = false) Parameters Type Name Description bool machineTrusted When true, also install machine-wide trust (LocalMachine on Windows; System.keychain / system CA store on macOS/Linux, with an admin prompt). Defaults to false — user-only trust is the recommended default. Check LastOsTrustResult and VerifyOsUserSslTrust() after calling. | Edit this page View Source TrustRootCertificateAsAdmin(bool) Puts the certificate to the user store, optionally also to the machine store, prompting with UAC when elevation is required. Works only on Windows. Declaration public bool TrustRootCertificateAsAdmin(bool machineTrusted = false) Parameters Type Name Description bool machineTrusted When true, elevate to install into local-machine stores. Defaults to false (user store only). Returns Type Description bool True if success. | Edit this page View Source VerifyOsUserSslTrust() Re-checks macOS/Linux user SSL trust for the current root. Declaration public bool VerifyOsUserSslTrust() Returns Type Description bool Implements IDisposable" + "summary": "Class CertificateManager A class to manage SSL certificates used by this proxy server. Inheritance object CertificateManager Implements IDisposable Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Network Assembly: Titanium.Web.Proxy.dll Syntax public sealed class CertificateManager : IDisposable Properties | Edit this page View Source AreInteractiveRootStoreMutationsSuppressed True when Root-store Add/Remove should be skipped to avoid modal CryptUI prompts (static flag, CI env, or TITANIUM_SKIP_ROOT_STORE_UI=1). Declaration public static bool AreInteractiveRootStoreMutationsSuppressed { get; } Property Value Type Description bool | Edit this page View Source CertificateCacheTimeOutMinutes Minutes certificates should be kept in cache when not used. Declaration public int CertificateCacheTimeOutMinutes { get; set; } Property Value Type Description int | Edit this page View Source CertificateEngine Selects the certificate generation engine. Default is BouncyCastle on all platforms. On non-Windows runtimes, DefaultWindows is coerced to BouncyCastle; both BouncyCastle engines are supported. Declaration public CertificateEngine CertificateEngine { get; set; } Property Value Type Description CertificateEngine | Edit this page View Source CertificateGraceDays Number of days by which the certificate's NotBefore timestamp is backdated relative to the current UTC time. A small backdate (the default is 2 days) compensates for minor clock-skew between the proxy machine and clients; it is not necessary to backdate by a year. The total certificate lifetime is CertificateValidDays + CertificateGraceDays. Chrome 70+ and iOS 14+ cap this at 398 days for TLS leaf certificates. Declaration public int CertificateGraceDays { get; set; } Property Value Type Description int | Edit this page View Source CertificateStorage The fake certificate cache storage. The default implementation stores leaf certificates in a crts subdirectory of the per-user Titanium.Web.Proxy directory (%LocalAppData% on Windows, ApplicationData on Linux/macOS). Implement ICertificateCache and assign a concrete class here to customize. Declaration public ICertificateCache CertificateStorage { get; set; } Property Value Type Description ICertificateCache | Edit this page View Source CertificateValidDays Number of days generated HTTPS leaf certificates are valid for, measured forward from the moment of creation. The certificate's NotBefore is set to UtcNow - CertificateGraceDays, so the effective total validity window (NotAfter − NotBefore) equals CertificateValidDays + CertificateGraceDays. Chrome 70+ and iOS 14+ reject certificates whose total validity window exceeds 398 days. To stay within that limit, keep CertificateValidDays + CertificateGraceDays <= 398. The default value of 396, combined with the default grace of 2, equals exactly 398 days total. Declaration public int CertificateValidDays { get; set; } Property Value Type Description int | Edit this page View Source DisableWildCardCertificates When true, issue per-host certificates instead of *.parent.tld wildcards. Default false (wildcards enabled where applicable). Declaration public bool DisableWildCardCertificates { get; set; } Property Value Type Description bool | Edit this page View Source IntermediateCertificates Additional certificates to send to clients as part of the TLS certificate chain. Use this when RootCertificate is an intermediate CA rather than the trust anchor: set this to the ordered list of intermediate certificates between the signing certificate and the client-trusted root so that clients can build a complete verified chain. When RootCertificate is not self-signed it is automatically included in the chain even if this collection is empty; any certificates in this collection are appended after it. Declaration public X509Certificate2Collection? IntermediateCertificates { get; set; } Property Value Type Description X509Certificate2Collection | Edit this page View Source LastOsTrustResult Last OS/browser trust outcome from TrustRootCertificate(bool) / related helpers. Declaration public CertificateOsTrustResult? LastOsTrustResult { get; } Property Value Type Description CertificateOsTrustResult | Edit this page View Source LeafCertificateKeyAlgorithm Key algorithm for generated leaf certificates. Honoured by the BouncyCastle engines; the Windows engine always issues RSA. Defaults to Rsa2048. Switching to EcdsaP256 makes generating a certificate for a not-yet-seen host roughly fifty times cheaper, which is the single largest cost the proxy adds to a first visit. Only clients that accept ECDSA server certificates can be intercepted afterwards. Declaration public CertificateKeyAlgorithm LeafCertificateKeyAlgorithm { get; set; } Property Value Type Description CertificateKeyAlgorithm | Edit this page View Source LeafRsaKeyPairBufferSize How many RSA-2048 leaf private keys to keep ready in a background-refilled buffer so first visits do not pay key-generation cost on the CONNECT that needs the certificate. Defaults to 8. Set to 0 to disable buffering (keys are generated on demand). Only applies when LeafCertificateKeyAlgorithm is Rsa2048. ECDSA P-256 keys are cheap enough that they are always generated inline. The buffer is process-wide and shared by every CertificateManager instance. Declaration public static int LeafRsaKeyPairBufferSize { get; set; } Property Value Type Description int | Edit this page View Source OverwritePfxFile Overwrite Root certificate file. true : replace an existing .pfx file if password is incorrect or if RootCertificate = null. Declaration public bool OverwritePfxFile { get; set; } Property Value Type Description bool | Edit this page View Source PfxFilePath Name(path) of the Root certificate file. Set the name or path of the .pfx file. When empty, the file is named rootCert.pfx. Relative or empty values are resolved under the per-user Titanium.Web.Proxy directory (%LocalAppData% on Windows, ApplicationData on Linux/macOS). Absolute paths are honored as-is. Declaration public string PfxFilePath { get; set; } Property Value Type Description string | Edit this page View Source PfxPassword Password of the Root certificate file. Set a password for the .pfx file Declaration public string PfxPassword { get; set; } Property Value Type Description string | Edit this page View Source RootCertificate The root certificate. Declaration public X509Certificate2? RootCertificate { get; set; } Property Value Type Description X509Certificate2 | Edit this page View Source RootCertificateIssuerName Name of the root certificate issuer. (This is valid only when RootCertificate property is not set.) Declaration public string RootCertificateIssuerName { get; set; } Property Value Type Description string | Edit this page View Source RootCertificateName Subject/CN name used when generating a root certificate. (This is valid only when RootCertificate property is not set.) If no certificate is provided then a default root certificate will be created and used. Persistence uses PfxFilePath / CertificateStorage under the per-user Titanium.Web.Proxy directory (not the process executable directory). Declaration public string RootCertificateName { get; set; } Property Value Type Description string | Edit this page View Source SaveFakeCertificates When true, persist generated leaf certificates via CertificateStorage so subsequent runs can reload them instead of regenerating. Declaration public bool SaveFakeCertificates { get; set; } Property Value Type Description bool | Edit this page View Source StorageFlag Adjust behaviour when certificates are saved to filesystem. Declaration public X509KeyStorageFlags StorageFlag { get; set; } Property Value Type Description X509KeyStorageFlags | Edit this page View Source SuppressInteractiveRootStoreMutations When true, skip Root Add/Remove that trigger Windows CryptUI \"Root Certificate Store\" Yes/No dialogs (which hang headless CI and unattended dotnet test). Personal (My) mutations still run. Also treated as true when CI, GITHUB_ACTIONS, TF_BUILD, or TITANIUM_SKIP_ROOT_STORE_UI=1 is set. Opt back in for intentional interactive Install CA (e.g. local E2E-Slow Chrome) by setting this to false in a process that does not set those env vars. Declaration public static bool SuppressInteractiveRootStoreMutations { get; set; } Property Value Type Description bool Methods | Edit this page View Source ApplyFastColdStartLeafSettings() Fast first-visit MITM for modern TLS clients (browsers, current HttpClient): BouncyCastleFast, ECDSA P-256 leaves, and SaveFakeCertificates enabled. The root CA stays RSA. Library Balanced still defaults to RSA-2048 leaves for widest compatibility. Call this from Inspector, CLI, and desktop MITM hosts where clients are known to accept ECDSA server certificates — RSA leaf generation is the dominant cold-start cost when a page hits many not-yet-seen hosts (often ~1 s per host). Declaration public void ApplyFastColdStartLeafSettings() | Edit this page View Source ApplyUnixSslTrustAfterStoreInstall(bool) macOS/Linux SSL trust (Keychain / NSS). May show auth UI — keep on a pumping thread. No-op on Windows (Root store presence is trust). Declaration public void ApplyUnixSslTrustAfterStoreInstall(bool machineTrusted = false) Parameters Type Name Description bool machineTrusted | Edit this page View Source ApplyUnixSslUntrust() macOS/Linux Keychain/NSS untrust. May show auth UI — keep on a pumping thread. Declaration public void ApplyUnixSslUntrust() | Edit this page View Source ClearRootCertificate() Clear the root certificate and cache. Declaration public void ClearRootCertificate() | Edit this page View Source CreateRootCertificate(bool) Attempts to create a RootCertificate. Declaration public bool CreateRootCertificate(bool persistToFile = true) Parameters Type Name Description bool persistToFile if set to true try to load/save the certificate from rootCert.pfx. Returns Type Description bool true if succeeded, else false. | Edit this page View Source CreateServerCertificate(string) Creates a server certificate signed by the root certificate. Declaration public Task CreateServerCertificate(string certificateName) Parameters Type Name Description string certificateName Returns Type Description Task | Edit this page View Source Dispose() Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. Declaration public void Dispose() | Edit this page View Source EnsureRootCertificate() Ensure certificates are setup (creates root if required). Also makes root certificate trusted based on initial setup from proxy constructor for user/machine trust. Declaration public void EnsureRootCertificate() | Edit this page View Source EnsureRootCertificate(bool, bool, bool) Ensure certificates are setup (creates root if required). Also makes root certificate trusted based on provided parameters. Declaration public void EnsureRootCertificate(bool userTrustRootCertificate, bool machineTrustRootCertificate, bool trustRootCertificateAsAdmin = false) Parameters Type Name Description bool userTrustRootCertificate Trust in the current-user stores. Prefer true for interactive MITM; false for fully opt-in trust. bool machineTrustRootCertificate Also trust in local-machine stores (needs elevation). Implies user trust. Prefer false unless installing for a service / all users. bool trustRootCertificateAsAdmin Elevate via UAC when installing (Windows only). Defaults to false. | Edit this page View Source InstallNssCertutilAndRetryUserTrust() Installs NSS certutil (Linux package or macOS Homebrew) after user consent, then retries user SSL trust. Declaration public CertificateOsTrustResult InstallNssCertutilAndRetryUserTrust() Returns Type Description CertificateOsTrustResult | Edit this page View Source InstallRootIntoCertificateStores(bool) Installs the root into Personal + Trusted Root stores only (Windows CryptUI Yes/No on Root Add). Does not prune orphans or run Unix Keychain/NSS trust — UI callers should finish those off the dispatcher after CryptUI returns so Avalonia does not show Not Responding. Declaration public bool InstallRootIntoCertificateStores(bool machineTrusted = false) Parameters Type Name Description bool machineTrusted Returns Type Description bool True when the user Root store entry was newly added. | Edit this page View Source IsOsRootStillPresent() True when a Titanium root (current hash or known CN) remains in login or System keychain. Declaration public bool IsOsRootStillPresent() Returns Type Description bool | Edit this page View Source IsRootCertificateMachineTrusted() Determines whether the root certificate is machine trusted. Declaration public bool IsRootCertificateMachineTrusted() Returns Type Description bool | Edit this page View Source IsRootCertificateUserTrusted() Determines whether the root certificate is trusted. Declaration public bool IsRootCertificateUserTrusted() Returns Type Description bool | Edit this page View Source IsRootInLoginKeychain() Best-effort: true when the current root appears in the macOS login keychain. Does not imply SSL Always Trust — use VerifyOsUserSslTrust(). Declaration public bool IsRootInLoginKeychain() Returns Type Description bool | Edit this page View Source ListSameCommonNameRootThumbprints(StoreLocation, string?) Read-only: Root-store thumbprints matching RootCertificateName. Uses FindBySubjectName (not a full store enumeration) so interactive Clear/reinstall does not sit on Busy for tens of seconds on large Windows Root stores. Declaration public IReadOnlyList ListSameCommonNameRootThumbprints(StoreLocation storeLocation, string? keepThumbprint = null) Parameters Type Name Description StoreLocation storeLocation string keepThumbprint Returns Type Description IReadOnlyList | Edit this page View Source LoadRootCertificate() Loads the root certificate via CertificateStorage (default: per-user Titanium.Web.Proxy directory, file name from PfxFilePath or rootCert.pfx). Declaration public X509Certificate2? LoadRootCertificate() Returns Type Description X509Certificate2 | Edit this page View Source LoadRootCertificate(string, string, bool, X509KeyStorageFlags) Manually load a Root certificate file from give path (.pfx file). Declaration public bool LoadRootCertificate(string pfxFilePath, string password, bool overwritePfXFile = true, X509KeyStorageFlags storageFlag = X509KeyStorageFlags.Exportable) Parameters Type Name Description string pfxFilePath Set the name or path of the .pfx file. When empty, the file is named rootCert.pfx under the per-user Titanium.Web.Proxy directory. Absolute paths are honored as-is. string password Set a password for the .pfx file. bool overwritePfXFile true : replace an existing .pfx file if password is incorrect or if RootCertificate==null. X509KeyStorageFlags storageFlag Returns Type Description bool true if succeeded, else false. | Edit this page View Source OpenMacKeychainGuidance() Opens Keychain Access (and a temp .cer) for manual Always Trust on macOS. Declaration public string? OpenMacKeychainGuidance() Returns Type Description string | Edit this page View Source PruneOrphanedPersonalCertificates(StoreLocation, bool) Personal (My) store same-CN cleanup only — typically no CryptUI. Safe off the UI thread. Declaration public void PruneOrphanedPersonalCertificates(StoreLocation storeLocation, bool keepCurrentThumbprint) Parameters Type Name Description StoreLocation storeLocation bool keepCurrentThumbprint | Edit this page View Source PruneOrphanedSameCommonNameCertificates(bool, bool) Removes same-CN Root/My entries (may show Windows Root Delete CryptUI). Prefer a pumping UI thread when interactive. Declaration public void PruneOrphanedSameCommonNameCertificates(bool machineTrusted, bool keepCurrentThumbprint) Parameters Type Name Description bool machineTrusted bool keepCurrentThumbprint | Edit this page View Source RemoveCertificateByThumbprint(StoreName, StoreLocation, string) Removes one certificate by thumbprint. Root Remove may show Windows CryptUI — UI thread. Declaration public bool RemoveCertificateByThumbprint(StoreName storeName, StoreLocation storeLocation, string thumbprint) Parameters Type Name Description StoreName storeName StoreLocation storeLocation string thumbprint Returns Type Description bool | Edit this page View Source RemoveTrustedRootCertificate(bool) Removes the trusted certificates from the current-user Personal and Trusted Root stores, and optionally also from the local-machine Personal and Trusted Root stores. Declaration public void RemoveTrustedRootCertificate(bool machineTrusted = false) Parameters Type Name Description bool machineTrusted When true, also remove from local-machine stores (needs elevation; fails silently otherwise). Pass the same value used when trusting. | Edit this page View Source RemoveTrustedRootCertificateAsAdmin(bool) Removes the trusted certificates from user store, optionally also from machine store Declaration public bool RemoveTrustedRootCertificateAsAdmin(bool machineTrusted = false) Parameters Type Name Description bool machineTrusted Returns Type Description bool Should also remove from machine store? | Edit this page View Source TrustRootCertificate(bool) Trusts the root certificate in the current-user Personal and Trusted Root stores, and optionally also in the local-machine Personal and Trusted Root stores. Declaration public void TrustRootCertificate(bool machineTrusted = false) Parameters Type Name Description bool machineTrusted When true, also install machine-wide trust (LocalMachine on Windows; System.keychain / system CA store on macOS/Linux, with an admin prompt). Defaults to false — user-only trust is the recommended default. Check LastOsTrustResult and VerifyOsUserSslTrust() after calling. | Edit this page View Source TrustRootCertificateAsAdmin(bool) Puts the certificate to the user store, optionally also to the machine store, prompting with UAC when elevation is required. Works only on Windows. Declaration public bool TrustRootCertificateAsAdmin(bool machineTrusted = false) Parameters Type Name Description bool machineTrusted When true, elevate to install into local-machine stores. Defaults to false (user store only). Returns Type Description bool True if success. | Edit this page View Source VerifyOsUserSslTrust() Re-checks macOS/Linux user SSL trust for the current root. Declaration public bool VerifyOsUserSslTrust() Returns Type Description bool Implements IDisposable" }, "api/Titanium.Web.Proxy.Network.CertificateOsTrustKind.html": { "href": "api/Titanium.Web.Proxy.Network.CertificateOsTrustKind.html", @@ -477,7 +477,7 @@ "api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html": { "href": "api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html", "title": "Class FirefoxCertificateTrust | Titanium Web Proxy", - "summary": "Class FirefoxCertificateTrust Firefox-specific CA trust: Windows ImportEnterpriseRoots policy / policies.json, profile user.js (and prefs.js when Firefox is not running) so Firefox uses OS roots, plus optional NSS import into the default profile (cert9.db). Does not write into the Firefox.app bundle (that would invalidate the code signature). Inheritance object FirefoxCertificateTrust Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Network Assembly: Titanium.Web.Proxy.dll Syntax public static class FirefoxCertificateTrust Methods | Edit this page View Source IsFirefoxProcessRunning() True when a firefox process is running (best-effort). Declaration public static bool IsFirefoxProcessRunning() Returns Type Description bool | Edit this page View Source IsFirefoxProfilePresent() True when a Firefox profiles.ini (or common profile root) is present. Declaration public static bool IsFirefoxProfilePresent() Returns Type Description bool | Edit this page View Source TrustDefaultProfile(X509Certificate2, string) Imports the CA into the default Firefox profile NSS DB via certutil. Caller should ensure Firefox is not locking the DB. Declaration public static CertificateOsTrustResult TrustDefaultProfile(X509Certificate2 certificate, string friendlyName) Parameters Type Name Description X509Certificate2 certificate string friendlyName Returns Type Description CertificateOsTrustResult | Edit this page View Source TryClearWindowsEnterpriseRoots() Clears the HKCU ImportEnterpriseRoots value and profile user.js pref we may have set. Declaration public static bool TryClearWindowsEnterpriseRoots() Returns Type Description bool | Edit this page View Source TryEnableEnterpriseRootsUserPref() Enables security.enterprise_roots.enabled in the default Firefox profile (user.js; also prefs.js when Firefox is not running) so Firefox trusts OS roots (Windows store / macOS Keychain / Linux system CAs). Declaration public static CertificateOsTrustResult TryEnableEnterpriseRootsUserPref() Returns Type Description CertificateOsTrustResult | Edit this page View Source TryEnableWindowsEnterpriseRoots() Windows: enable OS-root trust for Firefox via HKCU policy when allowed, otherwise set security.enterprise_roots.enabled in the default profile user.js. Also best-effort writes/merges Mozilla policies.json on all OSes. Declaration public static CertificateOsTrustResult TryEnableWindowsEnterpriseRoots() Returns Type Description CertificateOsTrustResult | Edit this page View Source TryRequestFirefoxQuit(TimeSpan?) Asks Firefox to quit gracefully (user already consented). Waits briefly for exit. Does not force-kill; returns false if Firefox is still running after the wait. Declaration public static bool TryRequestFirefoxQuit(TimeSpan? waitForExit = null) Parameters Type Name Description TimeSpan? waitForExit Returns Type Description bool | Edit this page View Source TryResolveDefaultProfileDirectory(out string, out string?) Resolves the default Firefox profile directory from profiles.ini. Declaration public static bool TryResolveDefaultProfileDirectory(out string profileDirectory, out string? error) Parameters Type Name Description string profileDirectory string error Returns Type Description bool | Edit this page View Source UntrustDefaultProfile(string) Best-effort removal of the CA nickname from the default Firefox profile. Declaration public static bool UntrustDefaultProfile(string friendlyName) Parameters Type Name Description string friendlyName Returns Type Description bool" + "summary": "Class FirefoxCertificateTrust Firefox-specific CA trust: Windows ImportEnterpriseRoots policy / policies.json, profile user.js (and prefs.js when Firefox is not running) so Firefox uses OS roots, plus optional NSS import into the default profile (cert9.db). Does not write into the Firefox.app bundle (that would invalidate the code signature). Inheritance object FirefoxCertificateTrust Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Network Assembly: Titanium.Web.Proxy.dll Syntax public static class FirefoxCertificateTrust Properties | Edit this page View Source LastEnterpriseRootsStep Last step tag from TryEnableWindowsEnterpriseRoots() / TryEnableEnterpriseRootsUserPref() for Inspector ux-trace (e.g. HkcuOk, UserJsSkippedFirefoxRunning, UserJsOk, Failed). Declaration public static string? LastEnterpriseRootsStep { get; } Property Value Type Description string Methods | Edit this page View Source ClearRootTrustBestEffort(string?) Clears enterprise-roots policy/prefs (HKCU / user.js). Does not run NSS certutil — that can hang for minutes on a locked Firefox profile and blocked Clear/reinstall on \"Finishing root CA removal…\". Use Trust/Untrust Firefox for NSS. Declaration public static void ClearRootTrustBestEffort(string? friendlyName) Parameters Type Name Description string friendlyName | Edit this page View Source IsFirefoxProcessRunning() True when a firefox process is running (best-effort). Declaration public static bool IsFirefoxProcessRunning() Returns Type Description bool | Edit this page View Source IsFirefoxProfilePresent() True when a Firefox profiles.ini (or common profile root) is present. Declaration public static bool IsFirefoxProfilePresent() Returns Type Description bool | Edit this page View Source TrustDefaultProfile(X509Certificate2, string) Imports the CA into the default Firefox profile NSS DB via certutil. Caller should ensure Firefox is not locking the DB. Declaration public static CertificateOsTrustResult TrustDefaultProfile(X509Certificate2 certificate, string friendlyName) Parameters Type Name Description X509Certificate2 certificate string friendlyName Returns Type Description CertificateOsTrustResult | Edit this page View Source TryClearWindowsEnterpriseRoots() Clears the HKCU ImportEnterpriseRoots value and profile user.js pref we may have set. Declaration public static bool TryClearWindowsEnterpriseRoots() Returns Type Description bool | Edit this page View Source TryEnableEnterpriseRootsUserPref() Enables security.enterprise_roots.enabled in the default Firefox profile (user.js; also prefs.js when Firefox is not running) so Firefox trusts OS roots (Windows store / macOS Keychain / Linux system CAs). Declaration public static CertificateOsTrustResult TryEnableEnterpriseRootsUserPref() Returns Type Description CertificateOsTrustResult | Edit this page View Source TryEnableWindowsEnterpriseRoots() Windows: enable OS-root trust for Firefox via HKCU policy when allowed, otherwise set security.enterprise_roots.enabled in the default profile user.js. Also best-effort writes/merges Mozilla policies.json on all OSes. Declaration public static CertificateOsTrustResult TryEnableWindowsEnterpriseRoots() Returns Type Description CertificateOsTrustResult | Edit this page View Source TryRequestFirefoxQuit(TimeSpan?) Asks Firefox to quit gracefully (user already consented). Waits briefly for exit. Does not force-kill; returns false if Firefox is still running after the wait. Declaration public static bool TryRequestFirefoxQuit(TimeSpan? waitForExit = null) Parameters Type Name Description TimeSpan? waitForExit Returns Type Description bool | Edit this page View Source TryResolveDefaultProfileDirectory(out string, out string?) Resolves the default Firefox profile directory from profiles.ini. Declaration public static bool TryResolveDefaultProfileDirectory(out string profileDirectory, out string? error) Parameters Type Name Description string profileDirectory string error Returns Type Description bool | Edit this page View Source UntrustDefaultProfile(string) Best-effort removal of the CA nickname from the default Firefox profile. Declaration public static bool UntrustDefaultProfile(string friendlyName) Parameters Type Name Description string friendlyName Returns Type Description bool" }, "api/Titanium.Web.Proxy.Network.ICertificateCache.html": { "href": "api/Titanium.Web.Proxy.Network.ICertificateCache.html", @@ -552,7 +552,7 @@ "api/Titanium.Web.Proxy.ProxyServer.html": { "href": "api/Titanium.Web.Proxy.ProxyServer.html", "title": "Class ProxyServer | Titanium Web Proxy", - "summary": "Class ProxyServer Translates an HTTP/1.1 client connection onto an h2-only origin (Http2 with AllowHttpProtocolTranslation enabled - see ResolveHttp2ForClientAsync(SessionEventArgsBase, bool, string, int, string, int?, UpstreamHttpProtocol, bool, bool, CancellationToken, bool)), leasing one h2 stream per HTTP/1.1 request from a shared Titanium.Web.Proxy.Http2.Http2OriginConnection via Titanium.Web.Proxy.ProxyServer.Http2OriginConnectionPool rather than opening a new TCP/TLS connection for every request. Inheritance object ProxyServer Implements IDisposable Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy Assembly: Titanium.Web.Proxy.dll Syntax public class ProxyServer : IDisposable Remarks This re-implements the HTTP/1.1 client read loop (request line, headers, BeforeRequest, authorization, header preparation, CancelRequest/replaced-response handling) rather than reusing the private HandleHttpSessionRequest/HandleHttpSessionResponse methods, because those methods send/receive over TcpServerConnection.Stream using the raw HTTP/1.1 wire format, which an h2 origin connection cannot speak. This mirrors the precedent set by the h2-to-HTTP/1.1 bridge (Http2ToHttp11BridgeHandler), which similarly bypasses the wire-format-specific machinery for the leg that does not match it. Origin connections are multiplexed across independent HTTP/1.1 clients through Titanium.Web.Proxy.ProxyServer.Http2OriginConnectionPool (fan-in share). Response bodies are delivered via Titanium.Web.Proxy.Http2.Http2OriginConnection streaming writers where available. Constructors | Edit this page View Source ProxyServer(bool, bool, bool) Initializes a new instance of ProxyServer class with provided parameters. Declaration public ProxyServer(bool userTrustRootCertificate = true, bool machineTrustRootCertificate = false, bool trustRootCertificateAsAdmin = false) Parameters Type Name Description bool userTrustRootCertificate When true (the default), EnsureRootCertificate() installs the MITM root into the current-user Personal and Trusted Root stores. Prefer user-only trust for interactive apps; pass false when trust must be fully opt-in. bool machineTrustRootCertificate When true, also trust in the local-machine stores (needs elevation). Defaults to false — machine trust is opt-in for services/admin installs, not for normal desktop use. bool trustRootCertificateAsAdmin When true, attempt elevated trust via UAC (Windows only). Defaults to false. | Edit this page View Source ProxyServer(string?, string?, bool, bool, bool) Initializes a new instance of ProxyServer class with provided parameters. Declaration public ProxyServer(string? rootCertificateName, string? rootCertificateIssuerName, bool userTrustRootCertificate = true, bool machineTrustRootCertificate = false, bool trustRootCertificateAsAdmin = false) Parameters Type Name Description string rootCertificateName Name of the root certificate. string rootCertificateIssuerName Name of the root certificate issuer. bool userTrustRootCertificate When true (the default), EnsureRootCertificate() installs the MITM root into the current-user Personal and Trusted Root stores. Prefer user-only trust for interactive apps; pass false when trust must be fully opt-in. bool machineTrustRootCertificate When true, also trust in the local-machine stores (needs elevation). Defaults to false — machine trust is opt-in for services/admin installs, not for normal desktop use. bool trustRootCertificateAsAdmin When true, attempt elevated trust via UAC (Windows only). Defaults to false. Fields | Edit this page View Source DefaultViaHeaderPseudonym Default Via header pseudonym (RFC 9110 §7.6.3). Used by ViaHeaderPseudonym and by Inspector when Add Via header is enabled. Declaration public const string DefaultViaHeaderPseudonym = \"titanium-web-proxy\" Field Value Type Description string Properties | Edit this page View Source AdmittedClientConnectionCount Number of client connections currently admitted (accepted and past the admission gate, not yet finished being handled), across all TCP-based endpoints. Unlike ClientConnectionCount, this drops to zero as soon as the handler returns, without the trailing TIME_WAIT delay. Declaration public int AdmittedClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source BlockPrivateNetworkDestinations Outbound destination policy hook: when true, every resolved destination IP address is checked against loopback, private (RFC 1918/4193), link-local (which subsumes the 169.254.169.254 cloud metadata endpoint), and other non-globally-routable ranges before connecting, and the connection attempt is rejected with an OutboundDestinationBlockedException if it matches. Off by default: blocking private destinations would break this library's most common configurations, including upstream-proxy chaining to localhost and interception of local development servers. Only enable this when the proxy accepts requests from untrusted clients (an SSRF-relevant deployment), where those same destinations become an attacker-reachable pivot into the host's private network instead of an operator's own intentional configuration. An explicitly configured upstream proxy address (UpStreamHttpProxy, UpStreamHttpsProxy, or a per-session external proxy) is always exempt - that address is operator intent, not attacker-controlled. Checked against the resolved address actually used to connect (no re-resolution afterward, which would make the check a TOCTOU no-op against DNS rebinding). Not currently enforced for a SOCKS upstream with ProxyDnsRequests enabled, since the proxy never resolves the origin itself in that mode and has no address of its own to validate. Declaration public bool BlockPrivateNetworkDestinations { get; set; } Property Value Type Description bool | Edit this page View Source BufferPool The buffer pool used throughout this proxy instance. Set custom implementations by implementing this interface. By default this uses DefaultBufferPool implementation available in StreamExtended library package. Buffer size should be at least 10 bytes. Declaration public IBufferPool BufferPool { get; set; } Property Value Type Description IBufferPool | Edit this page View Source CertificateManager Manages certificates used by this proxy. Declaration public CertificateManager CertificateManager { get; } Property Value Type Description CertificateManager | Edit this page View Source CheckCertificateRevocation Should we check for certificate revocation during SSL authentication to servers Note: If enabled can reduce performance. Defaults to false. Declaration public X509RevocationMode CheckCertificateRevocation { get; set; } Property Value Type Description X509RevocationMode | Edit this page View Source ClientConnectionCount Total number of active TCP client connections. Does not include inbound HTTP/3 (QUIC) clients; see Http3ClientConnectionCount. Declaration public int ClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source ClientHeaderTimeoutSeconds Seconds to wait for a client to finish sending the request line and headers, from the moment this proxy starts reading a new request on the connection. Enforced with a linked CancellationTokenSource around the request-line and header read, not Socket.ReceiveTimeout: that property only bounds a single blocking Receive call, not the asynchronous reads this proxy actually issues, so without this deadline a client that opens a connection and trickles bytes arbitrarily slowly (or stops sending entirely) after the first byte ties up a read loop indefinitely. Default is 0 (disabled), matching every other deadline in this class - no per-session override exists because there is no SessionEventArgs for this request yet at the point this deadline applies. Declaration public int ClientHeaderTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source CompatibilityMode100Continue When true, the proxy immediately responds with a synthetic 100 Continue to any client request carrying Expect: 100-continue, before forwarding the headers to the origin and without waiting for the origin to respond. This breaks the strict handshake (client → proxy 100 → client body → origin body) but prevents the deadlock that occurs with strict clients when Enable100ContinueBehaviour is false (the default). Has no effect when Enable100ContinueBehaviour is true. Default: false. Declaration public bool CompatibilityMode100Continue { get; set; } Property Value Type Description bool | Edit this page View Source ConnectTimeOutSeconds Seconds server connection are to wait for connection to be established. Default value is 20 seconds. Declaration public int ConnectTimeOutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ConnectionTimeOutSeconds Seconds client/server connection are to be kept alive when waiting for read/write to complete. This will also determine the pool eviction time when connection pool is enabled. Default value is 60 seconds. Declaration public int ConnectionTimeOutSeconds { get; set; } Property Value Type Description int | Edit this page View Source CustomUpStreamProxyFailureFunc A callback to provide a chance for an upstream proxy failure to be handled by a new upstream proxy. User should return the ExternalProxy object with valid credentials or null. Declaration public Func>? CustomUpStreamProxyFailureFunc { get; set; } Property Value Type Description Func> | Edit this page View Source DecryptFailureBypassMaxEntries Maximum learned hosts retained (approximate LRU eviction). Default 256. Declaration public int DecryptFailureBypassMaxEntries { get; set; } Property Value Type Description int | Edit this page View Source DecryptFailureBypassThreshold Origin TLS failure strikes required before a host is bypassed on later CONNECTs. Same-CONNECT opaque fallback after an awaited H2 probe failure marks bypass immediately. Default 2. Declaration public int DecryptFailureBypassThreshold { get; set; } Property Value Type Description int | Edit this page View Source DecryptFailureBypassTtl How long a learned decrypt-bypass entry remains valid. Default 30 minutes. Declaration public TimeSpan DecryptFailureBypassTtl { get; set; } Property Value Type Description TimeSpan | Edit this page View Source DnsServerEndPoint DNS server endpoint used by Titanium.Web.Proxy.Http3.Dns.UdpSvcbDnsResolver for HTTPS/SVCB queries. Defaults to the first usable OS-configured plain-UDP DNS server discovered via NetworkInterface. This is a best-effort default and does not honor Windows NRPT, DoH, or VPN split-DNS policy. When no OS-configured DNS server can be discovered, the property reports 0.0.0.0:0 and proactive SVCB discovery is skipped (never falls back to a public third-party resolver). Assign an explicit endpoint to override discovery. Declaration [Experimental(\"TWP001\")] public IPEndPoint DnsServerEndPoint { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source Enable100ContinueBehaviour Does this proxy uses the HTTP protocol 100 continue behaviour strictly? Broken 100 continue implementations on server/client may cause problems if enabled. Defaults to false. Declaration public bool Enable100ContinueBehaviour { get; set; } Property Value Type Description bool | Edit this page View Source EnableConnectionPool Should we enable the server connection pool. Defaults to true. When connection pooling is enabled, instead of creating a new TCP connection to the server for each client TCP connection, we check if an idle server connection is available in our cached pool. If a compatible connection (same destination, scheme, upstream proxy, credentials and negotiated protocol) created from an earlier request is available, we reuse it. Only connections that are safe to reuse under the HTTP protocol are pooled: the response body must be fully received and the connection must be persistent (HTTP/1.1 keep-alive, or an HTTP/1.0 connection that explicitly opted in via \"Connection: keep-alive\"). Connections whose response asked to close, that failed, or that carry connection-oriented authentication state (WinAuth NTLM/Negotiate) or a per-session client certificate are never returned to the shared pool. The ConnectionTimeOutSeconds parameter determines the eviction time for inactive server connections. This reduces TCP (and TLS) connection establishment cost, both in wall clock time and CPU cycles. Set to false to force a fresh server connection for every client connection. Declaration public bool EnableConnectionPool { get; set; } Property Value Type Description bool | Edit this page View Source EnableDecryptFailureBypass When true, the proxy learns hosts whose origin TLS handshake fails under MITM (non-ALPN AuthenticationException, typically bot / TLS-fingerprint rejection) and tunnels subsequent CONNECTs without decrypt. Default false so library and RPS baselines are unchanged. Inspector enables this by default. Success-path cost when on is one dictionary lookup per CONNECT. Declaration public bool EnableDecryptFailureBypass { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttp2 Enable disable HTTP/2 support. Client-facing HTTP/2 is negotiated via TLS ALPN, or as prior-knowledge cleartext h2c on a transparent reverse endpoint (DecryptSsl: false). No Upgrade: h2c. Origin-facing HTTP/2 uses TLS ALPN h2 by default; with ForwardCleartext and Http2, the origin speaks cleartext HTTP/2 prior-knowledge (outbound h2c). A client/server that does not support HTTP/2 transparently falls back to HTTP/1.1 when policy allows. Request/response header and body modification in BeforeRequest/BeforeResponse, chunked trailers, interim (1xx) responses, and the synthetic-response APIs (Ok/Respond/Redirect/GenericResponse/ RespondStreaming) are all supported over HTTP/2, the same as over HTTP/1.x. Not supported: HTTP/2 server push (the wire frames are transcoded but there is no public API to originate a push) and Upgrade: h2c. Explicit-proxy inbound h2c is not implemented. See the protocol support matrix on the wiki for exact, up-to-date HTTP/1.x/HTTP/2 feature coverage. Declaration public bool EnableHttp2 { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttp3 Enable HTTP/3 (QUIC) support. When true: Any TransparentQuicProxyEndPoint is started as a UDP-only QUIC listener for transparent/NAT HTTP/3 interception. Any TransparentProxyEndPoint with EnableHttp3 also listens for HTTP/3 on the same IP:port (TCP H1/H2 + UDP H3) and injects client-facing Alt-Svc. With Auto (default), a cached Alt-Svc / HTTPS/SVCB capability only arms background QUIC warm-up. Outbound HTTP/3 is used once that origin is warm; until then the request stays on HTTP/2 or HTTP/1.1. Forced Http3 skips warm-up gating and fails closed with no TCP fallback. Requires MsQuic native library and a supported operating-system version (IsSupported). Setting to true with 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: false (opt-in). Experimental: HTTP/3 support has not yet completed the full interop/soak/fuzz gate process. Suppress TWP001 to opt in; the attribute is removed when the feature graduates to stable. Declaration [Experimental(\"TWP001\")] public bool EnableHttp3 { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttpInterception Forces the full interception path (SessionEventArgs, BeforeRequest, etc.) even when no event handlers are subscribed. Set this when consuming SessionEventArgs for timing or metrics without subscribing to any event. Default: false. Declaration public bool EnableHttpInterception { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttpsSvcbDnsDiscovery When true, the proxy queues a background HTTPS/SVCB RR (DNS type 65) lookup after an Auto-mode capability-cache miss. A positive result (ALPN h3 found) warms Titanium.Web.Proxy.Http3.Http3OriginCapabilityCache for subsequent connections; the CONNECT / request path itself never awaits DNS. Negative results are cached for 1 minute; transient failures use a short backoff. Defaults to true whenever EnableHttp3 is true. Set explicitly to false to disable discovery even when HTTP/3 is enabled — for example, when the configured DNS server is untrusted or unreachable. First-connection HTTP/3 adoption then comes from Alt-Svc. Declaration [Experimental(\"TWP001\")] public bool EnableHttpsSvcbDnsDiscovery { get; set; } Property Value Type Description bool | Edit this page View Source EnableIpv6UnreachableSoftSkip When true (default), after one IPv6 connect failure with NetworkUnreachable (or equivalent), temporarily omit IPv6 addresses from the Happy Eyeballs race for 30 seconds. Reduces first-chance SocketException noise on dual-stack hosts with a broken IPv6 path. Disable if operators require strict IPv6 preference even when the path is unreachable. Declaration public bool EnableIpv6UnreachableSoftSkip { get; set; } Property Value Type Description bool | Edit this page View Source EnableQpackDynamicTable When true, enables RFC 9204 QPACK dynamic table encoding and decoding for inbound HTTP/3 connections. Each connection gets its own Titanium.Web.Proxy.Http3.Qpack.QpackContext with two independent 4096-byte tables (one inbound, one outbound). Defaults to false (static-table-only); existing deployments are unaffected. Declaration [Experimental(\"TWP001\")] public bool EnableQpackDynamicTable { get; set; } Property Value Type Description bool | Edit this page View Source EnableRequestTimingCapture Enables structured request/connection timing capture. When false (the default) no timing objects are allocated and no UtcNow calls are made for timing purposes anywhere in the proxy, so there is zero overhead on the hot path. When enabled, every SessionEventArgsBase exposes a populated Timing (per-request phases: client header read, connection wait, request send, time-to-first-byte, response delivery, total), every upstream connection exposes a populated UpstreamConnectionTiming (reachable from a session via UpstreamConnectionTiming, describing DNS, TCP connect, optional upstream-proxy CONNECT, and TLS handshake durations), and a decrypted TunnelConnectSessionEventArgs exposes the client-facing TLS handshake duration via ClientTlsTiming. Can be toggled at any time; it only affects sessions/connections created after the change, never mutating timing objects already handed out. Defaults to false. Declaration public bool EnableRequestTimingCapture { get; set; } Property Value Type Description bool | Edit this page View Source EnableRfc8441 When true, the proxy enables RFC 8441 WebSocket-over-HTTP/2: Accepts extended CONNECT (:protocol = websocket) from h2 clients and advertises SETTINGS_ENABLE_CONNECT_PROTOCOL=1 to them. Per origin: if the origin is HTTP/2 and advertises RFC 8441 support, DATA frames are relayed directly; if the origin is HTTP/2 and does not, the stream is reset with REFUSED_STREAM; if the origin is HTTP/1.1, the h2→h1 WebSocket upgrade bridge is used. On the HTTP/1.1-client-to-h2-origin translation bridge, translates Upgrade: websocket into extended CONNECT when the origin advertises the setting; otherwise falls back to a dedicated HTTP/1.1 origin connection for that WebSocket. When this property is false, that bridge still returns synthetic 501 Not Implemented for WebSocket upgrades (historical default). Default: false (must opt-in). Declaration public bool EnableRfc8441 { get; set; } Property Value Type Description bool | Edit this page View Source EnableTcpKeepAlive Enable TCP KeepAlive on client and server sockets so NAT/firewall mappings for long-lived CONNECT tunnels are refreshed. Default: true. Declaration public bool EnableTcpKeepAlive { get; set; } Property Value Type Description bool | Edit this page View Source EnableTcpServerConnectionPrefetch Should we enable tcp server connection prefetching? When enabled, as soon as we receive a client connection we concurrently initiate corresponding server connection process using CONNECT hostname or SNI hostname on a separate task so that after parsing client request we will have the server connection immediately ready or in the process of getting ready. If a server connection is available in cache then this prefetch task will immediately return with the available connection from cache. Defaults to true. Declaration public bool EnableTcpServerConnectionPrefetch { get; set; } Property Value Type Description bool | Edit this page View Source EnableWinAuth Enable disable Windows Authentication (NTLM/Kerberos). By default SSPI uses the process identity. To authenticate as another user, set WinAuthCredentialsProvider (issue #461). Defaults to false. Declaration public bool EnableWinAuth { get; set; } Property Value Type Description bool | Edit this page View Source EndpointAdmissionRejectionCount Total number of client connections rejected by any endpoint's MaxConcurrentClients since this instance was created. Declaration public long EndpointAdmissionRejectionCount { get; } Property Value Type Description long | Edit this page View Source ForwardToUpstreamGateway Gets or sets a value indicating whether requests will be chained to upstream gateway. Defaults to false. Declaration public bool ForwardToUpstreamGateway { get; set; } Property Value Type Description bool | Edit this page View Source GetCustomUpStreamProxyFunc A callback to provide authentication credentials for up stream proxy this proxy is using for HTTP(S) requests. User should return the ExternalProxy object with valid credentials. Declaration public Func>? GetCustomUpStreamProxyFunc { get; set; } Property Value Type Description Func> | Edit this page View Source GlobalAdmissionRejectionCount Total number of client connections rejected by MaxConcurrentClientConnections since this instance was created. Declaration public long GlobalAdmissionRejectionCount { get; } Property Value Type Description long | Edit this page View Source Http3ClientConnectionCount Total number of active inbound HTTP/3 (QUIC) client connections. Declaration public int Http3ClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source Http3ServerConnectionCount Total number of active upstream HTTP/3 (QUIC) server connections. These are also included in ServerConnectionCount. Declaration public int Http3ServerConnectionCount { get; } Property Value Type Description int | Edit this page View Source IdleReadTimeoutSeconds Seconds of idle time allowed while reading from the origin (stalled header/body waits). Applied via CancelAfter on the active read operation. Default is 0 (disabled). Per-session override: IdleReadTimeout. Declaration public int IdleReadTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source IdleWriteTimeoutSeconds Seconds of idle time allowed while writing to the origin (stalled header/body waits). Applied via CancelAfter on the active write operation. Default is 0 (disabled). Per-session override: IdleWriteTimeout. Declaration public int IdleWriteTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source IgnoreServerCertificateErrors When true, origin TLS certificates that fail OS chain validation are still accepted (MITM of loopback/self-signed/private CAs). Inspector's \"Ignore server certificate errors\" maps here. Default false. A subscribed ServerCertificateValidationCallback still wins. Declaration public bool IgnoreServerCertificateErrors { get; set; } Property Value Type Description bool | Edit this page View Source ListenerBackLog TCP listener accept backlog. Default: 1024 for burst connection handling. Declaration public int ListenerBackLog { get; set; } Property Value Type Description int | Edit this page View Source Logger The live, shared logger used throughout this proxy instance. Reflects the most recent call to ApplyLoggingConfiguration(). Declaration public ILogger Logger { get; } Property Value Type Description ILogger | Edit this page View Source Logging Configuration for this proxy instance's built-in diagnostic logging - the replacement for the removed ExceptionFunc callback. Every exception the proxy catches (even when handled internally and never surfaced to user code) is reported through this logger at an appropriate severity; see ProxyLoggingOptions for the console/file sinks, enable/disable switch, and minimum level. Mutate the returned instance (or assign a new one) at any point; each assignment/mutation you want to take effect must be followed by ApplyLoggingConfiguration() (which Start(bool) also calls automatically, so the configuration active at the moment the proxy starts running is picked up for the run even if you never call it yourself). Calling it again later - including while the proxy is already running - immediately swaps in the new configuration; this is safe because logging never blocks or otherwise affects proxy traffic. Declaration public ProxyLoggingOptions Logging { get; set; } Property Value Type Description ProxyLoggingOptions | Edit this page View Source MaxBufferedBodyBytes Maximum bytes the proxy will buffer for a single request or response body when body buffering is required (body-read hooks, authentication retry, etc.). Bodies larger than this limit are rejected with 413 (upstream request) or connection teardown (upstream response). Set to 0 to disable the limit (not recommended). Default: 4,194,304 (4 MiB). Declaration public int MaxBufferedBodyBytes { get; set; } Property Value Type Description int | Edit this page View Source MaxCachedConnections Maximum number of concurrent connections per remote host in cache. Only meaningful when EnableConnectionPool is true; to disable pooling, set EnableConnectionPool to false rather than setting this to 0 - the pool eviction loop treats a value below 1 as \"evict without limit while holding the pool-wide lock\", which spins indefinitely once the cache for that host is empty and would stall every other connection acquire/release in the process. Rejected outright at assignment so that state cannot be reached. Default value is 128. Declaration public int MaxCachedConnections { get; set; } Property Value Type Description int Exceptions Type Condition ArgumentOutOfRangeException The assigned value is less than 1. | Edit this page View Source MaxConcurrentClientConnections Maximum number of client connections admitted across all TCP-based endpoints at once. null (the default) disables the global admission gate, preserving today's unbounded behavior. When set, a connection beyond this limit is rejected and disposed immediately after accept, before a handler task is even started. Enforced independently of ClientConnectionCount: see Titanium.Web.Proxy.ProxyServer.admittedClientConnectionCount for why. See also MaxConcurrentClients for a per-endpoint cap layered on top of this global one. Declaration public int? MaxConcurrentClientConnections { get; set; } Property Value Type Description int? | Edit this page View Source MaxConcurrentHttp11HttpsOriginCreates Caps concurrent new HTTPS origin TCP/TLS opens on the H2→H1 bridge only (MITM / re-encrypt). Pool hits (warm keep-alive) are uncapped. Cleartext H1 origins are not gated. Default is Clamp(ProcessorCount, 4, 32). Set before the first H2→H1 HTTPS origin open (typically before Start(bool)); changing the value after the create gate has been used has no effect on the live semaphore. Declaration public int MaxConcurrentHttp11HttpsOriginCreates { get; set; } Property Value Type Description int Exceptions Type Condition ArgumentOutOfRangeException The assigned value is less than 1. | Edit this page View Source MaxDecodedHeaderListBytes Maximum decoded HTTP/2 header list size in bytes, using RFC 7541 accounting (name.Length + value.Length + 32 per field). Requests or responses with a decoded header list exceeding this limit will be refused with RST_STREAM(ENHANCE_YOUR_CALM) (code 0xb). Set to 0 to disable the limit (not recommended). Default: 65,536 (64 KiB). Advertised via SETTINGS_MAX_HEADER_LIST_SIZE. Declaration public int MaxDecodedHeaderListBytes { get; set; } Property Value Type Description int | Edit this page View Source MaxWebSocketFramePayloadBytes Maximum WebSocket frame payload size in bytes that the proxy will accept during frame-level interception (i.e. when BeforeWebSocketFrame has at least one subscriber). Frames whose decoded payload exceeds this limit cause the WebSocket connection to be closed with Close code 1009 (Message Too Big). Raw-relay sessions (no BeforeWebSocketFrame subscriber) bypass this check entirely and pass all frames through unvalidated. Default: 16,777,216 (16 MiB). Declaration public int MaxWebSocketFramePayloadBytes { get; set; } Property Value Type Description int | Edit this page View Source NetworkFailureRetryAttempts Number of times to retry upon network failures when connection pool is enabled. Declaration public int NetworkFailureRetryAttempts { get; set; } Property Value Type Description int | Edit this page View Source NoDelay Gets or sets a Boolean value that specifies whether server and client stream Sockets are using the Nagle algorithm. Defaults to true, no nagle algorithm is used. Declaration public bool NoDelay { get; set; } Property Value Type Description bool | Edit this page View Source OriginHttpVersionPolicy Controls which HTTP version is declared to the origin server on the request line, independently of the version the client declared to the proxy. Defaults to PreserveClientVersion, which matches the proxy's historical pass-through behavior exactly. Set to NormalizeToHttp11 to let HTTP/1.0 clients share pooled, persistent origin connections the same way HTTP/1.1 clients already do. This only changes the wire version written to the origin request line - it never changes the client-facing Http.Request.HttpVersion that event handlers observe, nor the version/persistence used to write the response back to the client. Declaration public OriginHttpVersionPolicy OriginHttpVersionPolicy { get; set; } Property Value Type Description OriginHttpVersionPolicy | Edit this page View Source PolicyModes Which resource-bound PolicyFamily is enforced, observed, or disabled, per the plan's rollout section. Read live by each family's enforcement call site - not baked into a per-request snapshot at connection accept time - so assigning a new value here (a whole-object replacement, never a mutation of the previous instance) takes effect for the next check any in-flight or new request makes, without restarting the proxy. This is the \"runtime switch to drop to Observe without redeploying\" the plan requires; see WithAllObservedExceptDisabled() for the one-call way to do that. Defaults to AllEnforce, matching Balanced. Assigning Profile also replaces this value with that profile's bundle; assign PolicyModes afterward to deviate from the selected profile's modes without changing anything else the profile set. Declaration public ProxyPolicyModes PolicyModes { get; set; } Property Value Type Description ProxyPolicyModes | Edit this page View Source Profile The last profile applied via this property's setter, defaulting to Balanced - the profile every field on this instance already starts at, so a fresh new ProxyServer() reports Balanced without needing its setter to run once at construction time. Assigning this property applies its entire ProxyProfileSettings bundle - ResourceLimits, PolicyModes, SupportedSslProtocols, BlockPrivateNetworkDestinations, MaxConcurrentClientConnections and the deadline-seconds properties - as a single atomic assignment, so a reader can never observe a half-applied profile. Assigning any of those properties individually afterward overrides just that one, without reverting the rest of the profile's bundle. Logged once per Start(bool) call, by name only - never with hosts, URLs or secrets, per the plan's rollout section. Declaration public ProxyProfile Profile { get; set; } Property Value Type Description ProxyProfile | Edit this page View Source ProxyAuthenticationRealm Realm used during Proxy Basic Authentication. Declaration public string ProxyAuthenticationRealm { get; set; } Property Value Type Description string | Edit this page View Source ProxyAuthenticationSchemes A collection of scheme types, e.g. basic, NTLM, Kerberos, Negotiate, to return if scheme authentication is required. Works in relation with ProxySchemeAuthenticateFunc. Declaration public IEnumerable ProxyAuthenticationSchemes { get; set; } Property Value Type Description IEnumerable | Edit this page View Source ProxyBasicAuthenticateFunc A callback to authenticate proxy clients via basic authentication. Parameters are username and password as provided by client. Should return true for successful authentication. Declaration public Func>? ProxyBasicAuthenticateFunc { get; set; } Property Value Type Description Func> | Edit this page View Source ProxyEndPoints A list of IpAddress and port this proxy is listening to. Declaration public List ProxyEndPoints { get; set; } Property Value Type Description List | Edit this page View Source ProxyRunning Is the proxy currently running? Declaration public bool ProxyRunning { get; } Property Value Type Description bool | Edit this page View Source ProxySchemeAuthenticateFunc A pluggable callback to authenticate clients by scheme instead of requiring basic authentication through ProxyBasicAuthenticateFunc. Parameters are current working session, schemeType, and token as provided by a calling client. Should return success for successful authentication, continuation if the package requests, or failure. Declaration public Func>? ProxySchemeAuthenticateFunc { get; set; } Property Value Type Description Func> | Edit this page View Source RequestTimeoutSeconds Total seconds allowed for a single request/response exchange after BeforeRequest returns (connect, send, wait for headers, and body copy). Default is 0 (disabled). Per-session override: RequestTimeout. Declaration public int RequestTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ResourceLimits The shared, immutable resource-bound snapshot (concurrent-stream cap, CONTINUATION frame-count/wall-clock bounds, peer-initiated incomplete-stream-reset budget, and the other limits described in ProxyResourceLimits) consulted by the HTTP/2 relay so a single proxy-owned value governs both what is enforced and what is advertised to each peer, rather than admitting purely against whatever the origin advertised. Assign a new ProxyResourceLimits (constructed via Create(long, int, long, long?, long?, double?, int?, int, int?, int, TimeSpan, bool, int, int?)) to override the Default snapshot. There is no artificial upper clamp: high-CPU/RAM hosts may pass larger maxCachedConnectionsPerHost, maxConcurrentStreamsPerConnection, etc. as needed. The live TCP pool depth knob MaxCachedConnections remains independently settable and should usually be kept in sync with MaxCachedConnectionsPerHost. Declaration public ProxyResourceLimits ResourceLimits { get; set; } Property Value Type Description ProxyResourceLimits | Edit this page View Source ResponseHeaderTimeoutSeconds Seconds to wait for the origin to send the response status line and headers after the request has been sent. Enforced with a linked CancellationTokenSource (not Socket receive timeout alone). When the deadline elapses a ProxyTimeoutException with ResponseHeader is raised (and may be converted to HTTP 504 before any response bytes have been committed to the client). Default is 0 (disabled). WebSocket upgrades, Server-Sent Events, raw tunnels, and sessions that already wrote a response status to the client are exempt; those waits use IdleReadTimeoutSeconds when configured. Per-session override: ResponseHeaderTimeout. Declaration public int ResponseHeaderTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ReuseSocket When true (default), SO_REUSEADDR is requested where Titanium.Web.Proxy.Helpers.RunTime.IsSocketReuseAvailable() reports support (always on Windows; on non-Windows, .NET Core 3+ / compatible runtimes). Declaration public bool ReuseSocket { get; set; } Property Value Type Description bool | Edit this page View Source ReverseProxy Optional reverse-proxy route/cluster configuration. When null (default), Core keeps 6.x ForwardHost behavior with zero added cost on the hot path. Declaration public ReverseProxyOptions? ReverseProxy { get; set; } Property Value Type Description ReverseProxyOptions | Edit this page View Source ServerConnectionCount Total number of active server connections (TCP plus upstream QUIC). For HTTP/3-only upstreams see Http3ServerConnectionCount. Declaration public int ServerConnectionCount { get; } Property Value Type Description int | Edit this page View Source ShouldInterceptHttp Optional per-request/stream predicate consulted only when the global interception gate is active. Return true to use the full SessionEventArgs path; return false to use the fast-forward path. null (the default) intercepts every request — preserving today's behavior. Declaration public Func? ShouldInterceptHttp { get; set; } Property Value Type Description Func | Edit this page View Source SupportedServerSslProtocols Ssl versions offered on outbound HTTPS connections to origins (and upstream proxies). Default None means “use SupportedSslProtocols” (typically TLS 1.2 and 1.3). Set an explicit mask to restrict or expand outbound-only independently of inbound client TLS. Older docs described None as “same as the proxy client.” That coupling is incorrect across protocol translations (e.g. inbound QUIC is always TLS 1.3 while outbound TCP SslStream on macOS SecureTransport cannot offer TLS 1.3). Declaration public SslProtocols SupportedServerSslProtocols { get; set; } Property Value Type Description SslProtocols | Edit this page View Source SupportedSslProtocols List of supported Ssl versions. Defaults to TLS 1.2/1.3 only as of 5.0 - a breaking change from 4.x, which also enabled SSL 3.0/TLS 1.0/1.1. Those legacy, broken-by-design protocols require an explicit opt-in by assigning this property directly (e.g. SslProtocols.Tls | SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13) if a legacy client/server genuinely requires them. Declaration public SslProtocols SupportedSslProtocols { get; set; } Property Value Type Description SslProtocols | Edit this page View Source TcpTimeWaitSeconds SO_LINGER timeout in seconds applied to client and upstream sockets via LingerOption (enabled with this timeout). This is not the kernel TCP TIME_WAIT duration — TIME_WAIT is controlled by the OS. A positive value means Close may block up to that many seconds flushing send buffers; use 0 for an abortive close (RST). Default is 0 so high-churn proxies avoid TIME_WAIT accumulation; the 1-second connection disposal delay already prefers peer-first close. Declaration public int TcpTimeWaitSeconds { get; set; } Property Value Type Description int | Edit this page View Source ThreadPoolWorkerThread Customize the minimum ThreadPool size (increase it on a server). Defaults to max(ProcessorCount * 2, 16) so short loopback/proxy workloads are not starved while the pool is still ramping workers. Declaration public int ThreadPoolWorkerThread { get; set; } Property Value Type Description int | Edit this page View Source UpStreamEndPoint Local adapter/NIC endpoint where proxy makes request via. Defaults via any IP addresses of this machine. When the resolved destination address family does not match this endpoint, it is ignored so dual-stack destinations can still connect (see UpStreamEndPointIPv4 / UpStreamEndPointIPv6). Declaration public IPEndPoint? UpStreamEndPoint { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamEndPointIPv4 Local bind endpoint used when the resolved upstream destination is IPv4. Takes precedence over UpStreamEndPoint for IPv4 destinations. Declaration public IPEndPoint? UpStreamEndPointIPv4 { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamEndPointIPv6 Local bind endpoint used when the resolved upstream destination is IPv6. Takes precedence over UpStreamEndPoint for IPv6 destinations. Declaration public IPEndPoint? UpStreamEndPointIPv6 { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamHttpProxy External proxy used for Http requests. Declaration public IExternalProxy? UpStreamHttpProxy { get; set; } Property Value Type Description IExternalProxy | Edit this page View Source UpStreamHttpsProxy External proxy used for Https requests. Declaration public IExternalProxy? UpStreamHttpsProxy { get; set; } Property Value Type Description IExternalProxy | Edit this page View Source UpstreamProxyConfigurationScript If set, the upstream proxy will be detected by a script that will be loaded from the provided Uri Declaration public Uri? UpstreamProxyConfigurationScript { get; set; } Property Value Type Description Uri | Edit this page View Source ViaHeaderPseudonym Pseudonym used in Via header fields appended to forwarded requests and responses (RFC 9110 §7.6.3). Defaults to DefaultViaHeaderPseudonym. Set to an empty string to disable Via header injection entirely. Loop detection uses this value: a request arriving with this pseudonym already present in Via is refused with 508 Loop Detected. Declaration public string ViaHeaderPseudonym { get; set; } Property Value Type Description string | Edit this page View Source WinAuthCredentialsProvider Optional per-session credential provider for server 401 WinAuth (NTLM/Negotiate/Kerberos). Return null to use the current process identity (legacy behavior). Do not put plaintext passwords on SessionEventArgs — use this callback instead. Windows SSPI only; ignored on non-Windows platforms. Declaration public Func>? WinAuthCredentialsProvider { get; set; } Property Value Type Description Func> Methods | Edit this page View Source AddEndPoint(ProxyEndPoint) Add a proxy end point. Declaration public void AddEndPoint(ProxyEndPoint endPoint) Parameters Type Name Description ProxyEndPoint endPoint The proxy endpoint. | Edit this page View Source ApplyLoggingConfiguration() Rebuilds the active logger/logger factory from the current Logging configuration, disposing any previously owned built-in providers. Called automatically from the constructor (with the default configuration) and from Start(bool). Call this explicitly any time after changing Logging and you want the change to take effect immediately - whether the proxy is stopped (e.g. before using CertificateManager directly) or already running. Declaration public void ApplyLoggingConfiguration() | Edit this page View Source ClearDecryptFailureBypass() Clears all learned decrypt-bypass entries. Declaration public void ClearDecryptFailureBypass() | Edit this page View Source DisableAllSystemProxies() Clear all proxy settings for current machine. Declaration public void DisableAllSystemProxies() | Edit this page View Source DisableSystemHttpProxy() Clear HTTP proxy settings of current machine. Declaration public void DisableSystemHttpProxy() | Edit this page View Source DisableSystemHttpsProxy() Clear HTTPS proxy settings of current machine. Declaration public void DisableSystemHttpsProxy() | Edit this page View Source DisableSystemProxy(ProxyProtocolType) Clear the specified proxy setting for current machine. Declaration public void DisableSystemProxy(ProxyProtocolType protocolType) Parameters Type Name Description ProxyProtocolType protocolType | Edit this page View Source Dispose() Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. Declaration public void Dispose() | Edit this page View Source Dispose(bool) Declaration [SuppressMessage(\"ApiDesign\", \"RS0016:Add public types and members to the declared API\", Justification = \"Protected Dispose(bool) is required by the standard IDisposable pattern but is not public API.\")] protected virtual void Dispose(bool disposing) Parameters Type Name Description bool disposing | Edit this page View Source GetDecryptFailureBypassEntries() Snapshot of current learned decrypt-bypass entries (may include non-active strikes). Declaration public IReadOnlyList GetDecryptFailureBypassEntries() Returns Type Description IReadOnlyList | Edit this page View Source RemoveDecryptFailureBypass(string) Removes one host from the learned decrypt-bypass cache. Declaration public bool RemoveDecryptFailureBypass(string host) Parameters Type Name Description string host Returns Type Description bool | Edit this page View Source RemoveEndPoint(ProxyEndPoint) Remove a proxy end point. Will throw error if the end point doesn't exist. Declaration public void RemoveEndPoint(ProxyEndPoint endPoint) Parameters Type Name Description ProxyEndPoint endPoint The existing endpoint to remove. | Edit this page View Source RestoreOriginalProxySettings() Restores the original proxy settings. Declaration public void RestoreOriginalProxySettings() | Edit this page View Source SetAsSystemHttpProxy(ExplicitProxyEndPoint) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemHttpProxy(ExplicitProxyEndPoint endPoint) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. | Edit this page View Source SetAsSystemHttpProxy(ExplicitProxyEndPoint, SystemProxySettings) Set the given explicit end point as the default HTTP proxy server for current machine. Declaration public void SetAsSystemHttpProxy(ExplicitProxyEndPoint endPoint, SystemProxySettings settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. SystemProxySettings settings The Windows system proxy settings. | Edit this page View Source SetAsSystemHttpsProxy(ExplicitProxyEndPoint) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemHttpsProxy(ExplicitProxyEndPoint endPoint) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. | Edit this page View Source SetAsSystemHttpsProxy(ExplicitProxyEndPoint, SystemProxySettings) Set the given explicit end point as the default HTTPS proxy server for current machine. Declaration public void SetAsSystemHttpsProxy(ExplicitProxyEndPoint endPoint, SystemProxySettings settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. SystemProxySettings settings The Windows system proxy settings. | Edit this page View Source SetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. ProxyProtocolType protocolType The proxy protocol type. | Edit this page View Source SetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType, SystemProxySettings?) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType, SystemProxySettings? settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. ProxyProtocolType protocolType The proxy protocol type. SystemProxySettings settings The Windows system proxy settings, or null to preserve the current bypass list. | Edit this page View Source SetHttp3Enabled(bool) Enables or disables EnableHttp3. Enabling still requires MsQuic (IsSupported); disabling is always applied. Safe to call while the proxy is running — new origin connections pick up the change. Existing sessions keep the protocol they already negotiated. Declaration public bool SetHttp3Enabled(bool enabled) Parameters Type Name Description bool enabled Returns Type Description bool true when HTTP/3 is enabled after the call. | Edit this page View Source ShouldBypassDecryptForLearnedHost(string?) When EnableDecryptFailureBypass is on and host is actively bypassed, returns true (decrypt should be skipped). Declaration public bool ShouldBypassDecryptForLearnedHost(string? host) Parameters Type Name Description string host Returns Type Description bool | Edit this page View Source Start(bool) Start this proxy server instance. Transactional: if any endpoint fails to start, every listener this call already started is stopped, the system-upstream-proxy resolver (if this call created one) is disposed, and ProxyRunning is left false before the exception propagates. A caller that catches the exception is left with an instance in exactly the same state as before calling Start(bool), not a partially-bound proxy with some endpoints silently listening. Declaration public void Start(bool changeSystemProxySettings = true) Parameters Type Name Description bool changeSystemProxySettings Whether or not clear any system proxy settings which is pointing to our own endpoint (causing a cycle). E.g due to ungracious proxy shutdown before. | Edit this page View Source Stop() Stop this proxy server instance. Endpoints remain registered so Start(bool) can re-listen on the same ports. In-flight sessions are cancelled; pooled upstream connections are cleared. The connection factory itself stays usable for a subsequent Start (it is only disposed with the proxy). Declaration public void Stop() | Edit this page View Source StopAsync(TimeSpan?) Asynchronously stop this proxy server, cancel in-flight sessions, and wait briefly for client connection count to drain before clearing the upstream pool. Declaration public Task StopAsync(TimeSpan? drainTimeout = null) Parameters Type Name Description TimeSpan? drainTimeout Maximum time to wait for active client handlers to exit after cancellation. Defaults to 5 seconds. Returns Type Description Task | Edit this page View Source TryDisableAllSystemProxies() Clear all OS proxy settings without throwing. Declaration public SystemProxyChangeResult TryDisableAllSystemProxies() Returns Type Description SystemProxyChangeResult | Edit this page View Source TryDisableSystemProxy(ProxyProtocolType) Clear OS proxy for the given protocols without throwing. Declaration public SystemProxyChangeResult TryDisableSystemProxy(ProxyProtocolType protocolType) Parameters Type Name Description ProxyProtocolType protocolType Returns Type Description SystemProxyChangeResult | Edit this page View Source TryEnableHttp3IfSupported() Turns on EnableHttp3 when MsQuic is available (IsSupported). Hosts (CLI, Inspector, examples) should call this instead of setting EnableHttp3 blindly. Returns true when HTTP/3 was enabled. Declaration public bool TryEnableHttp3IfSupported() Returns Type Description bool | Edit this page View Source TryRestoreOriginalProxySettings() Restore OS proxy without throwing. Declaration public SystemProxyChangeResult TryRestoreOriginalProxySettings() Returns Type Description SystemProxyChangeResult | Edit this page View Source TrySetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType, SystemProxySettings?) Enable OS system proxy without throwing. Failures are logged and returned so Inspector/CLI can show a status message instead of crashing. Declaration public SystemProxyChangeResult TrySetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType, SystemProxySettings? settings = null) Parameters Type Name Description ExplicitProxyEndPoint endPoint ProxyProtocolType protocolType SystemProxySettings settings Returns Type Description SystemProxyChangeResult Events | Edit this page View Source AfterResponse Intercept after response event from server. Declaration public event AsyncEventHandler? AfterResponse Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeRequest Intercept request event to server. Declaration public event AsyncEventHandler? BeforeRequest Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeResponse Intercept response event from server. Declaration public event AsyncEventHandler? BeforeResponse Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeUpStreamConnectRequest Intercept connect request sent to upstream proxy. Declaration public event AsyncEventHandler? BeforeUpStreamConnectRequest Event Type Type Description AsyncEventHandler | Edit this page View Source ClientCertificateSelectionCallback Event to override client certificate selection during mutual SSL authentication. Declaration public event AsyncEventHandler? ClientCertificateSelectionCallback Event Type Type Description AsyncEventHandler | Edit this page View Source ClientConnectionCountChanged Event occurs when client connection count changed. Declaration public event EventHandler? ClientConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source DecryptFailureBypassChanged Raised when a host becomes actively bypassed (threshold reached or same-CONNECT mark). Handlers must not block; Inspector marshals to the UI thread. Declaration public event EventHandler? DecryptFailureBypassChanged Event Type Type Description EventHandler | Edit this page View Source Http3ClientConnectionCountChanged Event occurs when inbound HTTP/3 client connection count changed. Declaration public event EventHandler? Http3ClientConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source Http3ServerConnectionCountChanged Event occurs when upstream HTTP/3 server connection count changed. Declaration public event EventHandler? Http3ServerConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source OnClientConnectionCreate Customize TcpClient used for client connection upon create. Declaration public event AsyncEventHandler? OnClientConnectionCreate Event Type Type Description AsyncEventHandler | Edit this page View Source OnRequestBodyWrite Intercept request body send event to server. Subscribe to inspect or modify the request body chunk-by-chunk as it streams to the server, without buffering the whole body. Do not combine with SessionEventArgs.GetRequestBody (which buffers). Declaration public event AsyncEventHandler? OnRequestBodyWrite Event Type Type Description AsyncEventHandler | Edit this page View Source OnResponseBodyWrite Intercept response body send event to client. Subscribe to inspect or modify the response body chunk-by-chunk as it streams to the client, without buffering the whole body. Do not combine with SessionEventArgs.GetResponseBody (which buffers). Declaration public event AsyncEventHandler? OnResponseBodyWrite Event Type Type Description AsyncEventHandler | Edit this page View Source OnServerConnectionCreate Customize TcpClient used for server connection upon create. Declaration public event AsyncEventHandler? OnServerConnectionCreate Event Type Type Description AsyncEventHandler | Edit this page View Source ServerCertificateValidationCallback Event to override the default verification logic of remote SSL certificate received during authentication. Declaration public event AsyncEventHandler? ServerCertificateValidationCallback Event Type Type Description AsyncEventHandler | Edit this page View Source ServerConnectionCountChanged Event occurs when server connection count changed. Declaration public event EventHandler? ServerConnectionCountChanged Event Type Type Description EventHandler Implements IDisposable" + "summary": "Class ProxyServer Translates an HTTP/1.1 client connection onto an h2-only origin (Http2 with AllowHttpProtocolTranslation enabled - see ResolveHttp2ForClientAsync(SessionEventArgsBase, bool, string, int, string, int?, UpstreamHttpProtocol, bool, bool, CancellationToken, bool)), leasing one h2 stream per HTTP/1.1 request from a shared Titanium.Web.Proxy.Http2.Http2OriginConnection via Titanium.Web.Proxy.ProxyServer.Http2OriginConnectionPool rather than opening a new TCP/TLS connection for every request. Inheritance object ProxyServer Implements IDisposable Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy Assembly: Titanium.Web.Proxy.dll Syntax public class ProxyServer : IDisposable Remarks This re-implements the HTTP/1.1 client read loop (request line, headers, BeforeRequest, authorization, header preparation, CancelRequest/replaced-response handling) rather than reusing the private HandleHttpSessionRequest/HandleHttpSessionResponse methods, because those methods send/receive over TcpServerConnection.Stream using the raw HTTP/1.1 wire format, which an h2 origin connection cannot speak. This mirrors the precedent set by the h2-to-HTTP/1.1 bridge (Http2ToHttp11BridgeHandler), which similarly bypasses the wire-format-specific machinery for the leg that does not match it. Origin connections are multiplexed across independent HTTP/1.1 clients through Titanium.Web.Proxy.ProxyServer.Http2OriginConnectionPool (fan-in share). Response bodies are delivered via Titanium.Web.Proxy.Http2.Http2OriginConnection streaming writers where available. Constructors | Edit this page View Source ProxyServer(bool, bool, bool) Initializes a new instance of ProxyServer class with provided parameters. Declaration public ProxyServer(bool userTrustRootCertificate = true, bool machineTrustRootCertificate = false, bool trustRootCertificateAsAdmin = false) Parameters Type Name Description bool userTrustRootCertificate When true (the default), EnsureRootCertificate() installs the MITM root into the current-user Personal and Trusted Root stores. Prefer user-only trust for interactive apps; pass false when trust must be fully opt-in. bool machineTrustRootCertificate When true, also trust in the local-machine stores (needs elevation). Defaults to false — machine trust is opt-in for services/admin installs, not for normal desktop use. bool trustRootCertificateAsAdmin When true, attempt elevated trust via UAC (Windows only). Defaults to false. | Edit this page View Source ProxyServer(string?, string?, bool, bool, bool) Initializes a new instance of ProxyServer class with provided parameters. Declaration public ProxyServer(string? rootCertificateName, string? rootCertificateIssuerName, bool userTrustRootCertificate = true, bool machineTrustRootCertificate = false, bool trustRootCertificateAsAdmin = false) Parameters Type Name Description string rootCertificateName Name of the root certificate. string rootCertificateIssuerName Name of the root certificate issuer. bool userTrustRootCertificate When true (the default), EnsureRootCertificate() installs the MITM root into the current-user Personal and Trusted Root stores. Prefer user-only trust for interactive apps; pass false when trust must be fully opt-in. bool machineTrustRootCertificate When true, also trust in the local-machine stores (needs elevation). Defaults to false — machine trust is opt-in for services/admin installs, not for normal desktop use. bool trustRootCertificateAsAdmin When true, attempt elevated trust via UAC (Windows only). Defaults to false. Fields | Edit this page View Source DefaultViaHeaderPseudonym Default Via header pseudonym (RFC 9110 §7.6.3). Used by ViaHeaderPseudonym and by Inspector when Add Via header is enabled. Declaration public const string DefaultViaHeaderPseudonym = \"titanium-web-proxy\" Field Value Type Description string Properties | Edit this page View Source AdmittedClientConnectionCount Number of client connections currently admitted (accepted and past the admission gate, not yet finished being handled), across all TCP-based endpoints. Unlike ClientConnectionCount, this drops to zero as soon as the handler returns, without the trailing TIME_WAIT delay. Declaration public int AdmittedClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source BlockPrivateNetworkDestinations Outbound destination policy hook: when true, every resolved destination IP address is checked against loopback, private (RFC 1918/4193), link-local (which subsumes the 169.254.169.254 cloud metadata endpoint), and other non-globally-routable ranges before connecting, and the connection attempt is rejected with an OutboundDestinationBlockedException if it matches. Off by default: blocking private destinations would break this library's most common configurations, including upstream-proxy chaining to localhost and interception of local development servers. Only enable this when the proxy accepts requests from untrusted clients (an SSRF-relevant deployment), where those same destinations become an attacker-reachable pivot into the host's private network instead of an operator's own intentional configuration. An explicitly configured upstream proxy address (UpStreamHttpProxy, UpStreamHttpsProxy, or a per-session external proxy) is always exempt - that address is operator intent, not attacker-controlled. Checked against the resolved address actually used to connect (no re-resolution afterward, which would make the check a TOCTOU no-op against DNS rebinding). Not currently enforced for a SOCKS upstream with ProxyDnsRequests enabled, since the proxy never resolves the origin itself in that mode and has no address of its own to validate. Declaration public bool BlockPrivateNetworkDestinations { get; set; } Property Value Type Description bool | Edit this page View Source BufferPool The buffer pool used throughout this proxy instance. Set custom implementations by implementing this interface. By default this uses DefaultBufferPool implementation available in StreamExtended library package. Buffer size should be at least 10 bytes. Declaration public IBufferPool BufferPool { get; set; } Property Value Type Description IBufferPool | Edit this page View Source CertificateManager Manages certificates used by this proxy. Declaration public CertificateManager CertificateManager { get; } Property Value Type Description CertificateManager | Edit this page View Source CheckCertificateRevocation Should we check for certificate revocation during SSL authentication to servers Note: If enabled can reduce performance. Defaults to false. Declaration public X509RevocationMode CheckCertificateRevocation { get; set; } Property Value Type Description X509RevocationMode | Edit this page View Source ClientConnectionCount Total number of active TCP client connections. Does not include inbound HTTP/3 (QUIC) clients; see Http3ClientConnectionCount. Declaration public int ClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source ClientHeaderTimeoutSeconds Seconds to wait for a client to finish sending the request line and headers, from the moment this proxy starts reading a new request on the connection. Enforced with a linked CancellationTokenSource around the request-line and header read, not Socket.ReceiveTimeout: that property only bounds a single blocking Receive call, not the asynchronous reads this proxy actually issues, so without this deadline a client that opens a connection and trickles bytes arbitrarily slowly (or stops sending entirely) after the first byte ties up a read loop indefinitely. Default is 0 (disabled), matching every other deadline in this class - no per-session override exists because there is no SessionEventArgs for this request yet at the point this deadline applies. Declaration public int ClientHeaderTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source CompatibilityMode100Continue When true, the proxy immediately responds with a synthetic 100 Continue to any client request carrying Expect: 100-continue, before forwarding the headers to the origin and without waiting for the origin to respond. This breaks the strict handshake (client → proxy 100 → client body → origin body) but prevents the deadlock that occurs with strict clients when Enable100ContinueBehaviour is false (the default). Has no effect when Enable100ContinueBehaviour is true. Default: false. Declaration public bool CompatibilityMode100Continue { get; set; } Property Value Type Description bool | Edit this page View Source ConnectTimeOutSeconds Seconds server connection are to wait for connection to be established. Default value is 20 seconds. Declaration public int ConnectTimeOutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ConnectionTimeOutSeconds Seconds client/server connection are to be kept alive when waiting for read/write to complete. This will also determine the pool eviction time when connection pool is enabled. Default value is 60 seconds. Declaration public int ConnectionTimeOutSeconds { get; set; } Property Value Type Description int | Edit this page View Source CustomUpStreamProxyFailureFunc A callback to provide a chance for an upstream proxy failure to be handled by a new upstream proxy. User should return the ExternalProxy object with valid credentials or null. Declaration public Func>? CustomUpStreamProxyFailureFunc { get; set; } Property Value Type Description Func> | Edit this page View Source DecryptFailureBypassMaxEntries Maximum learned hosts retained (approximate LRU eviction). Default 256. Declaration public int DecryptFailureBypassMaxEntries { get; set; } Property Value Type Description int | Edit this page View Source DecryptFailureBypassThreshold Origin TLS failure strikes required before a host is bypassed on later CONNECTs. Same-CONNECT opaque fallback after an awaited H2 probe failure marks bypass immediately. Default 2. Declaration public int DecryptFailureBypassThreshold { get; set; } Property Value Type Description int | Edit this page View Source DecryptFailureBypassTtl How long a learned decrypt-bypass entry remains valid. Default 30 minutes. Declaration public TimeSpan DecryptFailureBypassTtl { get; set; } Property Value Type Description TimeSpan | Edit this page View Source DnsServerEndPoint DNS server endpoint used by Titanium.Web.Proxy.Http3.Dns.UdpSvcbDnsResolver for HTTPS/SVCB queries. Defaults to the first usable OS-configured plain-UDP DNS server discovered via NetworkInterface. This is a best-effort default and does not honor Windows NRPT, DoH, or VPN split-DNS policy. When no OS-configured DNS server can be discovered, the property reports 0.0.0.0:0 and proactive SVCB discovery is skipped (never falls back to a public third-party resolver). Assign an explicit endpoint to override discovery. Declaration [Experimental(\"TWP001\")] public IPEndPoint DnsServerEndPoint { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source Enable100ContinueBehaviour Does this proxy uses the HTTP protocol 100 continue behaviour strictly? Broken 100 continue implementations on server/client may cause problems if enabled. Defaults to false. Declaration public bool Enable100ContinueBehaviour { get; set; } Property Value Type Description bool | Edit this page View Source EnableConnectionPool Should we enable the server connection pool. Defaults to true. When connection pooling is enabled, instead of creating a new TCP connection to the server for each client TCP connection, we check if an idle server connection is available in our cached pool. If a compatible connection (same destination, scheme, upstream proxy, credentials and negotiated protocol) created from an earlier request is available, we reuse it. Only connections that are safe to reuse under the HTTP protocol are pooled: the response body must be fully received and the connection must be persistent (HTTP/1.1 keep-alive, or an HTTP/1.0 connection that explicitly opted in via \"Connection: keep-alive\"). Connections whose response asked to close, that failed, or that carry connection-oriented authentication state (WinAuth NTLM/Negotiate) or a per-session client certificate are never returned to the shared pool. The ConnectionTimeOutSeconds parameter determines the eviction time for inactive server connections. This reduces TCP (and TLS) connection establishment cost, both in wall clock time and CPU cycles. Set to false to force a fresh server connection for every client connection. Declaration public bool EnableConnectionPool { get; set; } Property Value Type Description bool | Edit this page View Source EnableDecryptFailureBypass When true, the proxy learns hosts whose origin TLS handshake fails under MITM (non-ALPN AuthenticationException, typically bot / TLS-fingerprint rejection) and tunnels subsequent CONNECTs without decrypt. Default false so library and RPS baselines are unchanged. Inspector enables this by default. Success-path cost when on is one dictionary lookup per CONNECT. Declaration public bool EnableDecryptFailureBypass { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttp2 Enable disable HTTP/2 support. Client-facing HTTP/2 is negotiated via TLS ALPN, or as prior-knowledge cleartext h2c on a transparent reverse endpoint (DecryptSsl: false). No Upgrade: h2c. Origin-facing HTTP/2 uses TLS ALPN h2 by default; with ForwardCleartext and Http2, the origin speaks cleartext HTTP/2 prior-knowledge (outbound h2c). A client/server that does not support HTTP/2 transparently falls back to HTTP/1.1 when policy allows. Request/response header and body modification in BeforeRequest/BeforeResponse, chunked trailers, interim (1xx) responses, and the synthetic-response APIs (Ok/Respond/Redirect/GenericResponse/ RespondStreaming) are all supported over HTTP/2, the same as over HTTP/1.x. Not supported: HTTP/2 server push (the wire frames are transcoded but there is no public API to originate a push) and Upgrade: h2c. Explicit-proxy inbound h2c is not implemented. See the protocol support matrix on the wiki for exact, up-to-date HTTP/1.x/HTTP/2 feature coverage. Declaration public bool EnableHttp2 { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttp3 Enable HTTP/3 (QUIC) support. When true: Any TransparentQuicProxyEndPoint is started as a UDP-only QUIC listener for transparent/NAT HTTP/3 interception. Any TransparentProxyEndPoint with EnableHttp3 also listens for HTTP/3 on the same IP:port (TCP H1/H2 + UDP H3) and injects client-facing Alt-Svc. With Auto (default), a cached Alt-Svc / HTTPS/SVCB capability selects outbound HTTP/3 on the next CONNECT or new HTTP/1.1 request. Background QUIC warm-up starts when the cache is filled so that handshake is often already done. An already-open H2↔H2 MITM session is not upgraded mid-connection. Forced Http3 fails closed with no TCP fallback. Requires MsQuic native library and a supported operating-system version (IsSupported). Setting to true with 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: false (opt-in). Experimental: HTTP/3 support has not yet completed the full interop/soak/fuzz gate process. Suppress TWP001 to opt in; the attribute is removed when the feature graduates to stable. Declaration [Experimental(\"TWP001\")] public bool EnableHttp3 { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttpInterception Forces the full interception path (SessionEventArgs, BeforeRequest, etc.) even when no event handlers are subscribed. Set this when consuming SessionEventArgs for timing or metrics without subscribing to any event. Default: false. Declaration public bool EnableHttpInterception { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttpsSvcbDnsDiscovery When true, the proxy queues a background HTTPS/SVCB RR (DNS type 65) lookup after an Auto-mode capability-cache miss. A positive result (ALPN h3 found) warms Titanium.Web.Proxy.Http3.Http3OriginCapabilityCache for subsequent connections; the CONNECT / request path itself never awaits DNS. Negative results are cached for 1 minute; transient failures use a short backoff. Defaults to true whenever EnableHttp3 is true. Set explicitly to false to disable discovery even when HTTP/3 is enabled — for example, when the configured DNS server is untrusted or unreachable. First-connection HTTP/3 adoption then comes from Alt-Svc. Declaration [Experimental(\"TWP001\")] public bool EnableHttpsSvcbDnsDiscovery { get; set; } Property Value Type Description bool | Edit this page View Source EnableIpv6UnreachableSoftSkip When true (default), after one IPv6 connect failure with NetworkUnreachable (or equivalent), temporarily omit IPv6 addresses from the Happy Eyeballs race for 30 seconds. Reduces first-chance SocketException noise on dual-stack hosts with a broken IPv6 path. Disable if operators require strict IPv6 preference even when the path is unreachable. Declaration public bool EnableIpv6UnreachableSoftSkip { get; set; } Property Value Type Description bool | Edit this page View Source EnableQpackDynamicTable When true, enables RFC 9204 QPACK dynamic table encoding and decoding for inbound HTTP/3 connections. Each connection gets its own Titanium.Web.Proxy.Http3.Qpack.QpackContext with two independent 4096-byte tables (one inbound, one outbound). Defaults to false (static-table-only); existing deployments are unaffected. Declaration [Experimental(\"TWP001\")] public bool EnableQpackDynamicTable { get; set; } Property Value Type Description bool | Edit this page View Source EnableRequestTimingCapture Enables structured request/connection timing capture. When false (the default) no timing objects are allocated and no UtcNow calls are made for timing purposes anywhere in the proxy, so there is zero overhead on the hot path. When enabled, every SessionEventArgsBase exposes a populated Timing (per-request phases: client header read, connection wait, request send, time-to-first-byte, response delivery, total), every upstream connection exposes a populated UpstreamConnectionTiming (reachable from a session via UpstreamConnectionTiming, describing DNS, TCP connect, optional upstream-proxy CONNECT, and TLS handshake durations), and a decrypted TunnelConnectSessionEventArgs exposes the client-facing TLS handshake duration via ClientTlsTiming. Can be toggled at any time; it only affects sessions/connections created after the change, never mutating timing objects already handed out. Defaults to false. Declaration public bool EnableRequestTimingCapture { get; set; } Property Value Type Description bool | Edit this page View Source EnableRfc8441 When true, the proxy enables RFC 8441 WebSocket-over-HTTP/2: Accepts extended CONNECT (:protocol = websocket) from h2 clients and advertises SETTINGS_ENABLE_CONNECT_PROTOCOL=1 to them. Per origin: if the origin is HTTP/2 and advertises RFC 8441 support, DATA frames are relayed directly; if the origin is HTTP/2 and does not, the stream is reset with REFUSED_STREAM; if the origin is HTTP/1.1, the h2→h1 WebSocket upgrade bridge is used. On the HTTP/1.1-client-to-h2-origin translation bridge, translates Upgrade: websocket into extended CONNECT when the origin advertises the setting; otherwise falls back to a dedicated HTTP/1.1 origin connection for that WebSocket. When this property is false, that bridge still returns synthetic 501 Not Implemented for WebSocket upgrades (historical default). Default: false (must opt-in). Declaration public bool EnableRfc8441 { get; set; } Property Value Type Description bool | Edit this page View Source EnableTcpKeepAlive Enable TCP KeepAlive on client and server sockets so NAT/firewall mappings for long-lived CONNECT tunnels are refreshed. Default: true. Declaration public bool EnableTcpKeepAlive { get; set; } Property Value Type Description bool | Edit this page View Source EnableTcpServerConnectionPrefetch Should we enable tcp server connection prefetching? When enabled, as soon as we receive a client connection we concurrently initiate corresponding server connection process using CONNECT hostname or SNI hostname on a separate task so that after parsing client request we will have the server connection immediately ready or in the process of getting ready. If a server connection is available in cache then this prefetch task will immediately return with the available connection from cache. Defaults to true. Declaration public bool EnableTcpServerConnectionPrefetch { get; set; } Property Value Type Description bool | Edit this page View Source EnableWinAuth Enable disable Windows Authentication (NTLM/Kerberos). By default SSPI uses the process identity. To authenticate as another user, set WinAuthCredentialsProvider (issue #461). Defaults to false. Declaration public bool EnableWinAuth { get; set; } Property Value Type Description bool | Edit this page View Source EndpointAdmissionRejectionCount Total number of client connections rejected by any endpoint's MaxConcurrentClients since this instance was created. Declaration public long EndpointAdmissionRejectionCount { get; } Property Value Type Description long | Edit this page View Source ForwardToUpstreamGateway Gets or sets a value indicating whether requests will be chained to upstream gateway. Defaults to false. Declaration public bool ForwardToUpstreamGateway { get; set; } Property Value Type Description bool | Edit this page View Source GetCustomUpStreamProxyFunc A callback to provide authentication credentials for up stream proxy this proxy is using for HTTP(S) requests. User should return the ExternalProxy object with valid credentials. Declaration public Func>? GetCustomUpStreamProxyFunc { get; set; } Property Value Type Description Func> | Edit this page View Source GlobalAdmissionRejectionCount Total number of client connections rejected by MaxConcurrentClientConnections since this instance was created. Declaration public long GlobalAdmissionRejectionCount { get; } Property Value Type Description long | Edit this page View Source Http3ClientConnectionCount Total number of active inbound HTTP/3 (QUIC) client connections. Declaration public int Http3ClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source Http3ServerConnectionCount Total number of active upstream HTTP/3 (QUIC) server connections. These are also included in ServerConnectionCount. Declaration public int Http3ServerConnectionCount { get; } Property Value Type Description int | Edit this page View Source IdleReadTimeoutSeconds Seconds of idle time allowed while reading from the origin (stalled header/body waits). Applied via CancelAfter on the active read operation. Default is 0 (disabled). Per-session override: IdleReadTimeout. Declaration public int IdleReadTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source IdleWriteTimeoutSeconds Seconds of idle time allowed while writing to the origin (stalled header/body waits). Applied via CancelAfter on the active write operation. Default is 0 (disabled). Per-session override: IdleWriteTimeout. Declaration public int IdleWriteTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source IgnoreServerCertificateErrors When true, origin TLS certificates that fail OS chain validation are still accepted (MITM of loopback/self-signed/private CAs). Inspector's \"Ignore server certificate errors\" maps here. Default false. A subscribed ServerCertificateValidationCallback still wins. Declaration public bool IgnoreServerCertificateErrors { get; set; } Property Value Type Description bool | Edit this page View Source ListenerBackLog TCP listener accept backlog. Default: 1024 for burst connection handling. Declaration public int ListenerBackLog { get; set; } Property Value Type Description int | Edit this page View Source Logger The live, shared logger used throughout this proxy instance. Reflects the most recent call to ApplyLoggingConfiguration(). Declaration public ILogger Logger { get; } Property Value Type Description ILogger | Edit this page View Source Logging Configuration for this proxy instance's built-in diagnostic logging - the replacement for the removed ExceptionFunc callback. Every exception the proxy catches (even when handled internally and never surfaced to user code) is reported through this logger at an appropriate severity; see ProxyLoggingOptions for the console/file sinks, enable/disable switch, and minimum level. Mutate the returned instance (or assign a new one) at any point; each assignment/mutation you want to take effect must be followed by ApplyLoggingConfiguration() (which Start(bool) also calls automatically, so the configuration active at the moment the proxy starts running is picked up for the run even if you never call it yourself). Calling it again later - including while the proxy is already running - immediately swaps in the new configuration; this is safe because logging never blocks or otherwise affects proxy traffic. Declaration public ProxyLoggingOptions Logging { get; set; } Property Value Type Description ProxyLoggingOptions | Edit this page View Source MaxBufferedBodyBytes Maximum bytes the proxy will buffer for a single request or response body when body buffering is required (body-read hooks, authentication retry, etc.). Bodies larger than this limit are rejected with 413 (upstream request) or connection teardown (upstream response). Set to 0 to disable the limit (not recommended). Default: 4,194,304 (4 MiB). Declaration public int MaxBufferedBodyBytes { get; set; } Property Value Type Description int | Edit this page View Source MaxCachedConnections Maximum number of concurrent connections per remote host in cache. Only meaningful when EnableConnectionPool is true; to disable pooling, set EnableConnectionPool to false rather than setting this to 0 - the pool eviction loop treats a value below 1 as \"evict without limit while holding the pool-wide lock\", which spins indefinitely once the cache for that host is empty and would stall every other connection acquire/release in the process. Rejected outright at assignment so that state cannot be reached. Default value is 128. Declaration public int MaxCachedConnections { get; set; } Property Value Type Description int Exceptions Type Condition ArgumentOutOfRangeException The assigned value is less than 1. | Edit this page View Source MaxConcurrentClientConnections Maximum number of client connections admitted across all TCP-based endpoints at once. null (the default) disables the global admission gate, preserving today's unbounded behavior. When set, a connection beyond this limit is rejected and disposed immediately after accept, before a handler task is even started. Enforced independently of ClientConnectionCount: see Titanium.Web.Proxy.ProxyServer.admittedClientConnectionCount for why. See also MaxConcurrentClients for a per-endpoint cap layered on top of this global one. Declaration public int? MaxConcurrentClientConnections { get; set; } Property Value Type Description int? | Edit this page View Source MaxConcurrentHttp11HttpsOriginCreates Caps concurrent new HTTPS origin TCP/TLS opens on the H2→H1 bridge only (MITM / re-encrypt). Pool hits (warm keep-alive) are uncapped. Cleartext H1 origins are not gated. Default is Clamp(ProcessorCount, 4, 32). Set before the first H2→H1 HTTPS origin open (typically before Start(bool)); changing the value after the create gate has been used has no effect on the live semaphore. Declaration public int MaxConcurrentHttp11HttpsOriginCreates { get; set; } Property Value Type Description int Exceptions Type Condition ArgumentOutOfRangeException The assigned value is less than 1. | Edit this page View Source MaxDecodedHeaderListBytes Maximum decoded HTTP/2 header list size in bytes, using RFC 7541 accounting (name.Length + value.Length + 32 per field). Requests or responses with a decoded header list exceeding this limit will be refused with RST_STREAM(ENHANCE_YOUR_CALM) (code 0xb). Set to 0 to disable the limit (not recommended). Default: 65,536 (64 KiB). Advertised via SETTINGS_MAX_HEADER_LIST_SIZE. Declaration public int MaxDecodedHeaderListBytes { get; set; } Property Value Type Description int | Edit this page View Source MaxWebSocketFramePayloadBytes Maximum WebSocket frame payload size in bytes that the proxy will accept during frame-level interception (i.e. when BeforeWebSocketFrame has at least one subscriber). Frames whose decoded payload exceeds this limit cause the WebSocket connection to be closed with Close code 1009 (Message Too Big). Raw-relay sessions (no BeforeWebSocketFrame subscriber) bypass this check entirely and pass all frames through unvalidated. Default: 16,777,216 (16 MiB). Declaration public int MaxWebSocketFramePayloadBytes { get; set; } Property Value Type Description int | Edit this page View Source NetworkFailureRetryAttempts Number of times to retry upon network failures when connection pool is enabled. Declaration public int NetworkFailureRetryAttempts { get; set; } Property Value Type Description int | Edit this page View Source NoDelay Gets or sets a Boolean value that specifies whether server and client stream Sockets are using the Nagle algorithm. Defaults to true, no nagle algorithm is used. Declaration public bool NoDelay { get; set; } Property Value Type Description bool | Edit this page View Source OriginHttpVersionPolicy Controls which HTTP version is declared to the origin server on the request line, independently of the version the client declared to the proxy. Defaults to PreserveClientVersion, which matches the proxy's historical pass-through behavior exactly. Set to NormalizeToHttp11 to let HTTP/1.0 clients share pooled, persistent origin connections the same way HTTP/1.1 clients already do. This only changes the wire version written to the origin request line - it never changes the client-facing Http.Request.HttpVersion that event handlers observe, nor the version/persistence used to write the response back to the client. Declaration public OriginHttpVersionPolicy OriginHttpVersionPolicy { get; set; } Property Value Type Description OriginHttpVersionPolicy | Edit this page View Source PolicyModes Which resource-bound PolicyFamily is enforced, observed, or disabled, per the plan's rollout section. Read live by each family's enforcement call site - not baked into a per-request snapshot at connection accept time - so assigning a new value here (a whole-object replacement, never a mutation of the previous instance) takes effect for the next check any in-flight or new request makes, without restarting the proxy. This is the \"runtime switch to drop to Observe without redeploying\" the plan requires; see WithAllObservedExceptDisabled() for the one-call way to do that. Defaults to AllEnforce, matching Balanced. Assigning Profile also replaces this value with that profile's bundle; assign PolicyModes afterward to deviate from the selected profile's modes without changing anything else the profile set. Declaration public ProxyPolicyModes PolicyModes { get; set; } Property Value Type Description ProxyPolicyModes | Edit this page View Source Profile The last profile applied via this property's setter, defaulting to Balanced - the profile every field on this instance already starts at, so a fresh new ProxyServer() reports Balanced without needing its setter to run once at construction time. Assigning this property applies its entire ProxyProfileSettings bundle - ResourceLimits, PolicyModes, SupportedSslProtocols, BlockPrivateNetworkDestinations, MaxConcurrentClientConnections and the deadline-seconds properties - as a single atomic assignment, so a reader can never observe a half-applied profile. Assigning any of those properties individually afterward overrides just that one, without reverting the rest of the profile's bundle. Logged once per Start(bool) call, by name only - never with hosts, URLs or secrets, per the plan's rollout section. Declaration public ProxyProfile Profile { get; set; } Property Value Type Description ProxyProfile | Edit this page View Source ProxyAuthenticationRealm Realm used during Proxy Basic Authentication. Declaration public string ProxyAuthenticationRealm { get; set; } Property Value Type Description string | Edit this page View Source ProxyAuthenticationSchemes A collection of scheme types, e.g. basic, NTLM, Kerberos, Negotiate, to return if scheme authentication is required. Works in relation with ProxySchemeAuthenticateFunc. Declaration public IEnumerable ProxyAuthenticationSchemes { get; set; } Property Value Type Description IEnumerable | Edit this page View Source ProxyBasicAuthenticateFunc A callback to authenticate proxy clients via basic authentication. Parameters are username and password as provided by client. Should return true for successful authentication. Declaration public Func>? ProxyBasicAuthenticateFunc { get; set; } Property Value Type Description Func> | Edit this page View Source ProxyEndPoints A list of IpAddress and port this proxy is listening to. Declaration public List ProxyEndPoints { get; set; } Property Value Type Description List | Edit this page View Source ProxyRunning Is the proxy currently running? Declaration public bool ProxyRunning { get; } Property Value Type Description bool | Edit this page View Source ProxySchemeAuthenticateFunc A pluggable callback to authenticate clients by scheme instead of requiring basic authentication through ProxyBasicAuthenticateFunc. Parameters are current working session, schemeType, and token as provided by a calling client. Should return success for successful authentication, continuation if the package requests, or failure. Declaration public Func>? ProxySchemeAuthenticateFunc { get; set; } Property Value Type Description Func> | Edit this page View Source RequestTimeoutSeconds Total seconds allowed for a single request/response exchange after BeforeRequest returns (connect, send, wait for headers, and body copy). Default is 0 (disabled). Per-session override: RequestTimeout. Declaration public int RequestTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ResourceLimits The shared, immutable resource-bound snapshot (concurrent-stream cap, CONTINUATION frame-count/wall-clock bounds, peer-initiated incomplete-stream-reset budget, and the other limits described in ProxyResourceLimits) consulted by the HTTP/2 relay so a single proxy-owned value governs both what is enforced and what is advertised to each peer, rather than admitting purely against whatever the origin advertised. Assign a new ProxyResourceLimits (constructed via Create(long, int, long, long?, long?, double?, int?, int, int?, int, TimeSpan, bool, int, int?)) to override the Default snapshot. There is no artificial upper clamp: high-CPU/RAM hosts may pass larger maxCachedConnectionsPerHost, maxConcurrentStreamsPerConnection, etc. as needed. The live TCP pool depth knob MaxCachedConnections remains independently settable and should usually be kept in sync with MaxCachedConnectionsPerHost. Declaration public ProxyResourceLimits ResourceLimits { get; set; } Property Value Type Description ProxyResourceLimits | Edit this page View Source ResponseHeaderTimeoutSeconds Seconds to wait for the origin to send the response status line and headers after the request has been sent. Enforced with a linked CancellationTokenSource (not Socket receive timeout alone). When the deadline elapses a ProxyTimeoutException with ResponseHeader is raised (and may be converted to HTTP 504 before any response bytes have been committed to the client). Default is 0 (disabled). WebSocket upgrades, Server-Sent Events, raw tunnels, and sessions that already wrote a response status to the client are exempt; those waits use IdleReadTimeoutSeconds when configured. Per-session override: ResponseHeaderTimeout. Declaration public int ResponseHeaderTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ReuseSocket When true (default), SO_REUSEADDR is requested where Titanium.Web.Proxy.Helpers.RunTime.IsSocketReuseAvailable() reports support (always on Windows; on non-Windows, .NET Core 3+ / compatible runtimes). Declaration public bool ReuseSocket { get; set; } Property Value Type Description bool | Edit this page View Source ReverseProxy Optional reverse-proxy route/cluster configuration. When null (default), Core keeps 6.x ForwardHost behavior with zero added cost on the hot path. Declaration public ReverseProxyOptions? ReverseProxy { get; set; } Property Value Type Description ReverseProxyOptions | Edit this page View Source ServerConnectionCount Total number of active server connections (TCP plus upstream QUIC). For HTTP/3-only upstreams see Http3ServerConnectionCount. Declaration public int ServerConnectionCount { get; } Property Value Type Description int | Edit this page View Source ShouldInterceptHttp Optional per-request/stream predicate consulted only when the global interception gate is active. Return true to use the full SessionEventArgs path; return false to use the fast-forward path. null (the default) intercepts every request — preserving today's behavior. Declaration public Func? ShouldInterceptHttp { get; set; } Property Value Type Description Func | Edit this page View Source SupportedServerSslProtocols Ssl versions offered on outbound HTTPS connections to origins (and upstream proxies). Default None means “use SupportedSslProtocols” (typically TLS 1.2 and 1.3). Set an explicit mask to restrict or expand outbound-only independently of inbound client TLS. Older docs described None as “same as the proxy client.” That coupling is incorrect across protocol translations (e.g. inbound QUIC is always TLS 1.3 while outbound TCP SslStream on macOS SecureTransport cannot offer TLS 1.3). Declaration public SslProtocols SupportedServerSslProtocols { get; set; } Property Value Type Description SslProtocols | Edit this page View Source SupportedSslProtocols List of supported Ssl versions. Defaults to TLS 1.2/1.3 only as of 5.0 - a breaking change from 4.x, which also enabled SSL 3.0/TLS 1.0/1.1. Those legacy, broken-by-design protocols require an explicit opt-in by assigning this property directly (e.g. SslProtocols.Tls | SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13) if a legacy client/server genuinely requires them. Declaration public SslProtocols SupportedSslProtocols { get; set; } Property Value Type Description SslProtocols | Edit this page View Source TcpTimeWaitSeconds SO_LINGER timeout in seconds applied to client and upstream sockets via LingerOption (enabled with this timeout). This is not the kernel TCP TIME_WAIT duration — TIME_WAIT is controlled by the OS. A positive value means Close may block up to that many seconds flushing send buffers; use 0 for an abortive close (RST). Default is 0 so high-churn proxies avoid TIME_WAIT accumulation; the 1-second connection disposal delay already prefers peer-first close. Declaration public int TcpTimeWaitSeconds { get; set; } Property Value Type Description int | Edit this page View Source ThreadPoolWorkerThread Customize the minimum ThreadPool size (increase it on a server). Defaults to max(ProcessorCount * 2, 16) so short loopback/proxy workloads are not starved while the pool is still ramping workers. Declaration public int ThreadPoolWorkerThread { get; set; } Property Value Type Description int | Edit this page View Source UpStreamEndPoint Local adapter/NIC endpoint where proxy makes request via. Defaults via any IP addresses of this machine. When the resolved destination address family does not match this endpoint, it is ignored so dual-stack destinations can still connect (see UpStreamEndPointIPv4 / UpStreamEndPointIPv6). Declaration public IPEndPoint? UpStreamEndPoint { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamEndPointIPv4 Local bind endpoint used when the resolved upstream destination is IPv4. Takes precedence over UpStreamEndPoint for IPv4 destinations. Declaration public IPEndPoint? UpStreamEndPointIPv4 { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamEndPointIPv6 Local bind endpoint used when the resolved upstream destination is IPv6. Takes precedence over UpStreamEndPoint for IPv6 destinations. Declaration public IPEndPoint? UpStreamEndPointIPv6 { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamHttpProxy External proxy used for Http requests. Declaration public IExternalProxy? UpStreamHttpProxy { get; set; } Property Value Type Description IExternalProxy | Edit this page View Source UpStreamHttpsProxy External proxy used for Https requests. Declaration public IExternalProxy? UpStreamHttpsProxy { get; set; } Property Value Type Description IExternalProxy | Edit this page View Source UpstreamProxyConfigurationScript If set, the upstream proxy will be detected by a script that will be loaded from the provided Uri Declaration public Uri? UpstreamProxyConfigurationScript { get; set; } Property Value Type Description Uri | Edit this page View Source ViaHeaderPseudonym Pseudonym used in Via header fields appended to forwarded requests and responses (RFC 9110 §7.6.3). Defaults to DefaultViaHeaderPseudonym. Set to an empty string to disable Via header injection entirely. Loop detection uses this value: a request arriving with this pseudonym already present in Via is refused with 508 Loop Detected. Declaration public string ViaHeaderPseudonym { get; set; } Property Value Type Description string | Edit this page View Source WinAuthCredentialsProvider Optional per-session credential provider for server 401 WinAuth (NTLM/Negotiate/Kerberos). Return null to use the current process identity (legacy behavior). Do not put plaintext passwords on SessionEventArgs — use this callback instead. Windows SSPI only; ignored on non-Windows platforms. Declaration public Func>? WinAuthCredentialsProvider { get; set; } Property Value Type Description Func> Methods | Edit this page View Source AddEndPoint(ProxyEndPoint) Add a proxy end point. Declaration public void AddEndPoint(ProxyEndPoint endPoint) Parameters Type Name Description ProxyEndPoint endPoint The proxy endpoint. | Edit this page View Source ApplyLoggingConfiguration() Rebuilds the active logger/logger factory from the current Logging configuration, disposing any previously owned built-in providers. Called automatically from the constructor (with the default configuration) and from Start(bool). Call this explicitly any time after changing Logging and you want the change to take effect immediately - whether the proxy is stopped (e.g. before using CertificateManager directly) or already running. Declaration public void ApplyLoggingConfiguration() | Edit this page View Source ClearDecryptFailureBypass() Clears all learned decrypt-bypass entries. Declaration public void ClearDecryptFailureBypass() | Edit this page View Source DisableAllSystemProxies() Clear all proxy settings for current machine. Declaration public void DisableAllSystemProxies() | Edit this page View Source DisableSystemHttpProxy() Clear HTTP proxy settings of current machine. Declaration public void DisableSystemHttpProxy() | Edit this page View Source DisableSystemHttpsProxy() Clear HTTPS proxy settings of current machine. Declaration public void DisableSystemHttpsProxy() | Edit this page View Source DisableSystemProxy(ProxyProtocolType) Clear the specified proxy setting for current machine. Declaration public void DisableSystemProxy(ProxyProtocolType protocolType) Parameters Type Name Description ProxyProtocolType protocolType | Edit this page View Source Dispose() Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. Declaration public void Dispose() | Edit this page View Source Dispose(bool) Declaration [SuppressMessage(\"ApiDesign\", \"RS0016:Add public types and members to the declared API\", Justification = \"Protected Dispose(bool) is required by the standard IDisposable pattern but is not public API.\")] protected virtual void Dispose(bool disposing) Parameters Type Name Description bool disposing | Edit this page View Source ForceDecryptFailureBypass(string) Marks host as actively bypassed (same as a forced learn after origin TLS failure). Raises DecryptFailureBypassChanged when bypass newly becomes active. Declaration public bool ForceDecryptFailureBypass(string host) Parameters Type Name Description string host Returns Type Description bool | Edit this page View Source GetDecryptFailureBypassEntries() Snapshot of current learned decrypt-bypass entries (may include non-active strikes). Declaration public IReadOnlyList GetDecryptFailureBypassEntries() Returns Type Description IReadOnlyList | Edit this page View Source RemoveDecryptFailureBypass(string) Removes one host from the learned decrypt-bypass cache. Declaration public bool RemoveDecryptFailureBypass(string host) Parameters Type Name Description string host Returns Type Description bool | Edit this page View Source RemoveEndPoint(ProxyEndPoint) Remove a proxy end point. Will throw error if the end point doesn't exist. Declaration public void RemoveEndPoint(ProxyEndPoint endPoint) Parameters Type Name Description ProxyEndPoint endPoint The existing endpoint to remove. | Edit this page View Source RestoreOriginalProxySettings() Restores the original proxy settings. Declaration public void RestoreOriginalProxySettings() | Edit this page View Source SetAsSystemHttpProxy(ExplicitProxyEndPoint) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemHttpProxy(ExplicitProxyEndPoint endPoint) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. | Edit this page View Source SetAsSystemHttpProxy(ExplicitProxyEndPoint, SystemProxySettings) Set the given explicit end point as the default HTTP proxy server for current machine. Declaration public void SetAsSystemHttpProxy(ExplicitProxyEndPoint endPoint, SystemProxySettings settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. SystemProxySettings settings The Windows system proxy settings. | Edit this page View Source SetAsSystemHttpsProxy(ExplicitProxyEndPoint) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemHttpsProxy(ExplicitProxyEndPoint endPoint) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. | Edit this page View Source SetAsSystemHttpsProxy(ExplicitProxyEndPoint, SystemProxySettings) Set the given explicit end point as the default HTTPS proxy server for current machine. Declaration public void SetAsSystemHttpsProxy(ExplicitProxyEndPoint endPoint, SystemProxySettings settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. SystemProxySettings settings The Windows system proxy settings. | Edit this page View Source SetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. ProxyProtocolType protocolType The proxy protocol type. | Edit this page View Source SetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType, SystemProxySettings?) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType, SystemProxySettings? settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. ProxyProtocolType protocolType The proxy protocol type. SystemProxySettings settings The Windows system proxy settings, or null to preserve the current bypass list. | Edit this page View Source SetHttp3Enabled(bool) Enables or disables EnableHttp3. Enabling still requires MsQuic (IsSupported); disabling is always applied. Safe to call while the proxy is running — new origin connections pick up the change. Existing sessions keep the protocol they already negotiated. Declaration public bool SetHttp3Enabled(bool enabled) Parameters Type Name Description bool enabled Returns Type Description bool true when HTTP/3 is enabled after the call. | Edit this page View Source ShouldBypassDecryptForLearnedHost(string?) When EnableDecryptFailureBypass is on and host is actively bypassed, returns true (decrypt should be skipped). Declaration public bool ShouldBypassDecryptForLearnedHost(string? host) Parameters Type Name Description string host Returns Type Description bool | Edit this page View Source Start(bool) Start this proxy server instance. Transactional: if any endpoint fails to start, every listener this call already started is stopped, the system-upstream-proxy resolver (if this call created one) is disposed, and ProxyRunning is left false before the exception propagates. A caller that catches the exception is left with an instance in exactly the same state as before calling Start(bool), not a partially-bound proxy with some endpoints silently listening. Declaration public void Start(bool changeSystemProxySettings = true) Parameters Type Name Description bool changeSystemProxySettings Whether or not clear any system proxy settings which is pointing to our own endpoint (causing a cycle). E.g due to ungracious proxy shutdown before. | Edit this page View Source Stop() Stop this proxy server instance. Endpoints remain registered so Start(bool) can re-listen on the same ports. In-flight sessions are cancelled; pooled upstream connections are cleared. The connection factory itself stays usable for a subsequent Start (it is only disposed with the proxy). Declaration public void Stop() | Edit this page View Source StopAsync(TimeSpan?) Asynchronously stop this proxy server, cancel in-flight sessions, and wait briefly for client connection count to drain before clearing the upstream pool. Declaration public Task StopAsync(TimeSpan? drainTimeout = null) Parameters Type Name Description TimeSpan? drainTimeout Maximum time to wait for active client handlers to exit after cancellation. Defaults to 5 seconds. Returns Type Description Task | Edit this page View Source TryDisableAllSystemProxies() Clear all OS proxy settings without throwing. Declaration public SystemProxyChangeResult TryDisableAllSystemProxies() Returns Type Description SystemProxyChangeResult | Edit this page View Source TryDisableSystemProxy(ProxyProtocolType) Clear OS proxy for the given protocols without throwing. Declaration public SystemProxyChangeResult TryDisableSystemProxy(ProxyProtocolType protocolType) Parameters Type Name Description ProxyProtocolType protocolType Returns Type Description SystemProxyChangeResult | Edit this page View Source TryEnableHttp3IfSupported() Turns on EnableHttp3 when MsQuic is available (IsSupported). Hosts (CLI, Inspector, examples) should call this instead of setting EnableHttp3 blindly. Returns true when HTTP/3 was enabled. Declaration public bool TryEnableHttp3IfSupported() Returns Type Description bool | Edit this page View Source TryRestoreOriginalProxySettings() Restore OS proxy without throwing. Declaration public SystemProxyChangeResult TryRestoreOriginalProxySettings() Returns Type Description SystemProxyChangeResult | Edit this page View Source TrySetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType, SystemProxySettings?) Enable OS system proxy without throwing. Failures are logged and returned so Inspector/CLI can show a status message instead of crashing. Declaration public SystemProxyChangeResult TrySetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType, SystemProxySettings? settings = null) Parameters Type Name Description ExplicitProxyEndPoint endPoint ProxyProtocolType protocolType SystemProxySettings settings Returns Type Description SystemProxyChangeResult Events | Edit this page View Source AfterResponse Intercept after response event from server. Declaration public event AsyncEventHandler? AfterResponse Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeRequest Intercept request event to server. Declaration public event AsyncEventHandler? BeforeRequest Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeResponse Intercept response event from server. Declaration public event AsyncEventHandler? BeforeResponse Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeUpStreamConnectRequest Intercept connect request sent to upstream proxy. Declaration public event AsyncEventHandler? BeforeUpStreamConnectRequest Event Type Type Description AsyncEventHandler | Edit this page View Source ClientCertificateSelectionCallback Event to override client certificate selection during mutual SSL authentication. Declaration public event AsyncEventHandler? ClientCertificateSelectionCallback Event Type Type Description AsyncEventHandler | Edit this page View Source ClientConnectionCountChanged Event occurs when client connection count changed. Declaration public event EventHandler? ClientConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source DecryptFailureBypassChanged Raised when a host becomes actively bypassed (threshold reached or same-CONNECT mark). Handlers must not block; Inspector marshals to the UI thread. Declaration public event EventHandler? DecryptFailureBypassChanged Event Type Type Description EventHandler | Edit this page View Source Http3ClientConnectionCountChanged Event occurs when inbound HTTP/3 client connection count changed. Declaration public event EventHandler? Http3ClientConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source Http3ServerConnectionCountChanged Event occurs when upstream HTTP/3 server connection count changed. Declaration public event EventHandler? Http3ServerConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source OnClientConnectionCreate Customize TcpClient used for client connection upon create. Declaration public event AsyncEventHandler? OnClientConnectionCreate Event Type Type Description AsyncEventHandler | Edit this page View Source OnRequestBodyWrite Intercept request body send event to server. Subscribe to inspect or modify the request body chunk-by-chunk as it streams to the server, without buffering the whole body. Do not combine with SessionEventArgs.GetRequestBody (which buffers). Declaration public event AsyncEventHandler? OnRequestBodyWrite Event Type Type Description AsyncEventHandler | Edit this page View Source OnResponseBodyWrite Intercept response body send event to client. Subscribe to inspect or modify the response body chunk-by-chunk as it streams to the client, without buffering the whole body. Do not combine with SessionEventArgs.GetResponseBody (which buffers). Declaration public event AsyncEventHandler? OnResponseBodyWrite Event Type Type Description AsyncEventHandler | Edit this page View Source OnServerConnectionCreate Customize TcpClient used for server connection upon create. Declaration public event AsyncEventHandler? OnServerConnectionCreate Event Type Type Description AsyncEventHandler | Edit this page View Source ServerCertificateValidationCallback Event to override the default verification logic of remote SSL certificate received during authentication. Declaration public event AsyncEventHandler? ServerCertificateValidationCallback Event Type Type Description AsyncEventHandler | Edit this page View Source ServerConnectionCountChanged Event occurs when server connection count changed. Declaration public event EventHandler? ServerConnectionCountChanged Event Type Type Description EventHandler Implements IDisposable" }, "api/Titanium.Web.Proxy.Routing.ReverseProxyFastPath.html": { "href": "api/Titanium.Web.Proxy.Routing.ReverseProxyFastPath.html", diff --git a/docs/inspector-trust-ux-matrix.md b/docs/inspector-trust-ux-matrix.md new file mode 100644 index 000000000..0f48b050a --- /dev/null +++ b/docs/inspector-trust-ux-matrix.md @@ -0,0 +1,72 @@ +# Inspector trust / proxy UX matrix + +Living scorecard for Capture → Install / Remove / Clear+Install / Decrypt / System proxy / Trust Firefox. +Fill outcomes during manual attended CryptUI/Keychain runs; automated Yes/No/Cancel leaves are covered by `Inspector-Trust-Decision` + Headless suites. + +## Invariants + +| # | Rule | +|---|------| +| 1 | UI never blocks on Root Find, WinINET/scutil, Firefox prefs, or certutil (`RunOffUiAsync` / TrustBg). | +| 2 | CryptUI / Keychain / polkit stay on a pumping UI thread — never `Task.Run`. | +| 3 | Decrypt stays unchecked until trust succeeds (except optimistic already-running+trusted). | +| 4 | Every Cancel / No / fail → model + OneWay snap + visible status/toast. | +| 5 | No Firefox `user.js` / locked prefs I/O while Firefox is running. | +| 6 | TrustBg bounded (drop pending; Clear/Enable timeout). | +| 7 | Platform honesty — Linux ≠ Keychain; Windows Trust Firefox ≠ NSS certutil PATH. | + +## Flow × platform × outcome + +Legend: **A** = automated (`ScriptedInspectorDialogs` + in-memory trust); **M** = manual/attended OS prompt; **—** = N/A. + +| Flow | Outcome | Win | macOS | Linux | Thread lane | Decrypt / SystemProxy | Status / toast | TrustBg | +|------|---------|-----|-------|-------|-------------|------------------------|----------------|---------| +| Start (persisted Decrypt, untrusted) | auto | A | A | A | off-UI refresh | Decrypt force off + snap | Ready / decrypt off | — | +| System proxy enable | Yes | A | A | A | off-UI | optimistic then snap on fail | success / fail toast | — | +| System proxy | PAC Cancel | A | A | A | UI dialog | unchanged / snap | cancelled toast | — | +| ProxyLoopback reapply | fail | A | A | A | off-UI | revert + snap | fail toast | — | +| Decrypt on | Start-proxy No | A | A | A | UI | off + snap | cancelled | — | +| Decrypt on | Install-CA No | A | A | A | UI | off + snap | cancelled | — | +| Decrypt on | CryptUI No / Cancelled | A* | A* | A* | UI OS prompt | off + snap | cancelled | — | +| Decrypt on | recovery Cancel | A | A | A | UI | off + snap | cancelled | busy gate | +| Decrypt on | Mac wait Trusted | — | A/M | — | UI | on | Decrypting HTTPS | Enable best-effort | +| Decrypt on | Mac wait NotSavedYet/Cancel | — | A/M | — | UI | off + snap | Keychain copy | — | +| Decrypt on | Linux incomplete | — | — | A | UI | NSS/certutil recovery (not Keychain) | Export CA / tools | — | +| Install CA | already trusted | A | A | A | — | unchanged | trusted toast | — | +| Install CA | CryptUI Yes | M | M | M | UI | unchanged | trusted toast | Enable bg | +| Install CA | CryptUI No | M | M | M | UI | unchanged | cancelled | — | +| Remove CA | Confirm No | A | A | A | UI | unchanged | cancelled toast | — | +| Remove CA | Confirm Yes + Delete declined | M | M | M | UI | **Decrypt force off** (product rule) | removed / still present | Clear bg | +| Clear+Install | ConfirmRotate No | A | A | A | UI | unchanged | cancelled toast | — | +| Clear+Install | ConfirmRotate Yes → CryptUI | A*/M | M | M | UI (no ConfirmInstall) | Decrypt force off | trusted toast | Await before Remove only | +| Clear+Install | immediate 2nd Yes | A*/M | M | M | UI | Decrypt off | trusted toast | drop pending Clear | +| Trust Firefox | Windows enterprise ok | A | — | — | off-UI | — | restart Firefox | — | +| Trust Firefox | Windows fail (no certutil PATH) | A | — | — | off-UI | — | quit FF / Export CA | — | +| Trust Firefox | FF running → Quit Yes/No | A | A | A | UI + off-UI | — | quit / cancelled | — | +| Trust Firefox | Linux CertutilMissing | — | A | A | UI recovery | — | brew/apt / Export | — | +| Busy gate | Decrypt while Install | A | A | A | — | snap + busy toast | in progress | — | + +\* In-memory / `TITANIUM_SKIP_ROOT_STORE_UI` scripts CryptUI as Cancelled/Ok without native Security Warning. + +## Attended CryptUI smoke (not CI) — Phase 5d + +On a developer Windows box (clear `TITANIUM_SKIP_ROOT_STORE_UI`): + +1. Start proxy → **Install root CA** → CryptUI **Yes** → success toast; ux-trace shows `InstallRootStoresOnly.CryptUI` END. +2. **Remove root CA** → Confirm Yes → CryptUI Delete **Yes** → Decrypt off; toast matches store state. +3. Optional: CryptUI **No** on Install → cancelled toast; Decrypt stays off. + +Do **not** loop CryptUI in unattended automation. + +## Headless Confirm chrome (Phase 5c) + +`E2E-UI-Headless` clicks `ConfirmAccept` / `ConfirmCancel` / `TrustRecovery*` via Avalonia Headless + in-memory trust (see MenuActions + Trust decision Headless tests). + +## Expected under load + +| Marker | Budget | +|--------|--------| +| `AwaitTrustBg` | ≤2s wait; TimeoutOrCancel OK | +| `TrustBg.Job` Clear/Enable with Firefox open | no SLOW ≥3s (skip prefs I/O) | +| Mint crypto | allow ~1–2s | +| Gap Mint → CryptUI | ConfirmInstall skipped on rotate; no TrustBg await | diff --git a/docs/xrefmap.yml b/docs/xrefmap.yml index 2a8496912..765dcab44 100644 --- a/docs/xrefmap.yml +++ b/docs/xrefmap.yml @@ -6766,6 +6766,35 @@ references: isSpec: "True" fullName: Titanium.Web.Proxy.Network.CertificateManager.ApplyFastColdStartLeafSettings nameWithType: CertificateManager.ApplyFastColdStartLeafSettings +- uid: Titanium.Web.Proxy.Network.CertificateManager.ApplyUnixSslTrustAfterStoreInstall(System.Boolean) + name: ApplyUnixSslTrustAfterStoreInstall(bool) + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_ApplyUnixSslTrustAfterStoreInstall_System_Boolean_ + commentId: M:Titanium.Web.Proxy.Network.CertificateManager.ApplyUnixSslTrustAfterStoreInstall(System.Boolean) + name.vb: ApplyUnixSslTrustAfterStoreInstall(Boolean) + fullName: Titanium.Web.Proxy.Network.CertificateManager.ApplyUnixSslTrustAfterStoreInstall(bool) + fullName.vb: Titanium.Web.Proxy.Network.CertificateManager.ApplyUnixSslTrustAfterStoreInstall(Boolean) + nameWithType: CertificateManager.ApplyUnixSslTrustAfterStoreInstall(bool) + nameWithType.vb: CertificateManager.ApplyUnixSslTrustAfterStoreInstall(Boolean) +- uid: Titanium.Web.Proxy.Network.CertificateManager.ApplyUnixSslTrustAfterStoreInstall* + name: ApplyUnixSslTrustAfterStoreInstall + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_ApplyUnixSslTrustAfterStoreInstall_ + commentId: Overload:Titanium.Web.Proxy.Network.CertificateManager.ApplyUnixSslTrustAfterStoreInstall + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.CertificateManager.ApplyUnixSslTrustAfterStoreInstall + nameWithType: CertificateManager.ApplyUnixSslTrustAfterStoreInstall +- uid: Titanium.Web.Proxy.Network.CertificateManager.ApplyUnixSslUntrust + name: ApplyUnixSslUntrust() + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_ApplyUnixSslUntrust + commentId: M:Titanium.Web.Proxy.Network.CertificateManager.ApplyUnixSslUntrust + fullName: Titanium.Web.Proxy.Network.CertificateManager.ApplyUnixSslUntrust() + nameWithType: CertificateManager.ApplyUnixSslUntrust() +- uid: Titanium.Web.Proxy.Network.CertificateManager.ApplyUnixSslUntrust* + name: ApplyUnixSslUntrust + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_ApplyUnixSslUntrust_ + commentId: Overload:Titanium.Web.Proxy.Network.CertificateManager.ApplyUnixSslUntrust + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.CertificateManager.ApplyUnixSslUntrust + nameWithType: CertificateManager.ApplyUnixSslUntrust - uid: Titanium.Web.Proxy.Network.CertificateManager.AreInteractiveRootStoreMutationsSuppressed name: AreInteractiveRootStoreMutationsSuppressed href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_AreInteractiveRootStoreMutationsSuppressed @@ -6950,6 +6979,22 @@ references: isSpec: "True" fullName: Titanium.Web.Proxy.Network.CertificateManager.InstallNssCertutilAndRetryUserTrust nameWithType: CertificateManager.InstallNssCertutilAndRetryUserTrust +- uid: Titanium.Web.Proxy.Network.CertificateManager.InstallRootIntoCertificateStores(System.Boolean) + name: InstallRootIntoCertificateStores(bool) + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_InstallRootIntoCertificateStores_System_Boolean_ + commentId: M:Titanium.Web.Proxy.Network.CertificateManager.InstallRootIntoCertificateStores(System.Boolean) + name.vb: InstallRootIntoCertificateStores(Boolean) + fullName: Titanium.Web.Proxy.Network.CertificateManager.InstallRootIntoCertificateStores(bool) + fullName.vb: Titanium.Web.Proxy.Network.CertificateManager.InstallRootIntoCertificateStores(Boolean) + nameWithType: CertificateManager.InstallRootIntoCertificateStores(bool) + nameWithType.vb: CertificateManager.InstallRootIntoCertificateStores(Boolean) +- uid: Titanium.Web.Proxy.Network.CertificateManager.InstallRootIntoCertificateStores* + name: InstallRootIntoCertificateStores + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_InstallRootIntoCertificateStores_ + commentId: Overload:Titanium.Web.Proxy.Network.CertificateManager.InstallRootIntoCertificateStores + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.CertificateManager.InstallRootIntoCertificateStores + nameWithType: CertificateManager.InstallRootIntoCertificateStores - uid: Titanium.Web.Proxy.Network.CertificateManager.IntermediateCertificates name: IntermediateCertificates href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_IntermediateCertificates @@ -7054,6 +7099,22 @@ references: isSpec: "True" fullName: Titanium.Web.Proxy.Network.CertificateManager.LeafRsaKeyPairBufferSize nameWithType: CertificateManager.LeafRsaKeyPairBufferSize +- uid: Titanium.Web.Proxy.Network.CertificateManager.ListSameCommonNameRootThumbprints(System.Security.Cryptography.X509Certificates.StoreLocation,System.String) + name: ListSameCommonNameRootThumbprints(StoreLocation, string?) + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_ListSameCommonNameRootThumbprints_System_Security_Cryptography_X509Certificates_StoreLocation_System_String_ + commentId: M:Titanium.Web.Proxy.Network.CertificateManager.ListSameCommonNameRootThumbprints(System.Security.Cryptography.X509Certificates.StoreLocation,System.String) + name.vb: ListSameCommonNameRootThumbprints(StoreLocation, String) + fullName: Titanium.Web.Proxy.Network.CertificateManager.ListSameCommonNameRootThumbprints(System.Security.Cryptography.X509Certificates.StoreLocation, string?) + fullName.vb: Titanium.Web.Proxy.Network.CertificateManager.ListSameCommonNameRootThumbprints(System.Security.Cryptography.X509Certificates.StoreLocation, String) + nameWithType: CertificateManager.ListSameCommonNameRootThumbprints(StoreLocation, string?) + nameWithType.vb: CertificateManager.ListSameCommonNameRootThumbprints(StoreLocation, String) +- uid: Titanium.Web.Proxy.Network.CertificateManager.ListSameCommonNameRootThumbprints* + name: ListSameCommonNameRootThumbprints + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_ListSameCommonNameRootThumbprints_ + commentId: Overload:Titanium.Web.Proxy.Network.CertificateManager.ListSameCommonNameRootThumbprints + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.CertificateManager.ListSameCommonNameRootThumbprints + nameWithType: CertificateManager.ListSameCommonNameRootThumbprints - uid: Titanium.Web.Proxy.Network.CertificateManager.LoadRootCertificate name: LoadRootCertificate() href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_LoadRootCertificate @@ -7128,6 +7189,54 @@ references: isSpec: "True" fullName: Titanium.Web.Proxy.Network.CertificateManager.PfxPassword nameWithType: CertificateManager.PfxPassword +- uid: Titanium.Web.Proxy.Network.CertificateManager.PruneOrphanedPersonalCertificates(System.Security.Cryptography.X509Certificates.StoreLocation,System.Boolean) + name: PruneOrphanedPersonalCertificates(StoreLocation, bool) + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_PruneOrphanedPersonalCertificates_System_Security_Cryptography_X509Certificates_StoreLocation_System_Boolean_ + commentId: M:Titanium.Web.Proxy.Network.CertificateManager.PruneOrphanedPersonalCertificates(System.Security.Cryptography.X509Certificates.StoreLocation,System.Boolean) + name.vb: PruneOrphanedPersonalCertificates(StoreLocation, Boolean) + fullName: Titanium.Web.Proxy.Network.CertificateManager.PruneOrphanedPersonalCertificates(System.Security.Cryptography.X509Certificates.StoreLocation, bool) + fullName.vb: Titanium.Web.Proxy.Network.CertificateManager.PruneOrphanedPersonalCertificates(System.Security.Cryptography.X509Certificates.StoreLocation, Boolean) + nameWithType: CertificateManager.PruneOrphanedPersonalCertificates(StoreLocation, bool) + nameWithType.vb: CertificateManager.PruneOrphanedPersonalCertificates(StoreLocation, Boolean) +- uid: Titanium.Web.Proxy.Network.CertificateManager.PruneOrphanedPersonalCertificates* + name: PruneOrphanedPersonalCertificates + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_PruneOrphanedPersonalCertificates_ + commentId: Overload:Titanium.Web.Proxy.Network.CertificateManager.PruneOrphanedPersonalCertificates + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.CertificateManager.PruneOrphanedPersonalCertificates + nameWithType: CertificateManager.PruneOrphanedPersonalCertificates +- uid: Titanium.Web.Proxy.Network.CertificateManager.PruneOrphanedSameCommonNameCertificates(System.Boolean,System.Boolean) + name: PruneOrphanedSameCommonNameCertificates(bool, bool) + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_PruneOrphanedSameCommonNameCertificates_System_Boolean_System_Boolean_ + commentId: M:Titanium.Web.Proxy.Network.CertificateManager.PruneOrphanedSameCommonNameCertificates(System.Boolean,System.Boolean) + name.vb: PruneOrphanedSameCommonNameCertificates(Boolean, Boolean) + fullName: Titanium.Web.Proxy.Network.CertificateManager.PruneOrphanedSameCommonNameCertificates(bool, bool) + fullName.vb: Titanium.Web.Proxy.Network.CertificateManager.PruneOrphanedSameCommonNameCertificates(Boolean, Boolean) + nameWithType: CertificateManager.PruneOrphanedSameCommonNameCertificates(bool, bool) + nameWithType.vb: CertificateManager.PruneOrphanedSameCommonNameCertificates(Boolean, Boolean) +- uid: Titanium.Web.Proxy.Network.CertificateManager.PruneOrphanedSameCommonNameCertificates* + name: PruneOrphanedSameCommonNameCertificates + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_PruneOrphanedSameCommonNameCertificates_ + commentId: Overload:Titanium.Web.Proxy.Network.CertificateManager.PruneOrphanedSameCommonNameCertificates + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.CertificateManager.PruneOrphanedSameCommonNameCertificates + nameWithType: CertificateManager.PruneOrphanedSameCommonNameCertificates +- uid: Titanium.Web.Proxy.Network.CertificateManager.RemoveCertificateByThumbprint(System.Security.Cryptography.X509Certificates.StoreName,System.Security.Cryptography.X509Certificates.StoreLocation,System.String) + name: RemoveCertificateByThumbprint(StoreName, StoreLocation, string) + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_RemoveCertificateByThumbprint_System_Security_Cryptography_X509Certificates_StoreName_System_Security_Cryptography_X509Certificates_StoreLocation_System_String_ + commentId: M:Titanium.Web.Proxy.Network.CertificateManager.RemoveCertificateByThumbprint(System.Security.Cryptography.X509Certificates.StoreName,System.Security.Cryptography.X509Certificates.StoreLocation,System.String) + name.vb: RemoveCertificateByThumbprint(StoreName, StoreLocation, String) + fullName: Titanium.Web.Proxy.Network.CertificateManager.RemoveCertificateByThumbprint(System.Security.Cryptography.X509Certificates.StoreName, System.Security.Cryptography.X509Certificates.StoreLocation, string) + fullName.vb: Titanium.Web.Proxy.Network.CertificateManager.RemoveCertificateByThumbprint(System.Security.Cryptography.X509Certificates.StoreName, System.Security.Cryptography.X509Certificates.StoreLocation, String) + nameWithType: CertificateManager.RemoveCertificateByThumbprint(StoreName, StoreLocation, string) + nameWithType.vb: CertificateManager.RemoveCertificateByThumbprint(StoreName, StoreLocation, String) +- uid: Titanium.Web.Proxy.Network.CertificateManager.RemoveCertificateByThumbprint* + name: RemoveCertificateByThumbprint + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_RemoveCertificateByThumbprint_ + commentId: Overload:Titanium.Web.Proxy.Network.CertificateManager.RemoveCertificateByThumbprint + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.CertificateManager.RemoveCertificateByThumbprint + nameWithType: CertificateManager.RemoveCertificateByThumbprint - uid: Titanium.Web.Proxy.Network.CertificateManager.RemoveTrustedRootCertificate(System.Boolean) name: RemoveTrustedRootCertificate(bool) href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_RemoveTrustedRootCertificate_System_Boolean_ @@ -7583,6 +7692,22 @@ references: commentId: T:Titanium.Web.Proxy.Network.FirefoxCertificateTrust fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust nameWithType: FirefoxCertificateTrust +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.ClearRootTrustBestEffort(System.String) + name: ClearRootTrustBestEffort(string?) + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_ClearRootTrustBestEffort_System_String_ + commentId: M:Titanium.Web.Proxy.Network.FirefoxCertificateTrust.ClearRootTrustBestEffort(System.String) + name.vb: ClearRootTrustBestEffort(String) + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.ClearRootTrustBestEffort(string?) + fullName.vb: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.ClearRootTrustBestEffort(String) + nameWithType: FirefoxCertificateTrust.ClearRootTrustBestEffort(string?) + nameWithType.vb: FirefoxCertificateTrust.ClearRootTrustBestEffort(String) +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.ClearRootTrustBestEffort* + name: ClearRootTrustBestEffort + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_ClearRootTrustBestEffort_ + commentId: Overload:Titanium.Web.Proxy.Network.FirefoxCertificateTrust.ClearRootTrustBestEffort + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.ClearRootTrustBestEffort + nameWithType: FirefoxCertificateTrust.ClearRootTrustBestEffort - uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.IsFirefoxProcessRunning name: IsFirefoxProcessRunning() href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_IsFirefoxProcessRunning @@ -7609,6 +7734,19 @@ references: isSpec: "True" fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.IsFirefoxProfilePresent nameWithType: FirefoxCertificateTrust.IsFirefoxProfilePresent +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.LastEnterpriseRootsStep + name: LastEnterpriseRootsStep + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_LastEnterpriseRootsStep + commentId: P:Titanium.Web.Proxy.Network.FirefoxCertificateTrust.LastEnterpriseRootsStep + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.LastEnterpriseRootsStep + nameWithType: FirefoxCertificateTrust.LastEnterpriseRootsStep +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.LastEnterpriseRootsStep* + name: LastEnterpriseRootsStep + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_LastEnterpriseRootsStep_ + commentId: Overload:Titanium.Web.Proxy.Network.FirefoxCertificateTrust.LastEnterpriseRootsStep + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.LastEnterpriseRootsStep + nameWithType: FirefoxCertificateTrust.LastEnterpriseRootsStep - uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TrustDefaultProfile(System.Security.Cryptography.X509Certificates.X509Certificate2,System.String) name: TrustDefaultProfile(X509Certificate2, string) href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_TrustDefaultProfile_System_Security_Cryptography_X509Certificates_X509Certificate2_System_String_ @@ -9345,6 +9483,22 @@ references: isSpec: "True" fullName: Titanium.Web.Proxy.ProxyServer.EndpointAdmissionRejectionCount nameWithType: ProxyServer.EndpointAdmissionRejectionCount +- uid: Titanium.Web.Proxy.ProxyServer.ForceDecryptFailureBypass(System.String) + name: ForceDecryptFailureBypass(string) + href: api/Titanium.Web.Proxy.ProxyServer.html#Titanium_Web_Proxy_ProxyServer_ForceDecryptFailureBypass_System_String_ + commentId: M:Titanium.Web.Proxy.ProxyServer.ForceDecryptFailureBypass(System.String) + name.vb: ForceDecryptFailureBypass(String) + fullName: Titanium.Web.Proxy.ProxyServer.ForceDecryptFailureBypass(string) + fullName.vb: Titanium.Web.Proxy.ProxyServer.ForceDecryptFailureBypass(String) + nameWithType: ProxyServer.ForceDecryptFailureBypass(string) + nameWithType.vb: ProxyServer.ForceDecryptFailureBypass(String) +- uid: Titanium.Web.Proxy.ProxyServer.ForceDecryptFailureBypass* + name: ForceDecryptFailureBypass + href: api/Titanium.Web.Proxy.ProxyServer.html#Titanium_Web_Proxy_ProxyServer_ForceDecryptFailureBypass_ + commentId: Overload:Titanium.Web.Proxy.ProxyServer.ForceDecryptFailureBypass + isSpec: "True" + fullName: Titanium.Web.Proxy.ProxyServer.ForceDecryptFailureBypass + nameWithType: ProxyServer.ForceDecryptFailureBypass - uid: Titanium.Web.Proxy.ProxyServer.ForwardToUpstreamGateway name: ForwardToUpstreamGateway href: api/Titanium.Web.Proxy.ProxyServer.html#Titanium_Web_Proxy_ProxyServer_ForwardToUpstreamGateway diff --git a/src/Titanium.Cli/Config/ConfigReloadGate.cs b/src/Titanium.Cli/Config/ConfigReloadGate.cs new file mode 100644 index 000000000..c211a10b2 --- /dev/null +++ b/src/Titanium.Cli/Config/ConfigReloadGate.cs @@ -0,0 +1,132 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; + +namespace Titanium.Cli.Config; + +/// +/// Cross-platform reload coordination for a running titanium run -c … process: +/// Unix uses SIGHUP (via pid file); Windows uses a named . +/// +internal static partial class ConfigReloadGate +{ + private const string PidDirName = "TitaniumWebProxy"; + + public static string ConfigKey(string configPath) + { + var full = Path.GetFullPath(configPath); + // Normalize for Windows case-insensitivity so reload finds the same gate. + if (OperatingSystem.IsWindows()) + full = full.ToLowerInvariant(); + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(full)); + return Convert.ToHexString(hash.AsSpan(0, 8)); + } + + public static string PidFilePath(string configPath) => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + PidDirName, + "run-" + ConfigKey(configPath) + ".pid"); + + /// Windows-only local named event for titanium reload. + public static string WindowsEventName(string configPath) => + @"Local\TitaniumWebProxy.Reload." + ConfigKey(configPath); + + public static void WritePidFile(string configPath, int pid) + { + var path = PidFilePath(configPath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, pid.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + public static void TryDeletePidFile(string configPath) + { + try + { + var path = PidFilePath(configPath); + if (File.Exists(path)) + File.Delete(path); + } + catch + { + // Best-effort cleanup. + } + } + + public static int? TryReadPid(string configPath) + { + try + { + var path = PidFilePath(configPath); + if (!File.Exists(path)) + return null; + var text = File.ReadAllText(path).Trim(); + return int.TryParse(text, out var pid) ? pid : null; + } + catch + { + return null; + } + } + + /// Creates (or opens) the Windows reload event for this config. Caller owns disposal. + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + public static EventWaitHandle CreateWindowsReloadEvent(string configPath, out bool createdNew) + { + var name = WindowsEventName(configPath); + return new EventWaitHandle(false, EventResetMode.AutoReset, name, out createdNew); + } + + /// Signals a running Windows run process to reload. Returns false if no waiter. + public static bool TrySignalWindowsReload(string configPath) + { + if (!OperatingSystem.IsWindows()) + return false; + + try + { + if (!EventWaitHandle.TryOpenExisting(WindowsEventName(configPath), out var handle)) + return false; + using (handle) + { + handle.Set(); + return true; + } + } + catch + { + return false; + } + } + + /// Sends SIGHUP to (Unix). Returns false on failure. + public static bool TrySendSighup(int pid) + { + if (OperatingSystem.IsWindows()) + return false; + try + { + return NativeKill(pid, 1) == 0; + } + catch + { + return false; + } + } + + public static bool IsProcessAlive(int pid) + { + try + { + using var p = Process.GetProcessById(pid); + return !p.HasExited; + } + catch + { + return false; + } + } + + [System.Runtime.InteropServices.LibraryImport("libc", EntryPoint = "kill", SetLastError = true)] + private static partial int NativeKill(int pid, int sig); +} diff --git a/src/Titanium.Cli/Config/ReloadCommand.cs b/src/Titanium.Cli/Config/ReloadCommand.cs new file mode 100644 index 000000000..0b63472fe --- /dev/null +++ b/src/Titanium.Cli/Config/ReloadCommand.cs @@ -0,0 +1,116 @@ +namespace Titanium.Cli.Config; + +/// titanium reload — live-apply routes/clusters from the config file. +internal static class ReloadCommand +{ + public static Task ExecuteAsync(string[] args) + { + if (CliHelp.RequestsHelp(args.AsSpan(1))) + return Task.FromResult(PrintHelp()); + + try + { + var configPath = TryParseConfigPath(args); + var pidOverride = TryParsePid(args); + + if (configPath is null && pidOverride is null) + { + throw new ArgumentException( + "Provide -c (preferred) or --pid (Unix only)."); + } + + if (OperatingSystem.IsWindows()) + { + if (configPath is null) + { + throw new ArgumentException( + "On Windows, titanium reload requires -c " + + "(matches the named reload event of the running process)."); + } + + if (!ConfigReloadGate.TrySignalWindowsReload(configPath)) + { + throw new InvalidOperationException( + $"No running titanium process is waiting to reload config '{configPath}'. " + + "Start with `titanium run -c …` first."); + } + + var pid = ConfigReloadGate.TryReadPid(configPath); + AsyncConsole.WriteLine(pid is int p + ? $"Reload signaled (Windows event) for pid {p}." + : "Reload signaled (Windows event)."); + return Task.FromResult(0); + } + + // Unix: SIGHUP via pid file or --pid. + // After the Windows return and the (configPath, pidOverride) guard above, a null + // pidOverride on this path implies configPath is non-null. + int? pidUnix = pidOverride ?? ConfigReloadGate.TryReadPid(configPath!); + if (pidUnix is null) + { + throw new InvalidOperationException( + $"No running titanium process found for config '{configPath}'. " + + "Start with `titanium run -c …` first, or pass --pid."); + } + + if (!ConfigReloadGate.IsProcessAlive(pidUnix.Value)) + { + throw new InvalidOperationException($"Process {pidUnix.Value} is not running."); + } + + if (!ConfigReloadGate.TrySendSighup(pidUnix.Value)) + { + throw new InvalidOperationException($"kill(SIGHUP) failed for pid {pidUnix.Value}."); + } + + AsyncConsole.WriteLine($"Reload signaled (SIGHUP) to pid {pidUnix.Value}."); + return Task.FromResult(0); + } + catch (Exception ex) + { + AsyncConsole.WriteError(ex.Message); + return Task.FromResult(1); + } + } + + internal static int PrintHelp() + { + AsyncConsole.WriteLine(""" + titanium reload -c [--pid ] + + -c, --config Config path used by the running `titanium run` (required on Windows). + --pid Process id (Unix only; optional when -c finds the pid file). + + Reloads routes, clusters, and server: knobs without dropping listeners or in-flight + requests. On Unix this sends SIGHUP; on Windows it signals a named event. + Listeners, certificates, Plus plugins, and static files still require a process restart. + """); + CliHelp.WriteDocsFooter(); + return 0; + } + + private static string? TryParseConfigPath(string[] args) + { + for (var i = 1; i < args.Length; i++) + { + if ((args[i] is "-c" or "--config") && i + 1 < args.Length) + return args[i + 1]; + } + + return null; + } + + private static int? TryParsePid(string[] args) + { + for (var i = 1; i < args.Length; i++) + { + if (args[i] is "--pid" && i + 1 < args.Length && + int.TryParse(args[i + 1], out var pid) && pid > 0) + { + return pid; + } + } + + return null; + } +} diff --git a/src/Titanium.Cli/Config/RunCommand.cs b/src/Titanium.Cli/Config/RunCommand.cs index d73e87da7..ed6d7e644 100644 --- a/src/Titanium.Cli/Config/RunCommand.cs +++ b/src/Titanium.Cli/Config/RunCommand.cs @@ -41,6 +41,7 @@ public static async Task ExecuteAsync(string[] args) var configPath = ParseConfigPath(args); var verbose = ParseVerbose(args); var serviceMode = ParseServiceMode(args); + var watch = ParseWatch(args); var serviceName = ParseServiceName(args) ?? Service.ServiceDefaults.DefaultServiceName; if (serviceMode && OperatingSystem.IsWindows()) @@ -49,7 +50,7 @@ public static async Task ExecuteAsync(string[] args) .ConfigureAwait(false); } - return await ExecuteCoreAsync(configPath, verbose, serviceMode, CancellationToken.None) + return await ExecuteCoreAsync(configPath, verbose, serviceMode, CancellationToken.None, watch) .ConfigureAwait(false); } @@ -58,7 +59,8 @@ internal static async Task ExecuteCoreAsync( // NOSONAR S3776 -- CLI run li string configPath, bool verbose, bool serviceMode, - CancellationToken stoppingToken) + CancellationToken stoppingToken, + bool watch = false) { var loaded = ConfigLoader.Load(configPath); var errors = TwpConfigValidator.Validate(loaded.Config); @@ -184,33 +186,44 @@ void RefreshReverseProxy() await AsyncConsole.FlushAsync().ConfigureAwait(false); Console.WriteLine("awaiting-shutdown-or-reload"); await Console.Out.FlushAsync(stoppingToken).ConfigureAwait(false); - await WaitForShutdownOrReloadAsync( - stoppingToken, - onReload: async () => - { - try - { - await ReloadConfigAsync( - configPath, - proxy, - clusterManager, - routes, - middleware, - loadBalancer, - responseCache, - plusOptions, - () => grpcJsonTranscoder, - t => grpcJsonTranscoder = t, - RefreshReverseProxy, - stoppingToken).ConfigureAwait(false); - AsyncConsole.WriteLine("Config reloaded."); - await AsyncConsole.FlushAsync().ConfigureAwait(false); - } - catch (Exception ex) + ConfigReloadGate.WritePidFile(configPath, Environment.ProcessId); + try + { + await WaitForShutdownOrReloadAsync( + stoppingToken, + configPath, + watch, + onReload: async () => { - AsyncConsole.WriteError("Config reload failed: " + ex.Message); - } - }).ConfigureAwait(false); + try + { + await ReloadConfigAsync( + configPath, + proxy, + clusterManager, + routes, + middleware, + loadBalancer, + responseCache, + plusOptions, + () => grpcJsonTranscoder, + t => grpcJsonTranscoder = t, + RefreshReverseProxy, + stoppingToken).ConfigureAwait(false); + AsyncConsole.WriteLine("Config reloaded."); + await AsyncConsole.FlushAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + AsyncConsole.WriteError("Config reload failed: " + ex.Message); + } + }).ConfigureAwait(false); + } + finally + { + ConfigReloadGate.TryDeletePidFile(configPath); + } + await proxy.StopAsync().ConfigureAwait(false); return 0; } @@ -314,16 +327,17 @@ internal static void ReplaceRoutes(List routes, IEnumerable [-v|--verbose] [--service] [--name ] + titanium run -c [-v|--verbose] [--service] [--name ] [--watch] -c, --config Path to twp.yaml / .json / .twp / .conf (required). -v, --verbose Enable debug console logging. --service Run as an OS service worker (used by `titanium service install`). --name Windows SCM service name when --service is set (default: titanium). + --watch Debounced reload when the config file changes (off by default). Starts the proxy and blocks until Ctrl+C, SIGTERM, or the service manager stops it. - On Unix, SIGHUP reloads routes/clusters from the config file without dropping the - process or in-flight connections (listeners stay bound). + Reload routes/clusters without restart: `titanium reload -c ` (all OS), + Unix SIGHUP, or systemd `systemctl reload` when installed as a service. """); CliHelp.WriteDocsFooter(); return 0; @@ -368,6 +382,19 @@ internal static bool ParseServiceMode(string[] args) return false; } + internal static bool ParseWatch(string[] args) + { + for (var i = 1; i < args.Length; i++) + { + if (args[i] is "--watch") + { + return true; + } + } + + return false; + } + internal static string? ParseServiceName(string[] args) { for (var i = 1; i < args.Length; i++) @@ -427,7 +454,11 @@ internal static void ApplyServiceLoggingDefaults(ProxyServer proxy, LoggingConfi } #pragma warning disable CA1068 // Token stays first so POSIX signal registration can observe the run CTS. - private static async Task WaitForShutdownOrReloadAsync(CancellationToken stoppingToken, Func? onReload) // NOSONAR CA1068 -- Token stays first so POSIX signal registration can observe the run CTS. + private static async Task WaitForShutdownOrReloadAsync( // NOSONAR CA1068 -- Token stays first so POSIX signal registration can observe the run CTS. + CancellationToken stoppingToken, + string configPath, + bool watch, + Func? onReload) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -447,6 +478,10 @@ private static async Task WaitForShutdownOrReloadAsync(CancellationToken stoppin PosixSignalRegistration? sigTerm = null; PosixSignalRegistration? sigInt = null; PosixSignalRegistration? sigHup = null; + EventWaitHandle? winReload = null; + RegisteredWaitHandle? winReloadWait = null; + FileSystemWatcher? watcher = null; + CancellationTokenSource? watchDebounce = null; try { if (!OperatingSystem.IsWindows()) @@ -483,6 +518,76 @@ private static async Task WaitForShutdownOrReloadAsync(CancellationToken stoppin await Console.Out.FlushAsync(stoppingToken).ConfigureAwait(false); } } + else if (onReload is not null) + { + winReload = ConfigReloadGate.CreateWindowsReloadEvent(configPath, out _); + winReloadWait = ThreadPool.RegisterWaitForSingleObject( + winReload, + (_, _) => + { + _ = Task.Run(async () => + { + try + { + await onReload().ConfigureAwait(false); + } + catch + { + // Reload errors are logged by caller. + } + }, stoppingToken); + }, + state: null, + millisecondsTimeOutInterval: -1, + executeOnlyOnce: false); + Console.WriteLine("windows-reload-handler-registered"); + await Console.Out.FlushAsync(stoppingToken).ConfigureAwait(false); + } + + if (watch && onReload is not null) + { + var full = Path.GetFullPath(configPath); + var dir = Path.GetDirectoryName(full) ?? "."; + var file = Path.GetFileName(full); + watcher = new FileSystemWatcher(dir, file) + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName, + EnableRaisingEvents = true, + }; + // Assigned on the main flow so finally can dispose; replaced on each FS event. + watchDebounce = new CancellationTokenSource(); + void OnWatch(object sender, FileSystemEventArgs e) + { + var prev = watchDebounce!; + prev.Cancel(); + prev.Dispose(); + var next = new CancellationTokenSource(); + watchDebounce = next; + var token = next.Token; + _ = Task.Run(async () => + { + try + { + await Task.Delay(250, token).ConfigureAwait(false); + await onReload().ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Debounced. + } + catch + { + // Reload errors are logged by caller. + } + }, CancellationToken.None); + } + + watcher.Changed += OnWatch; + watcher.Created += OnWatch; + watcher.Renamed += OnWatch; + Console.WriteLine("config-watch-enabled"); + await Console.Out.FlushAsync(stoppingToken).ConfigureAwait(false); + } await tcs.Task.ConfigureAwait(false); } @@ -492,6 +597,11 @@ private static async Task WaitForShutdownOrReloadAsync(CancellationToken stoppin sigTerm?.Dispose(); sigInt?.Dispose(); sigHup?.Dispose(); + winReloadWait?.Unregister(null); + winReload?.Dispose(); + watcher?.Dispose(); + watchDebounce?.Cancel(); + watchDebounce?.Dispose(); } } #pragma warning restore CA1068 diff --git a/src/Titanium.Cli/Program.cs b/src/Titanium.Cli/Program.cs index 124e1fc98..29c8c1c20 100644 --- a/src/Titanium.Cli/Program.cs +++ b/src/Titanium.Cli/Program.cs @@ -30,6 +30,7 @@ public static async Task Main(string[] args) return command switch { "run" => await RunCommand.ExecuteAsync(args), + "reload" => await ReloadCommand.ExecuteAsync(args), "test" => await TestCommand.ExecuteAsync(args), "version" => await VersionCommand.ExecuteAsync(args), "update" => await UpdateCommand.ExecuteAsync(args), @@ -57,7 +58,8 @@ private static int PrintHelp() Titanium Web Proxy CLI Usage: - titanium run -c [-v|--verbose] [--service] + titanium run -c [-v|--verbose] [--service] [--watch] + titanium reload -c titanium test -c titanium version [--check] [--plus] [--channel beta] titanium update [--plus] [--remove-plus] [--channel beta] diff --git a/src/Titanium.Cli/Service/ServiceUnitFactory.cs b/src/Titanium.Cli/Service/ServiceUnitFactory.cs index 2977768a6..ae4568d36 100644 --- a/src/Titanium.Cli/Service/ServiceUnitFactory.cs +++ b/src/Titanium.Cli/Service/ServiceUnitFactory.cs @@ -47,6 +47,7 @@ public static string BuildSystemdUnit( sb.AppendLine("[Service]"); sb.AppendLine("Type=simple"); sb.AppendLine($"ExecStart={exec} run -c {EscapeSystemdArg(configPath)} --service"); + sb.AppendLine("ExecReload=/bin/kill -HUP $MAINPID"); sb.AppendLine($"WorkingDirectory={EscapeSystemdArg(workingDirectory)}"); sb.AppendLine("Restart=on-failure"); sb.AppendLine("RestartSec=5"); diff --git a/src/Titanium.Cli/Titanium.Cli.csproj b/src/Titanium.Cli/Titanium.Cli.csproj index ffe9499b7..35a707190 100644 --- a/src/Titanium.Cli/Titanium.Cli.csproj +++ b/src/Titanium.Cli/Titanium.Cli.csproj @@ -7,7 +7,7 @@ latest enable false - 7.0.8 + 7.0.9 Jehonathan Thomas Titanium Web Proxy CLI (titanium / twp). MIT diff --git a/src/Titanium.Inspector/App.axaml b/src/Titanium.Inspector/App.axaml index a42fc4526..b3966d80a 100644 --- a/src/Titanium.Inspector/App.axaml +++ b/src/Titanium.Inspector/App.axaml @@ -76,6 +76,38 @@ + + + + + + + diff --git a/src/Titanium.Inspector/Services/IInspectorDialogs.cs b/src/Titanium.Inspector/Services/IInspectorDialogs.cs index 0051b931d..6c87eb493 100644 --- a/src/Titanium.Inspector/Services/IInspectorDialogs.cs +++ b/src/Titanium.Inspector/Services/IInspectorDialogs.cs @@ -207,7 +207,7 @@ public Task ConfirmQuitFirefoxForTrustAsync(Window? owner) => public Task ShowDeviceCaSetupAsync(Window? owner, string message) => SimpleConfirmDialog.ShowAsync( owner, - "Device CA setup", + "Setup external device CA", message, accept: ExportCaLabel, cancel: "Close", diff --git a/src/Titanium.Inspector/Services/IInspectorPathPicker.cs b/src/Titanium.Inspector/Services/IInspectorPathPicker.cs index 7969c4abb..55c7e730e 100644 --- a/src/Titanium.Inspector/Services/IInspectorPathPicker.cs +++ b/src/Titanium.Inspector/Services/IInspectorPathPicker.cs @@ -15,7 +15,11 @@ public interface IInspectorPathPicker Task PickOpenPathAsync(string title, string filterName, params string[] patterns); } -/// Production picker: Avalonia StorageProvider when available, else Desktop fallback path. +/// +/// Production picker: Avalonia StorageProvider when a window can show a dialog. +/// Cancel must return null (never fall back to Desktop / the folder the dialog had open). +/// Headless / no-window uses a Desktop path only when no save/open UI was shown. +/// public sealed class AvaloniaInspectorPathPicker : IInspectorPathPicker { public Task PickSavePathAsync(string title, string suggestedFileName, string filterName, string pattern) => @@ -26,41 +30,24 @@ public sealed class AvaloniaInspectorPathPicker : IInspectorPathPicker string suggestedFileName, IReadOnlyList fileTypes) { - var fromUi = await InspectorPathPickerHelpers.TrySaveViaStorageAsync(title, suggestedFileName, fileTypes); - if (fromUi is not null) + var attempt = await InspectorPathPickerHelpers.TrySaveViaStorageAsync(title, suggestedFileName, fileTypes); + if (attempt.DialogShown) { - return fromUi; + return attempt.Path; } - var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); - return Path.Combine(desktop, suggestedFileName.Contains('{', StringComparison.Ordinal) - ? suggestedFileName - : Path.GetFileNameWithoutExtension(suggestedFileName) - + $"-{DateTime.Now:yyyyMMddHHmmss}" - + Path.GetExtension(suggestedFileName)); + return InspectorPathPickerHelpers.FallbackDesktopSavePath(suggestedFileName); } public async Task PickOpenPathAsync(string title, string filterName, params string[] patterns) { - var fromUi = await InspectorPathPickerHelpers.TryOpenViaStorageAsync(title, filterName, patterns); - if (fromUi is not null) + var attempt = await InspectorPathPickerHelpers.TryOpenViaStorageAsync(title, filterName, patterns); + if (attempt.DialogShown) { - return fromUi; + return attempt.Path; } - var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); - foreach (var pattern in patterns) - { - var hit = Directory.EnumerateFiles(desktop, pattern) - .OrderByDescending(f => f) - .FirstOrDefault(); - if (hit is not null) - { - return hit; - } - } - - return null; + return InspectorPathPickerHelpers.FallbackDesktopOpenPath(patterns); } } @@ -93,9 +80,15 @@ public sealed class ScriptedInspectorPathPicker : IInspectorPathPicker } } +/// +/// Result of a StorageProvider pick. is true when a native +/// dialog ran (including Cancel). Callers must not fall back to Desktop in that case. +/// +internal readonly record struct StoragePickAttempt(bool DialogShown, string? Path); + internal static class InspectorPathPickerHelpers { - public static async Task TrySaveViaStorageAsync( + public static async Task TrySaveViaStorageAsync( string title, string suggestedFileName, IReadOnlyList fileTypes) @@ -103,7 +96,7 @@ internal static class InspectorPathPickerHelpers var top = TryGetMainWindow(); if (top?.StorageProvider is not { CanSave: true } sp) { - return null; + return new StoragePickAttempt(false, null); } var choices = fileTypes.Count == 0 @@ -118,15 +111,15 @@ internal static class InspectorPathPickerHelpers SuggestedFileName = suggestedFileName, FileTypeChoices = choices, }); - return file?.TryGetLocalPath(); + return new StoragePickAttempt(true, NormalizePickedPath(file?.TryGetLocalPath())); } - public static async Task TryOpenViaStorageAsync(string title, string filterName, string[] patterns) + public static async Task TryOpenViaStorageAsync(string title, string filterName, string[] patterns) { var top = TryGetMainWindow(); if (top?.StorageProvider is not { CanOpen: true } sp) { - return null; + return new StoragePickAttempt(false, null); } var files = await sp.OpenFilePickerAsync(new FilePickerOpenOptions @@ -138,7 +131,65 @@ internal static class InspectorPathPickerHelpers new FilePickerFileType(filterName) { Patterns = patterns.ToList() }, ], }); - return files.Count > 0 ? files[0].TryGetLocalPath() : null; + var path = files.Count > 0 ? files[0].TryGetLocalPath() : null; + return new StoragePickAttempt(true, NormalizePickedPath(path)); + } + + /// + /// Reject cancel / empty / folder-only results. Some native pickers report the + /// directory that was open when the user cancelled instead of a file path. + /// + public static string? NormalizePickedPath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + try + { + if (Directory.Exists(path)) + { + return null; + } + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + + return path; + } + + public static string FallbackDesktopSavePath(string suggestedFileName) + { + var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); + return Path.Combine(desktop, suggestedFileName.Contains('{', StringComparison.Ordinal) + ? suggestedFileName + : Path.GetFileNameWithoutExtension(suggestedFileName) + + $"-{DateTime.Now:yyyyMMddHHmmss}" + + Path.GetExtension(suggestedFileName)); + } + + public static string? FallbackDesktopOpenPath(string[] patterns) + { + var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); + foreach (var pattern in patterns) + { + var hit = Directory.EnumerateFiles(desktop, pattern) + .OrderByDescending(f => f) + .FirstOrDefault(); + if (hit is not null) + { + return hit; + } + } + + return null; } private static Avalonia.Controls.Window? TryGetMainWindow() diff --git a/src/Titanium.Inspector/Services/InspectorBodyLimits.cs b/src/Titanium.Inspector/Services/InspectorBodyLimits.cs new file mode 100644 index 000000000..0721bd721 --- /dev/null +++ b/src/Titanium.Inspector/Services/InspectorBodyLimits.cs @@ -0,0 +1,342 @@ +using System.Text; +using System.Xml; +using System.Xml.Linq; + +namespace Titanium.Inspector.Services; + +/// How Inspector retained a request or response body for the session grid. +public enum BodyCaptureState +{ + /// No body expected, or not yet known. + None = 0, + + /// Full body kept (within preview caps). + Complete = 1, + + /// Body was larger than the preview cap; only a prefix is stored. + Truncated = 2, + + /// Known huge Content-Length — not buffered (download must not stall). + NotCaptured = 3, + + /// Endless / SSE-style stream — relayed; preview may fill asynchronously. + Streaming = 4, +} + +/// Shared Inspector body/preview limits (capture UI, Composer, AutoResponder). +public static class InspectorBodyLimits +{ + public const int MaxBodyBytes = 2 * 1024 * 1024; + public const int MaxBodyTextChars = 256 * 1024; + public const int MaxHexBytes = 4096; + public const int MaxInlineToolBodyChars = 256 * 1024; + public const int MaxScriptChars = 32 * 1024; + public const int MaxMapLocalFileBytes = 32 * 1024 * 1024; + public const int MaxDecodedImageEdgePx = 4096; + public const long MaxDecodedImagePixels = 16L * 1024 * 1024; + public const int TeeUiCoalesceMs = 200; + + public static byte[]? TruncateBytes(byte[]? body) + { + if (body is null || body.Length == 0) + { + return body; + } + + return body.Length <= MaxBodyBytes ? body : body.AsSpan(0, MaxBodyBytes).ToArray(); + } + + public static string TruncateText(string text) + => text.Length <= MaxBodyTextChars ? text : text[..MaxBodyTextChars] + "…"; + + public static bool IsImageContentType(string? contentType) + { + if (string.IsNullOrWhiteSpace(contentType)) + { + return false; + } + + var ct = contentType.Split(';', 2)[0].Trim(); + return ct.StartsWith("image/", StringComparison.OrdinalIgnoreCase) + && !ct.Equals("image/svg+xml", StringComparison.OrdinalIgnoreCase); + } + + public static bool IsPrettyPrintableContentType(string? contentType) + { + if (string.IsNullOrWhiteSpace(contentType)) + { + return false; + } + + var ct = contentType.Split(';', 2)[0].Trim(); + return ct.Contains("json", StringComparison.OrdinalIgnoreCase) + || ct.Contains("xml", StringComparison.OrdinalIgnoreCase) + || ct.StartsWith("text/html", StringComparison.OrdinalIgnoreCase) + || ct.Equals("application/xhtml+xml", StringComparison.OrdinalIgnoreCase); + } + + public static bool LooksLikeSseContentType(string? contentType) => + !string.IsNullOrEmpty(contentType) + && contentType.Contains("text/event-stream", StringComparison.OrdinalIgnoreCase); + + public static string FormatCaptureBanner( + BodyCaptureState state, + long? originalSize, + int capturedBytes, + bool streamOpen, + bool forHex) + { + var capturedLabel = SessionDisplayFormat.FormatByteSize(capturedBytes); + var originalLabel = originalSize is > 0 + ? SessionDisplayFormat.FormatByteSize(originalSize) + : null; + + if (forHex && capturedBytes > 0) + { + var hexShown = Math.Min(capturedBytes, MaxHexBytes); + var hexLabel = SessionDisplayFormat.FormatByteSize(hexShown); + if (capturedBytes > MaxHexBytes) + { + return $"Hex shows first {hexLabel} of {capturedLabel} captured"; + } + } + + return state switch + { + BodyCaptureState.Truncated when originalLabel is not null => + $"Showing first {capturedLabel} of {originalLabel}", + BodyCaptureState.Truncated => + $"Showing first {capturedLabel} (body truncated)", + BodyCaptureState.NotCaptured when originalLabel is not null => + $"Body not captured ({originalLabel}) — streamed so the download would not stall", + BodyCaptureState.NotCaptured => + "Body not captured — streamed so the download would not stall", + BodyCaptureState.Streaming when streamOpen => + $"Streaming · showing first {capturedLabel} captured so far", + BodyCaptureState.Streaming => + $"Streaming ended · captured {capturedLabel}", + BodyCaptureState.Complete => "", + _ => "", + }; + } + + public static BodyCaptureState InferFromBytes(byte[]? bytes, long? originalSize) + { + if (bytes is null) + { + return originalSize is > MaxBodyBytes + ? BodyCaptureState.NotCaptured + : BodyCaptureState.None; + } + + if (originalSize is long orig && orig > bytes.Length) + { + return BodyCaptureState.Truncated; + } + + if (bytes.Length >= MaxBodyBytes && originalSize is null or > MaxBodyBytes) + { + return BodyCaptureState.Truncated; + } + + return BodyCaptureState.Complete; + } + + /// Pretty-print JSON / XML / HTML source. Returns null when formatting is not applicable or fails. + public static string? TryPrettyPrint(string? text, string? contentType) + { + if (string.IsNullOrWhiteSpace(text) || IsImageContentType(contentType)) + { + return null; + } + + var ct = contentType?.Split(';', 2)[0].Trim() ?? ""; + try + { + if (ct.Contains("json", StringComparison.OrdinalIgnoreCase) + || (string.IsNullOrEmpty(ct) && LooksLikeJson(text))) + { + using var doc = System.Text.Json.JsonDocument.Parse(text); + var pretty = System.Text.Json.JsonSerializer.Serialize( + doc.RootElement, + new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + return TruncateText(pretty); + } + + if (ct.StartsWith("text/html", StringComparison.OrdinalIgnoreCase)) + { + return TruncateText(IndentMarkupSource(text)); + } + + if (ct.Contains("xml", StringComparison.OrdinalIgnoreCase) + || ct.Equals("application/xhtml+xml", StringComparison.OrdinalIgnoreCase)) + { + return TruncateText(PrettyPrintXml(text)); + } + } + catch (System.Text.Json.JsonException) + { + return null; + } + catch (XmlException) + { + return null; + } + + return null; + } + + private static bool LooksLikeJson(string text) + { + var t = text.AsSpan().TrimStart(); + return t.Length > 0 && (t[0] == '{' || t[0] == '['); + } + + private static string PrettyPrintXml(string text) + { + var settings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + IgnoreWhitespace = false, + }; + using var reader = XmlReader.Create(new StringReader(text), settings); + var doc = XDocument.Load(reader, LoadOptions.PreserveWhitespace); + return doc.Declaration is null + ? doc.ToString() + : doc.Declaration + Environment.NewLine + doc.ToString(); + } + + /// Best-effort indent of HTML/markup source without executing or validating as XML. + private static string IndentMarkupSource(string text) + { + var sb = new StringBuilder(text.Length + 64); + var depth = 0; + var i = 0; + while (i < text.Length) + { + if (text[i] == '<') + { + var end = text.IndexOf('>', i); + if (end < 0) + { + sb.Append(text.AsSpan(i)); + break; + } + + var tag = text.AsSpan(i, end - i + 1); + var isClosing = tag.Length > 1 && tag[1] == '/'; + var isSelfClosing = tag.EndsWith("/>", StringComparison.Ordinal) + || tag.StartsWith(" 0 && sb[^1] != '\n') + { + sb.AppendLine(); + } + + sb.Append(' ', depth * 2); + sb.Append(tag); + if (!isClosing && !isSelfClosing) + { + depth++; + } + + i = end + 1; + continue; + } + + var next = text.IndexOf('<', i); + if (next < 0) + { + sb.Append(text.AsSpan(i).Trim()); + break; + } + + var slice = text.AsSpan(i, next - i).Trim(); + if (slice.Length > 0) + { + if (sb.Length > 0 && sb[^1] != '\n') + { + sb.AppendLine(); + } + + sb.Append(' ', depth * 2); + sb.Append(slice); + } + + i = next; + } + + return sb.ToString(); + } + + public static string SuggestBodyFileName(string? url, string? contentDisposition, string? contentType, bool isRequest) + { + if (!string.IsNullOrWhiteSpace(contentDisposition)) + { + const string marker = "filename="; + var idx = contentDisposition.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (idx >= 0) + { + var name = contentDisposition[(idx + marker.Length)..].Trim().Trim('"', '\''); + if (name.Length > 0) + { + return SanitizeFileName(name); + } + } + } + + if (!string.IsNullOrWhiteSpace(url) && Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + var leaf = Path.GetFileName(uri.AbsolutePath); + if (!string.IsNullOrWhiteSpace(leaf) && leaf != "/" && leaf.Contains('.', StringComparison.Ordinal)) + { + return SanitizeFileName(leaf); + } + } + + var ext = ExtensionForContentType(contentType); + return (isRequest ? "request" : "response") + ext; + } + + private static string ExtensionForContentType(string? contentType) + { + if (string.IsNullOrWhiteSpace(contentType)) + { + return ".bin"; + } + + var ct = contentType.Split(';', 2)[0].Trim().ToLowerInvariant(); + return ct switch + { + "application/json" or "text/json" => ".json", + "text/html" => ".html", + "text/plain" => ".txt", + "text/xml" or "application/xml" => ".xml", + "image/png" => ".png", + "image/jpeg" => ".jpg", + "image/gif" => ".gif", + "image/webp" => ".webp", + "image/bmp" => ".bmp", + "application/octet-stream" => ".bin", + _ when ct.Contains("javascript", StringComparison.Ordinal) => ".js", + _ when ct.Contains("css", StringComparison.Ordinal) => ".css", + _ => ".bin", + }; + } + + private static string SanitizeFileName(string name) + { + foreach (var c in Path.GetInvalidFileNameChars()) + { + name = name.Replace(c, '_'); + } + + return string.IsNullOrWhiteSpace(name) ? "body.bin" : name; + } +} diff --git a/src/Titanium.Inspector/Services/InspectorUxTrace.cs b/src/Titanium.Inspector/Services/InspectorUxTrace.cs new file mode 100644 index 000000000..d4bc101e2 --- /dev/null +++ b/src/Titanium.Inspector/Services/InspectorUxTrace.cs @@ -0,0 +1,100 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; + +namespace Titanium.Inspector.Services; + +/// +/// Always-on, low-volume timing trace for Inspector UX / OS-trust hangs. +/// Writes to %AppData%/TitaniumInspector/logs/ux-trace.log (separate from +/// titanium-inspector.log) so Debug file logging does not need to be enabled. +/// +internal static class InspectorUxTrace +{ + private static readonly object Gate = new(); + private static readonly string Path = ResolvePath(); + private const long RotateBytes = 4 * 1024 * 1024; + private static long _seq; + + public static string LogFilePath => Path; + + public static IDisposable Scope(string name, string? detail = null) + { + var id = Interlocked.Increment(ref _seq); + var sw = Stopwatch.StartNew(); + Write("BEGIN", name, detail, elapsedMs: null, id); + return new ScopeEnd(name, detail, sw, id); + } + + public static void Event(string name, string? detail = null) => + Write("EVENT", name, detail, elapsedMs: null, id: Interlocked.Increment(ref _seq)); + + private static void Write(string kind, string name, string? detail, long? elapsedMs, long id) + { + try + { + var sb = new StringBuilder(160); + sb.Append(DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture)); + sb.Append(" t=").Append(Environment.CurrentManagedThreadId); + sb.Append(' ').Append(kind); + sb.Append(" #").Append(id); + sb.Append(' ').Append(name); + if (elapsedMs is not null) + sb.Append(" ms=").Append(elapsedMs.Value.ToString(CultureInfo.InvariantCulture)); + if (!string.IsNullOrEmpty(detail)) + sb.Append(' ').Append(detail); + sb.AppendLine(); + + lock (Gate) + { + RotateIfNeeded_NoLock(); + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!); + File.AppendAllText(Path, sb.ToString()); + } + } + catch + { + // never break UX because of tracing + } + } + + private static void RotateIfNeeded_NoLock() + { + try + { + var info = new FileInfo(Path); + if (!info.Exists || info.Length < RotateBytes) + return; + + var bak = Path + ".1"; + if (File.Exists(bak)) + File.Delete(bak); + File.Move(Path, bak); + } + catch + { + // ignore + } + } + + private static string ResolvePath() => + System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "TitaniumInspector", "logs", "ux-trace.log"); + + private sealed class ScopeEnd(string name, string? detail, Stopwatch sw, long id) : IDisposable + { + private int _disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + var ms = sw.ElapsedMilliseconds; + Write("END", name, detail, ms, id); + // Easy grep marker for hangs / multi-second stalls. + if (ms >= 750) + Write("SLOW", name, detail, ms, id); + } + } +} diff --git a/src/Titanium.Inspector/Services/InterceptionService.cs b/src/Titanium.Inspector/Services/InterceptionService.cs index 809cdfd59..64ce2fb2a 100644 --- a/src/Titanium.Inspector/Services/InterceptionService.cs +++ b/src/Titanium.Inspector/Services/InterceptionService.cs @@ -22,8 +22,8 @@ namespace Titanium.Inspector.Services; /// public sealed class InterceptionService : IDisposable { - public const int MaxBodyBytes = 2 * 1024 * 1024; - public const int MaxBodyTextChars = 256 * 1024; + public const int MaxBodyBytes = InspectorBodyLimits.MaxBodyBytes; + public const int MaxBodyTextChars = InspectorBodyLimits.MaxBodyTextChars; private long _nextId; private readonly ConcurrentDictionary _live = new(); @@ -38,6 +38,32 @@ public sealed class InterceptionService : IDisposable private Channel? _processResolveChannel; private CancellationTokenSource? _processResolveCts; + /// + /// Serializes fire-and-forget trust cleanup: Firefox prefs/HKCU/policies and Personal-store + /// prune. Rapid Clear+Install / Install / Untrust must not interleave ClearRootTrust, + /// EnableEnterpriseRoots, and My-store prune (prefs locks + Crypt32 contention). + /// + private readonly object _firefoxTrustBgGate = new(); + private readonly Queue _firefoxTrustBgQueue = new(); + private bool _firefoxTrustBgRunning; + private TaskCompletionSource _firefoxTrustBgIdle = CreateCompletedFirefoxTrustIdle(); + + private enum FirefoxTrustBgKind + { + Clear, + Enable, + Prune, + } + + private readonly record struct FirefoxTrustBgQueued(FirefoxTrustBgKind Kind, Action Work); + + private static TaskCompletionSource CreateCompletedFirefoxTrustIdle() + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + tcs.SetResult(); + return tcs; + } + private readonly record struct ProcessResolveWork(SessionSnapshot Snap, Lazy ProcessId); public InterceptionService(ISystemProxyController? systemProxy = null) @@ -85,8 +111,20 @@ public InterceptionService(ISystemProxyController? systemProxy = null) /// /// When set (tests), skip the Windows certificate store and track trust in-memory. /// Avoids modal "Root Certificate Store" UI that hangs headless / CI runs. + /// Also suppresses CertificateManager Root-store CryptUI when the proxy is started. /// - public bool UseInMemoryTrustState { get; set; } + public bool UseInMemoryTrustState + { + get => _useInMemoryTrustState; + set + { + _useInMemoryTrustState = value; + if (value) + CertificateManager.SuppressInteractiveRootStoreMutations = true; + } + } + + private bool _useInMemoryTrustState; /// Test seam: next returns false once (forces elevate path). public bool FailNextUserTrustInstall { get; set; } @@ -178,11 +216,15 @@ public bool RemoveDecryptFailureBypass(string host) => public void ClearDecryptFailureBypass() => _proxy?.ClearDecryptFailureBypass(); + /// Test / tooling hook: mark as actively bypassed and raise learned. + internal bool ForceLearnDecryptBypass(string host) => + _proxy?.ForceDecryptFailureBypass(host) ?? false; + private bool IsLearnedDecryptBypass(string? host) { if (!EnableDecryptFailureBypass || _proxy is null || string.IsNullOrWhiteSpace(host)) return false; - // O(1) cache consult — do not Snapshot the full list on every CONNECT. + // O(1) cache consult - do not Snapshot the full list on every CONNECT. return _proxy.ShouldBypassDecryptForLearnedHost(host); } @@ -191,6 +233,7 @@ private void OnDecryptFailureBypassChanged(object? sender, DecryptFailureBypassE public async Task StartAsync(IPAddress address, int port, CancellationToken cancellationToken = default) { + using var scope = InspectorUxTrace.Scope("Interception.StartAsync", $"{address}:{port}"); cancellationToken.ThrowIfCancellationRequested(); if (_proxy is not null) { @@ -238,14 +281,18 @@ public async Task StartAsync(IPAddress address, int port, CancellationToken canc _endPoint.BeforeTunnelConnectRequest += OnBeforeTunnelConnect; _endPoint.BeforeTunnelConnectResponse += OnBeforeTunnelConnectResponse; _proxy.AddEndPoint(_endPoint); - _proxy.Start(); + using (InspectorUxTrace.Scope("Interception.ProxyServer.Start")) + _proxy.Start(); BoundPort = _endPoint.Port; StartProcessResolveWorker(); // Do not treat Unix store/Keychain presence as SSL trust (see RefreshTrustState). - IsRootTrusted = UseInMemoryTrustState - ? _inMemoryTrusted - : RefreshTrustState(machineStore: false); + using (InspectorUxTrace.Scope("Interception.RefreshTrustState.OnStart")) + { + IsRootTrusted = UseInMemoryTrustState + ? _inMemoryTrusted + : RefreshTrustState(machineStore: false); + } TryPruneLegacySharedCrtsOnce(); @@ -307,7 +354,7 @@ private void ApplyViaHeaderOption() /// /// Idempotent shutdown: restore system proxy (even if already stopped) and dispose the proxy. /// Matches WPF example EnsureProxyShutdown semantics. - /// Must not run on the Avalonia UI thread — WinINET InternetSetOption broadcasts + /// Must not run on the Avalonia UI thread - WinINET InternetSetOption broadcasts /// back to the closing window and deadlocks (title-bar Close hangs; taskbar Close often /// terminates the process instead). /// @@ -410,6 +457,10 @@ private void ApplyLoggingOptions(InspectorSettings? settings) public void Stop() { + // Release a paused breakpoint so Stop does not wait out the 120s hit timeout + // and the client is not left hanging after the listener is gone. + Breakpoints?.ContinueIfPaused("Proxy stopped — paused request continued"); + if (_proxy is null) { return; @@ -445,62 +496,90 @@ public void Stop() Http3Enabled = false; } + private readonly object _systemProxyGate = new(); + /// /// Enable or disable system proxy. Returns false if the proxy is not running or the underlying call failed. /// - public bool SetSystemProxy(bool enable, InspectorSettings? settings = null) + /// + /// Optional gate evaluated under the system-proxy lock before mutating OS settings. + /// Used to cancel a superseded optimistic enable/disable (e.g. Stop while enable is in flight). + /// + public bool SetSystemProxy(bool enable, InspectorSettings? settings = null, Func? stillWanted = null) { LastSystemProxyError = null; - if (_proxy is null || _endPoint is null || !_proxy.ProxyRunning) + lock (_systemProxyGate) { - LastSystemProxyError = "Proxy is not running"; - return false; - } + if (stillWanted is not null && !stillWanted()) + { + return false; + } - try - { if (enable) { - var effective = settings ?? SystemProxySettings ?? new InspectorSettings(); - SystemProxySettings = effective; - var result = _systemProxy.SetAsSystemProxy(_proxy, _endPoint, effective); - if (!result.Succeeded) + if (_proxy is null || _endPoint is null || !_proxy.ProxyRunning) { - LastSystemProxyError = result.Message; - _proxy.Logger.LogWarning("System proxy enable failed: {Message}", result.Message); + LastSystemProxyError = "Proxy is not running"; return false; } - - _systemProxyEnabled = true; } - else + else if (!_systemProxyEnabled) { - var result = _systemProxy.RestoreOriginalProxySettings(_proxy); - if (!result.Succeeded) - { - LastSystemProxyError = result.Message; - _proxy.Logger.LogWarning("System proxy disable failed: {Message}", result.Message); - return false; - } + // Already restored - common when Stop raced an optimistic enable that never landed. + return true; + } - _systemProxyEnabled = false; + if (_proxy is null) + { + LastSystemProxyError = "Proxy is not running"; + return false; } - return true; - } - catch (Exception ex) - { - LastSystemProxyError = ex.Message; try { - _proxy.Logger.LogWarning(ex, "System proxy {Action} failed", enable ? "enable" : "disable"); + if (enable) + { + var effective = settings ?? SystemProxySettings ?? new InspectorSettings(); + SystemProxySettings = effective; + var result = _systemProxy.SetAsSystemProxy(_proxy, _endPoint!, effective); + if (!result.Succeeded) + { + LastSystemProxyError = result.Message; + _proxy.Logger.LogWarning("System proxy enable failed: {Message}", result.Message); + return false; + } + + _systemProxyEnabled = true; + } + else + { + var result = _systemProxy.RestoreOriginalProxySettings(_proxy); + if (!result.Succeeded) + { + LastSystemProxyError = result.Message; + _proxy.Logger.LogWarning("System proxy disable failed: {Message}", result.Message); + return false; + } + + _systemProxyEnabled = false; + } + + return true; } - catch + catch (Exception ex) { - // logging must not hide the original failure - } + LastSystemProxyError = ex.Message; + try + { + _proxy.Logger.LogWarning(ex, "System proxy {Action} failed", enable ? "enable" : "disable"); + } + catch + { + // logging must not hide the original failure + } - return false; + return false; + } } } @@ -517,6 +596,11 @@ public bool ReapplySystemProxyIfEnabled() /// Install root CA and refresh from the store. /// True when the cert is present in the target Root store after install (or Unix SSL trust succeeded / needs Keychain confirm). + /// + /// Combined API for tests/E2E. Inspector UI uses + + /// so CryptUI Yes is not followed by store + /// sweeps on the Avalonia dispatcher. + /// public bool InstallRootCertificate(bool machineStore) { if (_proxy is null) @@ -540,9 +624,6 @@ public bool InstallRootCertificate(bool machineStore) return true; } - // Already in the .NET Root store: on Windows that is SSL trust. On macOS/Linux the - // cert can sit in Keychain/NSS without "Always Trust" / SSL trust — do not treat - // presence alone as trusted (Chrome then gets NET::ERR_CERT_AUTHORITY_INVALID). if (IsRootPresentInStore(machineStore)) { if (OperatingSystem.IsWindows()) @@ -559,18 +640,16 @@ public bool InstallRootCertificate(bool machineStore) return CompleteRootTrustInstall(true); } - // .NET/Keychain has the cert but SSL trust is incomplete — push OS trust again. _proxy.CertificateManager.TrustRootCertificate(machineStore); LastOsTrustResult = _proxy.CertificateManager.LastOsTrustResult; IsRootTrusted = EvaluateUnixTrustSuccess(LastOsTrustResult) || _proxy.CertificateManager.VerifyOsUserSslTrust(); - // MacNeedsManualTrustConfirm: cert was added; UI should guide Always Trust then re-verify. - // Return true so the recovery loop runs, but keep IsRootTrusted false until verified. return CompleteRootTrustInstall( IsRootTrusted || LastOsTrustResult?.Kind == CertificateOsTrustKind.MacNeedsManualTrustConfirm); } + // Full trust (stores + orphan prune + Unix) - non-UI callers only. _proxy.CertificateManager.TrustRootCertificate(machineStore); LastOsTrustResult = _proxy.CertificateManager.LastOsTrustResult; @@ -582,7 +661,117 @@ public bool InstallRootCertificate(bool machineStore) IsRootTrusted = EvaluateUnixTrustSuccess(LastOsTrustResult) || _proxy.CertificateManager.VerifyOsUserSslTrust(); - // MacNeedsManualTrustConfirm: cert was added; UI should guide Always Trust then re-verify. + return CompleteRootTrustInstall( + IsRootTrusted || + LastOsTrustResult?.Kind == CertificateOsTrustKind.MacNeedsManualTrustConfirm); + } + + /// CryptUI Root Add only - must run on a pumping UI thread. + /// True when the Root entry was newly added. + public bool InstallRootStoresOnly(bool machineStore) + { + if (_proxy is null) + return false; + + if (FailNextUserTrustInstall) + { + FailNextUserTrustInstall = false; + LastOsTrustResult = CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, "Forced user-trust failure (test)"); + return false; + } + + if (UseInMemoryTrustState) + { + _inMemoryTrusted = true; + IsRootTrusted = true; + LastOsTrustResult = CertificateOsTrustResult.Ok("Root CA trusted (in-memory)"); + return true; + } + + var added = _proxy.CertificateManager.InstallRootIntoCertificateStores(machineStore); + LastOsTrustResult = _proxy.CertificateManager.LastOsTrustResult; + return added; + } + + /// macOS/Linux Keychain/NSS trust - may show auth UI; pumping thread required. + public void ApplyUnixSslTrustOnUi(bool machineStore) + { + using var scope = InspectorUxTrace.Scope("ApplyUnixSslTrustOnUi", $"machine={machineStore}"); + if (_proxy is null || UseInMemoryTrustState || OperatingSystem.IsWindows()) + return; + + _proxy.CertificateManager.ApplyUnixSslTrustAfterStoreInstall(machineStore); + LastOsTrustResult = _proxy.CertificateManager.LastOsTrustResult; + InspectorUxTrace.Event( + "ApplyUnixSslTrustOnUi.Result", + $"kind={LastOsTrustResult?.Kind} trusted={IsRootTrusted}"); + } + + /// + /// After CryptUI Add / Unix trust: verify trust + My-store prune. Safe off the UI thread. + /// Skips Root orphan CryptUI sweeps (those freeze Avalonia after Yes). + /// + /// CurrentUser vs LocalMachine. + /// + /// When , CryptUI just added the Root entry - skip an immediate + /// Root-store Find (Crypt32 is hot after Yes and routinely stalls ~10–15s, especially on a + /// second Clear+Install). Trust is assumed; My prune runs best-effort afterward. + /// + public bool FinalizeTrustAfterStoreMutation(bool machineStore, bool? rootStoreAdded = null) + { + using var scope = InspectorUxTrace.Scope( + "FinalizeTrustAfterStoreMutation", + $"machine={machineStore} added={rootStoreAdded}"); + if (_proxy is null) + return false; + + if (UseInMemoryTrustState) + { + IsRootTrusted = _inMemoryTrusted; + return IsRootTrusted; + } + + if (rootStoreAdded == true && OperatingSystem.IsWindows()) + { + IsRootTrusted = true; + if (LastOsTrustResult is null) + LastOsTrustResult = CertificateOsTrustResult.Ok("Root CA trusted in current-user store"); + + InspectorUxTrace.Event("FinalizeTrust.SkipRootFind", "assumeInstalled=true"); + // Prune on the serial trust background lane - never Task.Run beside Firefox prefs work. + SchedulePruneOrphanedPersonalCertificates(machineStore); + return CompleteRootTrustInstall(true); + } + + try + { + using (InspectorUxTrace.Scope("FinalizeTrust.PrunePersonal")) + { + _proxy.CertificateManager.PruneOrphanedPersonalCertificates( + machineStore ? StoreLocation.LocalMachine : StoreLocation.CurrentUser, + keepCurrentThumbprint: true); + } + } + catch + { + // best-effort + } + + if (OperatingSystem.IsWindows()) + { + using (InspectorUxTrace.Scope("FinalizeTrust.IsRootPresentInStore")) + IsRootTrusted = IsRootPresentInStore(machineStore); + if (IsRootTrusted && LastOsTrustResult is null) + LastOsTrustResult = CertificateOsTrustResult.Ok("Root CA trusted in current-user store"); + return CompleteRootTrustInstall(IsRootTrusted); + } + + using (InspectorUxTrace.Scope("FinalizeTrust.VerifyOsUserSslTrust")) + { + IsRootTrusted = EvaluateUnixTrustSuccess(LastOsTrustResult) || + _proxy.CertificateManager.VerifyOsUserSslTrust(); + } return CompleteRootTrustInstall( IsRootTrusted || LastOsTrustResult?.Kind == CertificateOsTrustKind.MacNeedsManualTrustConfirm); @@ -590,8 +779,7 @@ public bool InstallRootCertificate(bool machineStore) private bool CompleteRootTrustInstall(bool installed) { - if (IsRootTrusted) - TryEnableFirefoxEnterpriseRootsBestEffort(); + // Do not write Firefox prefs/policies here - schedule via RunOffUiAsync after success. return installed; } @@ -628,6 +816,40 @@ public bool InstallRootCertificateAsAdmin(bool machineStore) LastOsTrustResult?.Kind == CertificateOsTrustKind.MacNeedsManualTrustConfirm); } + /// + /// After UAC/admin install: re-verify off the UI thread (store Find can stall Crypt32). + /// + public bool FinalizeTrustAfterAdminInstall(bool machineStore) + { + if (_proxy is null) + return false; + if (UseInMemoryTrustState) + return IsRootTrusted; + + try + { + _proxy.CertificateManager.PruneOrphanedPersonalCertificates( + machineStore ? StoreLocation.LocalMachine : StoreLocation.CurrentUser, + keepCurrentThumbprint: true); + } + catch + { + // best-effort + } + + if (OperatingSystem.IsWindows()) + { + IsRootTrusted = IsRootPresentInStore(machineStore); + return CompleteRootTrustInstall(IsRootTrusted); + } + + IsRootTrusted = EvaluateUnixTrustSuccess(LastOsTrustResult) || + _proxy.CertificateManager.VerifyOsUserSslTrust(); + return CompleteRootTrustInstall( + IsRootTrusted || + LastOsTrustResult?.Kind == CertificateOsTrustKind.MacNeedsManualTrustConfirm); + } + /// Installs certutil (package/brew) then retries user SSL trust. public CertificateOsTrustResult InstallNssToolsAndRetryTrust() { @@ -660,18 +882,20 @@ public bool IsRootInLoginKeychain() => _proxy?.CertificateManager.IsRootInLoginKeychain() == true; /// Re-verifies macOS/Linux user SSL trust and updates . + /// + /// Does not write Firefox prefs - that is Install / Trust Firefox only (verify-only must stay cheap). + /// public bool VerifyOsUserSslTrust() { + using var scope = InspectorUxTrace.Scope("VerifyOsUserSslTrust"); if (_proxy is null) return false; if (UseInMemoryTrustState) return IsRootTrusted; - // Windows: Root store presence is trust. Unix: require real SSL trust verification — + // Windows: Root store presence is trust. Unix: require real SSL trust verification - // Keychain/NSS can hold the CA without trusting it for SSL (Chrome MITM fails). var ok = OperatingSystem.IsWindows() ? IsRootPresentInStore(false) : _proxy.CertificateManager.VerifyOsUserSslTrust(); IsRootTrusted = ok; - if (ok) - TryEnableFirefoxEnterpriseRootsBestEffort(); return ok; } @@ -681,6 +905,11 @@ public bool VerifyOsUserSslTrust() /// public CertificateOsTrustResult TrustFirefox() { + if (UseInMemoryTrustState) + { + return CertificateOsTrustResult.Ok("Firefox trust recorded (in-memory)"); + } + if (_proxy is null) { return CertificateOsTrustResult.Fail( @@ -697,18 +926,63 @@ public CertificateOsTrustResult TrustFirefox() if (OperatingSystem.IsWindows()) { var policy = FirefoxCertificateTrust.TryEnableWindowsEnterpriseRoots(); + InspectorUxTrace.Event( + "TrustFirefox.WindowsEnterpriseRoots", + $"ok={policy.Succeeded} kind={policy.Kind} step={FirefoxCertificateTrust.LastEnterpriseRootsStep} msg={TruncateTrustMsg(policy.Message)}"); + LogTrustFirefoxOutcome(policy); if (policy.Succeeded) return policy; - // Fall through to profile NSS import. + + // Windows does not ship NSS certutil on PATH (Microsoft certutil.exe is ignored). + // Never fall through to TrustDefaultProfile - that surfaces CertutilMissing / apt-style copy. + var failed = CertificateOsTrustResult.Fail( + policy.Kind == CertificateOsTrustKind.Cancelled + ? CertificateOsTrustKind.Cancelled + : CertificateOsTrustKind.Failed, + string.IsNullOrWhiteSpace(policy.Message) + ? "Could not enable Firefox OS-root trust. Quit Firefox and retry, or Export CA and import it under Firefox Authorities." + : policy.Message + " - or Export CA and import it under Firefox Authorities."); + LogTrustFirefoxOutcome(failed); + return failed; } - else + + var pref = FirefoxCertificateTrust.TryEnableEnterpriseRootsUserPref(); + InspectorUxTrace.Event( + "TrustFirefox.EnterpriseRootsUserPref", + $"ok={pref.Succeeded} kind={pref.Kind} step={FirefoxCertificateTrust.LastEnterpriseRootsStep}"); + if (pref.Succeeded) + { + LogTrustFirefoxOutcome(pref); + return pref; + } + + var nss = FirefoxCertificateTrust.TrustDefaultProfile(cert, RootCertificateName); + LogTrustFirefoxOutcome(nss); + return nss; + } + + private void LogTrustFirefoxOutcome(CertificateOsTrustResult result) + { + try + { + if (_proxy?.Logger is null) + return; + if (result.Succeeded) + _proxy.Logger.LogInformation("TrustFirefox: {Message}", result.Message); + else + _proxy.Logger.LogWarning("TrustFirefox failed ({Kind}): {Message}", result.Kind, result.Message); + } + catch { - var pref = FirefoxCertificateTrust.TryEnableEnterpriseRootsUserPref(); - if (pref.Succeeded) - return pref; + // never break trust UX for logging } + } - return FirefoxCertificateTrust.TrustDefaultProfile(cert, RootCertificateName); + private static string TruncateTrustMsg(string? message) + { + if (string.IsNullOrEmpty(message)) + return ""; + return message.Length <= 120 ? message : message[..120] + "..."; } /// @@ -719,9 +993,21 @@ public static void TryEnableFirefoxEnterpriseRootsBestEffort() { try { + // Unit tests set TITANIUM_SKIP_ROOT_STORE_UI=1 - never touch live Firefox profiles + // (prefs.js locks hang / balloon memory when Firefox is open). + if (string.Equals(Environment.GetEnvironmentVariable("TITANIUM_SKIP_ROOT_STORE_UI"), "1", + StringComparison.Ordinal)) + return; + if (!FirefoxCertificateTrust.IsFirefoxProfilePresent()) return; - FirefoxCertificateTrust.TryEnableEnterpriseRootsUserPref(); + + // Windows: HKCU ImportEnterpriseRoots first (cheap). user.js/prefs.js only as fallback + // inside TryEnableWindowsEnterpriseRoots - never prefs-first on the install path. + if (OperatingSystem.IsWindows()) + FirefoxCertificateTrust.TryEnableWindowsEnterpriseRoots(); + else + FirefoxCertificateTrust.TryEnableEnterpriseRootsUserPref(); } catch { @@ -729,6 +1015,169 @@ public static void TryEnableFirefoxEnterpriseRootsBestEffort() } } + /// + /// Queue Firefox enable work on the serial background lane (coalesces consecutive enables). + /// + public void ScheduleFirefoxEnterpriseRootsBestEffort() => + EnqueueFirefoxTrustBackground(FirefoxTrustBgKind.Enable, TryEnableFirefoxEnterpriseRootsBestEffort); + + /// + /// Queue Firefox clear work on the serial background lane (coalesces consecutive clears). + /// + public void ScheduleClearPendingFirefoxRootTrust() => + EnqueueFirefoxTrustBackground(FirefoxTrustBgKind.Clear, ClearPendingFirefoxRootTrust); + + /// + /// Queue Personal (My) store same-CN prune on the serial trust background lane. + /// Used after CryptUI Root Add so install returns immediately while Crypt32 settles. + /// + public void SchedulePruneOrphanedPersonalCertificates(bool machineStore) + { + if (_proxy is null || UseInMemoryTrustState) + return; + + var location = machineStore ? StoreLocation.LocalMachine : StoreLocation.CurrentUser; + var mgr = _proxy.CertificateManager; + EnqueueFirefoxTrustBackground(FirefoxTrustBgKind.Prune, () => + { + try + { + mgr.PruneOrphanedPersonalCertificates(location, keepCurrentThumbprint: true); + } + catch + { + // best-effort + } + }); + } + + /// + /// Await idle trust background lane (Firefox prefs + My prune) so the next trust mutation + /// does not collide with prior fire-and-forget work. + /// + public Task WaitForFirefoxTrustBackgroundIdleAsync(CancellationToken cancellationToken = default) + { + Task idle; + lock (_firefoxTrustBgGate) + idle = _firefoxTrustBgIdle.Task; + + if (idle.IsCompleted) + return Task.CompletedTask; + + return idle.WaitAsync(cancellationToken); + } + + /// + /// Drop queued (not yet started) Clear/Enable/Prune work. A job already running finishes; + /// callers still use a short WaitForFirefoxTrustBackgroundIdleAsync. + /// + public void DropPendingFirefoxTrustBackgroundWork() + { + lock (_firefoxTrustBgGate) + { + var dropped = _firefoxTrustBgQueue.Count; + if (dropped == 0) + return; + + _firefoxTrustBgQueue.Clear(); + InspectorUxTrace.Event( + "TrustBg.DropPending", + $"dropped={dropped} running={_firefoxTrustBgRunning}"); + + if (!_firefoxTrustBgRunning) + _firefoxTrustBgIdle.TrySetResult(); + } + } + + private void EnqueueFirefoxTrustBackground(FirefoxTrustBgKind kind, Action work) + { + lock (_firefoxTrustBgGate) + { + // Coalesce consecutive same-kind ops (double Enable from Ensure+SetOsTrustSuccess, + // double Clear, double Prune after rapid Install). + if (_firefoxTrustBgQueue.Count > 0) + { + var items = _firefoxTrustBgQueue.ToArray(); + if (items[^1].Kind == kind) + { + _firefoxTrustBgQueue.Clear(); + for (var i = 0; i < items.Length - 1; i++) + _firefoxTrustBgQueue.Enqueue(items[i]); + } + } + + _firefoxTrustBgQueue.Enqueue(new FirefoxTrustBgQueued(kind, work)); + InspectorUxTrace.Event( + "TrustBg.Enqueue", + $"kind={kind} depth={_firefoxTrustBgQueue.Count} running={_firefoxTrustBgRunning}"); + + if (_firefoxTrustBgRunning) + return; + + _firefoxTrustBgRunning = true; + _firefoxTrustBgIdle = new(TaskCreationOptions.RunContinuationsAsynchronously); + // Background drain is independent of process-resolve CTS; opt out explicitly (S8949). + _ = Task.Run(DrainFirefoxTrustBackground, CancellationToken.None); + } + } + + private void DrainFirefoxTrustBackground() + { + InspectorUxTrace.Event("TrustBg.Drain.Start"); + try + { + while (true) + { + FirefoxTrustBgQueued next; + lock (_firefoxTrustBgGate) + { + if (_firefoxTrustBgQueue.Count == 0) + { + _firefoxTrustBgRunning = false; + _firefoxTrustBgIdle.TrySetResult(); + InspectorUxTrace.Event("TrustBg.Drain.Idle"); + return; + } + + next = _firefoxTrustBgQueue.Dequeue(); + } + + using (InspectorUxTrace.Scope("TrustBg.Job", $"kind={next.Kind}")) + { + try + { + // Clear/Enable historically stalled 30–40s on locked Firefox prefs. + // Cap the lane so Remove / Clear+Install never wait on a wedged job. + if (next.Kind is FirefoxTrustBgKind.Clear or FirefoxTrustBgKind.Enable) + { + // Prefs I/O is best-effort and time-capped; do not tie to process-resolve CTS. + var work = Task.Run(next.Work, CancellationToken.None); + if (!work.Wait(TimeSpan.FromSeconds(3), CancellationToken.None)) + InspectorUxTrace.Event("TrustBg.Job.Timeout", $"kind={next.Kind}"); + } + else + { + next.Work(); + } + } + catch + { + // best-effort lane - never fail the proxy / UI on prefs I/O + } + } + } + } + catch + { + lock (_firefoxTrustBgGate) + { + _firefoxTrustBgRunning = false; + _firefoxTrustBgIdle.TrySetResult(); + } + InspectorUxTrace.Event("TrustBg.Drain.Fault"); + } + } + private static bool EvaluateUnixTrustSuccess(CertificateOsTrustResult? result) => result is { Succeeded: true }; @@ -741,12 +1190,95 @@ public void SetLastOsTrustCancelled() } public void UntrustRootCertificate(bool machineStore) + { + // Combined API for E2E / non-UI callers. Inspector ViewModel uses RemoveOsRootStoreOnly + // + ClearPendingFirefoxRootTrust off-UI so CryptUI does not freeze on Firefox prefs. + RemoveOsRootStoreOnly(machineStore); + ClearPendingFirefoxRootTrust(); + } + + /// Nickname to clear from Firefox after OS untrust; consumed by . + internal string? PendingFirefoxRootClearName { get; private set; } + + /// Best-effort Firefox cleanup after OS Root remove (call off the UI thread). + public void ClearPendingFirefoxRootTrust() + { + var name = PendingFirefoxRootClearName; + PendingFirefoxRootClearName = null; + FirefoxCertificateTrust.ClearRootTrustBestEffort(name); + } + + /// + /// 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. + /// + /// + /// CryptUI Remove must run on a pumping UI thread; Firefox clear + PFX recreate should run + /// off-UI via + . + /// This combined method remains for tests / non-UI callers. + /// + public bool RotateRootCertificate(bool machineStore) + { + RemoveOsRootStoreOnly(machineStore); + return MintNewRootCertificateCore(); + } + + /// + /// CryptUI Root Removes for every same-CN thumbprint, then My/Unix finalize off-UI via + /// . Inspector ViewModel lists thumbs off-UI first. + /// + public void RemoveOsRootStoreOnly(bool machineStore) { if (_proxy is null) + return; + + if (UseInMemoryTrustState) { + _inMemoryTrusted = false; + IsRootTrusted = false; + PendingFirefoxRootClearName = null; return; } + PendingFirefoxRootClearName = RootCertificateName; + var location = machineStore ? StoreLocation.LocalMachine : StoreLocation.CurrentUser; + // Combined path for tests: full CN sweep (may CryptUI). UI callers use List + RemoveByThumb. + _proxy.CertificateManager.PruneOrphanedSameCommonNameCertificates( + machineStore, keepCurrentThumbprint: false); + RefreshTrustAfterRootRemove(machineStore); + } + + /// Read-only list of same-CN Root thumbprints to delete. Safe off the UI thread. + public IReadOnlyList ListRootThumbprintsToRemove(bool machineStore) + { + if (_proxy is null || UseInMemoryTrustState) + return Array.Empty(); + + PendingFirefoxRootClearName = RootCertificateName; + var location = machineStore ? StoreLocation.LocalMachine : StoreLocation.CurrentUser; + return _proxy.CertificateManager.ListSameCommonNameRootThumbprints(location, keepThumbprint: null); + } + + /// One Root Remove by thumbprint (CryptUI). Must run on a pumping UI thread. + public void RemoveRootThumbprintOnUi(bool machineStore, string thumbprint) + { + if (_proxy is null || UseInMemoryTrustState) + return; + + var location = machineStore ? StoreLocation.LocalMachine : StoreLocation.CurrentUser; + _proxy.CertificateManager.RemoveCertificateByThumbprint(StoreName.Root, location, thumbprint); + } + + /// + /// After CryptUI Root Removes: drop matching Personal-store entries + refresh IsRootTrusted. + /// Safe off the UI thread (no Root CryptUI). Does not touch Firefox. + /// + public void FinalizeAfterRootRemove(bool machineStore) + { + if (_proxy is null) + return; + if (UseInMemoryTrustState) { _inMemoryTrusted = false; @@ -754,40 +1286,77 @@ public void UntrustRootCertificate(bool machineStore) return; } - _proxy.CertificateManager.RemoveTrustedRootCertificate(machineStore); - // Windows: Root store presence is trust. macOS: Chrome still trusts System.keychain - // copies after the .NET user store is cleared. Linux: Chrome reads NSS (~/.pki/nssdb), - // not the .NET store — leftover nicknames must keep IsRootTrusted true. + var location = machineStore ? StoreLocation.LocalMachine : StoreLocation.CurrentUser; + try + { + // Thumbprint remove only - avoid another subject scan of a large Personal store. + var thumb = RootCertificate?.Thumbprint; + if (!string.IsNullOrEmpty(thumb)) + _proxy.CertificateManager.RemoveCertificateByThumbprint(StoreName.My, location, thumb); + else + _proxy.CertificateManager.PruneOrphanedPersonalCertificates( + location, keepCurrentThumbprint: false); + } + catch + { + // best-effort + } + + RefreshTrustAfterRootRemove(machineStore); + } + + /// macOS/Linux Keychain/NSS untrust - may prompt; pumping UI thread. + public void ApplyUnixUntrustOnUi() + { + using var scope = InspectorUxTrace.Scope("ApplyUnixUntrustOnUi"); + if (_proxy is null || UseInMemoryTrustState || OperatingSystem.IsWindows()) + return; + if (CertificateManager.AreInteractiveRootStoreMutationsSuppressed) + return; + if (RootCertificate is null) + return; + + try + { + _proxy.CertificateManager.ApplyUnixSslUntrust(); + InspectorUxTrace.Event("ApplyUnixUntrustOnUi.Done"); + } + catch + { + InspectorUxTrace.Event("ApplyUnixUntrustOnUi.Fault"); + // best-effort + } + } + + private void RefreshTrustAfterRootRemove(bool machineStore) + { if (OperatingSystem.IsWindows()) IsRootTrusted = IsRootPresentInStore(machineStore); else if (OperatingSystem.IsMacOS()) - IsRootTrusted = _proxy.CertificateManager.IsOsRootStillPresent(); + IsRootTrusted = _proxy!.CertificateManager.IsOsRootStillPresent(); else - IsRootTrusted = _proxy.CertificateManager.VerifyOsUserSslTrust(); + IsRootTrusted = _proxy!.CertificateManager.VerifyOsUserSslTrust(); } /// - /// 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. + /// After OS Root remove: optionally clear Firefox prefs, delete PFX/leaf cache, mint new root. + /// Safe off the UI thread (no CryptUI). /// - public bool RotateRootCertificate(bool machineStore) + public bool MintNewRootCertificateCore(bool clearFirefox = true) { + using var scope = InspectorUxTrace.Scope("MintNewRoot", $"clearFirefox={clearFirefox}"); if (_proxy is null) return false; - EnsureRootPfxPath(); - var mgr = _proxy.CertificateManager; - - if (!UseInMemoryTrustState) - mgr.RemoveTrustedRootCertificate(machineStore); + if (clearFirefox) + ClearPendingFirefoxRootTrust(); else - { - _inMemoryTrusted = false; - IsRootTrusted = false; - } + PendingFirefoxRootClearName = null; - mgr.ClearRootCertificate(); + EnsureRootPfxPath(); + var mgr = _proxy.CertificateManager; + using (InspectorUxTrace.Scope("MintNewRoot.ClearRootCertificate")) + mgr.ClearRootCertificate(); try { @@ -811,10 +1380,15 @@ public bool RotateRootCertificate(bool machineStore) } mgr.PfxFilePath = _rootPfxPath!; - var ok = mgr.CreateRootCertificate(persistToFile: true); - IsRootTrusted = !UseInMemoryTrustState && IsRootPresentInStore(machineStore); - - PruneLegacySharedCrts(force: true); + bool ok; + using (InspectorUxTrace.Scope("MintNewRoot.CreateRootCertificate")) + ok = mgr.CreateRootCertificate(persistToFile: true); + // Brand-new thumbprint cannot be in the Root store yet - do not open Crypt32 here + // (after Remove the store is hot; a useless Find routinely stalls Clear+Install). + IsRootTrusted = UseInMemoryTrustState && _inMemoryTrusted; + + using (InspectorUxTrace.Scope("MintNewRoot.PruneLegacySharedCrts")) + PruneLegacySharedCrts(force: true); return ok && mgr.RootCertificate != null; } @@ -880,7 +1454,7 @@ public bool RefreshTrustState(bool machineStore = false) return IsRootTrusted; } - // Windows Root store presence == trust. On macOS/Linux, presence is not enough — + // Windows Root store presence == trust. On macOS/Linux, presence is not enough - // VerifyOsUserSslTrust checks Keychain/NSS SSL trust (security verify-cert / certutil). if (OperatingSystem.IsWindows()) { @@ -937,6 +1511,11 @@ public bool IsRootPresentInStore(bool machineStore) var path = destinationPath ?? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), "TitaniumInspector-RootCA.cer"); + if (Directory.Exists(path)) + { + throw new IOException("Export path is a directory: " + path); + } + var der = cert.Export(X509ContentType.Cert); if (IsPemExportPath(path)) { @@ -1006,7 +1585,7 @@ private Task OnBeforeTunnelConnect(object sender, TunnelConnectSessionEventArgs try { - // Opaque HTTPS (DecryptHttps=false) never hits BeforeRequest — publish CONNECT here + // Opaque HTTPS (DecryptHttps=false) never hits BeforeRequest - publish CONNECT here // so the session list matches Fiddler when decryption is off. var snap = CreateTunnelSnapshot(e, opaqueReason); AttachTunnelByteCounters(e, snap); @@ -1072,13 +1651,9 @@ private SessionSnapshot CreateTunnelSnapshot(TunnelConnectSessionEventArgs e, Op { try { - // Buffer body when tools need GraphQL operationName matching. - var needsBodyForTools = - (AutoResponder is { Enabled: true } && AutoResponder.Rules.Any(r => r.Enabled && !string.IsNullOrWhiteSpace(r.GraphQlOperationName))) || - (MapRemote is { Enabled: true } && MapRemote.Rules.Any(r => r.Enabled && !string.IsNullOrWhiteSpace(r.GraphQlOperationName))) || - (Breakpoints is { Enabled: true } && !string.IsNullOrWhiteSpace(Breakpoints.GraphQlOperationName)); - - if (e.HttpClient.Request.HasBody && (ShouldBufferBody(e.HttpClient.Request, e) || needsBodyForTools)) + // Buffer when safe. GraphQL tools must NOT force GetRequestBody past the skip + // (huge POST would RST HTTP/2 with ENHANCE_YOUR_CALM). + if (e.HttpClient.Request.HasBody && ShouldBufferBody(e.HttpClient.Request, e, isRequest: true)) { e.HttpClient.Request.KeepBody = true; await e.GetRequestBody(CancellationToken.None); @@ -1090,6 +1665,10 @@ private SessionSnapshot CreateTunnelSnapshot(TunnelConnectSessionEventArgs e, Op } string? requestBody = null; + var needsBodyForTools = + (AutoResponder is { Enabled: true } && AutoResponder.Rules.Any(r => r.Enabled && !string.IsNullOrWhiteSpace(r.GraphQlOperationName))) || + (MapRemote is { Enabled: true } && MapRemote.Rules.Any(r => r.Enabled && !string.IsNullOrWhiteSpace(r.GraphQlOperationName))) || + (Breakpoints is { Enabled: true } && !string.IsNullOrWhiteSpace(Breakpoints.GraphQlOperationName)); if (needsBodyForTools && e.HttpClient.Request.IsBodyRead) { requestBody = await e.GetRequestBodyAsString(CancellationToken.None); @@ -1102,13 +1681,23 @@ private SessionSnapshot CreateTunnelSnapshot(TunnelConnectSessionEventArgs e, Op if (AutoResponder is not null && AutoResponder.TryMatch(requestUrl, requestBody, out var rule) && rule is not null && - AutoResponderViewModel.TryResolveBody(rule, out var bodyBytes, out _)) + AutoResponderViewModel.TryResolveResponse(rule, out var inlineBody, out var mapLocalPath, out _, out _)) { - var headers = new List + if (mapLocalPath is not null) + { + e.RespondStreaming( + ProxyResults.File(mapLocalPath, rule.ContentType, (HttpStatusCode)rule.StatusCode), + closeServerConnection: false); + } + else { - new("Content-Type", rule.ContentType), - }; - e.GenericResponse(bodyBytes, (HttpStatusCode)rule.StatusCode, headers); + var headers = new List + { + new("Content-Type", rule.ContentType), + }; + e.GenericResponse(inlineBody ?? Array.Empty(), (HttpStatusCode)rule.StatusCode, headers); + } + autoResponded = true; } @@ -1159,7 +1748,7 @@ private async Task OnBeforeResponse(object sender, SessionEventArgs e) { try { - if (e.HttpClient.Response.HasBody && ShouldBufferBody(e.HttpClient.Response, e)) + if (e.HttpClient.Response.HasBody && ShouldBufferBody(e.HttpClient.Response, e, isRequest: false)) { e.HttpClient.Response.KeepBody = true; await e.GetResponseBody(CancellationToken.None); @@ -1210,6 +1799,7 @@ private Task OnAfterResponse(object sender, SessionEventArgs e) { if (_live.TryGetValue(e.HttpClient, out var snap)) { + FinalizeStreamingBody(snap); ApplyTiming(snap, e.Timing, snap.StartedUtc); SessionUpdated?.Invoke(this, snap); } @@ -1230,8 +1820,9 @@ private Task OnServerCertValidation(object sender, CertificateValidationEventArg private SessionSnapshot CreatePreviewSnapshot(SessionEventArgs e, bool assignId) { var req = e.HttpClient.Request; - var bodyBytes = req.IsBodyRead ? TruncateBytes(req.Body) : null; - var bodyText = bodyBytes is null ? null : TruncateText(Encoding.UTF8.GetString(bodyBytes)); + var originalBody = req.IsBodyRead ? req.Body : null; + var bodyBytes = InspectorBodyLimits.TruncateBytes(originalBody); + var bodyText = bodyBytes is null ? null : InspectorBodyLimits.TruncateText(Encoding.UTF8.GetString(bodyBytes)); GrpcJsonTranscodeSessionMark.TryGet(e.UserData, out var mark); var snap = new SessionSnapshot @@ -1256,16 +1847,21 @@ private SessionSnapshot CreatePreviewSnapshot(SessionEventArgs e, bool assignId) (req.Headers.GetFirstHeader("Accept")?.Value?.Contains("text/event-stream", StringComparison.OrdinalIgnoreCase) == true), }; + ApplyRequestBodyCapture(snap, req, originalBody); ApplyTranscodeMark(snap, mark); if (mark?.ClientRequestBody is { Length: > 0 } clientBody) { - snap.RequestBodyBytes = TruncateBytes(clientBody); - snap.RequestBodyText = TruncateText(Encoding.UTF8.GetString(clientBody)); + snap.RequestBodyBytes = InspectorBodyLimits.TruncateBytes(clientBody); + snap.RequestBodyText = InspectorBodyLimits.TruncateText(Encoding.UTF8.GetString(clientBody)); + snap.RequestBodyOriginalSize = clientBody.LongLength; + snap.RequestBodyCapture = clientBody.Length > MaxBodyBytes + ? BodyCaptureState.Truncated + : BodyCaptureState.Complete; } if (mark?.UpstreamRequestBody is { Length: > 0 } upstreamReq) { - snap.UpstreamRequestBodyBytes = TruncateBytes(upstreamReq); + snap.UpstreamRequestBodyBytes = InspectorBodyLimits.TruncateBytes(upstreamReq); snap.GrpcFrames = ProtocolFrameInspectors.ParseGrpcFrames(snap.UpstreamRequestBodyBytes); snap.ProtobufDecodedText = ProtobufMessageDecoder.DecodeWireFormat(snap.UpstreamRequestBodyBytes); } @@ -1434,11 +2030,11 @@ private static void FillResponse(SessionSnapshot snap, SessionEventArgs e) // NO snap.ResponseHeadersText = FormatHeaders(resp.Headers); snap.Protocol = SessionDisplayFormat.FormatClientServer( e.HttpClient.Request.HttpVersion, resp.HttpVersion); - var bodyBytes = resp.IsBodyRead ? TruncateBytes(resp.Body) : null; + var originalBody = resp.IsBodyRead ? resp.Body : null; + var bodyBytes = InspectorBodyLimits.TruncateBytes(originalBody); snap.ResponseBodyBytes = bodyBytes; - snap.ResponseBodyText = bodyBytes is null ? null : TruncateText(Encoding.UTF8.GetString(bodyBytes)); - snap.BodySize = bodyBytes?.LongLength - ?? (resp.ContentLength >= 0 ? resp.ContentLength : null); + snap.ResponseBodyText = bodyBytes is null ? null : InspectorBodyLimits.TruncateText(Encoding.UTF8.GetString(bodyBytes)); + ApplyResponseBodyCapture(snap, resp, e.HttpClient.Request, originalBody); ApplyTiming(snap, e.Timing, snap.StartedUtc); @@ -1447,7 +2043,7 @@ private static void FillResponse(SessionSnapshot snap, SessionEventArgs e) // NO ApplyTranscodeMark(snap, mark); if (mark.UpstreamResponseBody is { Length: > 0 } upstreamResp) { - snap.UpstreamResponseBodyBytes = TruncateBytes(upstreamResp); + snap.UpstreamResponseBodyBytes = InspectorBodyLimits.TruncateBytes(upstreamResp); snap.GrpcFrames = ProtocolFrameInspectors.ParseGrpcFrames(snap.UpstreamResponseBodyBytes); } } @@ -1506,31 +2102,227 @@ private void AttachLiveWebSocketFrames(SessionEventArgs e, SessionSnapshot snap) private async Task OnRequestBodyWriteThrottle(object sender, BeforeBodyWriteEventArgs e) { var profile = ThrottleProfile; - if (profile is not { IsEnabled: true }) + if (profile is { IsEnabled: true }) + { + var delay = NetworkThrottle.DelayFor(profile, e.BodyBytes?.Length ?? 0, applyLatency: !e.IsChunked || e.BodyBytes?.Length > 0); + if (delay > TimeSpan.Zero) + { + await Task.Delay(delay, _processResolveCts?.Token ?? CancellationToken.None).ConfigureAwait(false); + } + } + } + + private async Task OnResponseBodyWriteThrottle(object sender, BeforeBodyWriteEventArgs e) + { + var profile = ThrottleProfile; + if (profile is { IsEnabled: true }) + { + var delay = NetworkThrottle.DelayFor(profile, e.BodyBytes?.Length ?? 0, applyLatency: true); + if (delay > TimeSpan.Zero) + { + await Task.Delay(delay, _processResolveCts?.Token ?? CancellationToken.None).ConfigureAwait(false); + } + } + + // Preview tee only for streamed SSE (not buffered). Do not tee NotCaptured huge downloads. + if (e.Session.HttpClient.Response.IsBodyRead) { return; } - var delay = NetworkThrottle.DelayFor(profile, e.BodyBytes?.Length ?? 0, applyLatency: !e.IsChunked || e.BodyBytes?.Length > 0); - if (delay > TimeSpan.Zero) + if (!_live.TryGetValue(e.Session.HttpClient, out var snap)) { - await Task.Delay(delay, _processResolveCts?.Token ?? CancellationToken.None).ConfigureAwait(false); + return; } + + if (snap.ResponseBodyCapture != BodyCaptureState.Streaming) + { + return; + } + + TeeResponseChunk(snap, e); } - private async Task OnResponseBodyWriteThrottle(object sender, BeforeBodyWriteEventArgs e) + private void TeeResponseChunk(SessionSnapshot snap, BeforeBodyWriteEventArgs e) { - var profile = ThrottleProfile; - if (profile is not { IsEnabled: true }) + var chunk = e.BodyBytes; + var len = chunk?.Length ?? 0; + if (len > 0) + { + snap.ResponseBytesSeen += len; + snap.ResponseBodyOriginalSize = snap.ResponseBytesSeen; + snap.BodySize = snap.ResponseBytesSeen; + + var tee = snap.ResponseTeeStream; + if (tee is null) + { + tee = new MemoryStream(Math.Min(MaxBodyBytes, Math.Max(len, 4096))); + snap.ResponseTeeStream = tee; + } + + if (tee.Length < MaxBodyBytes) + { + var toWrite = (int)Math.Min(len, MaxBodyBytes - tee.Length); + tee.Write(chunk!, 0, toWrite); + } + } + + if (e.IsLastChunk) + { + FinalizeStreamingBody(snap); + SessionUpdated?.Invoke(this, snap); + return; + } + + var now = DateTime.UtcNow.Ticks; + var last = snap.LastTeeUiUtcTicks; + if (last != 0 && (now - last) < TimeSpan.FromMilliseconds(InspectorBodyLimits.TeeUiCoalesceMs).Ticks) + { + return; + } + + snap.LastTeeUiUtcTicks = now; + PublishTeePreview(snap); + SessionUpdated?.Invoke(this, snap); + } + + private static void PublishTeePreview(SessionSnapshot snap) + { + var tee = snap.ResponseTeeStream; + if (tee is null || tee.Length == 0) + { + return; + } + + var bytes = tee.ToArray(); + snap.ResponseBodyBytes = bytes; + snap.ResponseBodyText = InspectorBodyLimits.TruncateText(Encoding.UTF8.GetString(bytes)); + if (snap.IsServerSentEvents) + { + snap.SseEvents = SseEventParser.Parse(snap.ResponseBodyText); + } + } + + private static void FinalizeStreamingBody(SessionSnapshot snap) + { + if (snap.ResponseBodyCapture != BodyCaptureState.Streaming) + { + snap.ResponseTeeStream?.Dispose(); + snap.ResponseTeeStream = null; + return; + } + + PublishTeePreview(snap); + snap.ResponseBodyStreamOpen = false; + var captured = snap.ResponseBodyBytes?.LongLength ?? 0; + if (snap.ResponseBytesSeen > captured && captured >= MaxBodyBytes) + { + snap.ResponseBodyCapture = BodyCaptureState.Truncated; + } + else if (captured > 0 && snap.ResponseBytesSeen <= MaxBodyBytes) { + snap.ResponseBodyCapture = BodyCaptureState.Complete; + } + + snap.ResponseBodyOriginalSize = snap.ResponseBytesSeen > 0 + ? snap.ResponseBytesSeen + : snap.ResponseBodyOriginalSize; + if (snap.ResponseBytesSeen > 0) + { + snap.BodySize = snap.ResponseBytesSeen; + } + + snap.ResponseTeeStream?.Dispose(); + snap.ResponseTeeStream = null; + } + + private static void ApplyRequestBodyCapture(SessionSnapshot snap, Request req, byte[]? originalBody) + { + if (originalBody is not null && req.IsBodyRead) + { + snap.RequestBodyOriginalSize = originalBody.LongLength; + snap.RequestBodyCapture = originalBody.Length > MaxBodyBytes + ? BodyCaptureState.Truncated + : BodyCaptureState.Complete; return; } - var delay = NetworkThrottle.DelayFor(profile, e.BodyBytes?.Length ?? 0, applyLatency: true); - if (delay > TimeSpan.Zero) + if (!req.HasBody) { - await Task.Delay(delay, _processResolveCts?.Token ?? CancellationToken.None).ConfigureAwait(false); + snap.RequestBodyCapture = BodyCaptureState.None; + return; } + + var limit = InspectorBodyLimits.MaxMapLocalFileBytes; + if (req.ContentLength > limit) + { + snap.RequestBodyCapture = BodyCaptureState.NotCaptured; + snap.RequestBodyOriginalSize = req.ContentLength; + return; + } + + snap.RequestBodyCapture = BodyCaptureState.None; + } + + private static void ApplyResponseBodyCapture( + SessionSnapshot snap, + Response resp, + Request req, + byte[]? originalBody) + { + var contentType = resp.ContentType ?? snap.ContentType ?? ""; + var isSse = InspectorBodyLimits.LooksLikeSseContentType(contentType) + || snap.IsServerSentEvents; + if (isSse) + { + snap.IsServerSentEvents = true; + } + + if (originalBody is not null && resp.IsBodyRead) + { + snap.ResponseBodyOriginalSize = originalBody.LongLength; + snap.ResponseBodyCapture = originalBody.Length > MaxBodyBytes + ? BodyCaptureState.Truncated + : BodyCaptureState.Complete; + snap.BodySize = originalBody.LongLength; + snap.ResponseBodyStreamOpen = false; + return; + } + + if (req.UpgradeToWebSocket) + { + snap.ResponseBodyCapture = BodyCaptureState.None; + snap.BodySize ??= resp.ContentLength >= 0 ? resp.ContentLength : null; + return; + } + + if (isSse) + { + snap.ResponseBodyCapture = BodyCaptureState.Streaming; + snap.ResponseBodyStreamOpen = true; + snap.BodySize = snap.ResponseBytesSeen > 0 ? snap.ResponseBytesSeen : null; + return; + } + + if (resp.HasBody && resp.ContentLength > InspectorBodyLimits.MaxMapLocalFileBytes) + { + snap.ResponseBodyCapture = BodyCaptureState.NotCaptured; + snap.ResponseBodyOriginalSize = resp.ContentLength; + snap.BodySize = resp.ContentLength; + return; + } + + if (resp.HasBody && !resp.IsBodyRead) + { + // Should not happen for finite bodies we chose to buffer; treat as not captured. + snap.ResponseBodyCapture = BodyCaptureState.NotCaptured; + snap.ResponseBodyOriginalSize = resp.ContentLength >= 0 ? resp.ContentLength : null; + snap.BodySize = snap.ResponseBodyOriginalSize; + return; + } + + snap.ResponseBodyCapture = BodyCaptureState.None; + snap.BodySize ??= resp.ContentLength >= 0 ? resp.ContentLength : null; } private static string? TryHost(Request req) @@ -1608,12 +2400,18 @@ private static string FormatHeaders(HeaderCollection headers) /// /// 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). + /// exceeds - that path RSTs HTTP/2 streams + /// with ENHANCE_YOUR_CALM and breaks the browser download. SSE and WebSocket upgrades are + /// never buffered (relay + optional 2 MiB tee). Finite unknown-length (chunked) bodies still + /// buffer up to the limit so gzip JSON can be inspected. /// - private bool ShouldBufferBody(RequestResponseBase message, SessionEventArgs session) + private bool ShouldBufferBody(RequestResponseBase message, SessionEventArgs session, bool isRequest) { + if (LooksLikeEndlessStream(message, session, isRequest)) + { + return false; + } + var limit = session.MaxBufferedBodyBytes ?? _proxy?.MaxBufferedBodyBytes ?? (4 * 1024 * 1024); if (limit <= 0) { @@ -1624,18 +2422,24 @@ private bool ShouldBufferBody(RequestResponseBase message, SessionEventArgs sess return contentLength < 0 || contentLength <= limit; } - private static byte[]? TruncateBytes(byte[]? body) + private static bool LooksLikeEndlessStream(RequestResponseBase message, SessionEventArgs session, bool isRequest) { - if (body is null || body.Length == 0) + if (session.HttpClient.Request.UpgradeToWebSocket) { - return body; + return true; } - return body.Length <= MaxBodyBytes ? body : body.AsSpan(0, MaxBodyBytes).ToArray(); + if (!isRequest && InspectorBodyLimits.LooksLikeSseContentType(message.ContentType)) + { + return true; + } + + return false; } - private static string TruncateText(string text) - => text.Length <= MaxBodyTextChars ? text : text[..MaxBodyTextChars] + "…"; + private static byte[]? TruncateBytes(byte[]? body) => InspectorBodyLimits.TruncateBytes(body); + + private static string TruncateText(string text) => InspectorBodyLimits.TruncateText(text); public void Dispose() => EnsureShutdown(); } diff --git a/src/Titanium.Inspector/Services/ReplayService.cs b/src/Titanium.Inspector/Services/ReplayService.cs index 8705f43d5..fedc51d2d 100644 --- a/src/Titanium.Inspector/Services/ReplayService.cs +++ b/src/Titanium.Inspector/Services/ReplayService.cs @@ -12,6 +12,7 @@ public static async Task ReplayAsync( string? editedMethod = null, string? editedBody = null, string? editedHeaders = null, + string? bodyFilePath = null, bool ignoreServerCertificateErrors = false, CancellationToken cancellationToken = default) { @@ -33,14 +34,21 @@ public static async Task ReplayAsync( #pragma warning restore S4830 } - using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(60) }; + var timeout = string.IsNullOrWhiteSpace(bodyFilePath) + ? TimeSpan.FromSeconds(60) + : TimeSpan.FromMinutes(10); + using var http = new HttpClient(handler) { Timeout = timeout }; using var request = new HttpRequestMessage(new HttpMethod(editedMethod ?? session.Method), url); ApplyEditedHeaders(request, editedHeaders ?? session.RequestHeadersText ?? ""); - AttachBody(request, session, editedBody); + await using var fileStream = await AttachBodyAsync(request, session, editedBody, bodyFilePath, cancellationToken) + .ConfigureAwait(false); + + using var response = await http.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); - using var response = await http.SendAsync(request, cancellationToken); - var respBody = await response.Content.ReadAsStringAsync(cancellationToken); var respHeaders = new StringBuilder(); foreach (var h in response.Headers) { @@ -52,12 +60,21 @@ public static async Task ReplayAsync( respHeaders.Append(h.Key).Append(": ").Append(string.Join(", ", h.Value)).AppendLine(); } + await using var respStream = await response.Content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + var (respBytes, respSeen, truncated) = await ReadPreviewAsync(respStream, cancellationToken) + .ConfigureAwait(false); + var respBody = Encoding.UTF8.GetString(respBytes); + return new ReplayResult( true, (int)response.StatusCode, Truncate(respBody, 64 * 1024), respHeaders.ToString(), - Truncate(respBody, InterceptionService.MaxBodyTextChars)); + InspectorBodyLimits.TruncateText(respBody), + respBytes, + respSeen, + truncated ? BodyCaptureState.Truncated : BodyCaptureState.Complete); } private static void ApplyEditedHeaders(HttpRequestMessage request, string headerBlock) @@ -87,8 +104,33 @@ private static void ApplyEditedHeaders(HttpRequestMessage request, string header } } - private static void AttachBody(HttpRequestMessage request, SessionSnapshot session, string? editedBody) + private static async Task AttachBodyAsync( + HttpRequestMessage request, + SessionSnapshot session, + string? editedBody, + string? bodyFilePath, + CancellationToken cancellationToken) { + if (!string.IsNullOrWhiteSpace(bodyFilePath)) + { + var path = bodyFilePath.Trim(); + if (!File.Exists(path)) + { + throw new FileNotFoundException("Composer body file not found.", path); + } + + var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.Asynchronous); + var content = new StreamContent(fs); + content.Headers.ContentLength = fs.Length; + if (!string.IsNullOrEmpty(session.ContentType)) + { + content.Headers.ContentType = MediaTypeHeaderValue.Parse(session.ContentType); + } + + request.Content = content; + return fs; + } + var bodyText = editedBody ?? session.RequestBodyText; if (!string.IsNullOrEmpty(bodyText)) { @@ -103,6 +145,36 @@ private static void AttachBody(HttpRequestMessage request, SessionSnapshot sessi { request.Content = new ByteArrayContent(session.RequestBodyBytes); } + + await Task.CompletedTask.ConfigureAwait(false); + return null; + } + + private static async Task<(byte[] Bytes, long Seen, bool Truncated)> ReadPreviewAsync( + Stream stream, + CancellationToken cancellationToken) + { + using var ms = new MemoryStream(); + var buffer = new byte[8192]; + long seen = 0; + while (true) + { + var read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken) + .ConfigureAwait(false); + if (read <= 0) + { + break; + } + + seen += read; + if (ms.Length < InspectorBodyLimits.MaxBodyBytes) + { + var toWrite = (int)Math.Min(read, InspectorBodyLimits.MaxBodyBytes - ms.Length); + ms.Write(buffer, 0, toWrite); + } + } + + return (ms.ToArray(), seen, seen > ms.Length); } private static string Truncate(string text, int max) @@ -114,4 +186,7 @@ public readonly record struct ReplayResult( int StatusCode, string Message, string? ResponseHeaders = null, - string? ResponseBody = null); + string? ResponseBody = null, + byte[]? ResponseBodyBytes = null, + long? ResponseBodyOriginalSize = null, + BodyCaptureState ResponseBodyCapture = BodyCaptureState.None); diff --git a/src/Titanium.Inspector/Services/SessionBodyDiskCache.cs b/src/Titanium.Inspector/Services/SessionBodyDiskCache.cs index c39278341..8c51f1da1 100644 --- a/src/Titanium.Inspector/Services/SessionBodyDiskCache.cs +++ b/src/Titanium.Inspector/Services/SessionBodyDiskCache.cs @@ -6,17 +6,20 @@ 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). +/// Version 2 appends: requestOriginalSize int64, responseOriginalSize int64, +/// requestCapture byte, responseCapture byte. /// Length -1 means null; 0 means empty. /// public sealed class SessionBodyDiskCache : IDisposable { private const string BodyFileSearchPattern = "*.bin"; - private const int Version = 1; + private const int Version = 2; + private const int VersionV1 = 1; private static readonly byte[] Magic = "TSIB"u8.ToArray(); private readonly string _directory; - private readonly long _maxBytes; - private readonly TimeSpan _maxAge; + private long _maxBytes; + private TimeSpan _maxAge; private readonly object _gate = new(); private long _trackedBytes; private bool _disposed; @@ -30,6 +33,19 @@ public SessionBodyDiskCache(string directory, long maxBytes, TimeSpan maxAge) PruneOnStartup(); } + /// Updates disk budget / age; next write or prune enforces the new limits. + public void UpdateLimits(long maxBytes, TimeSpan maxAge) + { + lock (_gate) + { + _maxBytes = maxBytes > 0 ? maxBytes : _maxBytes; + _maxAge = maxAge > TimeSpan.Zero ? maxAge : _maxAge; + } + + PruneExpiredFiles(); + EnforceDiskBudget(); + } + /// /// Default spill directory under LocalApplicationData (Windows LocalAppData, /// Linux ~/.local/share, macOS Application Support). @@ -58,6 +74,10 @@ public void Write(SessionSnapshot snapshot) WriteBytes(bw, snapshot.ResponseBodyBytes); WriteString(bw, snapshot.RequestBodyText); WriteString(bw, snapshot.ResponseBodyText); + bw.Write(snapshot.RequestBodyOriginalSize ?? -1L); + bw.Write(snapshot.ResponseBodyOriginalSize ?? -1L); + bw.Write((byte)snapshot.RequestBodyCapture); + bw.Write((byte)snapshot.ResponseBodyCapture); } if (File.Exists(path)) @@ -91,7 +111,7 @@ public bool TryLoad(SessionSnapshot snapshot) } var version = br.ReadInt32(); - if (version != Version) + if (version is not Version and not VersionV1) { return false; } @@ -100,6 +120,26 @@ public bool TryLoad(SessionSnapshot snapshot) snapshot.ResponseBodyBytes = ReadBytes(br); snapshot.RequestBodyText = ReadString(br); snapshot.ResponseBodyText = ReadString(br); + + if (version >= Version) + { + var reqOrig = br.ReadInt64(); + var respOrig = br.ReadInt64(); + snapshot.RequestBodyOriginalSize = reqOrig < 0 ? null : reqOrig; + snapshot.ResponseBodyOriginalSize = respOrig < 0 ? null : respOrig; + snapshot.RequestBodyCapture = (BodyCaptureState)br.ReadByte(); + snapshot.ResponseBodyCapture = (BodyCaptureState)br.ReadByte(); + } + else + { + snapshot.RequestBodyCapture = InspectorBodyLimits.InferFromBytes( + snapshot.RequestBodyBytes, snapshot.RequestBodyBytes?.LongLength); + snapshot.ResponseBodyCapture = InspectorBodyLimits.InferFromBytes( + snapshot.ResponseBodyBytes, snapshot.ResponseBodyBytes?.LongLength); + snapshot.RequestBodyOriginalSize = snapshot.RequestBodyBytes?.LongLength; + snapshot.ResponseBodyOriginalSize = snapshot.ResponseBodyBytes?.LongLength; + } + return true; } diff --git a/src/Titanium.Inspector/Services/SessionInspectors.cs b/src/Titanium.Inspector/Services/SessionInspectors.cs index feba4a1a0..1673a600f 100644 --- a/src/Titanium.Inspector/Services/SessionInspectors.cs +++ b/src/Titanium.Inspector/Services/SessionInspectors.cs @@ -200,7 +200,7 @@ private static string ResolveBodyText(string? headersText, string? bodyText, byt { if (!string.IsNullOrEmpty(bodyText)) { - return TryFormatJson(bodyText); + return bodyText; } var headers = ParseHeaderBlock(headersText); @@ -208,7 +208,7 @@ private static string ResolveBodyText(string? headersText, string? bodyText, byt var bytes = TryDecompress(bodyBytes, encoding); if (bytes is { Length: > 0 }) { - return TryFormatJson(Encoding.UTF8.GetString(bytes)); + return Encoding.UTF8.GetString(bytes); } return "(empty)"; diff --git a/src/Titanium.Inspector/Services/SessionSnapshot.cs b/src/Titanium.Inspector/Services/SessionSnapshot.cs index 903263c77..193b38238 100644 --- a/src/Titanium.Inspector/Services/SessionSnapshot.cs +++ b/src/Titanium.Inspector/Services/SessionSnapshot.cs @@ -23,6 +23,13 @@ public sealed class SessionSnapshot : INotifyPropertyChanged private string? _processName; private double? _durationMs; private double? _ttfbMs; + private BodyCaptureState _requestBodyCapture; + private BodyCaptureState _responseBodyCapture; + private long? _requestBodyOriginalSize; + private long? _responseBodyOriginalSize; + private bool _responseBodyStreamOpen; + private bool _isWebSocket; + private OpaqueTunnelReason _opaqueReason; public long Id { get; set; } public string Method { get; set; } = "GET"; @@ -34,11 +41,24 @@ public sealed class SessionSnapshot : INotifyPropertyChanged /// and must be reloaded via . /// public bool BodiesOnDisk { get; set; } - public bool IsWebSocket { get; set; } + public bool IsWebSocket + { + get => _isWebSocket; + set => SetField(ref _isWebSocket, value); + } public bool IsGrpc { get; set; } public bool IsTranscoded { get; set; } public bool IsTunnel { get; set; } - public OpaqueTunnelReason OpaqueReason { get; set; } + public OpaqueTunnelReason OpaqueReason + { + get => _opaqueReason; + set + { + if (!SetField(ref _opaqueReason, value)) + return; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(OpaqueReasonDisplay))); + } + } /// Client-facing HTTP method before gRPC-JSON rewrite (when ). public string? ClientMethod { get; set; } @@ -145,6 +165,50 @@ public long? BodySize /// Grid display for (B / KB / MB). public string BodySizeDisplay => SessionDisplayFormat.FormatByteSize(BodySize); + /// How the request body was retained for Inspect. + public BodyCaptureState RequestBodyCapture + { + get => _requestBodyCapture; + set => SetField(ref _requestBodyCapture, value); + } + + /// How the response body was retained for Inspect. + public BodyCaptureState ResponseBodyCapture + { + get => _responseBodyCapture; + set => SetField(ref _responseBodyCapture, value); + } + + /// Original request body size when known (Content-Length or pre-truncate length). + public long? RequestBodyOriginalSize + { + get => _requestBodyOriginalSize; + set => SetField(ref _requestBodyOriginalSize, value); + } + + /// Original response body size when known (Content-Length or pre-truncate / bytes-seen). + public long? ResponseBodyOriginalSize + { + get => _responseBodyOriginalSize; + set => SetField(ref _responseBodyOriginalSize, value); + } + + /// True while an SSE-style response stream is still open. + public bool ResponseBodyStreamOpen + { + get => _responseBodyStreamOpen; + set => SetField(ref _responseBodyStreamOpen, value); + } + + /// In-flight SSE tee buffer (not spilled; cleared when finalized). + internal MemoryStream? ResponseTeeStream { get; set; } + + /// Bytes seen on the response wire while teeing (may exceed preview). + internal long ResponseBytesSeen { get; set; } + + /// UTC ticks of last coalesced SessionUpdated from the tee. + internal long LastTeeUiUtcTicks { get; set; } + public int ProcessId { get => _processId; @@ -196,11 +260,12 @@ public string ProcessDisplay public event PropertyChangedEventHandler? PropertyChanged; - private void SetField(ref T field, T value, [CallerMemberName] string? name = null) + /// True when the value changed. + private bool SetField(ref T field, T value, [CallerMemberName] string? name = null) { if (Equals(field, value)) { - return; + return false; } field = value; @@ -214,6 +279,8 @@ private void SetField(ref T field, T value, [CallerMemberName] string? name = { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BodySizeDisplay))); } + + return true; } } diff --git a/src/Titanium.Inspector/Services/SessionStore.cs b/src/Titanium.Inspector/Services/SessionStore.cs index 337fa5445..4a18a1860 100644 --- a/src/Titanium.Inspector/Services/SessionStore.cs +++ b/src/Titanium.Inspector/Services/SessionStore.cs @@ -50,6 +50,43 @@ public SessionStore(SessionStoreOptions? options = null, string? cacheDirectory public SessionStoreOptions Options => _options; + /// + /// Applies retention knobs in-process and enforces limits immediately (no Inspector restart). + /// Disk spill enable/disable that requires a different spill loop is best-effort: toggling + /// spill off leaves existing spilled bodies loadable until restart when a disk cache was never started. + /// + public void ApplyOptions(SessionStoreOptions options) + { + ArgumentNullException.ThrowIfNull(options); + List? removed = null; + lock (_gate) + { + _options.MaxSessionsInMemory = options.MaxSessionsInMemory > 0 ? options.MaxSessionsInMemory : 10_000; + _options.MaxCaptureBytesInMemory = options.MaxCaptureBytesInMemory > 0 + ? options.MaxCaptureBytesInMemory + : 512L * 1024 * 1024; + _options.HotBodySessions = options.HotBodySessions > 0 ? options.HotBodySessions : 2_000; + _options.DiskCacheMaxBytes = options.DiskCacheMaxBytes > 0 + ? options.DiskCacheMaxBytes + : 2L * 1024 * 1024 * 1024; + _options.DiskCacheMaxAgeDays = options.DiskCacheMaxAgeDays > 0 ? options.DiskCacheMaxAgeDays : 7; + // SpillBodiesToDisk cannot be turned on mid-flight without constructing a disk cache; + // turning it off stops new spills while leaving the existing cache readable. + if (_disk is not null) + { + _options.SpillBodiesToDisk = options.SpillBodiesToDisk; + _disk.UpdateLimits(_options.DiskCacheMaxBytes, TimeSpan.FromDays(_options.DiskCacheMaxAgeDays)); + } + + EnforceLimitsLocked(ref removed); + } + + if (removed is { Count: > 0 }) + { + SessionsRemoved?.Invoke(removed); + } + } + public int Count { get diff --git a/src/Titanium.Inspector/Titanium.Inspector.csproj b/src/Titanium.Inspector/Titanium.Inspector.csproj index 86e0c1500..757ff6012 100644 --- a/src/Titanium.Inspector/Titanium.Inspector.csproj +++ b/src/Titanium.Inspector/Titanium.Inspector.csproj @@ -8,7 +8,7 @@ enable true false - 7.0.8 + 7.0.9 Jehonathan Thomas Titanium Inspector desktop traffic debugger (PolyForm Noncommercial). LICENSE diff --git a/src/Titanium.Inspector/ViewModels/AutoResponderViewModel.cs b/src/Titanium.Inspector/ViewModels/AutoResponderViewModel.cs index d4caaea7c..b689f482c 100644 --- a/src/Titanium.Inspector/ViewModels/AutoResponderViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/AutoResponderViewModel.cs @@ -121,11 +121,19 @@ public bool TryRespond(SessionSnapshot session, out AutoResponderRule? matched) => TryMatch(session.Url, session.RequestBodyText, out matched); /// - /// Resolves the response body for a matched rule. Map Local () - /// wins when the path is non-empty; otherwise uses the inline . + /// Resolves how to answer a matched rule. Map Local streams from disk (with size cap); + /// otherwise returns inline body bytes. /// - public static bool TryResolveBody(AutoResponderRule rule, out byte[] body, out string? error) + public static bool TryResolveResponse( + AutoResponderRule rule, + out byte[]? inlineBody, + out string? mapLocalPath, + out long mapLocalLength, + out string? error) { + inlineBody = null; + mapLocalPath = null; + mapLocalLength = 0; error = null; if (!string.IsNullOrWhiteSpace(rule.LocalFilePath)) { @@ -135,10 +143,56 @@ public static bool TryResolveBody(AutoResponderRule rule, out byte[] body, out s if (!File.Exists(path)) { error = $"Map Local file not found: {path}"; - body = Array.Empty(); return false; } + var length = new FileInfo(path).Length; + if (length > InspectorBodyLimits.MaxMapLocalFileBytes) + { + error = + $"Map Local file exceeds {SessionDisplayFormat.FormatByteSize(InspectorBodyLimits.MaxMapLocalFileBytes)}"; + return false; + } + + mapLocalPath = path; + mapLocalLength = length; + return true; + } + catch (Exception ex) + { + error = $"Map Local read failed: {ex.Message}"; + return false; + } + } + + inlineBody = Encoding.UTF8.GetBytes(rule.Body ?? string.Empty); + return true; + } + + /// + /// Resolves the response body for a matched rule. Map Local () + /// wins when the path is non-empty; otherwise uses the inline . + /// Prefer for streaming Map Local. + /// + public static bool TryResolveBody(AutoResponderRule rule, out byte[] body, out string? error) + { + if (!TryResolveResponse(rule, out var inline, out var path, out var length, out error)) + { + body = Array.Empty(); + return false; + } + + if (path is not null) + { + if (length > InspectorBodyLimits.MaxMapLocalFileBytes) + { + body = Array.Empty(); + error = $"Map Local file exceeds limit"; + return false; + } + + try + { body = File.ReadAllBytes(path); return true; } @@ -150,7 +204,7 @@ public static bool TryResolveBody(AutoResponderRule rule, out byte[] body, out s } } - body = Encoding.UTF8.GetBytes(rule.Body ?? string.Empty); + body = inline ?? Array.Empty(); return true; } @@ -207,7 +261,7 @@ public string LocalFilePath set => SetField(ref _localFilePath, value ?? string.Empty); } - /// Optional GraphQL operationName filter for same-URL APIs. + /// Optional GraphQL operation filter; when set, the rule matches only that client request. public string GraphQlOperationName { get => _graphQlOperationName; @@ -225,7 +279,7 @@ public string Display get { var map = string.IsNullOrWhiteSpace(LocalFilePath) ? string.Empty : " [Map Local]"; - var gql = string.IsNullOrWhiteSpace(GraphQlOperationName) ? string.Empty : $" gql:{GraphQlOperationName}"; + var gql = string.IsNullOrWhiteSpace(GraphQlOperationName) ? string.Empty : $" GraphQL:{GraphQlOperationName}"; return $"{(Enabled ? "✓" : "✗")} {StatusCode}{map}{gql} {MatchUrl}"; } } diff --git a/src/Titanium.Inspector/ViewModels/BreakpointViewModel.cs b/src/Titanium.Inspector/ViewModels/BreakpointViewModel.cs index 3bc9cacc5..892a6a3e4 100644 --- a/src/Titanium.Inspector/ViewModels/BreakpointViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/BreakpointViewModel.cs @@ -13,14 +13,28 @@ public sealed class BreakpointViewModel : System.ComponentModel.INotifyPropertyC private bool _enabled; private string _urlFilter = "*"; private string _graphQlOperationName = ""; + private string _activeSummary = ""; + private string _lastOverflowMessage = ""; public bool Enabled { get => _enabled; set { + if (_enabled == value) + { + return; + } + _enabled = value; PropertyChanged?.Invoke(this, new(nameof(Enabled))); + // Disabling while paused should release the client: Continue (let the + // request through), not Abort (403). Same idea as turning off a debugger + // breakpoint — don't leave the page hanging, and don't fail it. + if (!value) + { + ContinueIfPaused("Breakpoints disabled — paused request continued"); + } } } @@ -46,10 +60,55 @@ public string GraphQlOperationName } public TimeSpan Timeout { get; } = TimeSpan.FromSeconds(120); - public BreakpointHit? Active => _active; + + public BreakpointHit? Active + { + get => _active; + private set + { + if (ReferenceEquals(_active, value)) + return; + _active = value; + PropertyChanged?.Invoke(this, new(nameof(Active))); + PropertyChanged?.Invoke(this, new(nameof(HasActiveHit))); + ActiveSummary = value is null + ? "" + : $"Paused {value.Session.Method} {TruncateUrl(value.Session.Url)} — Continue or Abort ({(int)Timeout.TotalSeconds}s)"; + } + } + + public bool HasActiveHit => _active is not null; + + public string ActiveSummary + { + get => _activeSummary; + private set + { + if (_activeSummary == value) + return; + _activeSummary = value; + PropertyChanged?.Invoke(this, new(nameof(ActiveSummary))); + } + } + + /// Last overflow / auto-continue notice for status bar (cleared on next enter). + public string LastOverflowMessage + { + get => _lastOverflowMessage; + private set + { + if (_lastOverflowMessage == value) + return; + _lastOverflowMessage = value; + PropertyChanged?.Invoke(this, new(nameof(LastOverflowMessage))); + } + } public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged; + /// Raised on the thread that entered / cleared the hit (marshal to UI in the host). + public event EventHandler? ActiveHitChanged; + public bool TryEnter(Services.SessionSnapshot session, out BreakpointHit hit) { hit = null!; @@ -63,31 +122,52 @@ public bool TryEnter(Services.SessionSnapshot session, out BreakpointHit hit) if (_active is not null) { // Max 1 active — overflow auto-continue. + LastOverflowMessage = "Already paused — extra breakpoint hit continued"; return false; } - hit = new BreakpointHit(session, Timeout); - _active = hit; - return true; + LastOverflowMessage = ""; + hit = new BreakpointHit(session, Timeout, OnHitTimedOut); + Active = hit; } + + ActiveHitChanged?.Invoke(this, EventArgs.Empty); + return true; } public void Continue() { - lock (Gate) - { - _active?.Complete(BreakpointAction.Continue); - _active = null; - } + ClearActive(BreakpointAction.Continue, raiseHitChanged: true); } public void Abort() + { + ClearActive(BreakpointAction.Abort, raiseHitChanged: true); + } + + /// + /// Continue a paused hit (if any) so the client is not left waiting until timeout. + /// Used when breakpoints are turned off or the proxy stops. + /// + public bool ContinueIfPaused(string? message = null) { lock (Gate) { - _active?.Complete(BreakpointAction.Abort); - _active = null; + if (_active is null) + { + return false; + } + + _active.Complete(BreakpointAction.Continue); + Active = null; + if (!string.IsNullOrEmpty(message)) + { + LastOverflowMessage = message; + } } + + ActiveHitChanged?.Invoke(this, EventArgs.Empty); + return true; } public void EditBody(string newBody) @@ -104,6 +184,32 @@ public void EditBody(string newBody) } } + private void OnHitTimedOut(BreakpointHit hit) + { + lock (Gate) + { + if (!ReferenceEquals(_active, hit)) + return; + hit.Complete(BreakpointAction.Continue); + Active = null; + LastOverflowMessage = "Breakpoint auto-continued (timeout)"; + } + + ActiveHitChanged?.Invoke(this, EventArgs.Empty); + } + + private void ClearActive(BreakpointAction action, bool raiseHitChanged) + { + lock (Gate) + { + _active?.Complete(action); + Active = null; + } + + if (raiseHitChanged) + ActiveHitChanged?.Invoke(this, EventArgs.Empty); + } + private bool Matches(string url) { if (string.IsNullOrEmpty(UrlFilter) || UrlFilter == "*") @@ -114,6 +220,13 @@ private bool Matches(string url) var pattern = "^" + Regex.Escape(UrlFilter).Replace("\\*", ".*") + "$"; return Regex.IsMatch(url, pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1)); } + + private static string TruncateUrl(string url) + { + if (string.IsNullOrEmpty(url) || url.Length <= 64) + return url; + return url[..61] + "..."; + } } public enum BreakpointAction @@ -125,12 +238,20 @@ public enum BreakpointAction public sealed class BreakpointHit { private readonly TaskCompletionSource _tcs = new(); + private readonly Action? _onTimeout; - public BreakpointHit(Services.SessionSnapshot session, TimeSpan timeout) + public BreakpointHit(Services.SessionSnapshot session, TimeSpan timeout, Action? onTimeout = null) { Session = session; + _onTimeout = onTimeout; _ = Task.Delay(timeout).ContinueWith(_ => { + if (_onTimeout is not null) + { + _onTimeout(this); + return; + } + Complete(BreakpointAction.Continue); }); } diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.BodyInspect.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.BodyInspect.cs new file mode 100644 index 000000000..0d1f566d4 --- /dev/null +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.BodyInspect.cs @@ -0,0 +1,472 @@ +using System.ComponentModel; +using System.Text; +using System.Windows.Input; +using Avalonia.Media.Imaging; +using Titanium.Inspector.Services; + +namespace Titanium.Inspector.ViewModels; + +public sealed partial class MainWindowViewModel +{ + private bool _bodyPrettyMode = true; + private string _bodyCaptureHint = ""; + private string _hexCaptureHint = ""; + private Bitmap? _bodyPreviewBitmap; + private string? _composerBodyFilePath; + private string _composerBodyFromFileHint = ""; + private string? _cachedPrettyBody; + private long? _cachedPrettySessionId; + + public ICommand CopyHeadersCommand { get; private set; } = null!; + public ICommand SetBodyPrettyCommand { get; private set; } = null!; + public ICommand SetBodyRawCommand { get; private set; } = null!; + public ICommand SaveRequestBodyCommand { get; private set; } = null!; + public ICommand SaveResponseBodyCommand { get; private set; } = null!; + public ICommand LoadComposerBodyFileCommand { get; private set; } = null!; + + public bool BodyPrettyMode + { + get => _bodyPrettyMode; + set + { + if (SetField(ref _bodyPrettyMode, value) && _selected is not null) + { + RefreshBodyInspector(); + } + } + } + + public string BodyCaptureHint + { + get => _bodyCaptureHint; + private set + { + if (SetField(ref _bodyCaptureHint, value)) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowBodyCaptureHint))); + } + } + } + + public bool ShowBodyCaptureHint => !string.IsNullOrEmpty(_bodyCaptureHint); + + public string HexCaptureHint + { + get => _hexCaptureHint; + private set + { + if (SetField(ref _hexCaptureHint, value)) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowHexCaptureHint))); + } + } + } + + public bool ShowHexCaptureHint => !string.IsNullOrEmpty(_hexCaptureHint); + + public Bitmap? BodyPreviewBitmap + { + get => _bodyPreviewBitmap; + private set + { + var previous = _bodyPreviewBitmap; + if (!SetField(ref _bodyPreviewBitmap, value)) + { + return; + } + + previous?.Dispose(); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowBodyPreviewImage))); + } + } + + public bool ShowBodyPreviewImage => _bodyPreviewBitmap is not null; + + public bool CanSaveRequestBody => + _selected is not null + && (_selected.RequestBodyBytes is { Length: > 0 } + || !string.IsNullOrEmpty(_selected.RequestBodyText)) + && _selected.RequestBodyCapture != BodyCaptureState.NotCaptured; + + public bool CanSaveResponseBody => + _selected is not null + && (_selected.ResponseBodyBytes is { Length: > 0 } + || !string.IsNullOrEmpty(_selected.ResponseBodyText)) + && _selected.ResponseBodyCapture != BodyCaptureState.NotCaptured; + + public string? ComposerBodyFilePath + { + get => _composerBodyFilePath; + set + { + if (!SetField(ref _composerBodyFilePath, value)) + { + return; + } + + ComposerBodyFromFileHint = string.IsNullOrWhiteSpace(value) + ? "" + : $"Body from file: {value} (sent as stream; not loaded into the editor)"; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasComposerBodyFile))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ComposerBodyEditorEnabled))); + } + } + + public string ComposerBodyFromFileHint + { + get => _composerBodyFromFileHint; + private set => SetField(ref _composerBodyFromFileHint, value); + } + + public bool HasComposerBodyFile => !string.IsNullOrWhiteSpace(_composerBodyFilePath); + + public bool ComposerBodyEditorEnabled => !HasComposerBodyFile; + + private void WireBodyInspectCommands() + { + CopyHeadersCommand = Cmd(CopyHeadersAsync); + SetBodyPrettyCommand = Cmd(() => + { + BodyPrettyMode = true; + return Task.CompletedTask; + }); + SetBodyRawCommand = Cmd(() => + { + BodyPrettyMode = false; + return Task.CompletedTask; + }); + SaveRequestBodyCommand = Cmd(() => SaveBodyAsync(isRequest: true)); + SaveResponseBodyCommand = Cmd(() => SaveBodyAsync(isRequest: false)); + LoadComposerBodyFileCommand = Cmd(LoadComposerBodyFileAsync); + } + + private Task CopyHeadersAsync() + { + if (string.IsNullOrEmpty(SelectedHeaders)) + { + SetGuardStatus("No headers to copy"); + return Task.CompletedTask; + } + + return CopyHeadersToClipboardAsync(); + } + + private async Task CopyHeadersToClipboardAsync() + { + await CopyTextToClipboardAsync(SelectedHeaders).ConfigureAwait(false); + await MarshalToUiAsync(() => SetOutcomeStatus("Headers copied", StatusSeverity.Success), StatusCancelToken) + .ConfigureAwait(false); + } + + private async Task SaveBodyAsync(bool isRequest) + { + if (_selected is null) + { + SetGuardStatus("Select a session first"); + return; + } + + await _store.EnsureBodiesLoadedAsync(_selected, StatusCancelToken).ConfigureAwait(false); + var bytes = isRequest ? _selected.RequestBodyBytes : _selected.ResponseBodyBytes; + var text = isRequest ? _selected.RequestBodyText : _selected.ResponseBodyText; + var capture = isRequest ? _selected.RequestBodyCapture : _selected.ResponseBodyCapture; + var original = isRequest ? _selected.RequestBodyOriginalSize : _selected.ResponseBodyOriginalSize; + + if (capture == BodyCaptureState.NotCaptured || (bytes is null or { Length: 0 } && string.IsNullOrEmpty(text))) + { + SetGuardStatus("Body not captured — nothing to save"); + return; + } + + bytes ??= Encoding.UTF8.GetBytes(text ?? ""); + var headers = SessionInspectors.ParseHeaderBlock( + isRequest ? _selected.RequestHeadersText : _selected.ResponseHeadersText); + headers.TryGetValue("Content-Disposition", out var disposition); + headers.TryGetValue("Content-Type", out var contentType); + var suggested = InspectorBodyLimits.SuggestBodyFileName( + _selected.Url, disposition, contentType ?? _selected.ContentType, isRequest); + + var path = await _pathPicker.PickSavePathAsync( + isRequest ? "Save request body" : "Save response body", + suggested, + "All files", + "*.*").ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + await File.WriteAllBytesAsync(path, bytes, StatusCancelToken).ConfigureAwait(false); + var incomplete = capture is BodyCaptureState.Truncated or BodyCaptureState.Streaming + || (original is long o && o > bytes.Length); + await MarshalToUiAsync(() => + { + SetOutcomeStatus( + incomplete + ? $"Saved incomplete body ({SessionDisplayFormat.FormatByteSize(bytes.Length)} of {SessionDisplayFormat.FormatByteSize(original ?? bytes.Length)})" + : $"Saved body ({SessionDisplayFormat.FormatByteSize(bytes.Length)})", + incomplete ? StatusSeverity.Warning : StatusSeverity.Success, + toastImportant: incomplete); + }, StatusCancelToken).ConfigureAwait(false); + } + + private async Task LoadComposerBodyFileAsync() + { + var path = await _pathPicker.PickOpenPathAsync("Load body from file", "All files", "*.*") + .ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + var info = new FileInfo(path); + if (!info.Exists) + { + SetGuardStatus("File not found"); + return; + } + + if (info.Length <= InspectorBodyLimits.MaxBodyBytes) + { + var text = await File.ReadAllTextAsync(path, StatusCancelToken).ConfigureAwait(false); + await MarshalToUiAsync(() => + { + ComposerBodyFilePath = null; + ComposerBody = text; + if (text.Length > InspectorBodyLimits.MaxInlineToolBodyChars) + { + SetOutcomeStatus( + $"Body is large ({SessionDisplayFormat.FormatByteSize(text.Length)}); editor may feel slow", + StatusSeverity.Warning); + } + else + { + SetOutcomeStatus("Composer body loaded from file", StatusSeverity.Success); + } + }, StatusCancelToken).ConfigureAwait(false); + return; + } + + await MarshalToUiAsync(() => + { + ComposerBody = ""; + ComposerBodyFilePath = path; + SetOutcomeStatus( + $"Large file will be streamed on Send ({SessionDisplayFormat.FormatByteSize(info.Length)})", + StatusSeverity.Success); + }, StatusCancelToken).ConfigureAwait(false); + } + + private void RefreshBodyInspector() + { + if (_selected is null) + { + SelectedBody = ""; + BodyCaptureHint = ""; + HexCaptureHint = ""; + BodyPreviewBitmap = null; + NotifySaveBodyCanExecute(); + return; + } + + BodyCaptureHint = BuildBodyCaptureHint(_selected); + HexCaptureHint = BuildHexCaptureHint(_selected); + SelectedBody = BuildSelectedBodyText(_selected); + UpdateBodyPreviewImage(_selected); + NotifySaveBodyCanExecute(); + } + + private void NotifySaveBodyCanExecute() + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CanSaveRequestBody))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CanSaveResponseBody))); + } + + private static string BuildBodyCaptureHint(SessionSnapshot selected) + { + var req = InspectorBodyLimits.FormatCaptureBanner( + selected.RequestBodyCapture, + selected.RequestBodyOriginalSize, + selected.RequestBodyBytes?.Length ?? selected.RequestBodyText?.Length ?? 0, + streamOpen: false, + forHex: false); + var resp = InspectorBodyLimits.FormatCaptureBanner( + selected.ResponseBodyCapture, + selected.ResponseBodyOriginalSize ?? selected.BodySize, + selected.ResponseBodyBytes?.Length ?? selected.ResponseBodyText?.Length ?? 0, + selected.ResponseBodyStreamOpen, + forHex: false); + + if (string.IsNullOrEmpty(req) && string.IsNullOrEmpty(resp)) + { + return ""; + } + + if (string.IsNullOrEmpty(req)) + { + return "Response: " + resp; + } + + if (string.IsNullOrEmpty(resp)) + { + return "Request: " + req; + } + + return "Request: " + req + " · Response: " + resp; + } + + private static string BuildHexCaptureHint(SessionSnapshot selected) + { + var respBytes = selected.ResponseBodyBytes?.Length ?? 0; + var reqBytes = selected.RequestBodyBytes?.Length ?? 0; + var captured = Math.Max(respBytes, reqBytes); + if (captured <= 0) + { + return ""; + } + + return InspectorBodyLimits.FormatCaptureBanner( + selected.ResponseBodyCapture != BodyCaptureState.None + ? selected.ResponseBodyCapture + : selected.RequestBodyCapture, + selected.ResponseBodyOriginalSize ?? selected.RequestBodyOriginalSize ?? selected.BodySize, + captured, + selected.ResponseBodyStreamOpen, + forHex: true); + } + + private void UpdateBodyPreviewImage(SessionSnapshot selected) + { + byte[]? bytes = null; + string? contentType = null; + if (InspectorBodyLimits.IsImageContentType(selected.ContentType) + || LooksLikeImageHeaders(selected.ResponseHeadersText)) + { + bytes = selected.ResponseBodyBytes; + contentType = selected.ContentType; + if (SessionInspectors.ParseHeaderBlock(selected.ResponseHeadersText) + .TryGetValue("Content-Type", out var responseType)) + { + contentType = responseType; + } + } + + if (bytes is null or { Length: 0 } + && LooksLikeImageHeaders(selected.RequestHeadersText)) + { + bytes = selected.RequestBodyBytes; + contentType = SessionInspectors.ParseHeaderBlock(selected.RequestHeadersText) + .TryGetValue("Content-Type", out var requestType) + ? requestType + : contentType; + } + + if (bytes is null or { Length: 0 } || !InspectorBodyLimits.IsImageContentType(contentType)) + { + BodyPreviewBitmap = null; + return; + } + + try + { + using var ms = new MemoryStream(bytes); + var bitmap = new Bitmap(ms); + if (bitmap.PixelSize.Width > InspectorBodyLimits.MaxDecodedImageEdgePx + || bitmap.PixelSize.Height > InspectorBodyLimits.MaxDecodedImageEdgePx + || (long)bitmap.PixelSize.Width * bitmap.PixelSize.Height > InspectorBodyLimits.MaxDecodedImagePixels) + { + bitmap.Dispose(); + BodyPreviewBitmap = null; + if (string.IsNullOrEmpty(BodyCaptureHint)) + { + BodyCaptureHint = "Image too large to preview in Inspect"; + } + + return; + } + + BodyPreviewBitmap = bitmap; + } + catch + { + BodyPreviewBitmap = null; + } + } + + private static bool LooksLikeImageHeaders(string? headersText) + { + var headers = SessionInspectors.ParseHeaderBlock(headersText); + return headers.TryGetValue("Content-Type", out var ct) + && InspectorBodyLimits.IsImageContentType(ct); + } + + private static string BuildSelectedBodyTextCore(SessionSnapshot selected, bool pretty) + { + if (InspectorBodyLimits.IsImageContentType(selected.ContentType) + || LooksLikeImageHeaders(selected.ResponseHeadersText) + || LooksLikeImageHeaders(selected.RequestHeadersText)) + { + var sb = new StringBuilder(); + sb.AppendLine("=== Request ==="); + sb.AppendLine(selected.RequestBodyBytes is { Length: > 0 } + ? $"(image · {SessionDisplayFormat.FormatByteSize(selected.RequestBodyBytes.Length)})" + : "(empty)"); + sb.AppendLine(); + sb.AppendLine("=== Response ==="); + sb.Append(selected.ResponseBodyBytes is { Length: > 0 } + ? $"(image · {SessionDisplayFormat.FormatByteSize(selected.ResponseBodyBytes.Length)} — see preview above)" + : "(empty)"); + return sb.ToString(); + } + + var raw = SessionInspectors.FormatLabeledBody( + selected.RequestHeadersText, + selected.ResponseHeadersText, + selected.RequestBodyText, + selected.ResponseBodyText, + selected.RequestBodyBytes, + selected.ResponseBodyBytes); + + if (!pretty) + { + return raw; + } + + var reqCt = selected.ContentType; + if (SessionInspectors.ParseHeaderBlock(selected.RequestHeadersText) + .TryGetValue("Content-Type", out var requestCt)) + { + reqCt = requestCt; + } + + string? respCt = selected.ContentType; + if (SessionInspectors.ParseHeaderBlock(selected.ResponseHeadersText) + .TryGetValue("Content-Type", out var responseCt)) + { + respCt = responseCt; + } + + var reqPretty = InspectorBodyLimits.TryPrettyPrint(selected.RequestBodyText, reqCt); + var respPretty = InspectorBodyLimits.TryPrettyPrint(selected.ResponseBodyText, respCt); + if (reqPretty is null && respPretty is null) + { + if ((selected.RequestBodyCapture is BodyCaptureState.Truncated + || selected.ResponseBodyCapture is BodyCaptureState.Truncated) + && (InspectorBodyLimits.IsPrettyPrintableContentType(reqCt) + || InspectorBodyLimits.IsPrettyPrintableContentType(respCt))) + { + return raw; // caller may set banner for pretty failure + } + + return raw; + } + + var prettySb = new StringBuilder(); + prettySb.AppendLine("=== Request ==="); + prettySb.AppendLine(reqPretty ?? selected.RequestBodyText ?? "(empty)"); + prettySb.AppendLine(); + prettySb.AppendLine("=== Response ==="); + prettySb.Append(respPretty ?? selected.ResponseBodyText ?? "(empty)"); + return prettySb.ToString(); + } +} diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs index 29aaecf54..86076e892 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs @@ -24,18 +24,21 @@ private Task ClearSessionsAsync() // a neighbor row (SelectedSession setter would reopen a closed details pane). _selectedSessions.Clear(); SelectedSession = null; + ShowSessionDetails = false; _userRemovalDepth++; + _suppressOpenSessionDetails = true; try { _store.Clear(); + Sessions.Clear(); } finally { + _suppressOpenSessionDetails = false; _userRemovalDepth--; } - Sessions.Clear(); _retentionEvictedTotal = 0; _interception.ResetSessionIdSequence(); RefreshSessionCountText(); @@ -58,26 +61,28 @@ private Task RemoveSelectedSessionsAsync() if (SelectedSession is not null && ids.Contains(SelectedSession.Id)) { SelectedSession = null; + ShowSessionDetails = false; } _userRemovalDepth++; + _suppressOpenSessionDetails = true; try { _store.Remove(ids); + for (var i = Sessions.Count - 1; i >= 0; i--) + { + if (ids.Contains(Sessions[i].Id)) + { + Sessions.RemoveAt(i); + } + } } finally { + _suppressOpenSessionDetails = false; _userRemovalDepth--; } - for (var i = Sessions.Count - 1; i >= 0; i--) - { - if (ids.Contains(Sessions[i].Id)) - { - Sessions.RemoveAt(i); - } - } - RefreshSessionCountText(); NotifyFilterSelectionProperties(); SetOutcomeStatus( @@ -102,9 +107,54 @@ await MarshalToUiAsync(() => ComposerUrl = selected.Url; ComposerHeaders = selected.RequestHeadersText ?? ""; ComposerBody = selected.RequestBodyText ?? ""; - StatusText = "Composer loaded from selected session"; + ComposerBodyFilePath = null; + StatusText = selected.RequestBodyCapture is BodyCaptureState.Truncated or BodyCaptureState.NotCaptured + ? "Composer loaded (request body was truncated or not fully captured)" + : "Composer loaded from selected session"; + }, _statusRevertCts?.Token ?? CancellationToken.None).ConfigureAwait(false); + } + + private async Task FillGraphQlFromSelectedAsync() + { + var selected = SelectedSession; + if (selected is null) + { + SetGuardStatus("Select a session to copy its GraphQL operation"); + return; + } + + await _store.EnsureBodiesLoadedAsync(selected, _statusRevertCts?.Token ?? CancellationToken.None).ConfigureAwait(false); + await MarshalToUiAsync(() => + { + if (!GraphQlOperationMatcher.TryGetOperationName(selected.RequestBodyText, out var name) || + string.IsNullOrWhiteSpace(name)) + { + SetGuardStatus("Selected session has no GraphQL operation name in the request body"); + return; + } + + if (ShowBreakpointsPane) + { + Breakpoints.GraphQlOperationName = name; + } + else if (ShowAutoResponderPane) + { + AutoResponderGraphQlOperation = name; + } + else if (ShowMapRemotePane) + { + MapRemoteGraphQlOperation = name; + } + else + { + SetGuardStatus("Open Breakpoints, AutoResponder, or Map Remote to set the GraphQL filter"); + return; + } + + StatusText = $"GraphQL filter set to {name}"; }, _statusRevertCts?.Token ?? CancellationToken.None).ConfigureAwait(false); } + private async Task LoadIntoComposerAsync() { var selected = SelectedSession; @@ -121,7 +171,10 @@ await MarshalToUiAsync(() => ComposerUrl = selected.Url; ComposerHeaders = selected.RequestHeadersText ?? ""; ComposerBody = selected.RequestBodyText ?? ""; - StatusText = "Composer loaded from selected session"; + ComposerBodyFilePath = null; + StatusText = selected.RequestBodyCapture is BodyCaptureState.Truncated or BodyCaptureState.NotCaptured + ? "Composer loaded (request body was truncated or not fully captured)" + : "Composer loaded from selected session"; }, StatusCancelToken).ConfigureAwait(false); await OpenToolsTabAsync(0).ConfigureAwait(false); } @@ -381,6 +434,13 @@ private List ResolveCopyUrls() => .ToList(); private Task AddAutoResponderRuleAsync() { + if (string.IsNullOrWhiteSpace(AutoResponderLocalFilePath) + && AutoResponderBody.Length > InspectorBodyLimits.MaxInlineToolBodyChars) + { + SetGuardStatus("Inline AutoResponder body is too large — use Map Local for larger bodies"); + return Task.CompletedTask; + } + AutoResponder.Rules.Add(new AutoResponderRule { MatchUrl = AutoResponderMatch, @@ -417,6 +477,13 @@ private Task UpdateAutoResponderRuleAsync() return Task.CompletedTask; } + if (string.IsNullOrWhiteSpace(AutoResponderLocalFilePath) + && AutoResponderBody.Length > InspectorBodyLimits.MaxInlineToolBodyChars) + { + SetGuardStatus("Inline AutoResponder body is too large — use Map Local for larger bodies"); + return Task.CompletedTask; + } + var rule = AutoResponder.SelectedRule; rule.MatchUrl = AutoResponderMatch; rule.StatusCode = AutoResponderStatus; @@ -495,6 +562,35 @@ private void OnSessionAddedToFilter(SessionSnapshot snapshot) RefreshSessionCountText(); } + + /// + /// Re-evaluate filter membership when status/content-type/etc. arrive after the row was added. + /// Avoids a full rebuild on every SSE tee chunk. + /// + private void OnSessionUpdatedForFilter(SessionSnapshot snapshot) + { + var matches = SessionSearch.Matches(snapshot, SearchQuery); + var index = Sessions.IndexOf(snapshot); + if (matches) + { + if (index < 0) + { + Sessions.Add(snapshot); + RefreshSessionCountText(); + } + } + else if (index >= 0) + { + if (ReferenceEquals(SelectedSession, snapshot)) + { + SelectedSession = null; + } + + Sessions.RemoveAt(index); + RefreshSessionCountText(); + } + } + private void OnSessionsRemoved(IReadOnlyList removed) { if (removed.Count == 0) @@ -509,15 +605,24 @@ private void OnSessionsRemoved(IReadOnlyList removed) if (SelectedSession is not null && ids.Contains(SelectedSession.Id)) { SelectedSession = null; + ShowSessionDetails = false; } - for (var i = Sessions.Count - 1; i >= 0; i--) + _suppressOpenSessionDetails = true; + try { - if (ids.Contains(Sessions[i].Id)) + for (var i = Sessions.Count - 1; i >= 0; i--) { - Sessions.RemoveAt(i); + if (ids.Contains(Sessions[i].Id)) + { + Sessions.RemoveAt(i); + } } } + finally + { + _suppressOpenSessionDetails = false; + } if (_userRemovalDepth > 0) { diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Trust.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Trust.cs index 851932c0f..0b9d3b773 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Trust.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Trust.cs @@ -18,6 +18,29 @@ namespace Titanium.Inspector.ViewModels; public sealed partial class MainWindowViewModel { + private const string TrustActionInProgressStatus = + "Another certificate action is already in progress"; + + private bool TryBeginTrustCommand() + { + if (_trustCommandBusy) + { + InspectorUxTrace.Event("TrustCommand.Rejected", "busy=true"); + SetGuardStatus(TrustActionInProgressStatus); + return false; + } + + _trustCommandBusy = true; + InspectorUxTrace.Event("TrustCommand.Begin"); + return true; + } + + private void EndTrustCommand() + { + _trustCommandBusy = false; + InspectorUxTrace.Event("TrustCommand.End"); + } + private async Task InstallCaAsync() { if (!_interception.IsRunning) @@ -26,31 +49,68 @@ private async Task InstallCaAsync() return; } - SetBusyTrustingRootCa(); - var ok = await EnsureRootCaTrustedAsync(promptIfNeeded: true); - if (ok) - { - SetOsTrustSuccessStatus(); + if (!TryBeginTrustCommand()) return; - } - if (_interception.LastOsTrustResult?.Kind == CertificateOsTrustKind.Cancelled || - string.IsNullOrEmpty(_interception.LastOsTrustResult?.Message)) + using var scope = InspectorUxTrace.Scope( + "InstallCa", + $"trusted={_interception.IsRootTrusted}"); + // Defer terminal success/error status until after EndTrustCommand so IsStatusBusy is not + // cleared while _trustCommandBusy is still true (stress: Install → Rotate rejected). + var showTrustedSuccess = false; + string? terminalError = null; + string? terminalGuard = null; + try { - SetGuardStatus("Root CA install cancelled"); - return; + // Already trusted: do not re-open the Root store or rewrite Firefox prefs. + if (_interception.IsRootTrusted) + { + showTrustedSuccess = true; + } + else + { + SetStatus("Preparing install…", StatusSeverity.Busy); + await AwaitPriorFirefoxTrustBackgroundAsync(); + + SetBusyTrustingRootCa(); + var ok = await EnsureRootCaTrustedAsync(promptIfNeeded: true); + InspectorUxTrace.Event("InstallCa.Result", $"ok={ok}"); + if (ok) + { + showTrustedSuccess = true; + } + else if (_interception.LastOsTrustResult?.Kind == CertificateOsTrustKind.Cancelled || + string.IsNullOrEmpty(_interception.LastOsTrustResult?.Message)) + { + terminalGuard = "Root CA install cancelled"; + } + else if (await ResolveTerminalTrustFailureAsync(_interception.LastOsTrustResult)) + { + showTrustedSuccess = true; + } + else if (_interception.LastOsTrustResult?.Kind == CertificateOsTrustKind.Cancelled) + { + terminalGuard = "Root CA install cancelled"; + } + else + { + terminalError = OsTrustUxCopy.FormatStatus(_interception.LastOsTrustResult); + } + } + } + finally + { + EndTrustCommand(); } - if (await ResolveTerminalTrustFailureAsync(_interception.LastOsTrustResult)) + if (showTrustedSuccess) SetOsTrustSuccessStatus(); - else if (_interception.LastOsTrustResult?.Kind == CertificateOsTrustKind.Cancelled) - SetGuardStatus("Root CA install cancelled"); - else - SetOutcomeStatus( - OsTrustUxCopy.FormatStatus(_interception.LastOsTrustResult), - StatusSeverity.Error, - toastImportant: true); + else if (terminalGuard is not null) + SetGuardStatus(terminalGuard); + else if (terminalError is not null) + SetOutcomeStatus(terminalError, StatusSeverity.Error, toastImportant: true); } + private async Task TrustFirefoxCaAsync() { if (!_interception.IsRunning) @@ -59,33 +119,48 @@ private async Task TrustFirefoxCaAsync() return; } - var owner = TryGetMainWindow(); - if (!await TryEnsureRootBeforeFirefoxAsync(owner)) + if (!TryBeginTrustCommand()) return; - if (!FirefoxCertificateTrust.IsFirefoxProfilePresent()) + using var scope = InspectorUxTrace.Scope("TrustFirefox"); + try { + var owner = TryGetMainWindow(); + if (!await TryEnsureRootBeforeFirefoxAsync(owner)) + return; + + if (!FirefoxCertificateTrust.IsFirefoxProfilePresent()) + { + SetOutcomeStatus( + "Firefox profile not found — open Firefox once to create a profile " + + "(classic, Snap, or Flatpak), or use Export CA → Firefox Authorities", + StatusSeverity.Warning, + toastImportant: true); + return; + } + + SetStatus("Preparing Firefox trust…", StatusSeverity.Busy); + await AwaitPriorFirefoxTrustBackgroundAsync(); + + SetStatus("Updating Firefox trust…", StatusSeverity.Busy); + var result = await TrustFirefoxWithRecoveryAsync(owner); + InspectorUxTrace.Event("TrustFirefox.Result", $"ok={result.Succeeded} kind={result.Kind}"); SetOutcomeStatus( - "Firefox profile not found — open Firefox once to create a profile " + - "(classic, Snap, or Flatpak), or use Export CA → Firefox Authorities", - StatusSeverity.Warning, + FormatFirefoxTrustOutcome(result), + result.Succeeded ? StatusSeverity.Success : StatusSeverity.Error, toastImportant: true); - return; } - - SetStatus("Updating Firefox trust…", StatusSeverity.Busy); - var result = await TrustFirefoxWithRecoveryAsync(owner); - SetOutcomeStatus( - FormatFirefoxTrustOutcome(result), - result.Succeeded ? StatusSeverity.Success : StatusSeverity.Error, - toastImportant: true); + finally + { + EndTrustCommand(); + } } private async Task TryEnsureRootBeforeFirefoxAsync(Window? owner) { if (_interception.IsRootTrusted) return true; - if (!await AwaitCancellableAsync(_dialogs.ConfirmInstallRootCaBeforeFirefoxAsync(owner))) + if (!await AwaitDialogAsync(_dialogs.ConfirmInstallRootCaBeforeFirefoxAsync(owner))) { SetGuardStatus("Trust CA in Firefox cancelled — install root CA first"); return false; @@ -113,7 +188,10 @@ private async Task TrustFirefoxWithRecoveryAsync(Windo { for (var attempt = 0; attempt < 3; attempt++) { - var result = _interception.TrustFirefox(); + // Stay on UI sync context — recovery dialogs need the dispatcher. + var result = await RunOffUiAsync( + () => _interception.TrustFirefox(), + StatusCancelToken); if (result.Succeeded) return result; @@ -142,13 +220,15 @@ private async Task TrustFirefoxWithRecoveryAsync(Windo private async Task TryRecoverFirefoxCertutilAsync(Window? owner, CertificateOsTrustResult result) { - var choice = await AwaitCancellableAsync(_dialogs.ShowTrustRecoveryAsync(owner, result)); + var choice = await AwaitDialogAsync(_dialogs.ShowTrustRecoveryAsync(owner, result)); if (choice == TrustRecoveryChoice.Primary && result.Kind == CertificateOsTrustKind.CertutilMissing && (OperatingSystem.IsLinux() || result.BrewAvailable)) { SetStatus("Installing browser certificate tools…", StatusSeverity.Busy); - _ = _interception.InstallNssToolsAndRetryTrust(); + _ = await RunOffUiAsync( + () => _interception.InstallNssToolsAndRetryTrust(), + StatusCancelToken); return true; } @@ -167,11 +247,14 @@ private static bool IsFirefoxRunningTrustError(CertificateOsTrustResult result) private async Task TryQuitFirefoxForTrustAsync(Window? owner) { - if (!await AwaitCancellableAsync(_dialogs.ConfirmQuitFirefoxForTrustAsync(owner))) + if (!await AwaitDialogAsync(_dialogs.ConfirmQuitFirefoxForTrustAsync(owner))) return CertificateOsTrustResult.Fail(CertificateOsTrustKind.Cancelled, "Firefox trust cancelled"); SetStatus("Quitting Firefox…", StatusSeverity.Busy); - if (!FirefoxCertificateTrust.TryRequestFirefoxQuit()) + var quitOk = await RunOffUiAsync( + () => FirefoxCertificateTrust.TryRequestFirefoxQuit(), + StatusCancelToken); + if (!quitOk) { return CertificateOsTrustResult.Fail( CertificateOsTrustKind.Failed, @@ -184,17 +267,61 @@ private static bool IsFirefoxRunningTrustError(CertificateOsTrustResult result) /// /// Attempts user OS trust and adaptive recovery (certutil install / Keychain / elevate). /// - private async Task EnsureRootCaTrustedAsync(bool promptIfNeeded) // NOSONAR S3776 -- Adaptive OS-trust recovery loop shares dialog/state; splitting would hide the retry contract. + /// When true, show recovery dialogs on failure. + /// + /// When true (Clear+Install after mint), skip the pre-install Root-store Find — the new + /// thumbprint cannot be present yet and Crypt32 is often still hot from Remove. + /// + private async Task EnsureRootCaTrustedAsync(bool promptIfNeeded, bool skipInitialRefresh = false) // NOSONAR S3776 -- Adaptive OS-trust recovery loop shares dialog/state; splitting would hide the retry contract. { var owner = TryGetMainWindow(); - var ok = _interception.InstallRootCertificate(machineStore: false); - var result = _interception.LastOsTrustResult; + // Yield so Busy can paint. CryptUI / Keychain MUST stay on this thread (message pump). + await Task.Yield(); - if (ok && result?.Kind != CertificateOsTrustKind.MacNeedsManualTrustConfirm) + bool ok; + CertificateOsTrustResult? result; + + if (_interception.UseInMemoryTrustState) + { + // In-memory still honors FailNextUserTrustInstall so recovery loops are testable. + ok = _interception.InstallRootCertificate(machineStore: false); + result = _interception.LastOsTrustResult; + if (ok && result?.Kind != CertificateOsTrustKind.MacNeedsManualTrustConfirm) + { + ScheduleFirefoxEnterpriseRootsBestEffort(); + return true; + } + + if (!promptIfNeeded) + return ok; + } + else if (!skipInitialRefresh && + await RunOffUiAsync(() => + { + using var refreshScope = InspectorUxTrace.Scope("EnsureRoot.RefreshTrustState"); + return _interception.RefreshTrustState(false); + }, StatusCancelToken)) + { + ScheduleFirefoxEnterpriseRootsBestEffort(); return true; + } + else + { + ok = await InstallRootInteractiveAsync(); + result = _interception.LastOsTrustResult; + if (ok && result?.Kind != CertificateOsTrustKind.MacNeedsManualTrustConfirm) + { + ScheduleFirefoxEnterpriseRootsBestEffort(); + return true; + } + + // CryptUI No — do not open the trust-recovery dialog. + if (result?.Kind == CertificateOsTrustKind.Cancelled) + return false; - if (!promptIfNeeded) - return ok; + if (!promptIfNeeded) + return ok; + } // Adaptive recovery loop (certutil / Keychain / elevate). for (var i = 0; i < 4; i++) @@ -202,7 +329,10 @@ private static bool IsFirefoxRunningTrustError(CertificateOsTrustResult result) result = _interception.LastOsTrustResult; if (_interception.IsRootTrusted && result?.Kind != CertificateOsTrustKind.MacNeedsManualTrustConfirm) + { + ScheduleFirefoxEnterpriseRootsBestEffort(); return true; + } if (result?.Kind == CertificateOsTrustKind.MacNeedsManualTrustConfirm) return await TryCompleteMacManualTrustAsync(owner); @@ -211,7 +341,10 @@ private static bool IsFirefoxRunningTrustError(CertificateOsTrustResult result) { var recovered = await TryRecoverFailedOsTrustAsync(owner, result); if (recovered == true) + { + ScheduleFirefoxEnterpriseRootsBestEffort(); return true; + } if (recovered == false) return false; ok = _interception.IsRootTrusted || @@ -222,14 +355,73 @@ private static bool IsFirefoxRunningTrustError(CertificateOsTrustResult result) break; } + if (_interception.IsRootTrusted) + ScheduleFirefoxEnterpriseRootsBestEffort(); return _interception.IsRootTrusted; } + /// + /// CryptUI/Keychain on UI; store verify + My prune off UI after Yes (avoids Not Responding). + /// + private async Task InstallRootInteractiveAsync() + { + using var scope = InspectorUxTrace.Scope("InstallRootInteractive"); + await Task.Yield(); + bool added; + using (InspectorUxTrace.Scope("InstallRootStoresOnly.CryptUI")) + added = _interception.InstallRootStoresOnly(machineStore: false); + InspectorUxTrace.Event("InstallRootStoresOnly.Result", $"added={added}"); + + if (!OperatingSystem.IsWindows()) + { + await Task.Yield(); + using (InspectorUxTrace.Scope("ApplyUnixSslTrustOnUi")) + _interception.ApplyUnixSslTrustOnUi(machineStore: false); + } + + using (InspectorUxTrace.Scope("FinalizeTrustAfterStoreMutation", $"added={added}")) + { + return await RunOffUiAsync( + () => _interception.FinalizeTrustAfterStoreMutation( + machineStore: false, + rootStoreAdded: added), + StatusCancelToken); + } + } + + private void ScheduleFirefoxEnterpriseRootsBestEffort() => + _interception.ScheduleFirefoxEnterpriseRootsBestEffort(); + + private async Task AwaitPriorFirefoxTrustBackgroundAsync() + { + using var scope = InspectorUxTrace.Scope("AwaitTrustBg"); + // Drop queued Clear/Enable left by the previous Install — a wedged running Clear used to + // burn the full 8s budget before every Remove / Clear+Install. + _interception.DropPendingFirefoxTrustBackgroundWork(); + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(StatusCancelToken); + cts.CancelAfter(TimeSpan.FromSeconds(2)); + await _interception.WaitForFirefoxTrustBackgroundIdleAsync(cts.Token) + .ConfigureAwait(true); + } + catch (OperationCanceledException) + { + InspectorUxTrace.Event("AwaitTrustBg.TimeoutOrCancel"); + // Proceed; serial queue + job timeout still bound prefs/prune work. + } + } + private async Task TryCompleteMacManualTrustAsync(Window? owner) { var wait = await WaitForMacSslTrustAsync(owner); - if (wait == MacSslTrustWaitResult.Trusted || _interception.VerifyOsUserSslTrust()) + var trusted = wait == MacSslTrustWaitResult.Trusted || + await RunOffUiAsync(() => _interception.VerifyOsUserSslTrust(), StatusCancelToken); + if (trusted) + { + ScheduleFirefoxEnterpriseRootsBestEffort(); return true; + } _interception.SetLastOsTrustCancelled(); if (wait == MacSslTrustWaitResult.NotSavedYet || _interception.IsRootInLoginKeychain()) @@ -239,7 +431,7 @@ private async Task TryCompleteMacManualTrustAsync(Window? owner) private async Task TryRecoverFailedOsTrustAsync(Window? owner, CertificateOsTrustResult? result) { - var choice = await AwaitCancellableAsync(_dialogs.ShowTrustRecoveryAsync(owner, result)); + var choice = await AwaitDialogAsync(_dialogs.ShowTrustRecoveryAsync(owner, result)); if (choice == TrustRecoveryChoice.Cancel) { _interception.SetLastOsTrustCancelled(); @@ -262,7 +454,14 @@ private async Task TryCompleteMacManualTrustAsync(Window? owner) if (choice == TrustRecoveryChoice.Primary) { SetStatus("Trusting root CA (administrator)…", StatusSeverity.Busy); + // UAC/CryptUI need a message pump — do not Task.Run. + await Task.Yield(); var ok = _interception.InstallRootCertificateAsAdmin(machineStore: false); + // Store Find after UAC — off UI. + if (ok) + ok = await RunOffUiAsync( + () => _interception.FinalizeTrustAfterAdminInstall(machineStore: false), + StatusCancelToken); if (ok && _interception.LastOsTrustResult?.Kind != CertificateOsTrustKind.MacNeedsManualTrustConfirm) return true; @@ -279,7 +478,10 @@ private async Task TryCompleteMacManualTrustAsync(Window? owner) if (choice == TrustRecoveryChoice.Primary) { SetStatus("Installing browser certificate tools…", StatusSeverity.Busy); - var install = _interception.InstallNssToolsAndRetryTrust(); + // Stay on UI sync context — caller may show more dialogs / update StatusText. + var install = await RunOffUiAsync( + () => _interception.InstallNssToolsAndRetryTrust(), + StatusCancelToken); if (install.Succeeded) return true; if (install.Kind == CertificateOsTrustKind.MacNeedsManualTrustConfirm) @@ -306,7 +508,7 @@ private Task WaitForMacSslTrustAsync(Window? owner) : StatusReady); } - return AwaitCancellableAsync(_dialogs.ShowMacSslTrustWaitAsync( + return AwaitDialogAsync(_dialogs.ShowMacSslTrustWaitAsync( owner, () => _interception.VerifyOsUserSslTrust(), () => _interception.OpenMacKeychainGuidance(), @@ -314,6 +516,11 @@ private Task WaitForMacSslTrustAsync(Window? owner) } private void SetOsTrustSuccessStatus() { + // Firefox prefs/policies already scheduled from EnsureRootCaTrustedAsync when trust + // succeeded; schedule again here for paths that only call SetOsTrustSuccessStatus + // (idempotent / best-effort). + ScheduleFirefoxEnterpriseRootsBestEffort(); + var msg = "Root CA trusted — ready to decrypt HTTPS"; if (!_firefoxTrustHintShown && InterceptionService.IsFirefoxProfilePresent) { @@ -322,13 +529,38 @@ private void SetOsTrustSuccessStatus() } SetOutcomeStatus(msg, StatusSeverity.Success, toastImportant: true); + NotifyDecryptTrustHealth(); } + private static string FormatOsTrustFailureStatus(CertificateOsTrustResult? result) => OsTrustUxCopy.FormatStatus(result); + private void SetBusyTrustingRootCa() => SetStatus( OperatingSystem.IsWindows() ? TrustingRootCaWindowsStatus : TrustingRootCaStatus, StatusSeverity.Busy); + + private static string FormatRemoveRootPromptStatus(int total, int index) + { + if (OperatingSystem.IsWindows()) + { + return total == 1 + ? "Windows may ask to DELETE the root CA - choose Yes" + : $"Windows may ask to DELETE root CA ({index}/{total}) - choose Yes"; + } + + if (OperatingSystem.IsMacOS()) + { + return total == 1 + ? "macOS may ask for your password to remove the root CA from Keychain" + : $"macOS may ask for your password to remove root CA ({index}/{total})"; + } + + return total == 1 + ? "Removing root CA from the user certificate store..." + : $"Removing root CA ({index}/{total}) from the user certificate store..."; + } + private static string FormatUntrustStillPresentStatus() { if (OperatingSystem.IsMacOS()) @@ -357,74 +589,232 @@ private async Task UntrustCaAsync() return; } - var owner = TryGetMainWindow(); - if (!await AwaitCancellableAsync(_dialogs.ConfirmRemoveRootCaAsync(owner))) - { - SetTransientStatus("Remove root CA cancelled", StatusSeverity.Neutral, revertMs: GuardStatusRevertMs); + if (!TryBeginTrustCommand()) return; - } - _interception.UntrustRootCertificate(machineStore: false); - if (DecryptHttps) + using var scope = InspectorUxTrace.Scope("UntrustCa"); + string? outcomeMessage = null; + var outcomeSeverity = StatusSeverity.Success; + var cancelled = false; + try { - SetDecryptHttpsCore(false); + var owner = TryGetMainWindow(); + if (!await AwaitDialogAsync(_dialogs.ConfirmRemoveRootCaAsync(owner))) + { + cancelled = true; + } + else + { + // Busy before TrustBg / remove so dialog-call waiters cannot observe a non-busy window. + SetStatus("Preparing remove…", StatusSeverity.Busy); + await AwaitPriorFirefoxTrustBackgroundAsync(); + + SetStatus("Removing root CA…", StatusSeverity.Busy); + await RemoveOsRootInteractiveAsync(machineStore: false); + // Decrypt cannot continue without a trusted CA (and we turn it off even if CryptUI + // delete was declined — user asked to remove). + await ForceDecryptHttpsOffAsync(); + + var stillPresent = _interception.IsRootTrusted; + outcomeMessage = stillPresent + ? FormatUntrustStillPresentStatus() + : FormatUntrustRemovedStatus(); + outcomeSeverity = stillPresent ? StatusSeverity.Warning : StatusSeverity.Success; + InspectorUxTrace.Event("UntrustCa.Result", $"stillPresent={stillPresent}"); + } + } + finally + { + EndTrustCommand(); } - var stillPresent = _interception.IsRootTrusted; - string message = stillPresent - ? FormatUntrustStillPresentStatus() - : FormatUntrustRemovedStatus(); - - SetOutcomeStatus( - message, - stillPresent ? StatusSeverity.Warning : StatusSeverity.Success, - toastImportant: true); + if (cancelled) + { + SetTransientStatus( + "Remove root CA cancelled", + StatusSeverity.Neutral, + toastImportant: true, + revertMs: GuardStatusRevertMs); + } + else if (outcomeMessage is not null) + { + // After EndTrustCommand: clearing Busy via SetOutcomeStatus must not race a follow-up + // Install/Rotate that still sees _trustCommandBusy. + SetOutcomeStatus(outcomeMessage, outcomeSeverity, toastImportant: true); + } } - private async Task RotateCaAsync() + + /// + /// List Root thumbs off UI → CryptUI Remove each on UI → My/Firefox finalize off UI. + /// + private async Task RemoveOsRootInteractiveAsync(bool machineStore) { - if (!_interception.IsRunning) + using var scope = InspectorUxTrace.Scope("RemoveOsRootInteractive"); + await Task.Yield(); + if (_interception.UseInMemoryTrustState) { - SetGuardStatus(StartProxyFirstStatus); + _interception.RemoveOsRootStoreOnly(machineStore); return; } - var owner = TryGetMainWindow(); - if (!await AwaitCancellableAsync(_dialogs.ConfirmRotateRootCaAsync(owner))) + SetStatus("Finding Titanium root CA in the Windows store…", StatusSeverity.Busy); + IReadOnlyList thumbs; + using (InspectorUxTrace.Scope("ListRootThumbprintsToRemove")) { - SetTransientStatus("Clear and reinstall root CA cancelled", StatusSeverity.Neutral, revertMs: GuardStatusRevertMs); - return; + thumbs = await RunOffUiAsync( + () => _interception.ListRootThumbprintsToRemove(machineStore), + StatusCancelToken); } - if (DecryptHttps) - SetDecryptHttpsCore(false); + // Prefer known current thumb first so we do not depend solely on subject Find. + var current = _interception.RootCertificate?.Thumbprint; + if (!string.IsNullOrEmpty(current) && + !thumbs.Contains(current, StringComparer.OrdinalIgnoreCase)) + { + thumbs = thumbs.Prepend(current).ToList(); + } + + if (thumbs.Count == 0 && !string.IsNullOrEmpty(current)) + thumbs = new[] { current }; - var oldThumb = _interception.RootCertificate?.Thumbprint; - var ok = _interception.RotateRootCertificate(machineStore: false); - if (!ok) + InspectorUxTrace.Event("RemoveOsRoot.Thumbs", $"count={thumbs.Count}"); + for (var i = 0; i < thumbs.Count; i++) { - SetOutcomeStatus("Clear and reinstall root CA failed — see logs", StatusSeverity.Error, toastImportant: true); + SetStatus( + FormatRemoveRootPromptStatus(thumbs.Count, i + 1), + StatusSeverity.Busy); + await Task.Yield(); + using (InspectorUxTrace.Scope("RemoveRootThumbprint.CryptUI", $"i={i + 1}/{thumbs.Count}")) + _interception.RemoveRootThumbprintOnUi(machineStore, thumbs[i]); + } + + if (!OperatingSystem.IsWindows()) + { + await Task.Yield(); + using (InspectorUxTrace.Scope("ApplyUnixUntrustOnUi")) + _interception.ApplyUnixUntrustOnUi(); + } + + SetStatus("Finishing root CA removal…", StatusSeverity.Busy); + using (InspectorUxTrace.Scope("FinalizeAfterRootRemove")) + { + await RunOffUiAsync( + () => _interception.FinalizeAfterRootRemove(machineStore), + StatusCancelToken); + } + // Firefox prefs/HKCU only — serial background lane (never await certutil). + _interception.ScheduleClearPendingFirefoxRootTrust(); + } + private async Task RotateCaAsync() + { + if (!_interception.IsRunning) + { + SetGuardStatus(StartProxyFirstStatus); return; } - var newThumb = _interception.RootCertificate?.Thumbprint; - var changed = !string.IsNullOrEmpty(newThumb) && - !string.Equals(oldThumb, newThumb, StringComparison.OrdinalIgnoreCase); + if (!TryBeginTrustCommand()) + return; - if (await AwaitCancellableAsync(_dialogs.ConfirmInstallRootCaAsync(owner))) + using var scope = InspectorUxTrace.Scope("RotateCa"); + var rotateConfirmed = false; + var rotateCancelled = false; + bool? rotateTrusted = null; + string? rotateFailure = null; + try { - SetBusyTrustingRootCa(); - var trusted = await EnsureRootCaTrustedAsync(promptIfNeeded: true); - var message = trusted - ? FormatRotateCaTrustedStatus(changed) - : FormatOsTrustFailureStatus(_interception.LastOsTrustResult); - if (trusted) - SetOsTrustSuccessStatus(); + var owner = TryGetMainWindow(); + if (!await AwaitDialogAsync(_dialogs.ConfirmRotateRootCaAsync(owner))) + { + InspectorUxTrace.Event("RotateCa.ConfirmRotate", "accepted=false"); + rotateCancelled = true; + } else - SetOutcomeStatus(message, StatusSeverity.Error, toastImportant: true); - return; + { + rotateConfirmed = true; + InspectorUxTrace.Event("RotateCa.ConfirmRotate", "accepted=true"); + + // Mark Busy before any PersistSettings / decrypt-off work so waiters that key on + // RotateRootCaCalls (incremented at confirm) cannot observe a non-busy window while + // _trustCommandBusy is still true (macOS stress: Untrust then Rejected → timeout). + SetStatus("Preparing clear and reinstall…", StatusSeverity.Busy); + + // New root is untrusted until Install — MITM must not stay on across rotate. + // Skip ForceDecryptHttpsOffAsync/Snap (dispatcher bounce during CryptUI). Core + + // finally SetDecryptHttpsCore keep the glyph in sync without flipping the field. + Interlocked.Increment(ref _decryptEnableGeneration); + Interlocked.Increment(ref _decryptTrustVerifyGeneration); + _decryptHttpsBusy = false; + if (_decryptHttps) + SetDecryptHttpsCore(false); + + // Await TrustBg only before Remove — never between Mint and CryptUI. + await AwaitPriorFirefoxTrustBackgroundAsync(); + + SetStatus("Clearing root CA…", StatusSeverity.Busy); + await Task.Yield(); + var oldThumb = _interception.RootCertificate?.Thumbprint; + await RemoveOsRootInteractiveAsync(machineStore: false); + + SetStatus("Recreating root CA…", StatusSeverity.Busy); + bool ok; + using (InspectorUxTrace.Scope("MintNewRootCertificateCore")) + { + ok = await RunOffUiAsync( + () => _interception.MintNewRootCertificateCore(clearFirefox: false), + StatusCancelToken); + } + + if (!ok) + { + rotateFailure = "Clear and reinstall root CA failed — see logs"; + } + else + { + var newThumb = _interception.RootCertificate?.Thumbprint; + var changed = !string.IsNullOrEmpty(newThumb) && + !string.Equals(oldThumb, newThumb, StringComparison.OrdinalIgnoreCase); + + // User already confirmed Clear+Install — skip a second ConfirmInstall and go + // straight to OS CryptUI/Keychain (avoids “two install dialogs then stuck”). + InspectorUxTrace.Event("RotateCa.ConfirmInstall", "accepted=true skippedDuplicate=true"); + SetBusyTrustingRootCa(); + // Skip initial Root Find — we just minted; opening Crypt32 before CryptUI stalls. + var trusted = await EnsureRootCaTrustedAsync(promptIfNeeded: true, skipInitialRefresh: true); + InspectorUxTrace.Event("RotateCa.InstallResult", $"trusted={trusted} changed={changed}"); + rotateTrusted = trusted; + if (!trusted) + rotateFailure = FormatOsTrustFailureStatus(_interception.LastOsTrustResult); + } + } + } + finally + { + // After confirm, MITM stays off even if a stale UI snap or settings reload raced. + // Do not force-off on cancel — that would disable Decrypt HTTPS without rotating. + if (rotateConfirmed) + SetDecryptHttpsCore(false); + EndTrustCommand(); } - SetOutcomeStatus(FormatRotateCaDeferredTrustStatus(changed), StatusSeverity.Warning, toastImportant: true); + // Outcome after EndTrustCommand — never clear IsStatusBusy while the trust gate is held. + // (A return inside try would skip this block — keep fall-through only.) + if (rotateCancelled) + { + SetTransientStatus( + "Clear and reinstall root CA cancelled", + StatusSeverity.Neutral, + toastImportant: true, + revertMs: GuardStatusRevertMs); + } + else if (rotateTrusted == true) + { + SetOsTrustSuccessStatus(); + } + else if (rotateFailure is not null) + { + SetOutcomeStatus(rotateFailure, StatusSeverity.Error, toastImportant: true); + } } 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"; @@ -476,43 +866,146 @@ private async Task DeviceCaSetupAsync() "Use Bind address 0.0.0.0 so other devices can reach the proxy."; var owner = TryGetMainWindow(); - if (await AwaitCancellableAsync(_dialogs.ShowDeviceCaSetupAsync(owner, message))) + if (await AwaitDialogAsync(_dialogs.ShowDeviceCaSetupAsync(owner, message))) { await ExportCaAsync(); } } - private async Task EnableDecryptHttpsAsync() + private async Task EnableDecryptHttpsAsync(int enableGeneration) { + if (!TryBeginTrustCommand()) + { + await RejectDecryptHttpsEnableAsync(); + return; + } + _decryptHttpsBusy = true; + using var scope = InspectorUxTrace.Scope("EnableDecryptHttps", $"gen={enableGeneration}"); try { + // Stay on the Avalonia UI sync context after awaits. ConfigureAwait(false) here + // resumes on a thread-pool thread, then ShowDialog / CryptUI hang forever with + // status stuck on "Checking certificate trust…" (no message pump / wrong thread). if (!await TryStartProxyForDecryptAsync()) return; + if (enableGeneration != Volatile.Read(ref _decryptEnableGeneration)) + return; if (!await TryTrustRootForDecryptAsync()) return; + if (enableGeneration != Volatile.Read(ref _decryptEnableGeneration)) + return; if (!await TryCompleteMacSslTrustForDecryptAsync()) return; + if (enableGeneration != Volatile.Read(ref _decryptEnableGeneration)) + return; SetDecryptHttpsCore(true); SetOutcomeStatus("Decrypting HTTPS", StatusSeverity.Success, toastImportant: true); } + catch (OperationCanceledException) + { + if (enableGeneration == Volatile.Read(ref _decryptEnableGeneration)) + { + SetGuardStatus("Decrypt HTTPS cancelled"); + await RejectDecryptHttpsEnableAsync(); + } + } + catch (Exception ex) + { + if (enableGeneration == Volatile.Read(ref _decryptEnableGeneration)) + { + SetOutcomeStatus( + "Decrypt HTTPS failed: " + Truncate(ex.Message, 160), + StatusSeverity.Error, + toastImportant: true); + await RejectDecryptHttpsEnableAsync(); + } + } finally { - _decryptHttpsBusy = false; + if (enableGeneration == Volatile.Read(ref _decryptEnableGeneration)) + _decryptHttpsBusy = false; + EndTrustCommand(); } } + + /// + /// After optimistic decrypt-on, re-check Root-store trust off the UI thread. + /// Reverts decrypt + toast if the CA was removed while capturing. + /// + private async Task ReverifyDecryptTrustInBackgroundAsync() + { + var generation = Interlocked.Increment(ref _decryptTrustVerifyGeneration); + try + { + var trusted = await RunOffUiAsync( + () => _interception.RefreshTrustState(), + StatusCancelToken).ConfigureAwait(false); + if (generation != Volatile.Read(ref _decryptTrustVerifyGeneration)) + return; + await MarshalToUiAsync(NotifyDecryptTrustHealth, StatusCancelToken).ConfigureAwait(false); + if (trusted || !_decryptHttps) + return; + + async Task DisableIfStillWantedAsync() + { + if (generation != Volatile.Read(ref _decryptTrustVerifyGeneration) || !_decryptHttps) + return; + await ForceDecryptHttpsOffAsync(); + SetOutcomeStatus( + "Decrypt HTTPS off — root CA not trusted", + StatusSeverity.Error, + toastImportant: true); + } + + if (Application.Current is null || Dispatcher.UIThread.CheckAccess()) + await DisableIfStillWantedAsync().ConfigureAwait(true); + else + await Dispatcher.UIThread.InvokeAsync(DisableIfStillWantedAsync); + } + catch (OperationCanceledException) + { + // status revert / shutdown + } + } + + /// + /// Turn Decrypt HTTPS off in the model and snap Avalonia OneWay CheckBox/Menu targets. + /// Use whenever decrypt is no longer possible (remove/rotate CA, trust lost, start without trust). + /// + /// + /// When true, invalidate an in-flight (Untrust / Rotate / Start). + /// When false (enable cancel/reject), leave the generation alone so the enable finally-block can finish. + /// + private async Task ForceDecryptHttpsOffAsync(bool cancelInFlightEnable = true) + { + if (cancelInFlightEnable) + Interlocked.Increment(ref _decryptEnableGeneration); + Interlocked.Increment(ref _decryptTrustVerifyGeneration); + _decryptHttpsBusy = false; + if (_decryptHttps) + SetDecryptHttpsCore(false); + await SnapDecryptHttpsUiAsync(); + } + + /// + /// Enable was rejected — model never became true; bounce clears a locally flipped CheckBox. + /// + private Task RejectDecryptHttpsEnableAsync() => ForceDecryptHttpsOffAsync(cancelInFlightEnable: false); + private void NotifyDecryptHttpsUnchanged() => - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DecryptHttps))); + _ = ForceDecryptHttpsOffAsync(cancelInFlightEnable: false); + private async Task TryStartProxyForDecryptAsync() { if (_interception.IsRunning) return true; var owner = TryGetMainWindow(); - if (!await AwaitCancellableAsync(_dialogs.ConfirmStartProxyForDecryptAsync(owner))) + if (!await AwaitDialogAsync(_dialogs.ConfirmStartProxyForDecryptAsync(owner))) { SetGuardStatus("Decrypt HTTPS cancelled — start the proxy first"); - NotifyDecryptHttpsUnchanged(); + await RejectDecryptHttpsEnableAsync(); return false; } @@ -524,20 +1017,29 @@ private async Task TryStartProxyForDecryptAsync() "Could not start the proxy — Decrypt HTTPS stays off", StatusSeverity.Error, toastImportant: true); - NotifyDecryptHttpsUnchanged(); + await RejectDecryptHttpsEnableAsync(); return false; } private async Task TryTrustRootForDecryptAsync() { - _interception.RefreshTrustState(); + // Prefer cached trust from Start / Install — avoids Root-store Find on the UI thread. if (_interception.IsRootTrusted) return true; + SetStatus("Checking certificate trust…", StatusSeverity.Busy); + // No ConfigureAwait(false): dialogs and CryptUI below require the UI thread. + var trusted = await RunOffUiAsync( + () => _interception.RefreshTrustState(), + StatusCancelToken); + if (trusted) + return true; + var owner = TryGetMainWindow(); - if (!await AwaitCancellableAsync(_dialogs.ConfirmInstallRootCaAsync(owner))) + SetStatus("Root CA not trusted — confirm install…", StatusSeverity.Busy); + if (!await AwaitDialogAsync(_dialogs.ConfirmInstallRootCaAsync(owner))) { SetGuardStatus("Decrypt HTTPS cancelled — root CA not installed"); - NotifyDecryptHttpsUnchanged(); + await RejectDecryptHttpsEnableAsync(); return false; } @@ -548,7 +1050,7 @@ private async Task TryTrustRootForDecryptAsync() if (_interception.LastOsTrustResult?.Kind == CertificateOsTrustKind.Cancelled) { SetGuardStatus("Decrypt HTTPS cancelled — root CA not trusted"); - NotifyDecryptHttpsUnchanged(); + await RejectDecryptHttpsEnableAsync(); return false; } @@ -562,26 +1064,82 @@ private async Task TryTrustRootForDecryptAsync() OsTrustUxCopy.FormatStatus(_interception.LastOsTrustResult), StatusSeverity.Error, toastImportant: true); - NotifyDecryptHttpsUnchanged(); + await RejectDecryptHttpsEnableAsync(); return false; } private async Task TryCompleteMacSslTrustForDecryptAsync() { - if (_interception.VerifyOsUserSslTrust() || OperatingSystem.IsWindows()) + // Windows Root-store presence is trust - do not call VerifyOsUserSslTrust (second Find + Firefox prefs). + if (OperatingSystem.IsWindows()) return true; + // Stay on UI sync context - ResolveTerminalTrustFailureAsync shows dialogs. + var trusted = await RunOffUiAsync( + () => _interception.VerifyOsUserSslTrust(), + StatusCancelToken); + if (trusted) + { + _interception.ScheduleFirefoxEnterpriseRootsBestEffort(); + return true; + } + + // Linux: never fabricate Mac Keychain Always Trust - use NSS/certutil recovery instead. + if (OperatingSystem.IsLinux()) + { + var linuxIncomplete = _interception.LastOsTrustResult + ?? CertificateOsTrustResult.Fail( + CertificateOsTrustKind.CertutilMissing, + "Root CA is not trusted yet. Install NSS certutil tools, or Export CA and trust it for your browser."); + if (linuxIncomplete.Kind == CertificateOsTrustKind.MacNeedsManualTrustConfirm) + { + linuxIncomplete = CertificateOsTrustResult.Fail( + CertificateOsTrustKind.CertutilMissing, + string.IsNullOrWhiteSpace(linuxIncomplete.Message) + ? "Root CA is not trusted yet. Install NSS certutil tools, or Export CA and trust it for your browser." + : linuxIncomplete.Message); + } + + InspectorUxTrace.Event("Decrypt.LinuxTrustIncomplete", $"kind={linuxIncomplete.Kind}"); + if (await ResolveTerminalTrustFailureAsync(linuxIncomplete)) + { + trusted = await RunOffUiAsync( + () => _interception.VerifyOsUserSslTrust(), + StatusCancelToken); + if (trusted) + { + _interception.ScheduleFirefoxEnterpriseRootsBestEffort(); + return true; + } + } + + SetOutcomeStatus( + OsTrustUxCopy.FormatStatus(linuxIncomplete), + StatusSeverity.Error, + toastImportant: true); + await RejectDecryptHttpsEnableAsync(); + return false; + } + var incomplete = CertificateOsTrustResult.Fail( CertificateOsTrustKind.MacNeedsManualTrustConfirm, "Root CA needs Always Trust in Keychain Access before Decrypt HTTPS"); - if (await ResolveTerminalTrustFailureAsync(incomplete) && - (_interception.VerifyOsUserSslTrust() || OperatingSystem.IsWindows())) - return true; + if (await ResolveTerminalTrustFailureAsync(incomplete)) + { + trusted = await RunOffUiAsync( + () => _interception.VerifyOsUserSslTrust(), + StatusCancelToken); + if (trusted) + { + _interception.ScheduleFirefoxEnterpriseRootsBestEffort(); + return true; + } + } SetOutcomeStatus( OsTrustUxCopy.FormatStatus(incomplete), StatusSeverity.Error, toastImportant: true); - NotifyDecryptHttpsUnchanged(); + await RejectDecryptHttpsEnableAsync(); return false; } /// @@ -595,7 +1153,7 @@ private async Task ResolveTerminalTrustFailureAsync(CertificateOsTrustResu var owner = TryGetMainWindow(); for (var i = 0; i < 4; i++) { - var choice = await AwaitCancellableAsync(_dialogs.ShowDecryptTrustFailedAsync(owner, result)); + var choice = await AwaitDialogAsync(_dialogs.ShowDecryptTrustFailedAsync(owner, result)); if (choice == TrustRecoveryChoice.Cancel) { _interception.SetLastOsTrustCancelled(); @@ -617,7 +1175,8 @@ private async Task ResolveTerminalTrustFailureAsync(CertificateOsTrustResu return false; } - return _interception.IsRootTrusted || _interception.VerifyOsUserSslTrust(); + return _interception.IsRootTrusted || + await RunOffUiAsync(() => _interception.VerifyOsUserSslTrust(), StatusCancelToken); } private async Task TryHandleTerminalTrustChoiceAsync( @@ -656,5 +1215,14 @@ private void SetDecryptHttpsCore(bool enabled) _interception.DecryptHttps = enabled; PersistSettings(); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DecryptHttps))); + NotifyDecryptTrustHealth(); + SyncToggleVisual?.Invoke(nameof(DecryptHttps), enabled); + } + + private void NotifyDecryptTrustHealth() + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DecryptTrustHealthText))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowDecryptTrustHealth))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsDecryptTrustHealthy))); } } diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Updates.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Updates.cs index 5233c8597..6b845c7f3 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Updates.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Updates.cs @@ -63,7 +63,7 @@ public async Task CheckUpdatesAsync(bool promptIfAvailable = true) var owner = TryGetMainWindow(); var version = result.RemoteVersion ?? ""; - if (!await AwaitCancellableAsync(_dialogs.ConfirmInstallUpdateAsync(owner, version, result.ChannelDisplay, result.OfferKind))) + if (!await AwaitDialogAsync(_dialogs.ConfirmInstallUpdateAsync(owner, version, result.ChannelDisplay, result.OfferKind))) { SetOutcomeStatus(result.Message, StatusSeverity.Success); return; @@ -120,24 +120,43 @@ private async Task SendComposerAsync() return; } + if (string.IsNullOrWhiteSpace(ComposerBodyFilePath) + && !string.IsNullOrEmpty(ComposerBody) + && ComposerBody.Length > InspectorBodyLimits.MaxBodyBytes) + { + SetOutcomeStatus( + $"Composer body is {SessionDisplayFormat.FormatByteSize(ComposerBody.Length)} — consider Load body from file", + StatusSeverity.Warning); + } + SetStatus("Composer sending…", StatusSeverity.Busy); var template = new SessionSnapshot { Method = string.IsNullOrWhiteSpace(ComposerMethod) ? "GET" : ComposerMethod, Url = ComposerUrl, RequestHeadersText = ComposerHeaders, - RequestBodyText = ComposerBody, + RequestBodyText = HasComposerBodyFile ? null : ComposerBody, ContentType = GuessContentType(ComposerHeaders), }; - var result = await ReplayService.ReplayAsync( - template, - editedUrl: ComposerUrl, - editedMethod: ComposerMethod, - editedBody: ComposerBody, - editedHeaders: ComposerHeaders, - ignoreServerCertificateErrors: _interception.IgnoreServerCertificateErrors, - cancellationToken: _statusRevertCts?.Token ?? CancellationToken.None); + ReplayResult result; + try + { + result = await ReplayService.ReplayAsync( + template, + editedUrl: ComposerUrl, + editedMethod: ComposerMethod, + editedBody: HasComposerBodyFile ? null : ComposerBody, + editedHeaders: ComposerHeaders, + bodyFilePath: ComposerBodyFilePath, + ignoreServerCertificateErrors: _interception.IgnoreServerCertificateErrors, + cancellationToken: _statusRevertCts?.Token ?? CancellationToken.None); + } + catch (Exception ex) + { + SetOutcomeStatus("Composer failed: " + Truncate(ex.Message, 160), StatusSeverity.Error, toastImportant: true); + return; + } if (!result.Ok) { @@ -145,6 +164,9 @@ private async Task SendComposerAsync() return; } + var requestPreview = HasComposerBodyFile + ? $"(file: {Path.GetFileName(ComposerBodyFilePath)})" + : InspectorBodyLimits.TruncateText(ComposerBody ?? ""); var snap = new SessionSnapshot { Id = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), @@ -153,19 +175,23 @@ private async Task SendComposerAsync() Host = TryHost(ComposerUrl), StartedUtc = DateTimeOffset.UtcNow, RequestHeadersText = ComposerHeaders, - RequestBodyText = ComposerBody, + RequestBodyText = requestPreview, StatusCode = result.StatusCode, ResponseHeadersText = result.ResponseHeaders, ResponseBodyText = result.ResponseBody, + ResponseBodyBytes = result.ResponseBodyBytes, + ResponseBodyOriginalSize = result.ResponseBodyOriginalSize, + ResponseBodyCapture = result.ResponseBodyCapture, ContentType = template.ContentType, - BodySize = result.ResponseBody?.Length, + BodySize = result.ResponseBodyOriginalSize ?? result.ResponseBody?.Length, Protocol = "Composer", }; _store.Add(snap); ApplyFilter(); RefreshSessionCountText(); - SelectedSession = snap; + // Select the synthetic row without forcing Inspect open (Composer may already be showing). + SelectSessionWithoutOpeningDetails(snap); SetOutcomeStatus($"Composer → HTTP {result.StatusCode} (session #{snap.Id})", StatusSeverity.Success); } private static string? GuessContentType(string headers) diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs index 0e250287f..ec4c40aef 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs @@ -81,6 +81,9 @@ public sealed partial class MainWindowViewModel : INotifyPropertyChanged private bool _launchAutoSystemProxyOnStart = true; private bool _decryptHttps; private bool _decryptHttpsBusy; + private int _decryptEnableGeneration; + /// Exclusive gate for Install / Remove / Rotate / Trust Firefox (CryptUI + store). + private bool _trustCommandBusy; private string _autoResponderMatch = "*"; private string _autoResponderBody = "OK"; private string _autoResponderContentType = "text/plain"; @@ -98,6 +101,10 @@ public sealed partial class MainWindowViewModel : INotifyPropertyChanged /// Sticky intent: re-enable system proxy on the next Start after a Stop that had it on. private bool _reenableSystemProxyOnStart; private bool _stopBusy; + private bool _startBusy; + private int _systemProxyApplyGeneration; + private int _proxyLoopbackApplyGeneration; + private int _decryptTrustVerifyGeneration; private bool _breakpointOnResponse; private string _breakpointEditBody = ""; private string? _scriptOnRequest; @@ -105,7 +112,9 @@ public sealed partial class MainWindowViewModel : INotifyPropertyChanged private int _selectedOuterPaneIndex; private int _selectedInspectTabIndex; private int _selectedToolsTabIndex; + private int _selectedPaneNavIndex; private bool _showSessionDetails; + private double _sessionDetailsWidth = 520; /// /// When true, assigning must not force the details pane open /// (filter restore / bulk removal — DataGrid may briefly re-select a neighbor row). @@ -210,11 +219,7 @@ public MainWindowViewModel(InspectorViewModelServices services) AutoSystemProxyOnStart = !AutoSystemProxyOnStart; return Task.CompletedTask; }); - ToggleDecryptHttpsCommand = Cmd(() => - { - DecryptHttps = !DecryptHttps; - return Task.CompletedTask; - }); + ToggleDecryptHttpsCommand = Cmd(ToggleDecryptHttpsAsync); ToggleIgnoreServerCertificateErrorsCommand = Cmd(() => { IgnoreServerCertificateErrors = !IgnoreServerCertificateErrors; @@ -266,6 +271,7 @@ public MainWindowViewModel(InspectorViewModelServices services) DeleteAutoResponderRuleCommand = Cmd(DeleteAutoResponderRuleAsync); UpdateAutoResponderRuleCommand = Cmd(UpdateAutoResponderRuleAsync); BrowseAutoResponderLocalFileCommand = Cmd(BrowseAutoResponderLocalFileAsync); + FillGraphQlFromSelectedCommand = Cmd(FillGraphQlFromSelectedAsync); AddMapRemoteRuleCommand = Cmd(AddMapRemoteRuleAsync); DeleteMapRemoteRuleCommand = Cmd(DeleteMapRemoteRuleAsync); UpdateMapRemoteRuleCommand = Cmd(UpdateMapRemoteRuleAsync); @@ -282,6 +288,12 @@ public MainWindowViewModel(InspectorViewModelServices services) ApplyEditBodyCommand = Cmd(ApplyEditBodyAsync); ToggleDebugLoggingCommand = Cmd(ToggleDebugLoggingAsync); CloseSessionDetailsCommand = Cmd(CloseSessionDetailsAsync); + TogglePaneNavInspectCommand = Cmd(() => TogglePaneNavAsync(0)); + TogglePaneNavComposerCommand = Cmd(() => TogglePaneNavAsync(1)); + TogglePaneNavBreakpointsCommand = Cmd(() => TogglePaneNavAsync(2)); + TogglePaneNavAutoResponderCommand = Cmd(() => TogglePaneNavAsync(3)); + TogglePaneNavScriptsCommand = Cmd(() => TogglePaneNavAsync(4)); + TogglePaneNavMapRemoteCommand = Cmd(() => TogglePaneNavAsync(5)); OpenToolsComposerCommand = Cmd(() => OpenToolsTabAsync(0)); OpenToolsBreakpointsCommand = Cmd(() => OpenToolsTabAsync(1)); OpenToolsAutoResponderCommand = Cmd(() => OpenToolsTabAsync(2)); @@ -292,6 +304,7 @@ public MainWindowViewModel(InspectorViewModelServices services) SearchQuery = SessionSearch.ClearFilters(SearchQuery); return Task.CompletedTask; }); + WireBodyInspectCommands(); WireEventHandlers(); LoadPlusPanels(); @@ -317,6 +330,12 @@ public MainWindowViewModel(InspectorViewModelServices services) public void AttachStatusNotifier(IStatusNotifier notifier) => _statusNotifier = notifier ?? NullStatusNotifier.Instance; + /// + /// MainWindow pushes CheckBox/MenuItem IsChecked via SetCurrentValue (Avalonia 11.2 OneWay + /// bindings break after ToggleButton click). Null in unit tests. + /// + internal Action? SyncToggleVisual { get; set; } + /// Exposed for E2E / headless tests — seeds the in-memory capture list. public void SeedSession(SessionSnapshot snapshot) { @@ -324,6 +343,18 @@ public void SeedSession(SessionSnapshot snapshot) OnSessionAddedToFilter(snapshot); } + /// Test hook: same UI path as . + internal void ApplySessionUpdated(SessionSnapshot snapshot) + { + _store.NotifyUpdated(snapshot); + OnSessionUpdatedForFilter(snapshot); + if (ReferenceEquals(SelectedSession, snapshot)) + { + UpdateWsFramesVisibility(); + RefreshSelectedInspectors(); + } + } + /// Called from the session grid when Extended multi-select changes. public void SetSelectedSessions(IReadOnlyList selected) { @@ -490,6 +521,7 @@ await MarshalToUiAsync(() => /// public async Task TryAutoStartAsync() { + InspectorUxTrace.Event("Session.Open", $"uxTrace={InspectorUxTrace.LogFilePath}"); // MenuItem CheckBox TwoWay bindings can write false during init and PersistSettings. // Prefer the disk snapshot from LoadFromSettings for this first-start decision. RestoreLaunchPreferencesIfClobbered(); @@ -560,6 +592,7 @@ public void EnsureShutdown() _interception.EnsureShutdown(); CancelStatusRevert(); + Interlocked.Increment(ref _systemProxyApplyGeneration); SetSystemProxyCore(false); RefreshEndpointAndBindUi(); _registry.Dispose(); @@ -581,6 +614,7 @@ public void BeginBackgroundShutdown() } // UI flag only — do not call SetSystemProxy on the UI thread (WinINET deadlock risk). + Interlocked.Increment(ref _systemProxyApplyGeneration); SetSystemProxyCore(false); _interception.BeginBackgroundShutdown(); CancelStatusRevert(); @@ -639,7 +673,45 @@ or nameof(BreakpointViewModel.GraphQlOperationName)) { PersistSettings(); } + + if (e.PropertyName is nameof(BreakpointViewModel.LastOverflowMessage) + && !string.IsNullOrEmpty(Breakpoints.LastOverflowMessage)) + { + MarshalToUi(() => SetOutcomeStatus(Breakpoints.LastOverflowMessage, StatusSeverity.Warning)); + } }; + Breakpoints.ActiveHitChanged += (_, _) => MarshalToUi(OnBreakpointActiveHitChanged); + } + + private void OnBreakpointActiveHitChanged() + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasActiveBreakpoint))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BreakpointHitBanner))); + + if (Breakpoints.Active is { } hit) + { + var body = hit.Session.RequestBodyText + ?? (hit.Session.RequestBodyBytes is { Length: > 0 } bytes + ? Encoding.UTF8.GetString(bytes) + : ""); + BreakpointEditBody = body; + StatusText = Breakpoints.ActiveSummary; + StatusSeverity = StatusSeverity.Warning; + // If the tools pane is already open, switch to Breakpoints so Continue/Abort are + // visible — never force-open a closed pane (status/banner still notify). + if (ShowSessionDetails) + { + SelectedPaneNavIndex = 2; + } + } + else if (!string.IsNullOrEmpty(Breakpoints.LastOverflowMessage)) + { + SetOutcomeStatus(Breakpoints.LastOverflowMessage, StatusSeverity.Warning); + } + else + { + SetTransientStatus("Breakpoint cleared", StatusSeverity.Neutral); + } } private void WireSessionPipelineHandlers() @@ -655,8 +727,10 @@ private void WireSessionPipelineHandlers() MarshalToUi(() => { _store.NotifyUpdated(snap); + OnSessionUpdatedForFilter(snap); if (ReferenceEquals(SelectedSession, snap)) { + UpdateWsFramesVisibility(); RefreshSelectedInspectors(); } }); @@ -731,6 +805,16 @@ private static async Task MarshalToUiAsync(Action action, CancellationToken canc throw last!; } + /// + /// Run blocking OS I/O (Root store, WinINET, listener start/stop) off the Avalonia dispatcher + /// so checkboxes and Busy status can paint. Same rationale as . + /// + private static Task RunOffUiAsync(Action work, CancellationToken cancellationToken = default) => + Task.Run(work, cancellationToken); + + private static Task RunOffUiAsync(Func work, CancellationToken cancellationToken = default) => + Task.Run(work, cancellationToken); + private void LoadPlusPanels() { var panels = PlusInspectorLoader.TryLoadPanels(out var plusWarning); @@ -771,21 +855,24 @@ private async Task StopCaptureCoreAsync(string statusAfterStop) return; } + using var scope = InspectorUxTrace.Scope("StopCapture"); _stopBusy = true; _reenableSystemProxyOnStart = SystemProxy; + // Invalidate in-flight optimistic System proxy applies before WinINET restore in Stop(). + Interlocked.Increment(ref _systemProxyApplyGeneration); + SetSystemProxyCore(false); SetStatus("Stopping…", StatusSeverity.Busy); try { - await Task.Run(() => _interception.Stop(), _statusRevertCts?.Token ?? CancellationToken.None).ConfigureAwait(false); + await RunOffUiAsync(() => _interception.Stop(), _statusRevertCts?.Token ?? CancellationToken.None); - await MarshalToUiAsync(() => - { - SetSystemProxyCore(false); - PersistSettings(); - RefreshEndpointAndBindUi(); - SetSteadyStatus(statusAfterStop); - }, StatusCancelToken).ConfigureAwait(false); + // Stay on UI sync context when Avalonia has one (StatusText / checkbox). Unit tests + // without a sync context continue inline on the thread-pool — fine without bindings. + SetSystemProxyCore(false); + PersistSettings(); + RefreshEndpointAndBindUi(); + SetSteadyStatus(statusAfterStop); } finally { @@ -814,37 +901,48 @@ private async Task TryToggleSystemProxyAsync() if (!_interception.IsRunning) { SetGuardStatus("Start the proxy before enabling system proxy"); + await SnapSystemProxyUiAsync(); return; } var s = _settings.Current; - if (!s.WarnedAboutPacReplace && SystemProxyPacHelper.HasActivePacScript()) - { - var owner = TryGetMainWindow(); - if (!await AwaitCancellableAsync(_dialogs.ConfirmPacReplaceAsync(owner))) + if (!s.WarnedAboutPacReplace) + { + // macOS scutil can block up to 3s — keep it off the dispatcher. + // Stay on the UI sync context afterward: ConfirmPacReplaceAsync uses ShowDialog. + var hasPac = await RunOffUiAsync( + SystemProxyPacHelper.HasActivePacScript, + StatusCancelToken); + if (hasPac) { - StatusText = "System proxy not enabled (PAC replace cancelled)"; - return; - } + var owner = TryGetMainWindow(); + if (!await AwaitDialogAsync(_dialogs.ConfirmPacReplaceAsync(owner))) + { + StatusText = "System proxy not enabled (PAC replace cancelled)"; + await SnapSystemProxyUiAsync(); + return; + } - s.WarnedAboutPacReplace = true; - _settings.Save(); + s.WarnedAboutPacReplace = true; + _settings.Save(); + } } SystemProxy = true; } private Task AwaitCancellableAsync(Task task) => task.WaitAsync(StatusCancelToken); - - - - - - - - private Task AwaitCancellableAsync(Task task) => task.WaitAsync(StatusCancelToken); + /// + /// Modal consent dialogs must not share . + /// A prior outcome's status-bar revert cancels that token (~5s) and would abort + /// ShowDialog as a silent OperationCanceledException (no toast, no Confirm* ux-trace) + /// — the "Nth Clear+Install did nothing" failure mode. + /// + private static Task AwaitDialogAsync(Task task) => task; + private static Task AwaitDialogAsync(Task task) => task; + @@ -890,9 +988,14 @@ private async Task OpenSessionRetentionAsync() } var saved = await AwaitCancellableAsync(SessionRetentionWindow.ShowAsync(owner, _settings)); - StatusText = saved - ? "Session retention saved — restart Inspector to apply" - : "Session retention cancelled"; + if (!saved) + { + StatusText = "Session retention cancelled"; + return; + } + + _store.ApplyOptions(SessionStoreOptions.FromSettings(_settings.Current)); + StatusText = "Session retention applied"; } @@ -944,9 +1047,14 @@ private async Task OpenExcludedHostsAsync() _interception)); if (saved) { - if (SystemProxy && !_interception.ReapplySystemProxyIfEnabled()) + if (SystemProxy) { - StatusText = "Exclusions saved; re-toggle System proxy to apply OS bypass changes"; + var ok = await RunOffUiAsync( + () => _interception.ReapplySystemProxyIfEnabled(), + StatusCancelToken); + StatusText = ok + ? "Excluded hosts saved (applies to new connections)" + : "Exclusions saved; re-toggle System proxy to apply OS bypass changes"; } else { @@ -992,7 +1100,7 @@ private async Task ExcludeHostAsync() private async Task ResetSettingsAsync() { var owner = TryGetMainWindow(); - if (!await AwaitCancellableAsync(_dialogs.ConfirmResetSettingsAsync(owner))) + if (!await AwaitDialogAsync(_dialogs.ConfirmResetSettingsAsync(owner))) { StatusText = "Reset settings cancelled"; return; @@ -1001,6 +1109,8 @@ private async Task ResetSettingsAsync() _settings.ResetToFactoryDefaults(); LoadFromSettings(); NotifySettingsUiChanged(); + // Defaults turn Decrypt off — bounce in case Avalonia left a OneWay CheckBox ticked. + _ = SnapDecryptHttpsUiAsync(); StatusText = "Settings restored to defaults — restart Inspector so retention limits fully apply. Root CA and sessions were not changed."; } @@ -1056,6 +1166,31 @@ public string ExclusionSummaryText public bool HasExclusionSummary => !string.IsNullOrEmpty(_exclusionSummaryText); + /// True while a request is paused on a breakpoint. + public bool HasActiveBreakpoint => Breakpoints.HasActiveHit; + + /// Compact banner for the Breakpoints pane while a hit is active. + public string BreakpointHitBanner => Breakpoints.HasActiveHit + ? Breakpoints.ActiveSummary + : ""; + + /// Toolbar CA trust / decrypt health pip (empty when decrypt is off). + public string DecryptTrustHealthText + { + get + { + if (!_decryptHttps) + return ""; + return _interception.IsRootTrusted + ? "CA trusted" + : "CA not trusted"; + } + } + + public bool ShowDecryptTrustHealth => _decryptHttps; + + public bool IsDecryptTrustHealthy => _decryptHttps && _interception.IsRootTrusted; + public string SelectedOpaqueHint => _selected is { IsTunnel: true } && _selected.OpaqueReason != OpaqueTunnelReason.None ? _selected.OpaqueReasonDisplay @@ -1114,6 +1249,22 @@ private set private Task ApplyEditBodyAsync() { + if (_selected?.ResponseBodyCapture == BodyCaptureState.Streaming + || _selected?.ResponseBodyStreamOpen == true + || _selected?.IsServerSentEvents == true) + { + SetGuardStatus("Cannot edit a streaming body"); + return Task.CompletedTask; + } + + if (!string.IsNullOrEmpty(BreakpointEditBody) + && BreakpointEditBody.Length > InspectorBodyLimits.MaxInlineToolBodyChars) + { + SetOutcomeStatus( + $"Breakpoint body is large ({SessionDisplayFormat.FormatByteSize(BreakpointEditBody.Length)}); applying anyway", + StatusSeverity.Warning); + } + Breakpoints.EditBody(BreakpointEditBody); StatusText = "Breakpoint body edit applied (Continue to send)"; return Task.CompletedTask; @@ -1178,6 +1329,7 @@ private Task ApplyEditBodyAsync() public ICommand DeleteAutoResponderRuleCommand { get; } public ICommand UpdateAutoResponderRuleCommand { get; } public ICommand BrowseAutoResponderLocalFileCommand { get; } + public ICommand FillGraphQlFromSelectedCommand { get; } public ICommand AddMapRemoteRuleCommand { get; } public ICommand DeleteMapRemoteRuleCommand { get; } public ICommand UpdateMapRemoteRuleCommand { get; } @@ -1186,6 +1338,12 @@ private Task ApplyEditBodyAsync() public ICommand ApplyEditBodyCommand { get; } public ICommand ToggleDebugLoggingCommand { get; } public ICommand CloseSessionDetailsCommand { get; } + public ICommand TogglePaneNavInspectCommand { get; } + public ICommand TogglePaneNavComposerCommand { get; } + public ICommand TogglePaneNavBreakpointsCommand { get; } + public ICommand TogglePaneNavAutoResponderCommand { get; } + public ICommand TogglePaneNavScriptsCommand { get; } + public ICommand TogglePaneNavMapRemoteCommand { get; } public ICommand OpenToolsComposerCommand { get; } public ICommand OpenToolsBreakpointsCommand { get; } public ICommand OpenToolsAutoResponderCommand { get; } @@ -1252,6 +1410,12 @@ public string? ScriptOnRequest if (SetField(ref _scriptOnRequest, value)) { _interception.ScriptOnRequest = value; + if (value is { Length: > InspectorBodyLimits.MaxScriptChars }) + { + SetOutcomeStatus( + $"On-request script is large ({SessionDisplayFormat.FormatByteSize(value.Length)})", + StatusSeverity.Warning); + } } } } @@ -1264,6 +1428,12 @@ public string? ScriptOnResponse if (SetField(ref _scriptOnResponse, value)) { _interception.ScriptOnResponse = value; + if (value is { Length: > InspectorBodyLimits.MaxScriptChars }) + { + SetOutcomeStatus( + $"On-response script is large ({SessionDisplayFormat.FormatByteSize(value.Length)})", + StatusSeverity.Warning); + } } } } @@ -1383,45 +1553,29 @@ public bool SystemProxy if (!_interception.IsRunning) { SetGuardStatus("Start the proxy before enabling system proxy"); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SystemProxy))); - return; - } - - if (!_interception.SetSystemProxy(true, _settings.Current)) - { - var detail = _interception.LastSystemProxyError; - var text = string.IsNullOrWhiteSpace(detail) - ? "Failed to enable system proxy (permissions, cancelled admin prompt, or unsupported desktop environment)" - : "Failed to enable system proxy: " + Truncate(detail, 180); - SetOutcomeStatus(text, StatusSeverity.Error, toastImportant: true); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SystemProxy))); + _ = SnapSystemProxyUiAsync(); return; } + // Optimistic check; WinINET runs off the UI thread (same hang risk as Stop). SetSystemProxyCore(true); - SetOutcomeStatus( - SystemProxyEnabledStatusMessage(), - StatusSeverity.Success, - toastImportant: OperatingSystem.IsWindows()); + SetStatus("Enabling system proxy…", StatusSeverity.Busy); + _ = ApplySystemProxyAsync(enable: true); return; } - if (_interception.IsRunning && _interception.SystemProxyEnabled && - !_interception.SetSystemProxy(false)) + SetSystemProxyCore(false); + // Always schedule restore when the proxy is up — SystemProxyEnabled may still be false + // while an optimistic enable is in flight; generation + lock cancel the enable safely. + if (_interception.IsRunning) { - var detail = _interception.LastSystemProxyError; - var text = string.IsNullOrWhiteSpace(detail) - ? "Failed to restore system proxy settings" - : "Failed to restore system proxy: " + Truncate(detail, 180); - SetOutcomeStatus(text, StatusSeverity.Error, toastImportant: true); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SystemProxy))); + SetStatus("Restoring system proxy…", StatusSeverity.Busy); + _ = ApplySystemProxyAsync(enable: false); return; } - SetSystemProxyCore(false); - SetOutcomeStatus( - SystemProxyRestoredStatus, - StatusSeverity.Success); + SetOutcomeStatus(SystemProxyRestoredStatus, StatusSeverity.Success); + _ = SnapSystemProxyUiAsync(); } } @@ -1429,11 +1583,160 @@ private void SetSystemProxyCore(bool enabled) { if (_systemProxy == enabled) { + SyncToggleVisual?.Invoke(nameof(SystemProxy), enabled); return; } _systemProxy = enabled; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SystemProxy))); + SyncToggleVisual?.Invoke(nameof(SystemProxy), enabled); + } + + /// + /// Avalonia 11.2 OneWay + ToggleButton: after a local click, same-value PropertyChanged is + /// ignored. Snap via SetCurrentValue — never flip the backing field (MenuItem Command can + /// fire and toggle DecryptHttps back on). + /// + private Task SnapProxyLoopbackUiAsync() => + BounceBoolBindingAsync( + get: () => _interception.ProxyLoopback, + propertyName: nameof(ProxyLoopback)); + + private Task SnapSystemProxyUiAsync() => + BounceBoolBindingAsync( + get: () => _systemProxy, + propertyName: nameof(SystemProxy)); + + private Task SnapDecryptHttpsUiAsync() => + BounceBoolBindingAsync( + get: () => _decryptHttps, + propertyName: nameof(DecryptHttps)); + + private async Task BounceBoolBindingAsync(Func get, string propertyName) + { + void Bounce() + { + var actual = get(); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + SyncToggleVisual?.Invoke(propertyName, actual); + } + + // Unit tests / no Avalonia app — bounce inline. + if (Application.Current is null) + { + Bounce(); + return; + } + + try + { + // Defer past ToggleButton.OnClick. Never hang if the dispatcher is not pumping + // (headless tests, or a stuck UI thread during CryptUI). + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Dispatcher.UIThread.Post(() => + { + try + { + Bounce(); + tcs.TrySetResult(); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + }, DispatcherPriority.Background); + + var finished = await Task.WhenAny(tcs.Task, Task.Delay(250, CancellationToken.None)).ConfigureAwait(true); + if (finished != tcs.Task) + { + Bounce(); + return; + } + + await tcs.Task.ConfigureAwait(true); + } + catch + { + Bounce(); + } + } + + /// + /// Applies or restores WinINET / OS system proxy off the UI thread. Reverts the checkbox on failure. + /// Last-write-wins via generation counter when the user toggles quickly or Stop invalidates applies. + /// + private async Task ApplySystemProxyAsync(bool enable) + { + var generation = Interlocked.Increment(ref _systemProxyApplyGeneration); + using var scope = InspectorUxTrace.Scope("ApplySystemProxy", $"enable={enable} gen={generation}"); + try + { + var ok = await RunOffUiAsync( + () => _interception.SetSystemProxy( + enable, + enable ? _settings.Current : null, + stillWanted: () => generation == Volatile.Read(ref _systemProxyApplyGeneration)), + StatusCancelToken).ConfigureAwait(false); + + if (generation != Volatile.Read(ref _systemProxyApplyGeneration)) + { + return; + } + + await MarshalToUiAsync(() => + { + if (generation != Volatile.Read(ref _systemProxyApplyGeneration)) + { + return; + } + + if (ok) + { + // Stop / uncheck may have cleared the UI intent while WinINET still reported success. + if (enable && (!_systemProxy || !_interception.IsRunning)) + { + return; + } + + if (enable) + { + SetOutcomeStatus( + SystemProxyEnabledStatusMessage(), + StatusSeverity.Success, + toastImportant: OperatingSystem.IsWindows()); + } + else + { + SetOutcomeStatus(SystemProxyRestoredStatus, StatusSeverity.Success); + } + + return; + } + + // Cancelled by stillWanted (superseded) — do not treat as user-visible failure. + if (string.IsNullOrEmpty(_interception.LastSystemProxyError)) + { + return; + } + + // Revert optimistic checkbox to match OS state. + SetSystemProxyCore(!enable); + _ = SnapSystemProxyUiAsync(); + var detail = _interception.LastSystemProxyError; + var text = enable + ? (string.IsNullOrWhiteSpace(detail) + ? "Failed to enable system proxy (permissions, cancelled admin prompt, or unsupported desktop environment)" + : "Failed to enable system proxy: " + Truncate(detail, 180)) + : (string.IsNullOrWhiteSpace(detail) + ? "Failed to restore system proxy settings" + : "Failed to restore system proxy: " + Truncate(detail, 180)); + SetOutcomeStatus(text, StatusSeverity.Error, toastImportant: true); + }, StatusCancelToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // status revert / shutdown + } } /// When true, localhost uses the system proxy (WinINET <-loopback> / Unix NO_PROXY parity). @@ -1452,15 +1755,65 @@ public bool ProxyLoopback _interception.SystemProxySettings = _settings.Current; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ProxyLoopback))); - if (SystemProxy && !_interception.ReapplySystemProxyIfEnabled()) + if (!SystemProxy) { - StatusText = "Capture local traffic saved; re-toggle System proxy to apply"; + StatusText = value + ? "Capture local traffic on — localhost uses the system proxy" + : "Capture local traffic off — localhost skips the system proxy"; return; } + // Optimistic UI; re-apply WinINET off the dispatcher. StatusText = value - ? "Capture local traffic on — localhost uses the system proxy" - : "Capture local traffic off — localhost skips the system proxy"; + ? "Capture local traffic on — applying…" + : "Capture local traffic off — applying…"; + var generation = Interlocked.Increment(ref _proxyLoopbackApplyGeneration); + _ = ReapplySystemProxyAfterLoopbackChangeAsync(value, generation); + } + } + + private async Task ReapplySystemProxyAfterLoopbackChangeAsync(bool loopbackDesired, int generation) + { + try + { + var ok = await RunOffUiAsync( + () => _interception.ReapplySystemProxyIfEnabled(), + StatusCancelToken).ConfigureAwait(false); + + if (generation != Volatile.Read(ref _proxyLoopbackApplyGeneration)) + { + return; + } + + await MarshalToUiAsync(() => + { + if (generation != Volatile.Read(ref _proxyLoopbackApplyGeneration)) + { + return; + } + + if (!ok) + { + // Revert optimistic checkbox + model so OneWay targets match reality. + _interception.ProxyLoopback = !loopbackDesired; + PersistSettings(); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ProxyLoopback))); + _ = SnapProxyLoopbackUiAsync(); + SetOutcomeStatus( + "Capture local traffic apply failed - checkbox restored", + StatusSeverity.Error, + toastImportant: true); + return; + } + + StatusText = loopbackDesired + ? "Capture local traffic on — localhost uses the system proxy" + : "Capture local traffic off — localhost skips the system proxy"; + }, StatusCancelToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // status revert / shutdown } } @@ -1555,23 +1908,54 @@ public bool DecryptHttps get => _decryptHttps; set // NOSONAR S4275 -- true path updates _decryptHttps via SetDecryptHttpsCore after async trust flow { - if (_decryptHttpsBusy || _decryptHttps == value) + if (_decryptHttps == value) { return; } - if (value) - { - _ = EnableDecryptHttpsAsync(); - } - else + if (!value) { + // Last-write-wins: invalidate in-flight enable / background re-verify. + Interlocked.Increment(ref _decryptEnableGeneration); + Interlocked.Increment(ref _decryptTrustVerifyGeneration); + _decryptHttpsBusy = false; SetDecryptHttpsCore(false); StatusText = "Decrypt HTTPS off — HTTPS shown as encrypted tunnels (not decrypted)"; + return; + } + + if (_trustCommandBusy || _decryptHttpsBusy) + { + // ToggleButton already flipped the CheckBox locally - snap back. + _ = SnapDecryptHttpsUiAsync(); + if (_trustCommandBusy) + SetGuardStatus("Another certificate action is already in progress"); + return; + } + + // Already capturing + trusted: optimistic check + MITM (no store Find on UI thread). + if (_interception.IsRunning && _interception.IsRootTrusted) + { + SetDecryptHttpsCore(true); + SetOutcomeStatus("Decrypting HTTPS", StatusSeverity.Success, toastImportant: true); + _ = ReverifyDecryptTrustInBackgroundAsync(); + return; } + + var enableGeneration = Interlocked.Increment(ref _decryptEnableGeneration); + // CheckBox/Menu ToggleButton flips IsChecked locally; Avalonia 11.2 OneWay will not + // accept a same-value PropertyChanged until we bounce (see SnapDecryptHttpsUiAsync). + _ = SnapDecryptHttpsUiAsync(); + _ = EnableDecryptHttpsAsync(enableGeneration); } } + private Task ToggleDecryptHttpsAsync() + { + DecryptHttps = !DecryptHttps; + return Task.CompletedTask; + } + /// When true, accept upstream TLS certs that would otherwise fail validation. public bool IgnoreServerCertificateErrors { @@ -1617,7 +2001,7 @@ public bool AddViaHeader /// True when this OS can resolve local client process ids for the Process column. public bool ShowProcessColumn { get; } - /// Right pane visibility (Inspect + Tools). Kept name for tests. + /// Right content pane visibility (Inspect / tools). Icon rail stays visible. public bool ShowSessionDetails { get => _showSessionDetails; @@ -1626,12 +2010,35 @@ public bool ShowSessionDetails if (SetField(ref _showSessionDetails, value)) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SessionDetailsPaneWidth))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SessionDetailsPaneMinWidth))); + NotifyPaneNavChrome(); } } } public GridLength SessionDetailsPaneWidth => - _showSessionDetails ? new GridLength(420) : new GridLength(0); + _showSessionDetails ? new GridLength(_sessionDetailsWidth) : new GridLength(0); + + /// Min width for the content column when open; 0 when closed so only the rail remains. + public double SessionDetailsPaneMinWidth => _showSessionDetails ? 280 : 0; + + public string PaneContentTitle => SelectedPaneNavIndex switch + { + 0 => "Inspect", + 1 => "Composer", + 2 => "Breakpoints", + 3 => "AutoResponder", + 4 => "Scripts", + 5 => "Map Remote", + _ => "Inspect", + }; + + public bool IsInspectRailPressed => _showSessionDetails && SelectedPaneNavIndex == 0; + public bool IsComposerRailPressed => _showSessionDetails && SelectedPaneNavIndex == 1; + public bool IsBreakpointsRailPressed => _showSessionDetails && SelectedPaneNavIndex == 2; + public bool IsAutoResponderRailPressed => _showSessionDetails && SelectedPaneNavIndex == 3; + public bool IsScriptsRailPressed => _showSessionDetails && SelectedPaneNavIndex == 4; + public bool IsMapRemoteRailPressed => _showSessionDetails && SelectedPaneNavIndex == 5; public bool HasSelectedSession => _selected is not null; @@ -1768,8 +2175,14 @@ public SessionSnapshot? SelectedSession if (value is not null && !_suppressOpenSessionDetails) { + var openingPane = !ShowSessionDetails; ShowSessionDetails = true; - SelectedOuterPaneIndex = 0; + // Opening the pane from a closed state lands on Inspect. While a tool is + // showing (Composer, etc.), selecting a session must not steal focus. + if (openingPane) + { + SelectedPaneNavIndex = 0; + } } UpdateWsFramesVisibility(); @@ -1789,14 +2202,78 @@ public SessionSnapshot? SelectedSession public string SelectedHex { get => _selectedHex; set => SetField(ref _selectedHex, value); } public string SelectedFrames { get => _selectedFrames; set => SetField(ref _selectedFrames, value); } - /// 0 = Inspect, 1 = Tools. + /// Vertical pane nav: 0 Inspect, 1 Composer, 2 Breakpoints, 3 AutoResponder, 4 Scripts, 5 Map Remote. + public int SelectedPaneNavIndex + { + get => _selectedPaneNavIndex; + set + { + var clamped = Math.Clamp(value, 0, 5); + if (!SetField(ref _selectedPaneNavIndex, clamped)) + { + return; + } + + if (clamped == 0) + { + _selectedOuterPaneIndex = 0; + } + else + { + _selectedOuterPaneIndex = 1; + _selectedToolsTabIndex = clamped - 1; + } + + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedOuterPaneIndex))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedToolsTabIndex))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedDetailTabIndex))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowInspectPane))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowComposerPane))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowBreakpointsPane))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowAutoResponderPane))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowScriptsPane))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowMapRemotePane))); + NotifyPaneNavChrome(); + } + } + + public bool ShowInspectPane => SelectedPaneNavIndex == 0; + public bool ShowComposerPane => SelectedPaneNavIndex == 1; + public bool ShowBreakpointsPane => SelectedPaneNavIndex == 2; + public bool ShowAutoResponderPane => SelectedPaneNavIndex == 3; + public bool ShowScriptsPane => SelectedPaneNavIndex == 4; + public bool ShowMapRemotePane => SelectedPaneNavIndex == 5; + + private void NotifyPaneNavChrome() + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(PaneContentTitle))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsInspectRailPressed))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsComposerRailPressed))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsBreakpointsRailPressed))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsAutoResponderRailPressed))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsScriptsRailPressed))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsMapRemoteRailPressed))); + } + + /// 0 = Inspect, 1 = Tools (compatibility). public int SelectedOuterPaneIndex { get => _selectedOuterPaneIndex; set { - if (SetField(ref _selectedOuterPaneIndex, value)) + var clamped = value <= 0 ? 0 : 1; + if (clamped == 0) + { + SelectedPaneNavIndex = 0; + } + else if (SelectedPaneNavIndex == 0) + { + SelectedPaneNavIndex = 1 + Math.Clamp(_selectedToolsTabIndex, 0, 4); + } + else { + _selectedOuterPaneIndex = 1; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedOuterPaneIndex))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedDetailTabIndex))); } } @@ -1811,6 +2288,10 @@ public int SelectedInspectTabIndex if (SetField(ref _selectedInspectTabIndex, value)) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedDetailTabIndex))); + if (value == 1) + { + RefreshSelectedInspectors(); + } } } } @@ -1821,32 +2302,45 @@ public int SelectedToolsTabIndex get => _selectedToolsTabIndex; set { - if (SetField(ref _selectedToolsTabIndex, value)) + var clamped = Math.Clamp(value, 0, 4); + if (SelectedPaneNavIndex == 0) { + SelectedPaneNavIndex = 1 + clamped; + return; + } + + if (SetField(ref _selectedToolsTabIndex, clamped)) + { + _selectedPaneNavIndex = 1 + clamped; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedPaneNavIndex))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedDetailTabIndex))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowComposerPane))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowBreakpointsPane))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowAutoResponderPane))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowScriptsPane))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowMapRemotePane))); } } } /// - /// Compatibility index for tests: 0–3 Inspect, 4–8 Tools (Composer…Map Remote). + /// Compatibility index for tests: 0–6 Inspect, 4–8 Tools (Composer…Map Remote) when on tools. /// public int SelectedDetailTabIndex { - get => SelectedOuterPaneIndex == 0 + get => SelectedPaneNavIndex == 0 ? SelectedInspectTabIndex - : 4 + SelectedToolsTabIndex; + : 4 + (SelectedPaneNavIndex - 1); set { if (value < 4) { - SelectedOuterPaneIndex = 0; + SelectedPaneNavIndex = 0; SelectedInspectTabIndex = Math.Clamp(value, 0, 6); } else { - SelectedOuterPaneIndex = 1; - SelectedToolsTabIndex = Math.Clamp(value - 4, 0, 4); + SelectedPaneNavIndex = 1 + Math.Clamp(value - 4, 0, 4); } PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedDetailTabIndex))); @@ -2023,12 +2517,68 @@ private Task CloseSessionDetailsAsync() return Task.CompletedTask; } + /// + /// Assign without opening the details pane + /// (Composer Send, context-menu prep, filter restore). + /// + public void SelectSessionWithoutOpeningDetails(SessionSnapshot? snap) + { + using (SuppressOpenSessionDetails()) + { + SelectedSession = snap; + } + } + + /// + /// Suppress open-on-select for the duration of a DataGrid selection write + /// (e.g. right-click selecting a row for a context menu). + /// + public IDisposable SuppressOpenSessionDetails() + { + _suppressOpenSessionDetails = true; + return new OpenSessionDetailsSuppressor(this); + } + + private sealed class OpenSessionDetailsSuppressor : IDisposable + { + private MainWindowViewModel? _owner; + + public OpenSessionDetailsSuppressor(MainWindowViewModel owner) => _owner = owner; + + public void Dispose() + { + if (_owner is null) + { + return; + } + + _owner._suppressOpenSessionDetails = false; + _owner = null; + } + } + + /// + /// Icon-rail toggle: same icon while open closes content; otherwise select + open. + /// Does not rely on SelectedIndex re-selection (SetField would no-op). + /// + private Task TogglePaneNavAsync(int paneNavIndex) + { + var clamped = Math.Clamp(paneNavIndex, 0, 5); + if (ShowSessionDetails && SelectedPaneNavIndex == clamped) + { + ShowSessionDetails = false; + return Task.CompletedTask; + } + + SelectedPaneNavIndex = clamped; + ShowSessionDetails = true; + return Task.CompletedTask; + } + private Task OpenToolsTabAsync(int toolsTabIndex) { + SelectedPaneNavIndex = 1 + Math.Clamp(toolsTabIndex, 0, 4); ShowSessionDetails = true; - SelectedOuterPaneIndex = 1; - SelectedToolsTabIndex = Math.Clamp(toolsTabIndex, 0, 4); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedDetailTabIndex))); return Task.CompletedTask; } @@ -2096,14 +2646,20 @@ private void RefreshSelectedInspectors() if (_selected is null) { SelectedHeaders = SelectedBody = SelectedHex = SelectedFrames = ""; + BodyCaptureHint = ""; + HexCaptureHint = ""; + BodyPreviewBitmap = null; + _cachedPrettyBody = null; + _cachedPrettySessionId = null; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedOpaqueHint))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowSelectedOpaqueHint))); + NotifySaveBodyCanExecute(); return; } SelectedHeaders = BuildSelectedHeadersText(_selected); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedOpaqueHint))); - SelectedBody = BuildSelectedBodyText(_selected); + RefreshBodyInspector(); SelectedHex = SessionInspectors.FormatLabeledHex( _selected.RequestHeadersText, _selected.ResponseHeadersText, @@ -2161,15 +2717,52 @@ private static void AppendNameValues( sb.Append(pair.Key).Append('=').AppendLine(pair.Value); } - private static string BuildSelectedBodyText(SessionSnapshot selected) + private string BuildSelectedBodyText(SessionSnapshot selected) + { + if (_bodyPrettyMode + && _cachedPrettySessionId == selected.Id + && _cachedPrettyBody is not null + && SelectedInspectTabIndex == 1) + { + return AppendTranscodePrefix(selected, _cachedPrettyBody); + } + + var body = BuildSelectedBodyTextCore(selected, _bodyPrettyMode && SelectedInspectTabIndex == 1); + if (_bodyPrettyMode && SelectedInspectTabIndex == 1) + { + var reqCt = SessionInspectors.ParseHeaderBlock(selected.RequestHeadersText) + .TryGetValue("Content-Type", out var rct) ? rct : null; + var respCt = selected.ContentType + ?? (SessionInspectors.ParseHeaderBlock(selected.ResponseHeadersText) + .TryGetValue("Content-Type", out var sct) ? sct : null); + if ((InspectorBodyLimits.IsPrettyPrintableContentType(reqCt) + || InspectorBodyLimits.IsPrettyPrintableContentType(respCt)) + && InspectorBodyLimits.TryPrettyPrint(selected.RequestBodyText, reqCt) is null + && InspectorBodyLimits.TryPrettyPrint(selected.ResponseBodyText, respCt) is null + && (selected.RequestBodyCapture is BodyCaptureState.Truncated + || selected.ResponseBodyCapture is BodyCaptureState.Truncated + || !string.IsNullOrWhiteSpace(selected.RequestBodyText) + || !string.IsNullOrWhiteSpace(selected.ResponseBodyText))) + { + if (string.IsNullOrEmpty(BodyCaptureHint)) + { + BodyCaptureHint = "Cannot pretty-print (body truncated or invalid)"; + } + else if (!BodyCaptureHint.Contains("pretty-print", StringComparison.OrdinalIgnoreCase)) + { + BodyCaptureHint += " · Cannot pretty-print (body truncated or invalid)"; + } + } + + _cachedPrettySessionId = selected.Id; + _cachedPrettyBody = body; + } + + return AppendTranscodePrefix(selected, body); + } + + private static string AppendTranscodePrefix(SessionSnapshot selected, string body) { - var body = SessionInspectors.FormatLabeledBody( - selected.RequestHeadersText, - selected.ResponseHeadersText, - selected.RequestBodyText, - selected.ResponseBodyText, - selected.RequestBodyBytes, - selected.ResponseBodyBytes); if (!selected.IsTranscoded) return body; @@ -2273,6 +2866,14 @@ private Task ExitAsync() private async Task StartCaptureAsync() { + if (_startBusy || _interception.IsRunning) + { + return; + } + + using var scope = InspectorUxTrace.Scope("StartCapture", $"{BindAddress}:{BindPort}"); + InspectorUxTrace.Event("UxTrace.Path", InspectorUxTrace.LogFilePath); + _startBusy = true; var address = ParseBindAddress(BindAddress); PersistSettings(); _interception.BreakpointOnResponse = BreakpointOnResponse; @@ -2283,41 +2884,61 @@ private async Task StartCaptureAsync() _interception.DecryptHttps = _decryptHttps; _interception.ConfigureLogging(_settings.Current); SetStatus("Starting proxy…", StatusSeverity.Busy); - await _interception.StartAsync(address, BindPort, _statusRevertCts?.Token ?? CancellationToken.None); - if (_interception.BoundPort > 0) + var token = StatusCancelToken; + var port = BindPort; + try { - BindPort = _interception.BoundPort; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(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( + async () => await _interception.StartAsync(address, port, token).ConfigureAwait(false), + token); - Capturing = true; - RefreshEndpointAndBindUi(); + // Stay on UI sync context when present so StatusText / Capturing bind correctly. + // (ConfigureAwait(false) here left StatusText stuck on Busy in production.) + if (_interception.BoundPort > 0) + { + BindPort = _interception.BoundPort; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BindPort))); + } - var wantSystemProxy = _reenableSystemProxyOnStart || AutoSystemProxyOnStart; - _reenableSystemProxyOnStart = false; - var showedSystemProxyGuidance = false; - if (wantSystemProxy && !SystemProxy) - { - SystemProxy = true; - showedSystemProxyGuidance = SystemProxy; - } + Capturing = true; + RefreshEndpointAndBindUi(); - // If settings asked for decrypt but CA is gone, fall back to CONNECT (no silent re-trust). - if (_decryptHttps && !_interception.RefreshTrustState()) - { - SetDecryptHttpsCore(false); - SetStatus( - SystemProxy - ? $"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.", - StatusSeverity.Warning); - return; - } + var wantSystemProxy = _reenableSystemProxyOnStart || AutoSystemProxyOnStart; + _reenableSystemProxyOnStart = false; + var showedSystemProxyGuidance = false; + if (wantSystemProxy && !SystemProxy) + { + // Optimistic SystemProxy path — WinINET applies off UI. + SystemProxy = true; + showedSystemProxyGuidance = SystemProxy; + } + + // Trust was refreshed during StartAsync — do not open the Root store again on the UI thread. + if (_decryptHttps && !_interception.IsRootTrusted) + { + await ForceDecryptHttpsOffAsync(); + SetStatus( + SystemProxy + ? $"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.", + StatusSeverity.Warning); + return; + } - // Keep the system-proxy restart guidance visible; do not replace it with Ready. - if (!showedSystemProxyGuidance) + // Keep the system-proxy restart guidance visible; do not replace it with Ready. + // Also do not clobber a newer status if the user already acted during start + // (Install CA / Decrypt can finish while StartCaptureAsync is still awaiting UI marshal). + if (!showedSystemProxyGuidance && + (IsStatusBusy || StatusText.StartsWith("Starting proxy", StringComparison.Ordinal))) + { + SetSteadyStatus(StatusReady); + } + } + finally { - SetSteadyStatus(StatusReady); + _startBusy = false; } } diff --git a/src/Titanium.Inspector/ViewModels/MapRemoteViewModel.cs b/src/Titanium.Inspector/ViewModels/MapRemoteViewModel.cs index c214bbd9b..dac24482d 100644 --- a/src/Titanium.Inspector/ViewModels/MapRemoteViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/MapRemoteViewModel.cs @@ -231,7 +231,7 @@ public string Display { get { - var gql = string.IsNullOrWhiteSpace(GraphQlOperationName) ? string.Empty : $" gql:{GraphQlOperationName}"; + var gql = string.IsNullOrWhiteSpace(GraphQlOperationName) ? string.Empty : $" GraphQL:{GraphQlOperationName}"; return $"{(Enabled ? "✓" : "✗")} {MatchUrl}{gql} → {TargetUrl}"; } } diff --git a/src/Titanium.Inspector/Views/ExcludedHostsWindow.axaml.cs b/src/Titanium.Inspector/Views/ExcludedHostsWindow.axaml.cs index 322945d33..8eb0e52a8 100644 --- a/src/Titanium.Inspector/Views/ExcludedHostsWindow.axaml.cs +++ b/src/Titanium.Inspector/Views/ExcludedHostsWindow.axaml.cs @@ -1,5 +1,7 @@ +using Avalonia; using Avalonia.Controls; using Avalonia.Interactivity; +using Avalonia.Threading; using Titanium.Inspector.Services; using Titanium.Web.Proxy.Models; @@ -12,6 +14,7 @@ public partial class ExcludedHostsWindow : Window private readonly Action? _onSaved; private readonly InterceptionService? _interception; private bool _saved; + private bool _subscribed; public ExcludedHostsWindow() : this(SettingsService.Load(), readOnly: false, null, null) { @@ -57,6 +60,8 @@ public ExcludedHostsWindow( CancelButton.Click += (_, _) => Close(); UpdateLearningPausedUi(); + SubscribeLearnedUpdates(); + Closed += (_, _) => UnsubscribeLearnedUpdates(); } public bool Saved => _saved; @@ -91,19 +96,75 @@ private void UpdateLearningPausedUi() LearnedList.Opacity = on ? 1 : 0.55; } - private void RefreshLearnedList() + private void SubscribeLearnedUpdates() { + if (_interception is null || _subscribed) + return; + _interception.DecryptFailureBypassLearned += OnDecryptFailureBypassLearned; + _subscribed = true; + } + + private void UnsubscribeLearnedUpdates() + { + if (_interception is null || !_subscribed) + return; + _interception.DecryptFailureBypassLearned -= OnDecryptFailureBypassLearned; + _subscribed = false; + } + + private void OnDecryptFailureBypassLearned(object? sender, DecryptFailureBypassEntry entry) + { + // Preserve Bypass / Not decrypted text boxes — only refresh the learned list. + MarshalToUi(() => RefreshLearnedList(preferHost: entry.Host)); + } + + private static void MarshalToUi(Action action) + { + if (Application.Current is null || Dispatcher.UIThread.CheckAccess()) + { + action(); + return; + } + + Dispatcher.UIThread.Post(action); + } + + /// + /// Rebuilds the learned ListBox from the live cache. Preserves selection by hostname; + /// when is set and nothing was selected, selects that host. + /// + internal void RefreshLearnedList(string? preferHost = null) + { + var previousHost = SelectedLearned()?.Host; var entries = _interception?.GetDecryptFailureBypassEntries() ?? Array.Empty(); var active = entries.Where(e => e.BypassActive).ToList(); LearnedList.ItemsSource = active - .Select(e => $"{e.Host} · Decrypt failure · {e.LearnedAtUtc:u}") + .Select(e => FormatLearnedDisplay(e)) .ToList(); LearnedList.Tag = active; LearnedEmptyText.IsVisible = active.Count == 0; LearnedList.IsVisible = active.Count > 0; + + var selectHost = previousHost ?? preferHost; + if (selectHost is null || active.Count == 0) + return; + + var idx = active.FindIndex(e => + string.Equals(e.Host, selectHost, StringComparison.OrdinalIgnoreCase)); + if (idx < 0) + return; + + LearnedList.SelectedIndex = idx; + if (previousHost is null && preferHost is not null && LearnedList.SelectedItem is not null) + { + LearnedList.ScrollIntoView(LearnedList.SelectedItem); + } } + internal static string FormatLearnedDisplay(DecryptFailureBypassEntry e) => + $"{e.Host} · Decrypt failure · {e.LearnedAtUtc:u}"; + private DecryptFailureBypassEntry? SelectedLearned() { if (LearnedList.Tag is not List list) diff --git a/src/Titanium.Inspector/Views/LoggingSettingsWindow.axaml b/src/Titanium.Inspector/Views/LoggingSettingsWindow.axaml index 87eb71b6e..cf4154513 100644 --- a/src/Titanium.Inspector/Views/LoggingSettingsWindow.axaml +++ b/src/Titanium.Inspector/Views/LoggingSettingsWindow.axaml @@ -39,6 +39,8 @@ + diff --git a/src/Titanium.Inspector/Views/LoggingSettingsWindow.axaml.cs b/src/Titanium.Inspector/Views/LoggingSettingsWindow.axaml.cs index e91b892b9..d76df8bfa 100644 --- a/src/Titanium.Inspector/Views/LoggingSettingsWindow.axaml.cs +++ b/src/Titanium.Inspector/Views/LoggingSettingsWindow.axaml.cs @@ -68,7 +68,7 @@ private async void OnBrowse(object? sender, RoutedEventArgs e) new FilePickerFileType("All") { Patterns = ["*.*"] }, ], }); - if (file?.TryGetLocalPath() is { } path) + if (InspectorPathPickerHelpers.NormalizePickedPath(file?.TryGetLocalPath()) is { } path) { PathBox.Text = path; } diff --git a/src/Titanium.Inspector/Views/MainWindow.axaml b/src/Titanium.Inspector/Views/MainWindow.axaml index 5c801d603..2b5b916ad 100644 --- a/src/Titanium.Inspector/Views/MainWindow.axaml +++ b/src/Titanium.Inspector/Views/MainWindow.axaml @@ -38,16 +38,16 @@ IsChecked="{Binding AutoSystemProxyOnStart, Mode=OneWay}" Command="{Binding ToggleAutoSystemProxyOnStartCommand}" AutomationProperties.AutomationId="AutoSystemProxyCheck" /> - - - - + @@ -132,11 +132,12 @@ - + - + + - @@ -163,10 +164,15 @@ - + - @@ -200,6 +206,11 @@ + @@ -222,6 +235,12 @@ + + - + + + + + + + + + _notificationManager)); @@ -224,6 +234,38 @@ private void OnDataContextChanged(object? sender, EventArgs e) HookThemeVariantChanged(); } + /// + /// Avalonia 11.2: CheckBox/MenuItem toggle severs OneWay IsChecked bindings (SetValue). + /// Push visuals with SetCurrentValue whenever Decrypt/SystemProxy/ProxyLoopback change. + /// + private void HookOneWayToggleVisualSync(MainWindowViewModel? vm) + { + if (_toggleSyncVm is not null) + _toggleSyncVm.SyncToggleVisual = null; + + _toggleSyncVm = vm; + if (vm is null) + return; + + vm.SyncToggleVisual = (propertyName, isChecked) => + { + if (propertyName == nameof(MainWindowViewModel.DecryptHttps)) + { + OneWayToggleVisualSync.Apply(DecryptHttpsCheck, isChecked); + OneWayToggleVisualSync.Apply(MenuDecryptHttps, isChecked); + } + else if (propertyName == nameof(MainWindowViewModel.SystemProxy)) + { + OneWayToggleVisualSync.Apply(SystemProxyCheck, isChecked); + OneWayToggleVisualSync.Apply(MenuToggleSystemProxy, isChecked); + } + else if (propertyName == nameof(MainWindowViewModel.ProxyLoopback)) + { + OneWayToggleVisualSync.Apply(MenuProxyLocalhost, isChecked); + } + }; + } + private void HookThemeVariantChanged(bool unhook = false) { if (Application.Current is not { } app) diff --git a/src/Titanium.Inspector/Views/OneWayToggleVisualSync.cs b/src/Titanium.Inspector/Views/OneWayToggleVisualSync.cs new file mode 100644 index 000000000..b6961da96 --- /dev/null +++ b/src/Titanium.Inspector/Views/OneWayToggleVisualSync.cs @@ -0,0 +1,38 @@ +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Threading; + +namespace Titanium.Inspector.Views; + +/// +/// Avalonia 11.2 ToggleButton/MenuItem click uses SetValue on IsChecked, which +/// severs a OneWay binding. After that, ViewModel PropertyChanged never moves the glyph. +/// keeps (or restores) the visual without replacing the binding. +/// Fixed upstream in Avalonia 12 / #17674 — remove when Inspector upgrades past that. +/// +internal static class OneWayToggleVisualSync +{ + public static void Apply(CheckBox? box, bool isChecked) + { + if (box is null) + return; + + void Set() => box.SetCurrentValue(ToggleButton.IsCheckedProperty, isChecked); + if (Dispatcher.UIThread.CheckAccess()) + Set(); + else + Dispatcher.UIThread.Post(Set, DispatcherPriority.Input); + } + + public static void Apply(MenuItem? item, bool isChecked) + { + if (item is null) + return; + + void Set() => item.SetCurrentValue(MenuItem.IsCheckedProperty, isChecked); + if (Dispatcher.UIThread.CheckAccess()) + Set(); + else + Dispatcher.UIThread.Post(Set, DispatcherPriority.Input); + } +} diff --git a/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml b/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml index 5ba96473b..f3ff23734 100644 --- a/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml +++ b/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml @@ -8,7 +8,7 @@ CanResize="False"> + Text="Keep Inspector from running out of memory during long captures. Oldest sessions are removed first when limits are hit. Changes apply immediately on Save." />