diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 22c510ba3..6753dfb6e 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -1,16 +1,13 @@ name: Deploy website on: + # Always on product-channel and develop pushes so download.data.ts rebuilds when + # beta/stable merges land (those merges often do not touch website/**). push: branches: - develop - paths: - - 'website/**' - - 'wiki/images/**' - - 'docs/api/**' - - 'docs/styles/**' - - 'docs/fonts/**' - - '.github/workflows/deploy-website.yml' + - beta + - stable pull_request: branches: - develop @@ -21,6 +18,8 @@ on: - 'docs/styles/**' - 'docs/fonts/**' - '.github/workflows/deploy-website.yml' + # Human-published releases. Actions GITHUB_TOKEN `gh release create` does not + # cascade this event — release.yml also dispatches this workflow after assets. release: types: [published] workflow_dispatch: @@ -77,6 +76,29 @@ jobs: echo "Warning: docs/api missing; API pages will not be published" fi + # GitHub Pages + VitePress cleanUrls: /download sometimes keeps a stale + # object while /download.html updates. Publish both shapes so the nav link works. + - name: Mirror cleanUrls HTML as index.html + shell: bash + run: | + set -euo pipefail + dist=website/.vitepress/dist + for f in "$dist"/*.html; do + base="$(basename "$f" .html)" + [[ "$base" == "index" || "$base" == "404" ]] && continue + mkdir -p "$dist/$base" + cp -f "$f" "$dist/$base/index.html" + done + # Nested docs pages + if [ -d "$dist/docs" ]; then + find "$dist/docs" -name '*.html' ! -name 'index.html' ! -name '404.html' | while read -r f; do + base="$(basename "$f" .html)" + dir="$(dirname "$f")" + mkdir -p "$dir/$base" + cp -f "$f" "$dir/$base/index.html" + done + fi + - name: Upload Pages artifact if: github.event_name != 'pull_request' uses: actions/upload-pages-artifact@v3 @@ -84,7 +106,14 @@ jobs: path: website/.vitepress/dist deploy: - if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/develop' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') + if: > + github.event_name != 'pull_request' && ( + github.ref == 'refs/heads/develop' || + github.ref == 'refs/heads/beta' || + github.ref == 'refs/heads/stable' || + github.event_name == 'release' || + github.event_name == 'workflow_dispatch' + ) needs: build runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8ce7b332..7248d2238 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -249,6 +249,13 @@ jobs: dotnet publish src/Titanium.Inspector/Titanium.Inspector.csproj -c Release -r $rid --self-contained true -o $out ./tools/packaging/bundle-http3-native.ps1 -Rid $rid -PublishDir $out Copy-Item -Force tools/packaging/THIRD-PARTY-INSPECTOR.txt (Join-Path $out "THIRD-PARTY-INSPECTOR.txt") + Copy-Item -Force src/Titanium.Inspector/Assets/app.ico (Join-Path $out "app.ico") + if ($rid -like 'linux*') { + Copy-Item -Force tools/packaging/linux/install.sh, tools/packaging/linux/uninstall.sh, tools/packaging/linux/TitaniumInspector.desktop.in $out + } + if ($rid -like 'osx*') { + Copy-Item -Force tools/packaging/osx/install-app.sh, tools/packaging/osx/uninstall-app.sh $out + } if ($IsWindows) { Compress-Archive -Path "$out/*" -DestinationPath "TitaniumInspector-$rid.zip" } else { @@ -330,6 +337,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + actions: write steps: - uses: actions/checkout@v6 with: @@ -444,3 +452,19 @@ jobs: fi rm -f "$NOTES_FILE" fi + + - name: Refresh website download links + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_CHANNEL: ${{ needs.resolve-version.outputs.channel }} + run: | + set -euo pipefail + # GITHUB_TOKEN `gh release create` does not fire `release: published` for + # other workflows. Rebuild Pages so download.data.ts sees new zip/MSI assets. + REF=develop + case "$RELEASE_CHANNEL" in + beta) REF=beta ;; + stable) REF=stable ;; + esac + echo "Dispatching deploy-website.yml --ref $REF (channel=$RELEASE_CHANNEL)" + gh workflow run deploy-website.yml --ref "$REF" diff --git a/.gitignore b/.gitignore index f6b9e9ab1..44a04bfb6 100644 --- a/.gitignore +++ b/.gitignore @@ -231,4 +231,6 @@ website/.vitepress/cache/ # HTTP/3 native download cache (CI / local packaging) tools/packaging/.cache/ +# WiX extension cache from `dotnet wix extension add` +tools/packaging/wix/.wix/ diff --git a/README.md b/README.md index 39e92e074..5a5bbef25 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ A lightweight, high-performance HTTP(S) proxy — reverse / edge CLI, desktop In | **Titanium.Cli** (`titanium` / `twp`) | Standalone reverse / edge proxy for any stack: `run`, `test`, `version`, `update` | [Download (Windows, Linux & Mac)](https://titaniumproxy.com/download#cli) | | **Titanium Inspector** | Desktop MITM debugger (session grid, inspectors, AutoResponder, breakpoints, HAR) | [Download (Windows, Linux & Mac)](https://titaniumproxy.com/download#inspector) | | **Titanium.Plus** | Optional advanced features: control plane, ops, observability, and dashboard | After installing CLI, run `titanium update --plus` | -| **Titanium.Web.Proxy** | Core library. Embed a MITM and/or reverse proxy in a .NET app | [NuGet](https://www.nuget.org/packages/Titanium.Web.Proxy/7.0.2-beta) (`dotnet add package Titanium.Web.Proxy --prerelease`) | +| **Titanium.Web.Proxy** | Core library. Embed a MITM and/or reverse proxy in a .NET app | [NuGet](https://www.nuget.org/packages/Titanium.Web.Proxy/7.0.3-beta) (`dotnet add package Titanium.Web.Proxy --prerelease`) | CLI and Plus target reverse-proxy / edge workloads (routing, load balancing, health, discovery) on Windows, Linux, and macOS. Inspector is the MITM debugging product. The Core library is the embed path for .NET. Requires .NET 10 or later. @@ -67,7 +67,7 @@ On Windows, **winget is stable-only**: winget install justcoding121.TitaniumCli ``` -For **beta**, download self-contained zips from [Download](https://titaniumproxy.com/download) / [GitHub Releases](https://github.com/justcoding121/titanium-web-proxy/releases) when a product release includes `Titanium.Cli-*.zip` assets (e.g. `v7.0.2-beta`). Extract and run: +For **beta**, download self-contained zips from [Download](https://titaniumproxy.com/download) / [GitHub Releases](https://github.com/justcoding121/titanium-web-proxy/releases) when a product release includes `Titanium.Cli-*.zip` assets (e.g. `v7.0.3-beta`). Extract and run: ```shell titanium run -c twp.yaml @@ -82,7 +82,7 @@ Optional Plus: run `titanium update --plus` (add `--channel beta` for prerelease ### Titanium Inspector -Prefer [Download](https://titaniumproxy.com/download). On Windows, winget id `justcoding121.TitaniumInspector` is **stable-only**; MSI / portable zip for beta come from the product `v*` release (e.g. `v7.0.2-beta`). Start interception from the Capture menu, install the root CA, then toggle system proxy. +Prefer [Download](https://titaniumproxy.com/download). On Windows, winget id `justcoding121.TitaniumInspector` is **stable-only**; MSI / portable zip for beta come from the product `v*` release (e.g. `v7.0.3-beta`). Start interception from the Capture menu, install the root CA, then toggle system proxy. ## Quick start diff --git a/src/Titanium.Cli/Titanium.Cli.csproj b/src/Titanium.Cli/Titanium.Cli.csproj index b42c8fcb6..e7d35c46c 100644 --- a/src/Titanium.Cli/Titanium.Cli.csproj +++ b/src/Titanium.Cli/Titanium.Cli.csproj @@ -7,7 +7,7 @@ latest enable false - 7.0.2 + 7.0.3 Jehonathan Thomas Titanium Web Proxy CLI (titanium / twp). MIT diff --git a/src/Titanium.Cli/Updates/VersionAndUpdateCommands.cs b/src/Titanium.Cli/Updates/VersionAndUpdateCommands.cs index 5977984f3..3b6f92667 100644 --- a/src/Titanium.Cli/Updates/VersionAndUpdateCommands.cs +++ b/src/Titanium.Cli/Updates/VersionAndUpdateCommands.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Reflection; using System.Security.Cryptography; +using System.Text; using System.Text.Json; using Titanium.Cli; using Titanium.Cli.Config; @@ -15,6 +16,7 @@ public static async Task ExecuteAsync(string[] args) var check = args.Contains("--check", StringComparer.OrdinalIgnoreCase); var plus = args.Contains("--plus", StringComparer.OrdinalIgnoreCase); var channel = ParseChannel(args); + var channelDisplay = FormatChannel(channel); PrintLocalVersions(plus); @@ -33,17 +35,17 @@ public static async Task ExecuteAsync(string[] args) var local = typeof(Program).Assembly.GetName().Version ?? new Version(0, 0); var remote = Version.TryParse(StripPrerelease(manifest.Version), out var v) ? v : new Version(0, 0); - AsyncConsole.WriteLine($"Remote Cli ({channel}): {manifest.Version}"); + AsyncConsole.WriteLine($"Remote Cli ({channelDisplay}): {manifest.Version}"); var exit = 0; if (remote > local) { - AsyncConsole.WriteLine("A newer Cli build is available. Run: titanium update"); + AsyncConsole.WriteLine($"A newer Cli build is available ({channelDisplay}). Run: titanium update --channel {channel}"); exit = 2; } else { - AsyncConsole.WriteLine("Cli is up to date."); + AsyncConsole.WriteLine($"Cli is up to date ({channelDisplay})."); } if (plus) @@ -54,12 +56,13 @@ public static async Task ExecuteAsync(string[] args) Version.TryParse(StripPrerelease(plusRemote), out var pr) && (plusLocal is null || pr > plusLocal)) { - AsyncConsole.WriteLine($"A newer Plus build is available ({plusRemote}). Run: titanium update --plus"); + AsyncConsole.WriteLine( + $"A newer Plus build is available ({plusRemote}, {channelDisplay}). Run: titanium update --plus --channel {channel}"); exit = 2; } else if (plusLocal is not null) { - AsyncConsole.WriteLine($"Plus is up to date ({plusLocal})."); + AsyncConsole.WriteLine($"Plus is up to date ({plusLocal}, {channelDisplay})."); } } @@ -121,13 +124,16 @@ internal static string ParseChannel(string[] args) { if (args[i] is "--channel" && i + 1 < args.Length) { - return args[i + 1]; + return args[i + 1].Trim().ToLowerInvariant(); } } - return Environment.GetEnvironmentVariable("TITANIUM_UPDATE_CHANNEL") ?? "stable"; + return (Environment.GetEnvironmentVariable("TITANIUM_UPDATE_CHANNEL") ?? "stable").Trim().ToLowerInvariant(); } + internal static string FormatChannel(string channel) => + channel.Equals("beta", StringComparison.OrdinalIgnoreCase) ? "beta" : "stable"; + internal static string StripPrerelease(string? version) { if (string.IsNullOrEmpty(version)) @@ -135,8 +141,9 @@ internal static string StripPrerelease(string? version) return "0.0.0"; } - var dash = version.IndexOf('-'); - return dash > 0 ? version[..dash] : version.TrimStart('v'); + var trimmed = version.TrimStart('v'); + var dash = trimmed.IndexOf('-'); + return dash > 0 ? trimmed[..dash] : trimmed; } } @@ -146,6 +153,12 @@ public static async Task ExecuteAsync(string[] args) { var plus = args.Contains("--plus", StringComparer.OrdinalIgnoreCase); var channel = VersionCommand.ParseChannel(args); + var channelDisplay = VersionCommand.FormatChannel(channel); + + AsyncConsole.WriteLine(plus + ? $"Checking Plus updates ({channelDisplay})…" + : $"Checking for updates ({channelDisplay})…"); + var client = new UpdateFeedClient(channel); var manifest = await client.TryGetManifestAsync(); if (manifest is null) @@ -156,11 +169,20 @@ public static async Task ExecuteAsync(string[] args) if (plus) { - return await UpdatePlusAsync(manifest); + return await UpdatePlusAsync(manifest, channelDisplay); } - if (OperatingSystem.IsWindows() && await TryUpdateViaWingetAsync()) + return await UpdateCliAsync(manifest, channelDisplay); + } + + private static async Task UpdateCliAsync(ReleaseManifest manifest, string channelDisplay) + { + var local = typeof(Program).Assembly.GetName().Version ?? new Version(0, 0); + var remoteText = manifest.Version?.TrimStart('v') ?? "0.0.0"; + var remote = Version.TryParse(VersionCommand.StripPrerelease(remoteText), out var v) ? v : new Version(0, 0); + if (remote <= local) { + AsyncConsole.WriteLine($"Titanium CLI is up to date ({channelDisplay})."); return 0; } @@ -168,17 +190,24 @@ public static async Task ExecuteAsync(string[] args) var asset = manifest.Products?.Cli?.Assets?.GetValueOrDefault(rid); if (asset?.Url is null) { - AsyncConsole.WriteLine($"Cli update ({channel}): re-download Titanium.Cli-{rid}.zip from GitHub Releases."); - AsyncConsole.WriteLine($"Remote version: {manifest.Version}"); - return 0; + AsyncConsole.WriteError( + $"No CLI package for RID {rid} on channel {channelDisplay} (remote {remoteText})."); + return 1; } - var destZip = Path.Combine(Path.GetTempPath(), $"Titanium.Cli-{rid}-{manifest.Version}.zip"); - AsyncConsole.WriteLine($"Downloading {asset.Url}"); - using (var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }) + AsyncConsole.WriteLine($"Update {remoteText} ({channelDisplay}) is available. Installing…"); + AsyncConsole.WriteLine("Downloading…"); + + var workDir = Path.Combine(Path.GetTempPath(), "TitaniumCli-update"); + Directory.CreateDirectory(workDir); + var destZip = Path.Combine(workDir, $"Titanium.Cli-{rid}-{remoteText}.zip"); + + try { + using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; http.DefaultRequestHeaders.UserAgent.ParseAdd("Titanium.Cli/7.0"); var bytes = await http.GetByteArrayAsync(asset.Url); + AsyncConsole.WriteLine("Verifying SHA256…"); if (!string.IsNullOrEmpty(asset.Sha256)) { var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); @@ -191,118 +220,186 @@ public static async Task ExecuteAsync(string[] args) await File.WriteAllBytesAsync(destZip, bytes); } - - AsyncConsole.WriteLine($"Downloaded to {destZip}. Extract over the install directory and restart."); - AsyncConsole.WriteLine("If this process is locked, stop titanium and unzip manually."); - return 0; - } - - private static async Task TryUpdateViaWingetAsync() - { - try + catch (Exception ex) { - var winget = ResolveWingetPath(); - if (winget is null) - { - throw new FileNotFoundException("winget.exe not found"); - } - - var psi = new ProcessStartInfo(winget, "upgrade --id justcoding121.TitaniumCli -e --accept-package-agreements --accept-source-agreements") - { - UseShellExecute = false, - }; - using var proc = Process.Start(psi); - if (proc is not null) - { - await proc.WaitForExitAsync(); - if (proc.ExitCode == 0) - { - AsyncConsole.WriteLine("Updated via winget."); - return true; - } - } + AsyncConsole.WriteError($"Download failed: {ex.Message}"); + return 1; } - catch + + var installDir = AppContext.BaseDirectory.TrimEnd( + Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var exeName = OperatingSystem.IsWindows() ? "titanium.exe" : "titanium"; + var relaunch = Path.Combine(installDir, exeName); + if (!File.Exists(relaunch)) { - // fall through to download + // Published layout may use AssemblyName titanium without extension on Unix already handled; + // twp sibling is optional. + relaunch = Path.Combine(installDir, OperatingSystem.IsWindows() ? "twp.exe" : "twp"); } - return false; + AsyncConsole.WriteLine("Restarting updater…"); + CliUpdateApplyHelper.StartDetached( + Environment.ProcessId, + destZip, + installDir, + relaunch, + remoteText, + channelDisplay); + + AsyncConsole.WriteLine( + $"Installing {remoteText} ({channelDisplay}) in the background. When finished, run: titanium version"); + // Exit so the helper can replace locked binaries. + return 0; } - private static async Task UpdatePlusAsync(ReleaseManifest manifest) + private static async Task UpdatePlusAsync(ReleaseManifest manifest, string channelDisplay) { var asset = manifest.Products?.Plus?.Asset; if (asset?.Url is null) { - AsyncConsole.WriteLine("Plus update: download Titanium.Plus.dll from GitHub Releases and place beside this executable, then restart."); - return 0; + AsyncConsole.WriteError($"No Plus package on channel {channelDisplay}."); + return 1; } + var remoteLabel = manifest.Products?.Plus?.Version ?? manifest.Version ?? "unknown"; var dest = Path.Combine(AppContext.BaseDirectory, "Titanium.Plus.dll"); var backup = dest + ".bak"; - AsyncConsole.WriteLine($"Downloading {asset.Url}"); - using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(2) }; - http.DefaultRequestHeaders.UserAgent.ParseAdd("Titanium.Cli/7.0"); - var bytes = await http.GetByteArrayAsync(asset.Url); - if (!string.IsNullOrEmpty(asset.Sha256)) + AsyncConsole.WriteLine("Downloading…"); + try { - var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); - if (!hash.Equals(asset.Sha256, StringComparison.OrdinalIgnoreCase)) + using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(2) }; + http.DefaultRequestHeaders.UserAgent.ParseAdd("Titanium.Cli/7.0"); + var bytes = await http.GetByteArrayAsync(asset.Url); + AsyncConsole.WriteLine("Verifying SHA256…"); + if (!string.IsNullOrEmpty(asset.Sha256)) { - AsyncConsole.WriteError("SHA256 mismatch — aborting Plus update."); - return 1; + var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + if (!hash.Equals(asset.Sha256, StringComparison.OrdinalIgnoreCase)) + { + AsyncConsole.WriteError("SHA256 mismatch — aborting Plus update."); + return 1; + } } - } - if (File.Exists(dest)) + if (File.Exists(dest)) + { + File.Copy(dest, backup, overwrite: true); + } + + var staging = dest + ".new"; + await File.WriteAllBytesAsync(staging, bytes); + File.Move(staging, dest, overwrite: true); + AsyncConsole.WriteLine($"Updated Titanium.Plus.dll to {remoteLabel} ({channelDisplay})."); + return 0; + } + catch (Exception ex) { - File.Copy(dest, backup, overwrite: true); + AsyncConsole.WriteError($"Plus update failed: {ex.Message}"); + return 1; } - - await File.WriteAllBytesAsync(dest + ".new", bytes); - AsyncConsole.WriteLine($"Wrote {dest}.new — stop `titanium run` then rename to Titanium.Plus.dll (backup at .bak)."); - return 0; } private static string ResolveRid() => Http3DepsCommand.SuggestRid(); +} - private static string? ResolveWingetPath() +/// Detached helper that replaces the CLI install after this process exits. +internal static class CliUpdateApplyHelper +{ + public static void StartDetached( + int pid, + string zipPath, + string installDir, + string relaunchPath, + string version, + string channel) { - var localApps = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "Microsoft", - "WindowsApps", - "winget.exe"); - if (File.Exists(localApps)) + var workDir = Path.GetDirectoryName(zipPath) ?? Path.GetTempPath(); + if (OperatingSystem.IsWindows()) { - return localApps; - } - - return TryFindWingetUnderProgramFiles(); + var ps1 = Path.Combine(workDir, "apply-cli-update.ps1"); + File.WriteAllText(ps1, BuildWindowsScript(pid, zipPath, installDir, relaunchPath, version, channel), Encoding.UTF8); + Process.Start(new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = $"-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File \"{ps1}\"", + UseShellExecute = true, + CreateNoWindow = true, + WorkingDirectory = workDir, + }); + return; + } + + var sh = Path.Combine(workDir, "apply-cli-update.sh"); + File.WriteAllText(sh, BuildUnixScript(pid, zipPath, installDir, relaunchPath, version, channel), new UTF8Encoding(false)); + Process.Start(new ProcessStartInfo + { + FileName = "/bin/bash", + Arguments = $"\"{sh}\"", + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = workDir, + }); } - private static string? TryFindWingetUnderProgramFiles() + internal static string BuildWindowsScript( + int pid, + string zipPath, + string installDir, + string relaunchPath, + string version, + string channel) { - var programFiles = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), - "WindowsApps"); - if (!Directory.Exists(programFiles)) - { - return null; - } + var sb = new StringBuilder(); + sb.AppendLine("$ErrorActionPreference = 'Stop'"); + sb.AppendLine($"$pidToWait = {pid}"); + sb.AppendLine($"$package = '{EscapePs(zipPath)}'"); + sb.AppendLine($"$installDir = '{EscapePs(installDir)}'"); + sb.AppendLine($"$relaunch = '{EscapePs(relaunchPath)}'"); + sb.AppendLine("while (Get-Process -Id $pidToWait -ErrorAction SilentlyContinue) { Start-Sleep -Milliseconds 400 }"); + sb.AppendLine("Start-Sleep -Seconds 1"); + sb.AppendLine("$tmp = Join-Path $env:TEMP ('ti-cli-unz-' + [guid]::NewGuid().ToString('n'))"); + sb.AppendLine("New-Item -ItemType Directory -Force -Path $tmp | Out-Null"); + sb.AppendLine("Expand-Archive -LiteralPath $package -DestinationPath $tmp -Force"); + sb.AppendLine("Copy-Item -Path (Join-Path $tmp '*') -Destination $installDir -Recurse -Force"); + sb.AppendLine("Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue"); + sb.AppendLine($"Write-Host 'Updated to {EscapePs(version)} ({EscapePs(channel)}).'"); + sb.AppendLine("if (Test-Path -LiteralPath $relaunch) {"); + sb.AppendLine(" & $relaunch version"); + sb.AppendLine("}"); + return sb.ToString(); + } - try - { - return Directory.EnumerateFiles(programFiles, "winget.exe", SearchOption.AllDirectories) - .FirstOrDefault(); - } - catch - { - // WindowsApps may deny enumeration. - return null; - } + internal static string BuildUnixScript( + int pid, + string zipPath, + string installDir, + string relaunchPath, + string version, + string channel) + { + var sb = new StringBuilder(); + sb.AppendLine("#!/usr/bin/env bash"); + sb.AppendLine("set -euo pipefail"); + sb.AppendLine($"pid={pid}"); + sb.AppendLine($"package={BashQuote(zipPath)}"); + sb.AppendLine($"install_dir={BashQuote(installDir)}"); + sb.AppendLine($"relaunch={BashQuote(relaunchPath)}"); + sb.AppendLine("while kill -0 \"$pid\" 2>/dev/null; do sleep 0.4; done"); + sb.AppendLine("sleep 1"); + sb.AppendLine("tmp=$(mktemp -d)"); + sb.AppendLine("unzip -qo \"$package\" -d \"$tmp\""); + sb.AppendLine("cp -a \"$tmp\"/. \"$install_dir\"/"); + sb.AppendLine("rm -rf \"$tmp\""); + sb.AppendLine("chmod +x \"$install_dir/titanium\" \"$install_dir/twp\" 2>/dev/null || true"); + sb.AppendLine($"echo 'Updated to {version} ({channel}).'"); + sb.AppendLine("if [[ -x \"$relaunch\" ]]; then \"$relaunch\" version || true; fi"); + return sb.ToString(); } + + private static string EscapePs(string value) => value.Replace("'", "''", StringComparison.Ordinal); + + private static string BashQuote(string value) => + "'" + value.Replace("'", "'\\''", StringComparison.Ordinal) + "'"; } internal sealed class UpdateFeedClient @@ -335,7 +432,6 @@ internal sealed class UpdateFeedClient return JsonSerializer.Deserialize(json, ManifestJson); } - // Prefer release-manifest.json asset on latest (or first prerelease for beta). var api = _channel.Equals("beta", StringComparison.OrdinalIgnoreCase) ? "https://api.github.com/repos/justcoding121/titanium-web-proxy/releases" : "https://api.github.com/repos/justcoding121/titanium-web-proxy/releases/latest"; @@ -429,6 +525,7 @@ internal sealed class ProductsBlock { public CliBlock? Cli { get; set; } public PlusProductBlock? Plus { get; set; } + public InspectorBlock? Inspector { get; set; } } internal sealed class CliBlock @@ -436,6 +533,11 @@ internal sealed class CliBlock public Dictionary? Assets { get; set; } } +internal sealed class InspectorBlock +{ + public Dictionary? Assets { get; set; } +} + internal sealed class PlusProductBlock { public string? Version { get; set; } diff --git a/src/Titanium.Inspector/Services/DesktopShell.cs b/src/Titanium.Inspector/Services/DesktopShell.cs new file mode 100644 index 000000000..6c236f0b1 --- /dev/null +++ b/src/Titanium.Inspector/Services/DesktopShell.cs @@ -0,0 +1,191 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace Titanium.Inspector.Services; + +/// +/// Cross-platform open-in-file-manager helpers. Command builders are pure for unit tests; +/// / invoke the OS. +/// +public static class DesktopShell +{ + public readonly record struct ShellCommand(string FileName, string Arguments); + + public static bool TryBuildOpenDirectory( + string? directory, + OSPlatform platform, + out ShellCommand command, + out string? error) + { + command = default; + error = null; + if (string.IsNullOrWhiteSpace(directory)) + { + error = "Directory path is empty."; + return false; + } + + var full = Path.GetFullPath(directory.Trim()); + if (platform == OSPlatform.Windows) + { + command = new ShellCommand("explorer.exe", QuoteWindowsArg(full)); + return true; + } + + if (platform == OSPlatform.OSX) + { + command = new ShellCommand("open", QuoteUnixArg(full)); + return true; + } + + if (platform == OSPlatform.Linux) + { + command = new ShellCommand("xdg-open", QuoteUnixArg(full)); + return true; + } + + error = "Unsupported operating system."; + return false; + } + + public static bool TryBuildRevealFile( + string? filePath, + OSPlatform platform, + out ShellCommand command, + out string? error) + { + command = default; + error = null; + if (string.IsNullOrWhiteSpace(filePath)) + { + error = "File path is empty."; + return false; + } + + var full = Path.GetFullPath(filePath.Trim()); + var directory = Path.GetDirectoryName(full); + if (string.IsNullOrEmpty(directory)) + { + error = "File path has no directory."; + return false; + } + + if (platform == OSPlatform.Windows) + { + // explorer /select,"C:\path\file.log" + command = new ShellCommand("explorer.exe", "/select," + QuoteWindowsArg(full)); + return true; + } + + if (platform == OSPlatform.OSX) + { + command = new ShellCommand("open", "-R " + QuoteUnixArg(full)); + return true; + } + + if (platform == OSPlatform.Linux) + { + // No portable reveal; open the containing folder. + return TryBuildOpenDirectory(directory, platform, out command, out error); + } + + error = "Unsupported operating system."; + return false; + } + + public static bool TryOpenDirectory(string? directory, out string? error) => + TryOpenDirectory(directory, GetCurrentPlatform(), out error); + + public static bool TryOpenDirectory(string? directory, OSPlatform platform, out string? error) + { + if (!TryBuildOpenDirectory(directory, platform, out var command, out error)) + { + return false; + } + + try + { + Directory.CreateDirectory(Path.GetFullPath(directory!.Trim())); + Start(command); + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } + + public static bool TryRevealFileOrOpenDirectory(string? filePath, out string? error) => + TryRevealFileOrOpenDirectory(filePath, GetCurrentPlatform(), out error); + + public static bool TryRevealFileOrOpenDirectory(string? filePath, OSPlatform platform, out string? error) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + error = "File path is empty."; + return false; + } + + var full = Path.GetFullPath(filePath.Trim()); + var directory = Path.GetDirectoryName(full); + if (string.IsNullOrEmpty(directory)) + { + error = "File path has no directory."; + return false; + } + + try + { + Directory.CreateDirectory(directory); + if (!TryBuildRevealFile(full, platform, out var command, out error)) + { + return false; + } + + Start(command); + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } + + public static OSPlatform GetCurrentPlatform() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return OSPlatform.Windows; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return OSPlatform.OSX; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return OSPlatform.Linux; + } + + return OSPlatform.Create("Unknown"); + } + + private static void Start(ShellCommand command) + { + Process.Start(new ProcessStartInfo + { + FileName = command.FileName, + Arguments = command.Arguments, + UseShellExecute = false, + }); + } + + private static string QuoteWindowsArg(string path) => + "\"" + path.Replace("\"", "\"\"", StringComparison.Ordinal) + "\""; + + private static string QuoteUnixArg(string path) => + "'" + path.Replace("'", "'\\''", StringComparison.Ordinal) + "'"; +} diff --git a/src/Titanium.Inspector/Services/IInspectorDialogs.cs b/src/Titanium.Inspector/Services/IInspectorDialogs.cs index e5a4fed0f..f7319af01 100644 --- a/src/Titanium.Inspector/Services/IInspectorDialogs.cs +++ b/src/Titanium.Inspector/Services/IInspectorDialogs.cs @@ -27,6 +27,11 @@ public interface IInspectorDialogs /// Confirm resetting Inspector preferences to factory defaults (not the root CA or sessions). /// Task ConfirmResetSettingsAsync(Window? owner); + + /// + /// Confirm installing an Inspector update for the selected channel. Returns true if Install and restart. + /// + Task ConfirmInstallUpdateAsync(Window? owner, string version, string channelDisplay); } /// Avalonia modal dialogs. @@ -88,6 +93,16 @@ public Task ConfirmResetSettingsAsync(Window? owner) => accept: "Reset settings", cancel: CancelLabel, height: 300); + + public Task ConfirmInstallUpdateAsync(Window? owner, string version, string channelDisplay) => + SimpleConfirmDialog.ShowAsync( + owner, + "Update available", + $"Update {version} ({channelDisplay}) is available. Install and restart now?\n\n" + + "Inspector will close, apply the update, and relaunch.", + accept: "Install and restart", + cancel: "Later", + height: 240); } /// Scripted answers for unit / E2E-UI tests (no real windows). @@ -99,13 +114,17 @@ public sealed class ScriptedInspectorDialogs : IInspectorDialogs public bool DeviceCaSetupResult { get; set; } public bool ResetSettingsResult { get; set; } = true; public bool RotateRootCaResult { get; set; } = true; + public bool InstallUpdateResult { get; set; } = true; public int InstallRootCaCalls { get; private set; } public int RemoveRootCaCalls { get; private set; } public int ElevateRootCaCalls { get; private set; } public int DeviceCaSetupCalls { get; private set; } public int ResetSettingsCalls { get; private set; } public int RotateRootCaCalls { get; private set; } + public int InstallUpdateCalls { get; private set; } public string? LastDeviceCaSetupMessage { get; private set; } + public string? LastInstallUpdateVersion { get; private set; } + public string? LastInstallUpdateChannel { get; private set; } public Task ConfirmInstallRootCaAsync(Window? owner) { @@ -143,4 +162,12 @@ public Task ConfirmResetSettingsAsync(Window? owner) ResetSettingsCalls++; return Task.FromResult(ResetSettingsResult); } + + public Task ConfirmInstallUpdateAsync(Window? owner, string version, string channelDisplay) + { + InstallUpdateCalls++; + LastInstallUpdateVersion = version; + LastInstallUpdateChannel = channelDisplay; + return Task.FromResult(InstallUpdateResult); + } } diff --git a/src/Titanium.Inspector/Services/SessionArchive.cs b/src/Titanium.Inspector/Services/SessionArchive.cs index cd813a204..c6471d626 100644 --- a/src/Titanium.Inspector/Services/SessionArchive.cs +++ b/src/Titanium.Inspector/Services/SessionArchive.cs @@ -18,7 +18,7 @@ public static Task ExportHarAsync(IEnumerable sessions, string log = new { version = "1.2", - creator = new { name = "Titanium Inspector", version = "7.0.2" }, + creator = new { name = "Titanium Inspector", version = "7.0.3" }, entries, }, }; diff --git a/src/Titanium.Inspector/Services/SessionBodyDiskCache.cs b/src/Titanium.Inspector/Services/SessionBodyDiskCache.cs index e2a603e59..c39278341 100644 --- a/src/Titanium.Inspector/Services/SessionBodyDiskCache.cs +++ b/src/Titanium.Inspector/Services/SessionBodyDiskCache.cs @@ -30,6 +30,16 @@ public SessionBodyDiskCache(string directory, long maxBytes, TimeSpan maxAge) PruneOnStartup(); } + /// + /// Default spill directory under LocalApplicationData (Windows LocalAppData, + /// Linux ~/.local/share, macOS Application Support). + /// + public static string GetDefaultDirectory() => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "TitaniumInspector", + "session-cache"); + public string DirectoryPath => _directory; public string PathFor(long sessionId) => Path.Combine(_directory, sessionId.ToString("D") + ".bin"); diff --git a/src/Titanium.Inspector/Services/SessionStore.cs b/src/Titanium.Inspector/Services/SessionStore.cs index 90a9f12d0..3da2c0f3c 100644 --- a/src/Titanium.Inspector/Services/SessionStore.cs +++ b/src/Titanium.Inspector/Services/SessionStore.cs @@ -26,10 +26,7 @@ public SessionStore(SessionStoreOptions? options = null, string? cacheDirectory _options = options ?? new SessionStoreOptions(); if (_options.SpillBodiesToDisk) { - var dir = cacheDirectory ?? Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "TitaniumInspector", - "session-cache"); + var dir = cacheDirectory ?? SessionBodyDiskCache.GetDefaultDirectory(); _disk = new SessionBodyDiskCache( dir, _options.DiskCacheMaxBytes, @@ -48,6 +45,9 @@ public SessionStore(SessionStoreOptions? options = null, string? cacheDirectory public ObservableCollection Sessions { get; } + /// Resolved body spill directory when disk spill is enabled; otherwise null. + public string? DiskCacheDirectoryPath => _disk?.DirectoryPath; + public SessionStoreOptions Options => _options; public int Count diff --git a/src/Titanium.Inspector/Services/UpdateService.cs b/src/Titanium.Inspector/Services/UpdateService.cs index 3e625392f..fa2446b9d 100644 --- a/src/Titanium.Inspector/Services/UpdateService.cs +++ b/src/Titanium.Inspector/Services/UpdateService.cs @@ -1,90 +1,513 @@ -using System.Net.Http.Headers; -using System.Reflection; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; +using Microsoft.Win32; namespace Titanium.Inspector.Services; +public enum UpdateApplyKind +{ + Msi, + Zip, +} + public sealed class UpdateCheckResult { public bool UpdateAvailable { get; init; } public string Message { get; init; } = ""; public string? RemoteVersion { get; init; } + public string ChannelDisplay { get; init; } = "Stable"; + public string? AssetUrl { get; init; } + public string? AssetSha256 { get; init; } + public UpdateApplyKind ApplyKind { get; init; } = UpdateApplyKind.Zip; } -/// GitHub Releases updater for Stable/Beta channels. +/// GitHub Releases + release-manifest updater for Stable/Beta channels. public sealed class UpdateService { + private static readonly JsonSerializerOptions ManifestJson = new() + { + PropertyNameCaseInsensitive = true, + }; + private const string GitHubReleasesUrl = - "https://api.github.com/repos/justcoding121/titanium-web-proxy/releases"; // NOSONAR S1075 -- Official GitHub Releases API endpoint. + "https://api.github.com/repos/justcoding121/titanium-web-proxy/releases"; // NOSONAR S1075 private const string GitHubLatestReleaseUrl = - "https://api.github.com/repos/justcoding121/titanium-web-proxy/releases/latest"; // NOSONAR S1075 -- Official GitHub Releases API endpoint. + "https://api.github.com/repos/justcoding121/titanium-web-proxy/releases/latest"; // NOSONAR S1075 private readonly SettingsService _settings; + private readonly Func _httpFactory; - public UpdateService(SettingsService settings) => _settings = settings; + public UpdateService(SettingsService settings, Func? httpFactory = null) + { + _settings = settings; + _httpFactory = httpFactory ?? (() => new HttpClient { Timeout = TimeSpan.FromMinutes(5) }); + } + + public string ChannelDisplayName => + _settings.Current.UpdateChannel.Equals("Beta", StringComparison.OrdinalIgnoreCase) + ? "Beta" + : "Stable"; public async Task CheckAsync(CancellationToken cancellationToken = default) { _settings.Current.LastUpdateCheckUtc = DateTimeOffset.UtcNow; _settings.Save(); - var channel = _settings.Current.UpdateChannel; - var local = Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0); + var channelDisplay = ChannelDisplayName; + var local = AssemblyVersion(); try { - using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; - http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("TitaniumInspector", "7.0")); + using var http = _httpFactory(); + http.DefaultRequestHeaders.UserAgent.ParseAdd("TitaniumInspector/7.0"); - string json; - if (channel.Equals("Beta", StringComparison.OrdinalIgnoreCase)) + var manifest = await TryGetManifestAsync(http, channelDisplay, cancellationToken); + if (manifest is null) { - json = await http.GetStringAsync(GitHubReleasesUrl, cancellationToken); - using var arr = JsonDocument.Parse(json); - foreach (var el in arr.RootElement.EnumerateArray()) + return new UpdateCheckResult { - if (el.TryGetProperty("prerelease", out var pre) && pre.GetBoolean()) - { - return Compare(local, el.GetProperty("tag_name").GetString()); - } + ChannelDisplay = channelDisplay, + Message = channelDisplay.Equals("Beta", StringComparison.OrdinalIgnoreCase) + ? "No beta release found." + : "Update check failed: no release manifest.", + }; + } + + var remoteText = manifest.Version?.TrimStart('v') ?? "0.0.0"; + if (!Version.TryParse(remoteText.Split('-')[0], out var remote)) + { + remote = new Version(0, 0); + } + + if (remote <= local) + { + return new UpdateCheckResult + { + RemoteVersion = remoteText, + ChannelDisplay = channelDisplay, + Message = $"Titanium Inspector is up to date ({channelDisplay}).", + }; + } + + var (kind, asset) = ResolveAsset(manifest); + if (asset?.Url is null) + { + return new UpdateCheckResult + { + UpdateAvailable = true, + RemoteVersion = remoteText, + ChannelDisplay = channelDisplay, + Message = + $"Update {remoteText} ({channelDisplay}) is available, but no package was found for this install.", + }; + } + + return new UpdateCheckResult + { + UpdateAvailable = true, + RemoteVersion = remoteText, + ChannelDisplay = channelDisplay, + AssetUrl = asset.Url, + AssetSha256 = asset.Sha256, + ApplyKind = kind, + Message = $"Update available: {remoteText} ({channelDisplay})", + }; + } + catch (Exception ex) + { + return new UpdateCheckResult + { + ChannelDisplay = channelDisplay, + Message = $"Update check failed: {ex.Message}", + }; + } + } + + /// Download package, verify SHA256, spawn apply helper, return true if helper started. + public async Task<(bool Ok, string Message)> DownloadAndStartApplyAsync( + UpdateCheckResult check, + CancellationToken cancellationToken = default) + { + if (!check.UpdateAvailable || string.IsNullOrEmpty(check.AssetUrl)) + { + return (false, "No update package to install."); + } + + var workDir = Path.Combine(Path.GetTempPath(), "TitaniumInspector-update"); + Directory.CreateDirectory(workDir); + var fileName = check.ApplyKind == UpdateApplyKind.Msi + ? $"TitaniumInspector-{check.RemoteVersion}.msi" + : $"TitaniumInspector-{check.RemoteVersion}.zip"; + var packagePath = Path.Combine(workDir, fileName); + + try + { + using var http = _httpFactory(); + http.DefaultRequestHeaders.UserAgent.ParseAdd("TitaniumInspector/7.0"); + var bytes = await http.GetByteArrayAsync(check.AssetUrl, cancellationToken); + if (!string.IsNullOrEmpty(check.AssetSha256)) + { + var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + if (!hash.Equals(check.AssetSha256, StringComparison.OrdinalIgnoreCase)) + { + return (false, "SHA256 mismatch — aborting update."); } + } - return new UpdateCheckResult { Message = "No beta release found." }; + await File.WriteAllBytesAsync(packagePath, bytes, cancellationToken); + + var installDir = AppContext.BaseDirectory.TrimEnd( + Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var exeName = OperatingSystem.IsWindows() ? "TitaniumInspector.exe" : "TitaniumInspector"; + var relaunchPath = Path.Combine(installDir, exeName); + if (OperatingSystem.IsMacOS() && TryFindMacAppBundle(installDir, out var appBundle)) + { + relaunchPath = appBundle; } - json = await http.GetStringAsync(GitHubLatestReleaseUrl, cancellationToken); - using var doc = JsonDocument.Parse(json); - return Compare(local, doc.RootElement.GetProperty("tag_name").GetString()); + UpdateApplyHelper.StartDetached( + Process.GetCurrentProcess().Id, + check.ApplyKind, + packagePath, + installDir, + relaunchPath, + check.RemoteVersion ?? "", + check.ChannelDisplay); + + return (true, $"Installing {check.RemoteVersion} ({check.ChannelDisplay})…"); } catch (Exception ex) { - return new UpdateCheckResult { Message = $"Update check failed: {ex.Message}" }; + return (false, $"Update failed: {ex.Message}"); } } - private static UpdateCheckResult Compare(Version local, string? tag) + public static Version AssemblyVersion() => + System.Reflection.Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0); + + public static bool IsMsiInstall(string baseDirectory) { - var remoteText = tag?.TrimStart('v') ?? "0.0.0"; - if (!Version.TryParse(remoteText.Split('-')[0], out var remote)) + if (OperatingSystem.IsWindows()) { - remote = new Version(0, 0); + var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var pf86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + var full = Path.GetFullPath(baseDirectory); + if ((!string.IsNullOrEmpty(pf) && full.StartsWith(Path.GetFullPath(pf), StringComparison.OrdinalIgnoreCase)) + || (!string.IsNullOrEmpty(pf86) && full.StartsWith(Path.GetFullPath(pf86), StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + try + { + using var key = Registry.CurrentUser.OpenSubKey(@"Software\justcoding121\TitaniumInspector"); + if (key?.GetValue("installed") is not null) + { + return true; + } + } + catch + { + // ignore registry access issues + } } - if (remote > local) + return false; + } + + public static string SuggestRid() + { + var arm = RuntimeInformation.OSArchitecture is Architecture.Arm64 or Architecture.Arm; + if (OperatingSystem.IsWindows()) { - return new UpdateCheckResult + return "win-x64"; + } + + if (OperatingSystem.IsMacOS()) + { + return arm ? "osx-arm64" : "osx-x64"; + } + + if (File.Exists("/etc/alpine-release")) + { + return arm ? "linux-musl-arm64" : "linux-musl-x64"; + } + + return arm ? "linux-arm64" : "linux-x64"; + } + + public (UpdateApplyKind Kind, ManifestAsset? Asset) ResolveAsset(InspectorReleaseManifest manifest) + { + var assets = manifest.Products?.Inspector?.Assets; + if (assets is null) + { + return (UpdateApplyKind.Zip, null); + } + + if (IsMsiInstall(AppContext.BaseDirectory) + && assets.TryGetValue("win-x64-msi", out var msi) + && !string.IsNullOrEmpty(msi.Url)) + { + return (UpdateApplyKind.Msi, msi); + } + + var rid = SuggestRid(); + if (assets.TryGetValue(rid, out var zip) && !string.IsNullOrEmpty(zip.Url)) + { + return (UpdateApplyKind.Zip, zip); + } + + // Portable Windows without MSI asset key may still ship win-x64 zip. + if (OperatingSystem.IsWindows() + && assets.TryGetValue("win-x64", out var winZip) + && !string.IsNullOrEmpty(winZip.Url)) + { + return (UpdateApplyKind.Zip, winZip); + } + + return (UpdateApplyKind.Zip, null); + } + + private async Task TryGetManifestAsync( + HttpClient http, + string channelDisplay, + CancellationToken cancellationToken) + { + var feed = Environment.GetEnvironmentVariable("TITANIUM_UPDATE_FEED"); + if (feed == string.Empty) + { + return null; + } + + if (!string.IsNullOrEmpty(feed)) + { + var json = await http.GetStringAsync(feed, cancellationToken); + return JsonSerializer.Deserialize(json, ManifestJson); + } + + var beta = channelDisplay.Equals("Beta", StringComparison.OrdinalIgnoreCase); + var api = beta ? GitHubReleasesUrl : GitHubLatestReleaseUrl; + var payload = await http.GetStringAsync(api, cancellationToken); + using var doc = JsonDocument.Parse(payload); + if (!TrySelectRelease(doc.RootElement, beta, out var release)) + { + return null; + } + + var version = release.GetProperty("tag_name").GetString()?.TrimStart('v') ?? "0.0.0"; + if (!release.TryGetProperty("assets", out var assets)) + { + return new InspectorReleaseManifest { Version = version, Channel = channelDisplay.ToLowerInvariant() }; + } + + foreach (var asset in assets.EnumerateArray()) + { + var name = asset.GetProperty("name").GetString() ?? ""; + if (!name.Equals("release-manifest.json", StringComparison.OrdinalIgnoreCase)) { - UpdateAvailable = true, - RemoteVersion = remoteText, - Message = $"Update available: {remoteText}", - }; + continue; + } + + var url = asset.GetProperty("browser_download_url").GetString(); + if (url is null) + { + continue; + } + + var manifestJson = await http.GetStringAsync(url, cancellationToken); + var manifest = JsonSerializer.Deserialize(manifestJson, ManifestJson); + if (manifest is null) + { + continue; + } + + manifest.Version ??= version; + manifest.Channel ??= channelDisplay.ToLowerInvariant(); + return manifest; } - return new UpdateCheckResult + return new InspectorReleaseManifest { Version = version, Channel = channelDisplay.ToLowerInvariant() }; + } + + private static bool TrySelectRelease(JsonElement root, bool beta, out JsonElement release) + { + release = default; + if (beta) { - RemoteVersion = remoteText, - Message = "Titanium Inspector is up to date.", - }; + foreach (var el in root.EnumerateArray()) + { + if (el.TryGetProperty("prerelease", out var pre) && pre.GetBoolean()) + { + release = el; + return true; + } + } + + return false; + } + + release = root; + return true; } + + private static bool TryFindMacAppBundle(string installDir, out string appPath) + { + appPath = ""; + var dir = new DirectoryInfo(installDir); + while (dir is not null) + { + if (dir.Name.EndsWith(".app", StringComparison.OrdinalIgnoreCase)) + { + appPath = dir.FullName; + return true; + } + + dir = dir.Parent; + } + + return false; + } +} + +public sealed class InspectorReleaseManifest +{ + public string? Version { get; set; } + public string? Channel { get; set; } + public InspectorProductsBlock? Products { get; set; } +} + +public sealed class InspectorProductsBlock +{ + public InspectorProductAssets? Inspector { get; set; } +} + +public sealed class InspectorProductAssets +{ + public Dictionary? Assets { get; set; } +} + +public sealed class ManifestAsset +{ + public string? Url { get; set; } + public string? Sha256 { get; set; } +} + +/// Spawns a detached helper that applies an update after this process exits. +public static class UpdateApplyHelper +{ + public static void StartDetached( + int pid, + UpdateApplyKind kind, + string packagePath, + string installDir, + string relaunchPath, + string version, + string channel) + { + var workDir = Path.GetDirectoryName(packagePath) ?? Path.GetTempPath(); + if (OperatingSystem.IsWindows()) + { + var ps1 = Path.Combine(workDir, "apply-update.ps1"); + File.WriteAllText(ps1, BuildWindowsScript(pid, kind, packagePath, installDir, relaunchPath, version, channel), Encoding.UTF8); + Process.Start(new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = + $"-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File \"{ps1}\"", + UseShellExecute = true, + CreateNoWindow = true, + WorkingDirectory = workDir, + }); + return; + } + + var sh = Path.Combine(workDir, "apply-update.sh"); + File.WriteAllText(sh, BuildUnixScript(pid, packagePath, installDir, relaunchPath, version, channel), new UTF8Encoding(false)); + Process.Start(new ProcessStartInfo + { + FileName = "/bin/bash", + Arguments = $"\"{sh}\"", + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = workDir, + }); + } + + public static string BuildWindowsScript( + int pid, + UpdateApplyKind kind, + string packagePath, + string installDir, + string relaunchPath, + string version, + string channel) + { + var sb = new StringBuilder(); + sb.AppendLine("$ErrorActionPreference = 'Stop'"); + sb.AppendLine($"$pidToWait = {pid}"); + sb.AppendLine($"$package = '{EscapePs(packagePath)}'"); + sb.AppendLine($"$installDir = '{EscapePs(installDir)}'"); + sb.AppendLine($"$relaunch = '{EscapePs(relaunchPath)}'"); + sb.AppendLine("while (Get-Process -Id $pidToWait -ErrorAction SilentlyContinue) { Start-Sleep -Milliseconds 400 }"); + sb.AppendLine("Start-Sleep -Seconds 1"); + if (kind == UpdateApplyKind.Msi) + { + sb.AppendLine("Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', $package, '/qn', '/norestart') -Wait -Verb RunAs"); + } + else + { + sb.AppendLine("$tmp = Join-Path $env:TEMP ('ti-unz-' + [guid]::NewGuid().ToString('n'))"); + sb.AppendLine("New-Item -ItemType Directory -Force -Path $tmp | Out-Null"); + sb.AppendLine("Expand-Archive -LiteralPath $package -DestinationPath $tmp -Force"); + sb.AppendLine("Copy-Item -Path (Join-Path $tmp '*') -Destination $installDir -Recurse -Force"); + sb.AppendLine("Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue"); + } + + sb.AppendLine("if (Test-Path -LiteralPath $relaunch) {"); + sb.AppendLine(" if ($relaunch -like '*.app') { Start-Process 'open' -ArgumentList $relaunch }"); + sb.AppendLine(" else { Start-Process -FilePath $relaunch -WorkingDirectory $installDir }"); + sb.AppendLine("}"); + sb.AppendLine($"Write-Output 'Updated Inspector to {EscapePs(version)} ({EscapePs(channel)}).'"); + return sb.ToString(); + } + + public static string BuildUnixScript( + int pid, + string packagePath, + string installDir, + string relaunchPath, + string version, + string channel) + { + var sb = new StringBuilder(); + sb.AppendLine("#!/usr/bin/env bash"); + sb.AppendLine("set -euo pipefail"); + sb.AppendLine($"pid={pid}"); + sb.AppendLine($"package={BashQuote(packagePath)}"); + sb.AppendLine($"install_dir={BashQuote(installDir)}"); + sb.AppendLine($"relaunch={BashQuote(relaunchPath)}"); + sb.AppendLine("while kill -0 \"$pid\" 2>/dev/null; do sleep 0.4; done"); + sb.AppendLine("sleep 1"); + sb.AppendLine("tmp=$(mktemp -d)"); + sb.AppendLine("unzip -qo \"$package\" -d \"$tmp\""); + sb.AppendLine("cp -a \"$tmp\"/. \"$install_dir\"/"); + sb.AppendLine("rm -rf \"$tmp\""); + sb.AppendLine("chmod +x \"$install_dir/TitaniumInspector\" 2>/dev/null || true"); + sb.AppendLine("if [[ \"$relaunch\" == *.app ]]; then"); + sb.AppendLine(" open \"$relaunch\" || true"); + sb.AppendLine("elif [[ -x \"$relaunch\" ]]; then"); + sb.AppendLine(" (cd \"$install_dir\" && nohup \"$relaunch\" >/dev/null 2>&1 &)"); + sb.AppendLine("fi"); + sb.AppendLine($"echo 'Updated Inspector to {version} ({channel}).'"); + return sb.ToString(); + } + + private static string EscapePs(string value) => value.Replace("'", "''", StringComparison.Ordinal); + + private static string BashQuote(string value) => + "'" + value.Replace("'", "'\\''", StringComparison.Ordinal) + "'"; } diff --git a/src/Titanium.Inspector/Titanium.Inspector.csproj b/src/Titanium.Inspector/Titanium.Inspector.csproj index 98573f442..d4d7d426a 100644 --- a/src/Titanium.Inspector/Titanium.Inspector.csproj +++ b/src/Titanium.Inspector/Titanium.Inspector.csproj @@ -8,7 +8,7 @@ enable true false - 7.0.2 + 7.0.3 Jehonathan Thomas Titanium Inspector desktop traffic debugger (PolyForm Noncommercial). LICENSE diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs index 9bbedd49b..bf89402fe 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs @@ -98,7 +98,22 @@ public MainWindowViewModel( LoadFromSettings(); - CheckForUpdatesCommand = new RelayCommand(async () => await CheckUpdatesAsync()); + CheckForUpdatesCommand = new RelayCommand(async () => await CheckUpdatesAsync(promptIfAvailable: true)); + SetUpdateChannelStableCommand = new RelayCommand(() => + { + UpdateChannelIsBeta = false; + return Task.CompletedTask; + }); + SetUpdateChannelBetaCommand = new RelayCommand(() => + { + UpdateChannelIsBeta = true; + return Task.CompletedTask; + }); + ToggleCheckForUpdatesOnStartupCommand = new RelayCommand(() => + { + CheckForUpdatesOnStartup = !CheckForUpdatesOnStartup; + return Task.CompletedTask; + }); ExportHarCommand = new RelayCommand(async () => await ExportHarAsync()); ExportSelectedHarCommand = new RelayCommand(async () => await ExportSelectedHarAsync()); ImportHarCommand = new RelayCommand(async () => await ImportHarAsync()); @@ -384,15 +399,52 @@ private static void MarshalToUi(Action action) } } - private static Task MarshalToUiAsync(Action action) + private static async Task MarshalToUiAsync(Action action) { if (Application.Current is null || Dispatcher.UIThread.CheckAccess()) { action(); - return Task.CompletedTask; + return; + } + + // Prefer Post over InvokeAsync so headless WaitUntil pumps (RunJobs) can drain the + // callback without a nested InvokeAsync wait. Retry IFontManagerImpl races on macOS CI. + const int maxAttempts = 8; + Exception? last = null; + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Dispatcher.UIThread.Post(() => + { + try + { + action(); + tcs.TrySetResult(); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + }); + + try + { + await tcs.Task.ConfigureAwait(false); + return; + } + catch (InvalidOperationException ex) when ( + attempt < maxAttempts + && ex.Message.Contains("IFontManagerImpl", StringComparison.Ordinal)) + { + last = ex; + await Task.Delay(25 * attempt).ConfigureAwait(false); + } } - return Dispatcher.UIThread.InvokeAsync(action).GetTask(); + if (last is not null) + { + throw last; + } } private void LoadPlusPanels() @@ -1024,6 +1076,9 @@ private Task ApplyEditBodyAsync() public BreakpointViewModel Breakpoints { get; } public AutoResponderViewModel AutoResponder { get; } public ICommand CheckForUpdatesCommand { get; } + public ICommand SetUpdateChannelStableCommand { get; } + public ICommand SetUpdateChannelBetaCommand { get; } + public ICommand ToggleCheckForUpdatesOnStartupCommand { get; } public ICommand ExportHarCommand { get; } public ICommand ExportSelectedHarCommand { get; } public ICommand ImportHarCommand { get; } @@ -1297,6 +1352,52 @@ public bool AutoSystemProxyOnStart } } + public bool UpdateChannelIsBeta + { + get => _settings.Current.UpdateChannel.Equals("Beta", StringComparison.OrdinalIgnoreCase); + set + { + var next = value ? "Beta" : "Stable"; + if (string.Equals(_settings.Current.UpdateChannel, next, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + _settings.Current.UpdateChannel = next; + PersistSettings(); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(UpdateChannelIsBeta))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(UpdateChannelIsStable))); + } + } + + public bool UpdateChannelIsStable + { + get => !UpdateChannelIsBeta; + set + { + if (value) + { + UpdateChannelIsBeta = false; + } + } + } + + public bool CheckForUpdatesOnStartup + { + get => _settings.Current.CheckForUpdatesOnStartup; + set + { + if (_settings.Current.CheckForUpdatesOnStartup == value) + { + return; + } + + _settings.Current.CheckForUpdatesOnStartup = value; + PersistSettings(); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CheckForUpdatesOnStartup))); + } + } + /// When true, file logging is on at Debug level. public bool DebugFileLogging { @@ -1599,6 +1700,9 @@ private void NotifySettingsUiChanged() PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ScriptOnRequest))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ScriptOnResponse))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DebugFileLogging))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(UpdateChannelIsBeta))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(UpdateChannelIsStable))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CheckForUpdatesOnStartup))); } private static bool IsDebugFileLoggingEnabled(InspectorSettings s) => @@ -1923,11 +2027,50 @@ private void ApplyFilter() } } - private async Task CheckUpdatesAsync() + /// Startup or Help → Check for updates. When , offer install dialog. + public async Task CheckUpdatesAsync(bool promptIfAvailable = true) { - StatusText = "Checking for updates…"; + var channel = _updates.ChannelDisplayName; + StatusText = $"Checking for updates ({channel})…"; var result = await _updates.CheckAsync(); + if (!result.UpdateAvailable || string.IsNullOrEmpty(result.AssetUrl)) + { + StatusText = result.Message; + return; + } + StatusText = result.Message; + if (!promptIfAvailable) + { + return; + } + + var owner = TryGetMainWindow(); + var version = result.RemoteVersion ?? ""; + if (!await _dialogs.ConfirmInstallUpdateAsync(owner, version, result.ChannelDisplay)) + { + StatusText = $"Update available: {version} ({result.ChannelDisplay})"; + return; + } + + StatusText = "Downloading update…"; + var (ok, message) = await _updates.DownloadAndStartApplyAsync(result); + StatusText = message; + if (!ok) + { + return; + } + + StatusText = $"Installing {version} ({result.ChannelDisplay})… restarting."; + BeginBackgroundShutdown(); + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.Shutdown(); + } + else + { + TryGetMainWindow()?.Close(); + } } private async Task StartCaptureAsync() @@ -2254,6 +2397,8 @@ private async Task ImportArchiveAsync() { // Off the UI sync context for zip IO so headless WaitUntil pumps cannot deadlock the import. var imported = await SessionArchive.ImportNativeArchiveAsync(path).ConfigureAwait(false); + var count = imported.Count; + var fileName = Path.GetFileName(path); await MarshalToUiAsync(() => { foreach (var snap in imported) @@ -2263,12 +2408,14 @@ await MarshalToUiAsync(() => ApplyFilter(); RefreshSessionCountText(); - StatusText = $"Appended {imported.Count} sessions from {Path.GetFileName(path)}"; }); + // Match ExportArchive: set StatusText after ConfigureAwait(false) without nesting it + // inside MarshalToUiAsync (StatusText remeasure was leaving "Importing…" stuck on macOS CI). + StatusText = $"Appended {count} sessions from {fileName}"; } catch (Exception ex) { - await MarshalToUiAsync(() => StatusText = "Import archive failed: " + Truncate(ex.Message, 160)); + StatusText = "Import archive failed: " + Truncate(ex.Message, 160); } } diff --git a/src/Titanium.Inspector/Views/LoggingSettingsWindow.axaml b/src/Titanium.Inspector/Views/LoggingSettingsWindow.axaml index 56e921db2..111c78614 100644 --- a/src/Titanium.Inspector/Views/LoggingSettingsWindow.axaml +++ b/src/Titanium.Inspector/Views/LoggingSettingsWindow.axaml @@ -3,7 +3,7 @@ x:Class="Titanium.Inspector.Views.LoggingSettingsWindow" AutomationProperties.AutomationId="LoggingSettingsWindow" Title="Logging" - Width="520" Height="380" + Width="520" Height="400" WindowStartupLocation="CenterOwner" CanResize="False"> @@ -13,6 +13,8 @@