diff --git a/src/PlanViewer.App/AboutWindow.axaml b/src/PlanViewer.App/AboutWindow.axaml
index 17f422d..1aa0790 100644
--- a/src/PlanViewer.App/AboutWindow.axaml
+++ b/src/PlanViewer.App/AboutWindow.axaml
@@ -2,7 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PlanViewer.App.AboutWindow"
Title="About Performance Studio"
- Width="450" Height="500"
+ Width="480" Height="640"
CanResize="False"
WindowStartupLocation="CenterOwner"
Icon="avares://PlanViewer.App/EDD.ico"
@@ -47,14 +47,22 @@
-
+
+
@@ -80,6 +88,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/AboutWindow.axaml.cs b/src/PlanViewer.App/AboutWindow.axaml.cs
index 699a2b7..1432a2e 100644
--- a/src/PlanViewer.App/AboutWindow.axaml.cs
+++ b/src/PlanViewer.App/AboutWindow.axaml.cs
@@ -6,10 +6,8 @@
using System;
using System.Diagnostics;
-using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
-using System.Text.Json;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Platform;
@@ -25,6 +23,7 @@ public partial class AboutWindow : Window
private const string GitHubUrl = "https://github.com/erikdarlingdata/PerformanceStudio";
private const string IssuesUrl = "https://github.com/erikdarlingdata/PerformanceStudio/issues";
private const string DarlingDataUrl = "https://www.erikdarling.com";
+ private const string ReleasesUrl = "https://github.com/erikdarlingdata/PerformanceStudio/releases/latest";
public AboutWindow()
{
@@ -34,29 +33,75 @@ public AboutWindow()
VersionText.Text = $"Version {version.Major}.{version.Minor}.{version.Build}";
// Load current MCP settings
- var settings = McpSettings.Load();
- McpEnabledCheckBox.IsChecked = settings.Enabled;
- McpPortInput.Text = settings.Port.ToString();
+ var mcp = McpSettings.Load();
+ McpEnabledCheckBox.IsChecked = mcp.Enabled;
+ McpPortInput.Text = mcp.Port.ToString();
// Save on change
McpEnabledCheckBox.IsCheckedChanged += (_, _) => SaveMcpSettings();
McpPortInput.LostFocus += (_, _) => SaveMcpSettings();
+
+ // Load proxy settings. The password is intentionally NOT round-tripped
+ // through the UI — TextBox.PasswordChar only masks the glyph, the cleartext
+ // still lives in the visual/accessibility tree. We surface "(saved — leave
+ // blank to keep)" via the watermark instead, and only update the credential
+ // when the user types a new value.
+ var proxy = ProxySettings.Load();
+ _hasStoredProxyPassword = !string.IsNullOrEmpty(proxy.Password);
+ ProxySystemRadio.IsChecked = proxy.Mode == ProxyMode.System;
+ ProxyManualRadio.IsChecked = proxy.Mode == ProxyMode.Manual;
+ ProxyAddressInput.Text = proxy.Address;
+ ProxyUsernameInput.Text = proxy.Username;
+ ProxyPasswordInput.Watermark = _hasStoredProxyPassword
+ ? "(saved — leave blank to keep)"
+ : "";
+ ProxyManualPanel.IsVisible = proxy.Mode == ProxyMode.Manual;
+
+ // Both radios fire IsCheckedChanged on every selection (one going false,
+ // one going true). Only the now-checked one should drive the save —
+ // otherwise the credential write races itself.
+ void OnProxyRadioChanged(object? sender, RoutedEventArgs _)
+ {
+ if (sender is RadioButton rb && rb.IsChecked == true)
+ {
+ ProxyManualPanel.IsVisible = ProxyManualRadio.IsChecked == true;
+ SaveProxySettings();
+ }
+ }
+ ProxySystemRadio.IsCheckedChanged += OnProxyRadioChanged;
+ ProxyManualRadio.IsCheckedChanged += OnProxyRadioChanged;
+ ProxyAddressInput.LostFocus += (_, _) => SaveProxySettings();
+ ProxyUsernameInput.LostFocus += (_, _) => SaveProxySettings();
+ ProxyPasswordInput.LostFocus += (_, _) => SaveProxySettings();
}
+ private bool _hasStoredProxyPassword;
+
private void SaveMcpSettings()
{
- var settingsDir = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".planview");
- var settingsFile = Path.Combine(settingsDir, "settings.json");
-
- var json = JsonSerializer.Serialize(new
+ Services.SettingsFile.Update(o =>
{
- mcp_enabled = McpEnabledCheckBox.IsChecked == true,
- mcp_port = int.TryParse(McpPortInput.Text, out var p) && p >= 1024 && p <= 65535 ? p : 5152
- }, new JsonSerializerOptions { WriteIndented = true });
+ o["mcp_enabled"] = McpEnabledCheckBox.IsChecked == true;
+ o["mcp_port"] = int.TryParse(McpPortInput.Text, out var p) && p >= 1024 && p <= 65535 ? p : 5152;
+ });
+ }
- Directory.CreateDirectory(settingsDir);
- Services.AtomicFile.WriteAllText(settingsFile, json);
+ private void SaveProxySettings()
+ {
+ var typedPassword = ProxyPasswordInput.Text ?? "";
+ var settings = new ProxySettings
+ {
+ Mode = ProxyManualRadio.IsChecked == true ? ProxyMode.Manual : ProxyMode.System,
+ Address = ProxyAddressInput.Text ?? "",
+ Username = ProxyUsernameInput.Text ?? "",
+ Password = typedPassword
+ };
+ // Empty textbox + an existing stored password means "keep what's there".
+ // Save() signals "leave the credential alone" with TouchCredential=false.
+ settings.TouchCredential = !(typedPassword.Length == 0 && _hasStoredProxyPassword);
+ settings.Save();
+ if (settings.TouchCredential)
+ _hasStoredProxyPassword = !string.IsNullOrEmpty(typedPassword);
}
private void GitHubLink_Click(object? sender, PointerPressedEventArgs e) => OpenUrl(GitHubUrl);
@@ -83,15 +128,20 @@ private async void CheckUpdate_Click(object? sender, RoutedEventArgs e)
CheckUpdateButton.IsEnabled = false;
UpdateStatusText.Text = "Checking...";
UpdateLink.IsVisible = false;
+ ReleasesPageLink.IsVisible = false;
- // Try Velopack first (Windows only, supports download + apply)
+ // Try Velopack first (Windows only, supports download + apply). The custom
+ // downloader routes through the user's proxy + Windows credentials so this
+ // works on corporate networks (issue #314).
+ string? velopackError = null;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
try
{
_velopackMgr = new UpdateManager(
new Velopack.Sources.GithubSource(
- "https://github.com/erikdarlingdata/PerformanceStudio", null, false));
+ "https://github.com/erikdarlingdata/PerformanceStudio",
+ null, false, new ProxyAwareDownloader()));
_velopackUpdate = await _velopackMgr.CheckForUpdatesAsync();
if (_velopackUpdate != null)
@@ -103,9 +153,12 @@ private async void CheckUpdate_Click(object? sender, RoutedEventArgs e)
return;
}
}
- catch
+ catch (Exception ex)
{
- // Velopack packages may not exist yet — fall through
+ // Velopack packages may not exist yet — fall through to API check.
+ // Hold onto the message in case the API check also fails (issue #314
+ // is exactly the case where the auth error here is the useful one).
+ velopackError = ex.Message;
}
}
@@ -115,7 +168,10 @@ private async void CheckUpdate_Click(object? sender, RoutedEventArgs e)
if (result.Error != null)
{
- UpdateStatusText.Text = $"Error: {result.Error}";
+ UpdateStatusText.Text = velopackError != null && velopackError != result.Error
+ ? $"Error: {result.Error} (installer check also failed: {velopackError})"
+ : $"Error: {result.Error}";
+ ReleasesPageLink.IsVisible = true;
}
else if (result.UpdateAvailable)
{
@@ -132,6 +188,8 @@ private async void CheckUpdate_Click(object? sender, RoutedEventArgs e)
CheckUpdateButton.IsEnabled = true;
}
+ private void ReleasesPageLink_Click(object? sender, PointerPressedEventArgs e) => OpenUrl(ReleasesUrl);
+
private bool _updateDownloaded;
private async void UpdateLink_Click(object? sender, PointerPressedEventArgs e)
@@ -201,6 +259,7 @@ private async void UpdateLink_Click(object? sender, PointerPressedEventArgs e)
{
UpdateStatusText.Text = $"Update failed: {ex.Message}";
UpdateLink.IsVisible = false;
+ ReleasesPageLink.IsVisible = true;
}
return;
}
diff --git a/src/PlanViewer.App/Services/ProxyAwareDownloader.cs b/src/PlanViewer.App/Services/ProxyAwareDownloader.cs
new file mode 100644
index 0000000..bc5c420
--- /dev/null
+++ b/src/PlanViewer.App/Services/ProxyAwareDownloader.cs
@@ -0,0 +1,22 @@
+using System.Net.Http;
+using Velopack.Sources;
+
+namespace PlanViewer.App.Services;
+
+///
+/// Velopack downloader that routes through the user-configured proxy. The default
+/// creates a vanilla
+/// whose proxy never sees Windows credentials, which breaks "Check for Updates" on
+/// most corporate networks (GitHub issue #314).
+///
+internal sealed class ProxyAwareDownloader : HttpClientFileDownloader
+{
+ // Snapshot at construction so a long-running Velopack flow (retries, redirects,
+ // delta + full downloads) doesn't keep re-reading the credential manager.
+ private readonly ProxySettings _settings = ProxySettings.Load();
+
+ protected override HttpClientHandler CreateHttpClientHandler()
+ {
+ return ProxyHttpHandlerFactory.Create(_settings);
+ }
+}
diff --git a/src/PlanViewer.App/Services/ProxyHttpHandlerFactory.cs b/src/PlanViewer.App/Services/ProxyHttpHandlerFactory.cs
new file mode 100644
index 0000000..08a4c08
--- /dev/null
+++ b/src/PlanViewer.App/Services/ProxyHttpHandlerFactory.cs
@@ -0,0 +1,53 @@
+using System.Net;
+using System.Net.Http;
+
+namespace PlanViewer.App.Services;
+
+///
+/// Builds an configured from .
+/// Centralised so the update check and the Velopack downloader share one source of truth.
+///
+internal static class ProxyHttpHandlerFactory
+{
+ public static HttpClientHandler Create(ProxySettings settings)
+ {
+ var handler = new HttpClientHandler
+ {
+ AllowAutoRedirect = true,
+ MaxAutomaticRedirections = 10,
+ AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
+ };
+
+ if (settings.Mode == ProxyMode.Manual && !string.IsNullOrWhiteSpace(settings.Address))
+ {
+ var proxy = new WebProxy(settings.Address);
+ proxy.Credentials = string.IsNullOrEmpty(settings.Username)
+ ? CredentialCache.DefaultNetworkCredentials
+ : new NetworkCredential(settings.Username, settings.Password);
+
+ handler.UseProxy = true;
+ handler.Proxy = proxy;
+ handler.PreAuthenticate = true;
+ }
+ else
+ {
+ // System / auto-discovered proxy — most corporate setups need default
+ // Windows credentials sent for NTLM/Negotiate. This is the cheap fix
+ // that solves auto-detected proxies without any UI.
+ handler.UseProxy = true;
+ try
+ {
+ handler.Proxy = WebRequest.GetSystemWebProxy();
+ }
+ catch
+ {
+ // GetSystemWebProxy can throw on some Linux configs — fall back to
+ // HttpClient's default proxy resolution.
+ }
+ handler.DefaultProxyCredentials = CredentialCache.DefaultNetworkCredentials;
+ handler.PreAuthenticate = true;
+ }
+
+ return handler;
+ }
+}
diff --git a/src/PlanViewer.App/Services/ProxySettings.cs b/src/PlanViewer.App/Services/ProxySettings.cs
new file mode 100644
index 0000000..857422b
--- /dev/null
+++ b/src/PlanViewer.App/Services/ProxySettings.cs
@@ -0,0 +1,81 @@
+using System;
+using PlanViewer.Core.Services;
+
+namespace PlanViewer.App.Services;
+
+internal enum ProxyMode
+{
+ System,
+ Manual
+}
+
+internal sealed class ProxySettings
+{
+ private const string CredentialServerId = "__proxy__";
+
+ public ProxyMode Mode { get; set; } = ProxyMode.System;
+ public string Address { get; set; } = "";
+ public string Username { get; set; } = "";
+ public string Password { get; set; } = "";
+
+ ///
+ /// When false, writes the JSON fields but leaves the stored
+ /// credential untouched. Used by the UI to support "leave blank to keep" — the
+ /// user shouldn't have to retype the password every time they change a non-secret
+ /// field like the proxy address.
+ ///
+ public bool TouchCredential { get; set; } = true;
+
+ public static ProxySettings Load()
+ {
+ var obj = SettingsFile.Read();
+ var s = new ProxySettings();
+
+ if (obj["proxy_mode"]?.GetValue() is { } modeStr &&
+ Enum.TryParse(modeStr, ignoreCase: true, out var mode))
+ {
+ s.Mode = mode;
+ }
+ s.Address = obj["proxy_address"]?.GetValue() ?? "";
+ s.Username = obj["proxy_username"]?.GetValue() ?? "";
+
+ try
+ {
+ var cred = CredentialServiceFactory.Create().GetCredential(CredentialServerId);
+ if (cred.HasValue)
+ s.Password = cred.Value.Password;
+ }
+ catch
+ {
+ // Credential store unavailable — leave password empty.
+ }
+
+ return s;
+ }
+
+ public void Save()
+ {
+ SettingsFile.Update(o =>
+ {
+ o["proxy_mode"] = Mode.ToString().ToLowerInvariant();
+ o["proxy_address"] = Address;
+ o["proxy_username"] = Username;
+ });
+
+ if (!TouchCredential)
+ return;
+
+ try
+ {
+ var svc = CredentialServiceFactory.Create();
+ if (string.IsNullOrEmpty(Password))
+ svc.DeleteCredential(CredentialServerId);
+ else
+ svc.SaveCredential(CredentialServerId, Username, Password);
+ }
+ catch
+ {
+ // Credential store unavailable — password not persisted.
+ }
+ }
+}
diff --git a/src/PlanViewer.App/Services/SettingsFile.cs b/src/PlanViewer.App/Services/SettingsFile.cs
new file mode 100644
index 0000000..cb28173
--- /dev/null
+++ b/src/PlanViewer.App/Services/SettingsFile.cs
@@ -0,0 +1,38 @@
+using System;
+using System.IO;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace PlanViewer.App.Services;
+
+internal static class SettingsFile
+{
+ public static string Path { get; } = System.IO.Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".planview", "settings.json");
+
+ public static JsonObject Read()
+ {
+ if (!File.Exists(Path))
+ return new JsonObject();
+
+ try
+ {
+ var json = File.ReadAllText(Path);
+ return JsonNode.Parse(json) as JsonObject ?? new JsonObject();
+ }
+ catch
+ {
+ return new JsonObject();
+ }
+ }
+
+ public static void Update(Action mutate)
+ {
+ var obj = Read();
+ mutate(obj);
+ Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!);
+ var json = obj.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
+ AtomicFile.WriteAllText(Path, json);
+ }
+}
diff --git a/src/PlanViewer.App/Services/UpdateChecker.cs b/src/PlanViewer.App/Services/UpdateChecker.cs
index 765bf0b..d51b9e0 100644
--- a/src/PlanViewer.App/Services/UpdateChecker.cs
+++ b/src/PlanViewer.App/Services/UpdateChecker.cs
@@ -12,21 +12,21 @@ public static class UpdateChecker
private const string ReleasesApiUrl =
"https://api.github.com/repos/erikdarlingdata/PerformanceStudio/releases/latest";
- private static readonly HttpClient Http = new()
+ public static async Task CheckAsync(Version currentVersion)
{
- DefaultRequestHeaders =
+ // Build a fresh client per call so proxy-setting changes take effect without
+ // requiring an app restart. The check is rare and the handler is cheap.
+ using var handler = ProxyHttpHandlerFactory.Create(ProxySettings.Load());
+ using var http = new HttpClient(handler)
{
- { "User-Agent", "PerformanceStudio-UpdateCheck" },
- { "Accept", "application/vnd.github+json" }
- },
- Timeout = TimeSpan.FromSeconds(10)
- };
+ Timeout = TimeSpan.FromSeconds(10)
+ };
+ http.DefaultRequestHeaders.Add("User-Agent", "PerformanceStudio-UpdateCheck");
+ http.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json");
- public static async Task CheckAsync(Version currentVersion)
- {
try
{
- var json = await Http.GetStringAsync(ReleasesApiUrl);
+ var json = await http.GetStringAsync(ReleasesApiUrl);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;