Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 47 additions & 3 deletions src/PlanViewer.App/AboutWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -47,14 +47,22 @@
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,6,0,0">
<Button x:Name="CheckUpdateButton" Content="Check for Updates"
Click="CheckUpdate_Click" Padding="10,4" FontSize="12"/>
<TextBlock x:Name="UpdateStatusText" FontSize="12" VerticalAlignment="Center"
Foreground="{DynamicResource ForegroundBrush}"/>
<TextBlock x:Name="UpdateLink" FontSize="12" VerticalAlignment="Center"
Foreground="{DynamicResource AccentBrush}"
Cursor="Hand" IsVisible="False"
TextDecorations="Underline"
PointerPressed="UpdateLink_Click"/>
</StackPanel>
<TextBlock x:Name="UpdateStatusText" FontSize="12"
Foreground="{DynamicResource ForegroundBrush}"
TextWrapping="Wrap" Margin="0,4,0,0"/>
<TextBlock x:Name="ReleasesPageLink" FontSize="12"
Foreground="{DynamicResource AccentBrush}"
Cursor="Hand" IsVisible="False"
Text="Open the releases page in your browser"
TextDecorations="Underline"
PointerPressed="ReleasesPageLink_Click"
Margin="0,4,0,0"/>
</StackPanel>

<!-- MCP Settings -->
Expand All @@ -80,6 +88,42 @@
<TextBlock x:Name="McpCopyStatus" FontSize="11" VerticalAlignment="Center"
Foreground="{DynamicResource ForegroundBrush}"/>
</StackPanel>

<!-- Proxy Settings -->
<TextBlock Text="Proxy" FontWeight="SemiBold" FontSize="12"
Foreground="{DynamicResource ForegroundBrush}" Margin="0,16,0,4"/>
<StackPanel Orientation="Horizontal" Spacing="12">
<RadioButton x:Name="ProxySystemRadio" GroupName="ProxyMode"
Content="Use system proxy (Windows credentials)"
FontSize="12" IsChecked="True"
Foreground="{DynamicResource ForegroundBrush}"/>
<RadioButton x:Name="ProxyManualRadio" GroupName="ProxyMode"
Content="Manual"
FontSize="12"
Foreground="{DynamicResource ForegroundBrush}"/>
</StackPanel>
<Grid x:Name="ProxyManualPanel" IsVisible="False"
ColumnDefinitions="90,*" RowDefinitions="Auto,Auto,Auto"
Margin="0,8,0,0" RowSpacing="6" ColumnSpacing="8">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Address:" FontSize="12"
VerticalAlignment="Center"
Foreground="{DynamicResource ForegroundBrush}"/>
<TextBox Grid.Row="0" Grid.Column="1" x:Name="ProxyAddressInput"
Watermark="http://proxy.example.com:8080" FontSize="12"
Padding="6,2" Height="28"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Username:" FontSize="12"
VerticalAlignment="Center"
Foreground="{DynamicResource ForegroundBrush}"/>
<TextBox Grid.Row="1" Grid.Column="1" x:Name="ProxyUsernameInput"
Watermark="DOMAIN\user" FontSize="12"
Padding="6,2" Height="28"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Password:" FontSize="12"
VerticalAlignment="Center"
Foreground="{DynamicResource ForegroundBrush}"/>
<TextBox Grid.Row="2" Grid.Column="1" x:Name="ProxyPasswordInput"
PasswordChar="•" FontSize="12"
Padding="6,2" Height="28"/>
</Grid>
</StackPanel>

<!-- Close -->
Expand Down
99 changes: 79 additions & 20 deletions src/PlanViewer.App/AboutWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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()
{
Expand All @@ -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);
Expand All @@ -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)
Expand All @@ -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;
}
}

Expand All @@ -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)
{
Expand All @@ -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)
Expand Down Expand Up @@ -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;
}
Expand Down
22 changes: 22 additions & 0 deletions src/PlanViewer.App/Services/ProxyAwareDownloader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Net.Http;
using Velopack.Sources;

namespace PlanViewer.App.Services;

/// <summary>
/// Velopack downloader that routes through the user-configured proxy. The default
/// <see cref="HttpClientFileDownloader"/> creates a vanilla <see cref="HttpClientHandler"/>
/// whose proxy never sees Windows credentials, which breaks "Check for Updates" on
/// most corporate networks (GitHub issue #314).
/// </summary>
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);
}
}
53 changes: 53 additions & 0 deletions src/PlanViewer.App/Services/ProxyHttpHandlerFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using System.Net;
using System.Net.Http;

namespace PlanViewer.App.Services;

/// <summary>
/// Builds an <see cref="HttpClientHandler"/> configured from <see cref="ProxySettings"/>.
/// Centralised so the update check and the Velopack downloader share one source of truth.
/// </summary>
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;
}
}
Loading
Loading