mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 16:14:28 +08:00
709 lines
26 KiB
C#
709 lines
26 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.ComponentModel;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Runtime.InteropServices;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using LanMountainDesktop.PluginSdk;
|
|
using LanMountainDesktop.Services.Settings;
|
|
|
|
namespace LanMountainDesktop.Services;
|
|
|
|
public sealed record UpdatePendingInfo(
|
|
string InstallerPath,
|
|
string VersionText,
|
|
DateTimeOffset? PublishedAt,
|
|
string? Sha256 = null);
|
|
|
|
public sealed record UpdateVerifyResult(
|
|
bool Success,
|
|
bool HashMatched,
|
|
string? ExpectedHash,
|
|
string? ActualHash,
|
|
string? ErrorMessage);
|
|
|
|
public sealed record UpdateInstallerLaunchResult(
|
|
bool Success,
|
|
bool UserCancelledElevation,
|
|
string? ErrorMessage);
|
|
|
|
internal static class HostUpdateWorkflowServiceProvider
|
|
{
|
|
private static readonly object Gate = new();
|
|
private static UpdateWorkflowService? _instance;
|
|
|
|
public static UpdateWorkflowService GetOrCreate()
|
|
{
|
|
lock (Gate)
|
|
{
|
|
return _instance ??= new UpdateWorkflowService(HostSettingsFacadeProvider.GetOrCreate());
|
|
}
|
|
}
|
|
}
|
|
|
|
public sealed class UpdateWorkflowService
|
|
{
|
|
private readonly ISettingsFacadeService _settingsFacade;
|
|
private readonly string _updatesDirectory;
|
|
|
|
private const string LauncherDirectoryName = ".launcher";
|
|
private const string UpdateDirectoryName = "update";
|
|
private const string IncomingDirectoryName = "incoming";
|
|
private const string SignedFileMapName = "files.json";
|
|
private const string SignedFileMapSignatureName = "files.json.sig";
|
|
private const string UpdateArchiveName = "update.zip";
|
|
|
|
public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
|
|
{
|
|
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
|
_updatesDirectory = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
"LanMountainDesktop",
|
|
"Updates");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the path to the Launcher's incoming update directory where delta packages should be placed.
|
|
/// </summary>
|
|
public static string GetLauncherIncomingDirectory()
|
|
{
|
|
// The app runs from app-{version}/ subdirectory; Launcher root is one level up.
|
|
var appBaseDir = AppContext.BaseDirectory;
|
|
var launcherRoot = Path.GetDirectoryName(appBaseDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
|
if (string.IsNullOrWhiteSpace(launcherRoot))
|
|
{
|
|
launcherRoot = appBaseDir;
|
|
}
|
|
return Path.Combine(launcherRoot, LauncherDirectoryName, UpdateDirectoryName, IncomingDirectoryName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks whether a GitHub Release contains signed file-map assets needed for incremental updates.
|
|
/// </summary>
|
|
public static bool IsDeltaUpdateAvailable(GitHubReleaseInfo release)
|
|
{
|
|
if (release is null || release.Assets is null || release.Assets.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return TryResolveDeltaAssets(release.Assets, out _, out _, out _);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Downloads signed file-map assets to the Launcher's incoming directory.
|
|
/// </summary>
|
|
public async Task<UpdateDownloadResult> DownloadDeltaUpdateAsync(
|
|
UpdateCheckResult checkResult,
|
|
IProgress<double>? progress = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(checkResult);
|
|
|
|
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null)
|
|
{
|
|
return new UpdateDownloadResult(false, null, "No update available for delta download.");
|
|
}
|
|
|
|
if (!TryResolveDeltaAssets(checkResult.Release.Assets, out var manifestAsset, out var signatureAsset, out var archiveAsset))
|
|
{
|
|
return new UpdateDownloadResult(false, null, "Release does not contain compatible signed file-map assets.");
|
|
}
|
|
|
|
var incomingDir = GetLauncherIncomingDirectory();
|
|
|
|
try
|
|
{
|
|
Directory.CreateDirectory(incomingDir);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new UpdateDownloadResult(false, null, $"Failed to create incoming directory: {ex.Message}");
|
|
}
|
|
|
|
var state = _settingsFacade.Update.Get();
|
|
var downloadSource = state.UpdateDownloadSource;
|
|
var downloadThreads = state.UpdateDownloadThreads;
|
|
|
|
var requiredAssets = new List<(GitHubReleaseAsset Asset, string DestinationFileName)>
|
|
{
|
|
(manifestAsset, SignedFileMapName),
|
|
(signatureAsset, SignedFileMapSignatureName),
|
|
(archiveAsset, UpdateArchiveName)
|
|
};
|
|
|
|
var totalAssets = requiredAssets.Count;
|
|
var completedAssets = 0;
|
|
|
|
foreach (var (asset, destinationFileName) in requiredAssets)
|
|
{
|
|
var destinationPath = Path.Combine(incomingDir, destinationFileName);
|
|
|
|
// Skip if already downloaded and file exists
|
|
if (File.Exists(destinationPath))
|
|
{
|
|
var existingHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(destinationPath, cancellationToken);
|
|
if (asset.Sha256 is not null && string.Equals(existingHash, asset.Sha256, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
AppLogger.Info("UpdateWorkflow", $"Update asset {asset.Name} already downloaded with matching hash, skipping.");
|
|
completedAssets++;
|
|
progress?.Report((double)completedAssets / totalAssets);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
var assetProgress = progress is null ? null : new Progress<double>(p =>
|
|
{
|
|
var overallProgress = ((double)completedAssets + p) / totalAssets;
|
|
progress.Report(overallProgress);
|
|
});
|
|
|
|
var result = await _settingsFacade.Update.DownloadAssetAsync(
|
|
asset,
|
|
destinationPath,
|
|
downloadSource,
|
|
downloadThreads,
|
|
assetProgress,
|
|
cancellationToken);
|
|
|
|
if (!result.Success)
|
|
{
|
|
// Clean up partially downloaded files
|
|
foreach (var file in requiredAssets.Select(a => a.DestinationFileName))
|
|
{
|
|
try { File.Delete(Path.Combine(incomingDir, file)); } catch { }
|
|
}
|
|
return new UpdateDownloadResult(false, null, $"Failed to download update asset {asset.Name}: {result.ErrorMessage}");
|
|
}
|
|
|
|
completedAssets++;
|
|
progress?.Report((double)completedAssets / totalAssets);
|
|
}
|
|
|
|
// Save state indicating a signed file-map update is pending.
|
|
SaveState(state with
|
|
{
|
|
PendingUpdateInstallerPath = Path.Combine(incomingDir, SignedFileMapName),
|
|
PendingUpdateVersion = checkResult.LatestVersionText,
|
|
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
|
? null
|
|
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
|
|
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
|
PendingUpdateSha256 = null
|
|
});
|
|
|
|
AppLogger.Info("UpdateWorkflow", $"Signed file-map update payload downloaded to {incomingDir}. Will be applied by Launcher on next startup.");
|
|
|
|
return new UpdateDownloadResult(true, Path.Combine(incomingDir, SignedFileMapName), null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks whether the pending update is managed by Launcher incoming payload.
|
|
/// </summary>
|
|
public bool IsPendingDeltaUpdate()
|
|
{
|
|
var state = _settingsFacade.Update.Get();
|
|
var pendingPath = state.PendingUpdateInstallerPath?.Trim();
|
|
if (string.IsNullOrWhiteSpace(pendingPath))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Incoming payload updates are identified by files.json or incoming directory path.
|
|
return pendingPath.EndsWith(SignedFileMapName, StringComparison.OrdinalIgnoreCase)
|
|
|| pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static bool TryResolveDeltaAssets(
|
|
IReadOnlyList<GitHubReleaseAsset> assets,
|
|
out GitHubReleaseAsset manifestAsset,
|
|
out GitHubReleaseAsset signatureAsset,
|
|
out GitHubReleaseAsset archiveAsset)
|
|
{
|
|
manifestAsset = default!;
|
|
signatureAsset = default!;
|
|
archiveAsset = default!;
|
|
|
|
if (assets is null || assets.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var platformSuffix = GetPlatformAssetSuffix();
|
|
var platformManifest = $"files-{platformSuffix}.json";
|
|
var platformSignature = $"files-{platformSuffix}.json.sig";
|
|
var platformArchive = $"update-{platformSuffix}.zip";
|
|
|
|
var manifestCandidate = FindAsset(assets, platformManifest) ?? FindAsset(assets, SignedFileMapName);
|
|
var signatureCandidate = FindAsset(assets, platformSignature) ?? FindAsset(assets, SignedFileMapSignatureName);
|
|
var archiveCandidate = FindAsset(assets, platformArchive) ?? FindAsset(assets, UpdateArchiveName);
|
|
if (manifestCandidate is null || signatureCandidate is null || archiveCandidate is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
manifestAsset = manifestCandidate;
|
|
signatureAsset = signatureCandidate;
|
|
archiveAsset = archiveCandidate;
|
|
return true;
|
|
}
|
|
|
|
private static GitHubReleaseAsset? FindAsset(IReadOnlyList<GitHubReleaseAsset> assets, string name)
|
|
{
|
|
return assets.FirstOrDefault(a => string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private static string GetPlatformAssetSuffix()
|
|
{
|
|
var os = OperatingSystem.IsWindows()
|
|
? "windows"
|
|
: OperatingSystem.IsLinux()
|
|
? "linux"
|
|
: OperatingSystem.IsMacOS()
|
|
? "macos"
|
|
: "unknown";
|
|
|
|
var arch = RuntimeInformation.OSArchitecture switch
|
|
{
|
|
Architecture.X86 => "x86",
|
|
Architecture.Arm => "arm",
|
|
Architecture.Arm64 => "arm64",
|
|
_ => "x64"
|
|
};
|
|
|
|
return $"{os}-{arch}";
|
|
}
|
|
|
|
public UpdatePendingInfo? GetPendingUpdate()
|
|
{
|
|
var state = _settingsFacade.Update.Get();
|
|
return GetPendingUpdate(state);
|
|
}
|
|
|
|
public async Task<UpdateCheckResult> CheckForUpdatesAsync(
|
|
Version currentVersion,
|
|
bool isForce = false,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var state = _settingsFacade.Update.Get();
|
|
var includePrerelease = string.Equals(
|
|
UpdateSettingsValues.NormalizeChannel(state.UpdateChannel, state.IncludePrereleaseUpdates),
|
|
UpdateSettingsValues.ChannelPreview,
|
|
StringComparison.OrdinalIgnoreCase);
|
|
|
|
var result = isForce
|
|
? await _settingsFacade.Update.ForceCheckForUpdatesAsync(
|
|
currentVersion,
|
|
includePrerelease,
|
|
cancellationToken)
|
|
: await _settingsFacade.Update.CheckForUpdatesAsync(
|
|
currentVersion,
|
|
includePrerelease,
|
|
cancellationToken);
|
|
|
|
SaveState(state with
|
|
{
|
|
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
public async Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
|
Version currentVersion,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return await CheckForUpdatesAsync(currentVersion, true, cancellationToken);
|
|
}
|
|
|
|
public async Task<UpdateDownloadResult> DownloadReleaseAsync(
|
|
UpdateCheckResult checkResult,
|
|
IProgress<double>? progress = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(checkResult);
|
|
|
|
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null)
|
|
{
|
|
return new UpdateDownloadResult(false, null, "No compatible update asset is available.");
|
|
}
|
|
|
|
var state = _settingsFacade.Update.Get();
|
|
var existingPending = GetPendingUpdate(state);
|
|
if (existingPending is not null &&
|
|
string.Equals(existingPending.VersionText, checkResult.LatestVersionText, StringComparison.OrdinalIgnoreCase) &&
|
|
File.Exists(existingPending.InstallerPath))
|
|
{
|
|
var verifyResult = await VerifyPendingUpdateAsync();
|
|
if (verifyResult.Success)
|
|
{
|
|
return new UpdateDownloadResult(true, existingPending.InstallerPath, null, verifyResult.HashMatched, verifyResult.ExpectedHash, verifyResult.ActualHash);
|
|
}
|
|
|
|
AppLogger.Warn("UpdateWorkflow", $"Existing installer hash verification failed, will redownload. Expected: {verifyResult.ExpectedHash}, Actual: {verifyResult.ActualHash}");
|
|
}
|
|
|
|
Directory.CreateDirectory(_updatesDirectory);
|
|
var fileName = SanitizeFileName(checkResult.PreferredAsset.Name);
|
|
var destinationPath = Path.Combine(_updatesDirectory, fileName);
|
|
|
|
var result = await _settingsFacade.Update.DownloadAssetAsync(
|
|
checkResult.PreferredAsset,
|
|
destinationPath,
|
|
state.UpdateDownloadSource,
|
|
state.UpdateDownloadThreads,
|
|
progress,
|
|
cancellationToken);
|
|
|
|
if (result.Success)
|
|
{
|
|
SaveState(state with
|
|
{
|
|
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
|
|
PendingUpdateVersion = checkResult.LatestVersionText,
|
|
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
|
? null
|
|
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
|
|
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
|
PendingUpdateSha256 = result.ActualHash
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
public async Task<UpdateDownloadResult> RedownloadReleaseAsync(
|
|
UpdateCheckResult checkResult,
|
|
IProgress<double>? progress = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(checkResult);
|
|
|
|
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null)
|
|
{
|
|
return new UpdateDownloadResult(false, null, "No compatible update asset is available.");
|
|
}
|
|
|
|
var state = _settingsFacade.Update.Get();
|
|
var existingPending = GetPendingUpdate(state);
|
|
|
|
if (existingPending is not null && File.Exists(existingPending.InstallerPath))
|
|
{
|
|
try
|
|
{
|
|
File.Delete(existingPending.InstallerPath);
|
|
AppLogger.Info("UpdateWorkflow", $"Deleted existing installer for redownload: {existingPending.InstallerPath}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppLogger.Warn("UpdateWorkflow", $"Failed to delete existing installer: {existingPending.InstallerPath}", ex);
|
|
}
|
|
}
|
|
|
|
ClearPendingUpdate();
|
|
|
|
Directory.CreateDirectory(_updatesDirectory);
|
|
var fileName = SanitizeFileName(checkResult.PreferredAsset.Name);
|
|
var destinationPath = Path.Combine(_updatesDirectory, fileName);
|
|
|
|
state = _settingsFacade.Update.Get();
|
|
|
|
var result = await _settingsFacade.Update.DownloadAssetAsync(
|
|
checkResult.PreferredAsset,
|
|
destinationPath,
|
|
state.UpdateDownloadSource,
|
|
state.UpdateDownloadThreads,
|
|
progress,
|
|
cancellationToken);
|
|
|
|
if (result.Success)
|
|
{
|
|
SaveState(state with
|
|
{
|
|
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
|
|
PendingUpdateVersion = checkResult.LatestVersionText,
|
|
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
|
? null
|
|
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
|
|
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
|
PendingUpdateSha256 = result.ActualHash
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
public async Task<UpdateVerifyResult> VerifyPendingUpdateAsync()
|
|
{
|
|
var state = _settingsFacade.Update.Get();
|
|
var pending = GetPendingUpdate(state);
|
|
|
|
if (pending is null)
|
|
{
|
|
return new UpdateVerifyResult(false, false, null, null, "No pending update available.");
|
|
}
|
|
|
|
if (!File.Exists(pending.InstallerPath))
|
|
{
|
|
return new UpdateVerifyResult(false, false, null, null, "Installer file does not exist.");
|
|
}
|
|
|
|
var expectedHash = pending.Sha256;
|
|
var actualHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(pending.InstallerPath);
|
|
|
|
if (string.IsNullOrEmpty(expectedHash))
|
|
{
|
|
return new UpdateVerifyResult(true, true, null, actualHash, null);
|
|
}
|
|
|
|
var hashMatched = string.Equals(
|
|
expectedHash?.Trim().ToLowerInvariant(),
|
|
actualHash?.Trim().ToLowerInvariant(),
|
|
StringComparison.OrdinalIgnoreCase);
|
|
|
|
return new UpdateVerifyResult(
|
|
hashMatched,
|
|
hashMatched,
|
|
expectedHash,
|
|
actualHash,
|
|
hashMatched ? null : $"Hash mismatch. Expected: {expectedHash}, Actual: {actualHash}");
|
|
}
|
|
|
|
public async Task AutoCheckIfEnabledAsync(
|
|
Version currentVersion,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var state = _settingsFacade.Update.Get();
|
|
|
|
try
|
|
{
|
|
// Always check for updates on startup (removed AutoCheckUpdates check)
|
|
var result = await CheckForUpdatesAsync(currentVersion, isForce: false, cancellationToken);
|
|
if (!result.Success || !result.IsUpdateAvailable || result.Release is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var normalizedMode = UpdateSettingsValues.NormalizeMode(state.UpdateMode);
|
|
|
|
// For "Silent Download" and "Silent Install" modes, automatically download the update
|
|
if (string.Equals(normalizedMode, UpdateSettingsValues.ModeDownloadThenConfirm, StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(normalizedMode, UpdateSettingsValues.ModeSilentOnExit, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// Prefer delta update if available (smaller download, faster)
|
|
if (IsDeltaUpdateAvailable(result.Release))
|
|
{
|
|
AppLogger.Info("UpdateWorkflow", "Delta update available, downloading incremental package.");
|
|
await DownloadDeltaUpdateAsync(result, cancellationToken: cancellationToken);
|
|
}
|
|
else if (result.PreferredAsset is not null)
|
|
{
|
|
await DownloadReleaseAsync(result, cancellationToken: cancellationToken);
|
|
}
|
|
}
|
|
// For "Manual" mode, just check but don't download
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppLogger.Warn("UpdateWorkflow", "Automatic update check failed.", ex);
|
|
}
|
|
}
|
|
|
|
public UpdateInstallerLaunchResult LaunchPendingInstallerNow()
|
|
{
|
|
return LaunchPendingInstaller(silent: false, exitApplicationAfterLaunch: true);
|
|
}
|
|
|
|
public bool TryApplyPendingUpdateOnExit()
|
|
{
|
|
var state = _settingsFacade.Update.Get();
|
|
if (!string.Equals(
|
|
UpdateSettingsValues.NormalizeMode(state.UpdateMode),
|
|
UpdateSettingsValues.ModeSilentOnExit,
|
|
StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// For delta updates, launch the Launcher with apply-update command so it can
|
|
// apply the update immediately with a progress UI, matching the full installer experience.
|
|
if (IsPendingDeltaUpdate())
|
|
{
|
|
AppLogger.Info("UpdateWorkflow", "Delta update pending. Launching Launcher to apply update with progress UI.");
|
|
var launchResult = LaunchLauncherForApplyUpdate();
|
|
if (launchResult)
|
|
{
|
|
ClearPendingUpdate();
|
|
}
|
|
return launchResult;
|
|
}
|
|
|
|
var result = LaunchPendingInstaller(silent: true, exitApplicationAfterLaunch: false);
|
|
if (!result.Success && !string.IsNullOrWhiteSpace(result.ErrorMessage))
|
|
{
|
|
AppLogger.Warn("UpdateWorkflow", $"Silent update on exit failed: {result.ErrorMessage}");
|
|
}
|
|
|
|
return result.Success;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Launches the Launcher process with the apply-update command to apply a pending delta update
|
|
/// with a progress UI, providing an experience similar to a full installer.
|
|
/// </summary>
|
|
public bool LaunchLauncherForApplyUpdate()
|
|
{
|
|
try
|
|
{
|
|
var launcherExeName = OperatingSystem.IsWindows()
|
|
? "LanMountainDesktop.Launcher.exe"
|
|
: "LanMountainDesktop.Launcher";
|
|
|
|
// The Launcher is in the parent directory of the app's base directory
|
|
// (app runs from app-{version}/ subdirectory, Launcher is at root)
|
|
var appBaseDir = AppContext.BaseDirectory;
|
|
var launcherRoot = Path.GetDirectoryName(appBaseDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
|
if (string.IsNullOrWhiteSpace(launcherRoot))
|
|
{
|
|
launcherRoot = appBaseDir;
|
|
}
|
|
|
|
var launcherPath = Path.Combine(launcherRoot, launcherExeName);
|
|
if (!File.Exists(launcherPath))
|
|
{
|
|
AppLogger.Warn("UpdateWorkflow", $"Launcher executable not found at '{launcherPath}'. Falling back to next-startup apply.");
|
|
return false;
|
|
}
|
|
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = launcherPath,
|
|
Arguments = $"apply-update --app-root \"{launcherRoot}\"",
|
|
UseShellExecute = false,
|
|
WorkingDirectory = launcherRoot
|
|
};
|
|
|
|
Process.Start(startInfo);
|
|
AppLogger.Info("UpdateWorkflow", $"Launched Launcher for apply-update: {launcherPath}");
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppLogger.Warn("UpdateWorkflow", $"Failed to launch Launcher for apply-update: {ex.Message}");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public void ClearPendingUpdate()
|
|
{
|
|
var state = _settingsFacade.Update.Get();
|
|
SaveState(state with
|
|
{
|
|
PendingUpdateInstallerPath = null,
|
|
PendingUpdateVersion = null,
|
|
PendingUpdatePublishedAtUtcMs = null,
|
|
PendingUpdateSha256 = null
|
|
});
|
|
}
|
|
|
|
private UpdateInstallerLaunchResult LaunchPendingInstaller(bool silent, bool exitApplicationAfterLaunch)
|
|
{
|
|
var state = _settingsFacade.Update.Get();
|
|
var pending = GetPendingUpdate(state);
|
|
if (pending is null)
|
|
{
|
|
return new UpdateInstallerLaunchResult(false, false, "No pending installer is available.");
|
|
}
|
|
|
|
try
|
|
{
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = pending.InstallerPath,
|
|
WorkingDirectory = Path.GetDirectoryName(pending.InstallerPath) ?? _updatesDirectory,
|
|
UseShellExecute = true,
|
|
Verb = OperatingSystem.IsWindows() ? "runas" : string.Empty,
|
|
Arguments = silent ? "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" : string.Empty
|
|
};
|
|
|
|
Process.Start(startInfo);
|
|
ClearPendingUpdate();
|
|
|
|
if (exitApplicationAfterLaunch)
|
|
{
|
|
App.CurrentHostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest(
|
|
Source: "Update",
|
|
Reason: silent
|
|
? "Silent installer launched."
|
|
: "Installer launched from update page."));
|
|
}
|
|
|
|
return new UpdateInstallerLaunchResult(true, false, null);
|
|
}
|
|
catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
|
|
{
|
|
return new UpdateInstallerLaunchResult(false, true, ex.Message);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new UpdateInstallerLaunchResult(false, false, ex.Message);
|
|
}
|
|
}
|
|
|
|
private UpdatePendingInfo? GetPendingUpdate(UpdateSettingsState state)
|
|
{
|
|
var installerPath = state.PendingUpdateInstallerPath?.Trim();
|
|
if (string.IsNullOrWhiteSpace(installerPath))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (!File.Exists(installerPath))
|
|
{
|
|
ClearPendingUpdate();
|
|
return null;
|
|
}
|
|
|
|
DateTimeOffset? publishedAt = state.PendingUpdatePublishedAtUtcMs is > 0
|
|
? DateTimeOffset.FromUnixTimeMilliseconds(state.PendingUpdatePublishedAtUtcMs.Value)
|
|
: null;
|
|
|
|
return new UpdatePendingInfo(
|
|
installerPath,
|
|
string.IsNullOrWhiteSpace(state.PendingUpdateVersion) ? Path.GetFileNameWithoutExtension(installerPath) : state.PendingUpdateVersion,
|
|
publishedAt,
|
|
state.PendingUpdateSha256);
|
|
}
|
|
|
|
private void SaveState(UpdateSettingsState state)
|
|
{
|
|
_settingsFacade.Update.Save(state);
|
|
}
|
|
|
|
private static string SanitizeFileName(string? fileName)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(fileName))
|
|
{
|
|
return FormattableString.Invariant($"LanMountainDesktop-update-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}.exe");
|
|
}
|
|
|
|
var invalid = Path.GetInvalidFileNameChars();
|
|
Span<char> buffer = stackalloc char[fileName.Length];
|
|
var index = 0;
|
|
foreach (var ch in fileName)
|
|
{
|
|
buffer[index++] = Array.IndexOf(invalid, ch) >= 0 ? '_' : ch;
|
|
}
|
|
|
|
return new string(buffer[..index]);
|
|
}
|
|
}
|