Skip to content
Open
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
653 changes: 653 additions & 0 deletions FluentFlyoutWPF/Classes/AutoUpdater.cs

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions FluentFlyoutWPF/Classes/Notifications.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,18 @@ public static void HandleNotificationActivation(ToastNotificationActivatedEventA
OpenChangelogInBrowser();
break;
case "downloadUpdate":
#if GITHUB_RELEASE
// For GitHub Release builds, open settings to trigger in-app update
Application.Current.Dispatcher.Invoke(() =>
{
FluentFlyoutWPF.SettingsWindow.ShowInstance();
});
#else
if (args.TryGetValue("url", out string url))
{
OpenUrlInBrowser(url);
}
#endif
break;
}
}
Expand Down
68 changes: 68 additions & 0 deletions FluentFlyoutWPF/Classes/UpdateChecker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,74 @@ public static void OpenUpdateUrl(string url)
}
}

#if GITHUB_RELEASE
/// <summary>
/// Information about a GitHub Release asset
/// </summary>
public class GitHubReleaseAsset
{
public string DownloadUrl { get; set; } = string.Empty;
public long Size { get; set; }
public string Name { get; set; } = string.Empty;
public string TagName { get; set; } = string.Empty;
}

private const string GitHubApiEndpoint = "https://api.github.com/repos/unchihugo/FluentFlyout/releases/latest";

/// <summary>
/// Fetches the latest .msixbundle asset from GitHub Releases
/// </summary>
public static async Task<GitHubReleaseAsset?> GetGitHubReleaseAssetAsync()
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, GitHubApiEndpoint);
request.Headers.Add("User-Agent", "FluentFlyout-AutoUpdater");
request.Headers.Add("Accept", "application/vnd.github.v3+json");

using var response = await HttpClient.SendAsync(request);
response.EnsureSuccessStatusCode();

using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var tagName = json.RootElement.GetProperty("tag_name").GetString() ?? string.Empty;
var assets = json.RootElement.GetProperty("assets");

foreach (var asset in assets.EnumerateArray())
{
var name = asset.GetProperty("name").GetString() ?? string.Empty;
if (name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
var downloadUrl = asset.GetProperty("browser_download_url").GetString() ?? string.Empty;

// Security: enforce download originates from the pinned GitHub repository
const string allowedPrefix = "https://github.com/unchihugo/FluentFlyout/releases/download/";
if (!downloadUrl.StartsWith(allowedPrefix, StringComparison.OrdinalIgnoreCase))
{
Logger.Warn("Rejected download URL outside pinned repository: {Url}", downloadUrl);
return null;
}

return new GitHubReleaseAsset
{
DownloadUrl = downloadUrl,
Size = asset.GetProperty("size").GetInt64(),
Name = name,
TagName = tagName
};
}
}

Logger.Warn("No .zip installer asset found in latest GitHub release");
return null;
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to fetch GitHub release asset");
return null;
}
}
#endif

private static bool IsNewerVersion(string currentVersion, string newestVersion)
{
try
Expand Down
39 changes: 39 additions & 0 deletions FluentFlyoutWPF/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,30 @@ private async Task CheckForUpdatesOnStartupAsync()
if (result.IsUpdateAvailable)
{
Notifications.ShowUpdateAvailableNotification(result.NewestVersion, result.UpdateUrl);

#if GITHUB_RELEASE
// Auto-download update in background if enabled
if (SettingsManager.Current.AutoUpdateEnabled)
{
_ = Task.Run(async () =>
{
try
{
var asset = await UpdateChecker.GetGitHubReleaseAssetAsync();
if (asset != null)
{
await AutoUpdater.DownloadUpdateAsync(
asset.DownloadUrl, asset.Size, asset.Name);
Logger.Info("Auto-update downloaded in background, ready to install");
}
}
catch (Exception bgEx)
{
Logger.Error(bgEx, "Background auto-update download failed");
}
});
}
#endif
}
}
}
Expand Down Expand Up @@ -1302,6 +1326,21 @@ private void CleanupResources()
// dispose mutex
singleton?.Dispose();

#if GITHUB_RELEASE
// Clean up downloaded update files if not actively installing
try
{
if (!FluentFlyoutWPF.ViewModels.UpdateState.Current.IsInstalling)
{
AutoUpdater.CleanupDownloadedFiles();
}
}
catch (Exception cleanupEx)
{
Logger.Warn(cleanupEx, "Failed to cleanup update files on exit");
}
#endif

// flush and close NLog
NLog.LogManager.Shutdown();
}
Expand Down
28 changes: 28 additions & 0 deletions FluentFlyoutWPF/Pages/HomePage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,34 @@
</StackPanel>
</ui:Button>

<!--Auto-update progress panel (GitHub Release only, controlled by code-behind)-->
<StackPanel x:Name="AutoUpdatePanel" Visibility="Collapsed" Margin="0,0,16,16">
<StackPanel x:Name="DownloadProgressPanel" Visibility="Collapsed">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<ui:SymbolIcon Symbol="ArrowDownload24" FontSize="16" Margin="0,0,8,0" />
<ui:TextBlock x:Name="DownloadStatusText" Text="Downloading..." FontSize="12" />
</StackPanel>
<ProgressBar x:Name="DownloadProgressBar" Minimum="0" Maximum="100" Width="200" Height="4"
Value="{Binding DownloadProgress, Source={x:Static viewModels:UpdateState.Current}}" />
</StackPanel>
<StackPanel x:Name="InstallPanel" Visibility="Collapsed" Orientation="Horizontal">
<ui:Button x:Name="InstallUpdateButton" Appearance="Primary" Click="InstallUpdate_Click" Margin="0,0,8,0">
<StackPanel Orientation="Horizontal">
<ui:SymbolIcon Symbol="ArrowDownload24" FontSize="16" Margin="0,0,6,0" />
<ui:TextBlock Text="{DynamicResource AutoUpdateInstallButton}" />
</StackPanel>
</ui:Button>
<ui:SymbolIcon Symbol="CheckmarkCircle24" FontSize="16" Foreground="{DynamicResource SystemFillColorSuccessBrush}"
VerticalAlignment="Center" />
<ui:TextBlock Text="{DynamicResource AutoUpdateDownloaded}" FontSize="12" VerticalAlignment="Center" Margin="4,0,0,0" />
</StackPanel>
<StackPanel x:Name="InstallingPanel" Visibility="Collapsed" Orientation="Horizontal">
<ProgressBar IsIndeterminate="True" Width="100" Height="4" Margin="0,0,8,0" VerticalAlignment="Center" />
<ui:TextBlock Text="{DynamicResource AutoUpdateInstalling}" FontSize="12" VerticalAlignment="Center" />
</StackPanel>
<ui:InfoBar x:Name="UpdateErrorBar" IsOpen="False" Severity="Error" IsClosable="True" Margin="0,8,0,0" />
</StackPanel>

<!--Supporter status indicator-->
<ui:Button Appearance="Transparent" Padding="8" BorderBrush="Transparent" Margin="0,0,0,16" IsHitTestVisible="False">
<ui:Button.Style>
Expand Down
117 changes: 117 additions & 0 deletions FluentFlyoutWPF/Pages/HomePage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,12 @@ private async void CheckForUpdates_Click(object sender, RoutedEventArgs e)

if (UpdateState.Current.IsUpdateAvailable)
{
#if GITHUB_RELEASE
await StartAutoUpdateAsync();
#else
string url = !string.IsNullOrEmpty(UpdateState.Current.UpdateUrl) ? UpdateState.Current.UpdateUrl : "https://fluentflyout.com/changelog/";
UpdateChecker.OpenUpdateUrl(url);
#endif
}
else
{
Expand Down Expand Up @@ -209,4 +213,117 @@ private void ReportBug_Click(object sender, System.Windows.RoutedEventArgs e)
Logger.Error(ex, "Failed to open bug report page");
}
}

#if GITHUB_RELEASE
private CancellationTokenSource? _downloadCts;

private async Task StartAutoUpdateAsync()
{
if (UpdateState.Current.IsDownloading || UpdateState.Current.IsInstalling)
return;

// If already downloaded, show install panel
if (!string.IsNullOrEmpty(UpdateState.Current.DownloadedBundlePath)
&& File.Exists(UpdateState.Current.DownloadedBundlePath))
{
ShowInstallPanel();
return;
}

try
{
AutoUpdatePanel.Visibility = Visibility.Visible;
DownloadProgressPanel.Visibility = Visibility.Visible;
InstallPanel.Visibility = Visibility.Collapsed;
InstallingPanel.Visibility = Visibility.Collapsed;
UpdateErrorBar.IsOpen = false;

// Fetch the GitHub release asset info
DownloadStatusText.Text = Application.Current.FindResource("CheckingForUpdates")?.ToString() ?? "Checking...";
var asset = await UpdateChecker.GetGitHubReleaseAssetAsync();
if (asset == null)
{
ShowUpdateError("Could not find update package on GitHub.");
return;
}

DownloadStatusText.Text = Application.Current.FindResource("AutoUpdateDownloading")?.ToString() ?? "Downloading...";
_downloadCts = new CancellationTokenSource();

var progress = new Progress<double>(pct =>
{
DownloadProgressBar.Value = pct;
DownloadStatusText.Text = $"{Application.Current.FindResource("AutoUpdateDownloading")?.ToString() ?? "Downloading..."} {pct:F0}%";
});

var filePath = await AutoUpdater.DownloadUpdateAsync(
asset.DownloadUrl, asset.Size, asset.Name, progress, _downloadCts.Token);

if (filePath != null)
{
DownloadProgressPanel.Visibility = Visibility.Collapsed;
ShowInstallPanel();
}
else
{
ShowUpdateError(UpdateState.Current.UpdateError);
}
}
catch (Exception ex)
{
Logger.Error(ex, "Auto-update failed");
ShowUpdateError($"Update failed: {ex.Message}");
}
}

private void ShowInstallPanel()
{
AutoUpdatePanel.Visibility = Visibility.Visible;
DownloadProgressPanel.Visibility = Visibility.Collapsed;
InstallPanel.Visibility = Visibility.Visible;
InstallingPanel.Visibility = Visibility.Collapsed;
}

private void ShowUpdateError(string message)
{
AutoUpdatePanel.Visibility = Visibility.Visible;
UpdateErrorBar.Title = message;
UpdateErrorBar.IsOpen = true;
DownloadProgressPanel.Visibility = Visibility.Collapsed;
InstallPanel.Visibility = Visibility.Collapsed;
InstallingPanel.Visibility = Visibility.Collapsed;
}

#endif

// Event handler must exist unconditionally since XAML references it.
// The auto-update logic is only compiled for GitHub Release builds.
private async void InstallUpdate_Click(object sender, RoutedEventArgs e)
{
#if GITHUB_RELEASE
var path = UpdateState.Current.DownloadedBundlePath;
if (string.IsNullOrEmpty(path) || !File.Exists(path))
{
ShowUpdateError("Update file not found. Please try again.");
UpdateState.Current.DownloadedBundlePath = string.Empty;
InstallPanel.Visibility = Visibility.Collapsed;
return;
}

InstallPanel.Visibility = Visibility.Collapsed;
InstallingPanel.Visibility = Visibility.Visible;
UpdateErrorBar.IsOpen = false;

var success = await AutoUpdater.InstallUpdateAsync(path);

if (!success)
{
InstallingPanel.Visibility = Visibility.Collapsed;
ShowUpdateError(UpdateState.Current.UpdateError);
}
// If successful, the app will be shut down by -ForceApplicationShutdown
#else
await Task.CompletedTask;
#endif
}
}
11 changes: 10 additions & 1 deletion FluentFlyoutWPF/Pages/SystemPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -169,12 +169,21 @@

<!-- update section -->
<TextBlock Text="{DynamicResource UpdatesSectionTitle}" FontSize="14" FontWeight="SemiBold" Margin="0,12,0,6" />
<ui:CardControl Margin="0,0,0,36" >
<ui:CardControl Margin="0,3,0,0">
<ui:CardControl.Header>
<TextBlock Text="{DynamicResource ShowUpdateNotificationsTitle}" FontSize="14" FontWeight="Regular" VerticalAlignment="Center" />
</ui:CardControl.Header>
<controls:ToggleSwitch IsChecked="{Binding ShowUpdateNotifications, Mode=TwoWay}" />
</ui:CardControl>
<ui:CardControl x:Name="AutoUpdateCard" Margin="0,3,0,36" Visibility="Collapsed">
<ui:CardControl.Header>
<StackPanel Orientation="Vertical">
<TextBlock Text="{DynamicResource AutoUpdateTitle}" FontSize="14" FontWeight="Regular" VerticalAlignment="Center" />
<TextBlock Text="{DynamicResource AutoUpdateDescription}" FontSize="12" Opacity="0.5" />
</StackPanel>
</ui:CardControl.Header>
<controls:ToggleSwitch IsChecked="{Binding AutoUpdateEnabled, Mode=TwoWay}" />
</ui:CardControl>

<!-- advanced -->
<ui:CardAction Margin="0,0,0,76" IsChevronVisible="True" Click="Advanced_Click">
Expand Down
4 changes: 4 additions & 0 deletions FluentFlyoutWPF/Pages/SystemPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ public SystemPage()
InitializeComponent();
DataContext = SettingsManager.Current;
UpdateMonitorList();

#if GITHUB_RELEASE
AutoUpdateCard.Visibility = System.Windows.Visibility.Visible;
#endif
}

private void StartupSwitch_Click(object sender, RoutedEventArgs e)
Expand Down
6 changes: 6 additions & 0 deletions FluentFlyoutWPF/Resources/Localization/Dictionary-en-US.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -229,4 +229,10 @@ You can help contribute translations on</System:String>
<System:String x:Key="AdvancedSettingsTitle">Advanced Settings</System:String>
<System:String x:Key="LegacyTaskbarWidthTitle">Legacy Taskbar Width System</System:String>
<System:String x:Key="LegacyTaskbarWidthDescription">Use the legacy Taskbar width system for Taskbar Widgets</System:String>
<System:String x:Key="AutoUpdateTitle">Automatically download updates</System:String>
<System:String x:Key="AutoUpdateDescription">When an update is available, it will be downloaded in the background and ready to install</System:String>
<System:String x:Key="AutoUpdateDownloading">Downloading...</System:String>
<System:String x:Key="AutoUpdateInstallButton">Install Update</System:String>
<System:String x:Key="AutoUpdateInstalling">Installing...</System:String>
<System:String x:Key="AutoUpdateDownloaded">Ready to install</System:String>
</ResourceDictionary>
30 changes: 30 additions & 0 deletions FluentFlyoutWPF/ViewModels/UpdateState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,36 @@ public partial class UpdateState : ObservableObject
[ObservableProperty]
public partial string UpdateUrl { get; set; } = string.Empty;

/// <summary>
/// Whether a download is currently in progress
/// </summary>
[ObservableProperty]
public partial bool IsDownloading { get; set; }

/// <summary>
/// Download progress percentage (0-100)
/// </summary>
[ObservableProperty]
public partial double DownloadProgress { get; set; }

/// <summary>
/// Whether an installation is currently in progress
/// </summary>
[ObservableProperty]
public partial bool IsInstalling { get; set; }

/// <summary>
/// Error message from the last update attempt (empty if no error)
/// </summary>
[ObservableProperty]
public partial string UpdateError { get; set; } = string.Empty;

/// <summary>
/// The local file path of the downloaded .msixbundle (for install step)
/// </summary>
[ObservableProperty]
public partial string DownloadedBundlePath { get; set; } = string.Empty;

/// <summary>
/// Timestamp of the last update check
/// </summary>
Expand Down
Loading