diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf6fab9..8f4cbff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -244,6 +244,27 @@ jobs: -AssertClean shell: pwsh + - name: Verify Windows app host payload + run: | + $version = "${{ needs.prepare.outputs.version }}" + $arch = "${{ matrix.arch }}" + $publishDir = "publish/windows-$arch" + $appDir = Join-Path $publishDir "app-$version" + + $requiredFiles = @( + (Join-Path $publishDir "LanMountainDesktop.Launcher.exe"), + (Join-Path $appDir "LanMountainDesktop.exe"), + (Join-Path $appDir "LanMountainDesktop.AirAppHost.exe") + ) + + foreach ($path in $requiredFiles) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + Write-Error "Required release payload file is missing: $path" + exit 1 + } + } + shell: pwsh + - name: Install Inno Setup and 7z run: | choco install innosetup -y --no-progress diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs index 1bbe086..e1eb620 100644 --- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs +++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs @@ -12,8 +12,8 @@ namespace LanMountainDesktop.Launcher.Services; internal sealed class LauncherFlowCoordinator { - private static readonly TimeSpan StartupSoftTimeout = TimeSpan.FromSeconds(10); - private static readonly TimeSpan StartupHardTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan StartupSoftTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan StartupHardTimeout = TimeSpan.FromSeconds(120); private static readonly string SoftTimeoutStatusMessage = Strings.Coordinator_SlowDeviceMessage; private static readonly string SoftTimeoutDetailsMessage = Strings.Coordinator_RunningHostMessage; diff --git a/LanMountainDesktop.Tests/LauncherStartupTimeoutPolicyTests.cs b/LanMountainDesktop.Tests/LauncherStartupTimeoutPolicyTests.cs new file mode 100644 index 0000000..0239980 --- /dev/null +++ b/LanMountainDesktop.Tests/LauncherStartupTimeoutPolicyTests.cs @@ -0,0 +1,32 @@ +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class LauncherStartupTimeoutPolicyTests +{ + [Fact] + public void LauncherStartupTimeouts_MatchSlowStartupContract() + { + var source = ReadRepositoryFile("LanMountainDesktop.Launcher", "Services", "LauncherFlowCoordinator.cs"); + + Assert.Contains("StartupSoftTimeout = TimeSpan.FromSeconds(30)", source); + Assert.Contains("StartupHardTimeout = TimeSpan.FromSeconds(120)", source); + Assert.DoesNotContain("StartupHardTimeout = TimeSpan.FromSeconds(30)", source); + } + + private static string ReadRepositoryFile(params string[] pathParts) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "LanMountainDesktop.slnx"))) + { + directory = directory.Parent; + } + + if (directory is null) + { + throw new DirectoryNotFoundException("Unable to locate repository root."); + } + + return File.ReadAllText(Path.Combine([directory.FullName, .. pathParts])); + } +} diff --git a/LanMountainDesktop.Tests/PackagingRuntimePolicyTests.cs b/LanMountainDesktop.Tests/PackagingRuntimePolicyTests.cs index 79b9cec..70e8d5c 100644 --- a/LanMountainDesktop.Tests/PackagingRuntimePolicyTests.cs +++ b/LanMountainDesktop.Tests/PackagingRuntimePolicyTests.cs @@ -27,6 +27,26 @@ public sealed class PackagingRuntimePolicyTests Assert.Contains("System.Private.CoreLib.dll", script); } + [Fact] + public void WindowsPayloadGuard_RequiresLauncherMainAndAirAppHost() + { + var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "Optimize-PublishPayload.ps1"); + + Assert.Contains("Assert-WindowsPayloadContainsRequiredHosts", script); + Assert.Contains("LanMountainDesktop.Launcher.exe", script); + Assert.Contains("LanMountainDesktop.exe", script); + Assert.Contains("LanMountainDesktop.AirAppHost.exe", script); + } + + [Fact] + public void ReleaseWorkflow_VerifiesAirAppHostBeforePublishingInstaller() + { + var workflow = ReadRepositoryFile(".github", "workflows", "release.yml"); + + Assert.Contains("Verify Windows app host payload", workflow); + Assert.Contains("LanMountainDesktop.AirAppHost.exe", workflow); + } + [Fact] public void Installer_DownloadsArchitectureSpecificDesktopRuntime() { diff --git a/LanMountainDesktop/Services/Settings/SettingsContracts.cs b/LanMountainDesktop/Services/Settings/SettingsContracts.cs index 7e97752..69484cb 100644 --- a/LanMountainDesktop/Services/Settings/SettingsContracts.cs +++ b/LanMountainDesktop/Services/Settings/SettingsContracts.cs @@ -6,8 +6,10 @@ using System.Threading.Tasks; using LanMountainDesktop.Models; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; +using LanMountainDesktop.Services.Update; using LanMountainDesktop.Services.PluginMarket; using LanMountainDesktop.Settings.Core; +using LanMountainDesktop.Shared.Contracts.Update; namespace LanMountainDesktop.Services.Settings { @@ -356,8 +358,21 @@ public interface IPrivacySettingsService public interface IUpdateSettingsService { + UpdatePhase CurrentPhase { get; } + event Action? PhaseChanged; + event Action? ProgressChanged; + UpdateSettingsState Get(); void Save(UpdateSettingsState state); + Task CheckAsync(CancellationToken cancellationToken = default); + Task DownloadAsync(CancellationToken cancellationToken = default); + Task InstallAsync(CancellationToken cancellationToken = default); + Task RollbackAsync(CancellationToken cancellationToken = default); + Task PauseAsync(); + Task ResumeAsync(CancellationToken cancellationToken = default); + Task CancelAsync(); + Task AutoCheckIfEnabledAsync(CancellationToken cancellationToken = default); + bool TryApplyOnExit(); Task CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default); Task ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default); Task GetPlondsUpdatePayloadAsync(Version currentVersion, bool includePrerelease, bool isForce = false, CancellationToken cancellationToken = default); diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs index e35d9b1..3ca2a0a 100644 --- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs +++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs @@ -10,8 +10,10 @@ using Avalonia.Media.Imaging; using LanMountainDesktop.Models; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; +using LanMountainDesktop.Services.Update; using LanMountainDesktop.Settings.Core; using LanMountainDesktop.Services.PluginMarket; +using LanMountainDesktop.Shared.Contracts.Update; namespace LanMountainDesktop.Services.Settings; @@ -784,10 +786,40 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop"); private readonly PlondsStaticUpdateService _plondsStaticUpdateService = new(); private readonly PlondsReleaseUpdateService _plondsReleaseUpdateService = new(); + private readonly Lazy _orchestrator; - public UpdateSettingsService(ISettingsService settingsService) + public UpdateSettingsService(ISettingsService settingsService, Func? orchestratorFactory = null) { _settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); + _orchestrator = new Lazy( + orchestratorFactory ?? HostUpdateOrchestratorProvider.GetOrCreate, + LazyThreadSafetyMode.ExecutionAndPublication); + } + + public UpdatePhase CurrentPhase => _orchestrator.Value.CurrentPhase; + + public event Action? PhaseChanged + { + add => _orchestrator.Value.PhaseChanged += value; + remove + { + if (_orchestrator.IsValueCreated) + { + _orchestrator.Value.PhaseChanged -= value; + } + } + } + + public event Action? ProgressChanged + { + add => _orchestrator.Value.ProgressChanged += value; + remove + { + if (_orchestrator.IsValueCreated) + { + _orchestrator.Value.ProgressChanged -= value; + } + } } public UpdateSettingsState Get() @@ -862,6 +894,51 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl ]); } + public Task CheckAsync(CancellationToken cancellationToken = default) + { + return _orchestrator.Value.CheckAsync(cancellationToken); + } + + public Task DownloadAsync(CancellationToken cancellationToken = default) + { + return _orchestrator.Value.DownloadAsync(cancellationToken); + } + + public Task InstallAsync(CancellationToken cancellationToken = default) + { + return _orchestrator.Value.InstallAsync(cancellationToken); + } + + public Task RollbackAsync(CancellationToken cancellationToken = default) + { + return _orchestrator.Value.RollbackAsync(cancellationToken); + } + + public Task PauseAsync() + { + return _orchestrator.Value.PauseAsync(); + } + + public Task ResumeAsync(CancellationToken cancellationToken = default) + { + return _orchestrator.Value.ResumeAsync(cancellationToken); + } + + public Task CancelAsync() + { + return _orchestrator.Value.CancelAsync(); + } + + public Task AutoCheckIfEnabledAsync(CancellationToken cancellationToken = default) + { + return _orchestrator.Value.AutoCheckIfEnabledAsync(cancellationToken); + } + + public bool TryApplyOnExit() + { + return _orchestrator.Value.TryApplyOnExit(); + } + public Task CheckForUpdatesAsync( Version currentVersion, bool includePrerelease, @@ -945,6 +1022,15 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl bool isForce, CancellationToken cancellationToken) { + var source = UpdateSettingsValues.NormalizeDownloadSource(Get().UpdateDownloadSource); + if (string.Equals(source, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase) || + string.Equals(source, UpdateSettingsValues.DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase)) + { + return isForce + ? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken) + : await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); + } + var staticResult = isForce ? await _plondsStaticUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken) : await _plondsStaticUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); diff --git a/LanMountainDesktop/Services/Update/SettingsUpdateManifestProvider.cs b/LanMountainDesktop/Services/Update/SettingsUpdateManifestProvider.cs new file mode 100644 index 0000000..34a964c --- /dev/null +++ b/LanMountainDesktop/Services/Update/SettingsUpdateManifestProvider.cs @@ -0,0 +1,60 @@ +using LanMountainDesktop.Services.Settings; +using LanMountainDesktop.Shared.Contracts.Update; + +namespace LanMountainDesktop.Services.Update; + +internal sealed class SettingsUpdateManifestProvider : IUpdateManifestProvider +{ + private readonly ISettingsFacadeService _settingsFacade; + private readonly IUpdateManifestProvider _plondsWithFallback; + private readonly IUpdateManifestProvider _github; + + public SettingsUpdateManifestProvider( + ISettingsFacadeService settingsFacade, + IUpdateManifestProvider plonds, + IUpdateManifestProvider github) + { + _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade)); + _github = github ?? throw new ArgumentNullException(nameof(github)); + _plondsWithFallback = new CompositeManifestProvider(plonds ?? throw new ArgumentNullException(nameof(plonds)), _github); + } + + public string ProviderName => "settings-selected-update-source"; + + public Task GetLatestAsync( + string channel, + string platform, + Version currentVersion, + CancellationToken ct) + { + return SelectProvider().GetLatestAsync(channel, platform, currentVersion, ct); + } + + public Task GetByVersionAsync( + string version, + string channel, + string platform, + CancellationToken ct) + { + return SelectProvider().GetByVersionAsync(version, channel, platform, ct); + } + + public Task> GetIncrementalChainAsync( + string channel, + string platform, + Version fromVersion, + Version toVersion, + CancellationToken ct) + { + return SelectProvider().GetIncrementalChainAsync(channel, platform, fromVersion, toVersion, ct); + } + + private IUpdateManifestProvider SelectProvider() + { + var source = UpdateSettingsValues.NormalizeDownloadSource(_settingsFacade.Update.Get().UpdateDownloadSource); + return string.Equals(source, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase) || + string.Equals(source, UpdateSettingsValues.DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase) + ? _github + : _plondsWithFallback; + } +} diff --git a/LanMountainDesktop/Services/Update/UpdateDownloadEngine.cs b/LanMountainDesktop/Services/Update/UpdateDownloadEngine.cs index 45fefce..ac3ed7c 100644 --- a/LanMountainDesktop/Services/Update/UpdateDownloadEngine.cs +++ b/LanMountainDesktop/Services/Update/UpdateDownloadEngine.cs @@ -232,6 +232,7 @@ internal sealed class UpdateDownloadEngine UpdateManifest manifest, string destinationPath, int maxThreads, + string? downloadSource, IProgress? progress, CancellationToken ct) { @@ -281,7 +282,7 @@ internal sealed class UpdateDownloadEngine ct.ThrowIfCancellationRequested(); var result = await _downloadService.DownloadAsync( - mirror.Url, + ApplyDownloadSource(mirror.Url, downloadSource), destinationPath, new DownloadOptions(MaxParallelSegments: Math.Max(1, maxThreads)), downloadProgress, @@ -386,6 +387,22 @@ internal sealed class UpdateDownloadEngine throw lastError!; } + internal static string ApplyDownloadSource(string browserDownloadUrl, string? downloadSource) + { + if (!string.Equals( + UpdateSettingsValues.NormalizeDownloadSource(downloadSource), + UpdateSettingsValues.DownloadSourceGhProxy, + StringComparison.OrdinalIgnoreCase)) + { + return browserDownloadUrl; + } + + var normalizedBase = UpdateSettingsValues.DefaultGhProxyBaseUrl.TrimEnd('/') + "/"; + return browserDownloadUrl.StartsWith(normalizedBase, StringComparison.OrdinalIgnoreCase) + ? browserDownloadUrl + : normalizedBase + browserDownloadUrl; + } + private static async Task ComputeFileSha256Async(string filePath, CancellationToken ct) { using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 8192, true); diff --git a/LanMountainDesktop/Services/Update/UpdateManifestMapper.cs b/LanMountainDesktop/Services/Update/UpdateManifestMapper.cs index 3b3bf91..ba863db 100644 --- a/LanMountainDesktop/Services/Update/UpdateManifestMapper.cs +++ b/LanMountainDesktop/Services/Update/UpdateManifestMapper.cs @@ -96,6 +96,13 @@ internal static class UpdateManifestMapper ArchiveSha256: null, Metadata: null)); + mirrors.Add(new UpdateMirrorAsset( + Platform: platform, + Url: installerAsset.BrowserDownloadUrl, + Name: installerAsset.Name, + Sha256: installerAsset.Sha256, + Size: installerAsset.SizeBytes)); + foreach (var asset in release.Assets) { if (IsInstallerAsset(asset) && asset != installerAsset) diff --git a/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs b/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs index 9c704ab..8c95c0b 100644 --- a/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs +++ b/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs @@ -25,13 +25,13 @@ internal static class HostUpdateOrchestratorProvider var settingsFacade = HostSettingsFacadeProvider.GetOrCreate(); var githubProvider = new GithubReleaseManifestProvider("wwiinnddyy", "LanMountainDesktop"); - var staticProvider = new PlondsApiManifestProvider("https://api.classisland.tech"); - var compositeProvider = new CompositeManifestProvider(staticProvider, githubProvider); + var plondsProvider = new PlondsApiManifestProvider("https://api.classisland.tech"); + var manifestProvider = new SettingsUpdateManifestProvider(settingsFacade, plondsProvider, githubProvider); var httpClient = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(30) }; - var downloadEngine = new UpdateDownloadEngine(compositeProvider, new ResumableDownloadService(httpClient)); + var downloadEngine = new UpdateDownloadEngine(manifestProvider, new ResumableDownloadService(httpClient)); var installGateway = new UpdateInstallGateway(); var stateStore = new UpdateStateStore(settingsFacade); - _instance = new UpdateOrchestrator(compositeProvider, downloadEngine, installGateway, stateStore); + _instance = new UpdateOrchestrator(manifestProvider, downloadEngine, installGateway, stateStore); return _instance; } } @@ -106,8 +106,7 @@ public sealed class UpdateOrchestrator : IDisposable var settings = _stateStore.GetSettings(); var channel = UpdateSettingsValues.NormalizeChannel(settings.UpdateChannel); - var currentVersionText = _stateStore.GetSettings().PendingUpdateVersion - ?? AppVersionProvider.ResolveForCurrentProcess().Version; + var currentVersionText = AppVersionProvider.ResolveForCurrentProcess().Version; if (!TryParseVersion(currentVersionText, out var currentVersion)) { @@ -166,12 +165,14 @@ public sealed class UpdateOrchestrator : IDisposable if (manifest is null) { _stateStore.TransitionTo(UpdatePhase.Checked); + SaveLastChecked(); return new UpdateCheckReport( false, null, currentVersionText, null, null, null, null, null, null, null); } _stateStore.PendingManifest = manifest; _stateStore.TransitionTo(UpdatePhase.Checked); + SaveLastChecked(); long? totalBytes = manifest.IsDelta ? manifest.EstimatedDeltaBytes : null; long? installerBytes = manifest.InstallerMirrors?.Count > 0 @@ -262,6 +263,7 @@ public sealed class UpdateOrchestrator : IDisposable manifest, destinationPath, maxThreads, + settings.UpdateDownloadSource, downloadProgress, operationToken); } @@ -569,15 +571,12 @@ public sealed class UpdateOrchestrator : IDisposable return false; } - var manifest = _stateStore.PendingManifest; - if (manifest is null) - { - return false; - } - var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory); + var manifest = _stateStore.PendingManifest; + var deploymentLock = DeploymentLockService.ReadLock(launcherRoot); - if (manifest.IsDelta) + if (manifest?.IsDelta == true || + string.Equals(deploymentLock?.Kind, "delta", StringComparison.OrdinalIgnoreCase)) { AppLogger.Info("UpdateOrchestrator", "Delta update pending. Launching Launcher to apply on exit."); var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath(); @@ -638,6 +637,15 @@ public sealed class UpdateOrchestrator : IDisposable } } + private void SaveLastChecked() + { + var state = _stateStore.GetSettings(); + _stateStore.SaveSettings(state with + { + LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }); + } + private static void CleanupIncomingArtifacts(string launcherRoot) { var incomingDir = UpdatePaths.GetIncomingDirectory(launcherRoot); diff --git a/LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 b/LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 index a71b977..736348b 100644 --- a/LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 +++ b/LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 @@ -201,6 +201,52 @@ function Assert-WindowsPayloadClean { Write-Host "Windows payload guard passed for $Rid." } +function Assert-WindowsPayloadContainsRequiredHosts { + param([Parameter(Mandatory = $true)][string]$Root) + + $violations = [System.Collections.Generic.List[string]]::new() + + $launcherPath = Join-Path $Root "LanMountainDesktop.Launcher.exe" + if (-not (Test-Path -LiteralPath $launcherPath -PathType Leaf)) { + $violations.Add("LanMountainDesktop.Launcher.exe") + } + + $deploymentDirs = @(Get-ChildItem -LiteralPath $Root -Directory -Filter "app-*" -ErrorAction SilentlyContinue | + Where-Object { + -not (Test-Path -LiteralPath (Join-Path $_.FullName ".partial")) -and + -not (Test-Path -LiteralPath (Join-Path $_.FullName ".destroy")) + }) + + if ($deploymentDirs.Count -eq 0) { + $violations.Add("app-*/") + } + + foreach ($deploymentDir in $deploymentDirs) { + $mainHostPath = Join-Path $deploymentDir.FullName "LanMountainDesktop.exe" + if (-not (Test-Path -LiteralPath $mainHostPath -PathType Leaf)) { + $violations.Add((Join-Path $deploymentDir.Name "LanMountainDesktop.exe")) + } + + $airAppHostCandidates = @( + (Join-Path $deploymentDir.FullName "LanMountainDesktop.AirAppHost.exe"), + (Join-Path $deploymentDir.FullName "LanMountainDesktop.AirAppHost.dll"), + (Join-Path (Join-Path $deploymentDir.FullName "AirAppHost") "LanMountainDesktop.AirAppHost.exe"), + (Join-Path (Join-Path $deploymentDir.FullName "AirAppHost") "LanMountainDesktop.AirAppHost.dll") + ) + + if (-not ($airAppHostCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1)) { + $violations.Add((Join-Path $deploymentDir.Name "LanMountainDesktop.AirAppHost.exe")) + } + } + + if ($violations.Count -gt 0) { + $sample = ($violations | Select-Object -First 50) -join [Environment]::NewLine + throw "Windows publish payload is missing required Launcher/Main/AirAppHost files:$([Environment]::NewLine)$sample" + } + + Write-Host "Windows required host guard passed." +} + $resolvedPublishDir = [System.IO.Path]::GetFullPath($PublishDir) if (-not (Test-Path -LiteralPath $resolvedPublishDir)) { throw "Publish directory not found: $resolvedPublishDir" @@ -213,4 +259,7 @@ Write-PayloadAudit -Root $resolvedPublishDir if ($AssertClean) { Assert-WindowsPayloadClean -Root $resolvedPublishDir -Rid $RuntimeIdentifier + if ($RuntimeIdentifier -like "win-*") { + Assert-WindowsPayloadContainsRequiredHosts -Root $resolvedPublishDir + } }