diff --git a/src/TableCloth.App/Components/Implementations/AppUpdateManager.cs b/src/TableCloth.App/Components/Implementations/AppUpdateManager.cs index 9d740cf8..e23e44ff 100644 --- a/src/TableCloth.App/Components/Implementations/AppUpdateManager.cs +++ b/src/TableCloth.App/Components/Implementations/AppUpdateManager.cs @@ -196,15 +196,7 @@ public async Task DownloadAndApplyUpdatesAsync(IProgress? progress = null, // 현재 아키텍처 확인 var arch = GetCurrentArchitecture(); - // 파일 이름 규칙(이슈 #296): Retail = TableCloth_{ver}_Release_{arch}.exe, - // Preview = TableCloth-Preview_{ver}_Release_{arch}.exe. 채널에 맞는 자산만 고른다 - // ("-Preview" 포함 여부로 링 구분 — Retail 이 프리뷰 자산을 잡지 않도록). - var wantPreview = _channel == ReleaseChannel.Preview; - var matchingAsset = releaseInfo.Assets.FirstOrDefault(a => - a.Name != null && - a.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) && - a.Name.Contains($"_Release_{arch}", StringComparison.OrdinalIgnoreCase) && - a.Name.Contains("-Preview", StringComparison.OrdinalIgnoreCase) == wantPreview); + var matchingAsset = FindInstallerAsset(releaseInfo.Assets, arch, _channel); if (matchingAsset?.BrowserDownloadUrl != null && Uri.TryCreate(matchingAsset.BrowserDownloadUrl, UriKind.Absolute, out var downloadUri)) @@ -213,18 +205,8 @@ public async Task DownloadAndApplyUpdatesAsync(IProgress? progress = null, return downloadUri; } - // 아키텍처별 파일을 찾지 못하면 첫 번째 exe 파일 사용 - var fallbackAsset = releaseInfo.Assets.FirstOrDefault(a => - a.Name != null && - a.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)); - - if (fallbackAsset?.BrowserDownloadUrl != null && - Uri.TryCreate(fallbackAsset.BrowserDownloadUrl, UriKind.Absolute, out var fallbackUri)) - { - _logger.LogInformation("Using fallback release asset: {AssetName}", fallbackAsset.Name); - return fallbackUri; - } - + // 일치하는 설치 파일이 없으면 호출자가 릴리스 페이지로 안내한다. + // Spork/SporkBootstrap 등 다른 프로그램을 업데이트로 선택하지 않는다(#283). return null; } catch (Exception ex) @@ -234,6 +216,22 @@ public async Task DownloadAndApplyUpdatesAsync(IProgress? progress = null, } } + internal static GitHubAssetInfo? FindInstallerAsset( + GitHubAssetInfo[] assets, + string arch, + ReleaseChannel channel) + { + // Retail = TableCloth_{ver}_Release_{arch}.exe, + // Preview = TableCloth-Preview_{ver}_Release_{arch}.exe. + var prefix = channel == ReleaseChannel.Preview ? "TableCloth-Preview_" : "TableCloth_"; + var suffix = $"_Release_{arch}.exe"; + + return assets.FirstOrDefault(a => + a.Name != null && + a.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && + a.Name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)); + } + // 이슈 #296: 채널에 따라 GitHub 릴리스 폴백 소스를 분기. Retail 은 /releases/latest(프리릴리스 제외), // Preview 는 /releases 목록에서 최신 프리릴리스를 고른다. private async Task GetLatestReleaseInfoAsync(CancellationToken cancellationToken) @@ -336,7 +334,7 @@ private sealed class GitHubReleaseInfo public GitHubAssetInfo[]? Assets { get; set; } } - private sealed class GitHubAssetInfo + internal sealed class GitHubAssetInfo { public string? Name { get; set; } public string? BrowserDownloadUrl { get; set; } diff --git a/src/TableCloth.Test/AppUpdateManagerTests.cs b/src/TableCloth.Test/AppUpdateManagerTests.cs new file mode 100644 index 00000000..d71f6cee --- /dev/null +++ b/src/TableCloth.Test/AppUpdateManagerTests.cs @@ -0,0 +1,95 @@ +using TableCloth.Components.Implementations; +using TableCloth.Models.Configuration; + +namespace TableCloth.Test; + +[TestClass] +public sealed class AppUpdateManagerTests +{ + [TestMethod] + [DataRow("x64", ReleaseChannel.Retail, "TableCloth_1.21.1.0_Release_x64.exe")] + [DataRow("arm64", ReleaseChannel.Retail, "TableCloth_1.21.1.0_Release_arm64.exe")] + [DataRow("x64", ReleaseChannel.Preview, "TableCloth-Preview_1.22.0.0_Release_x64.exe")] + [DataRow("arm64", ReleaseChannel.Preview, "TableCloth-Preview_1.22.0.0_Release_arm64.exe")] + public void FindInstallerAsset_MixedRelease_SelectsRequestedProductArchitectureAndChannel( + string arch, + ReleaseChannel channel, + string expectedName) + { + var assets = CreateAssets( + "SporkBootstrap_1.21.1.0_Release_x64.exe", + "Spork_1.21.1.0_Release_x64.exe", + "SporkBootstrap_1.21.1.0_Release_arm64.exe", + "Spork_1.21.1.0_Release_arm64.exe", + "TableCloth-Preview_1.22.0.0_Release_arm64.exe", + "TableCloth-Preview_1.22.0.0_Release_x64.exe", + "TableCloth_1.21.1.0_Release_arm64.exe", + "TableCloth_1.21.1.0_Release_x64.exe"); + + var asset = AppUpdateManager.FindInstallerAsset(assets, arch, channel); + + Assert.IsNotNull(asset); + Assert.AreEqual(expectedName, asset.Name); + } + + [TestMethod] + [DataRow("x64", ReleaseChannel.Retail)] + [DataRow("arm64", ReleaseChannel.Retail)] + [DataRow("x64", ReleaseChannel.Preview)] + [DataRow("arm64", ReleaseChannel.Preview)] + public void FindInstallerAsset_NoMatchingInstaller_DoesNotFallBackToAnotherExecutable( + string arch, + ReleaseChannel channel) + { + var otherArch = arch == "x64" ? "arm64" : "x64"; + var otherProduct = channel == ReleaseChannel.Retail ? "TableCloth-Preview" : "TableCloth"; + var assets = CreateAssets( + $"SporkBootstrap_1.21.1.0_Release_{arch}.exe", + $"Spork_1.21.1.0_Release_{arch}.exe", + $"TableCloth_1.21.1.0_Release_{otherArch}.exe", + $"TableCloth-Preview_1.22.0.0_Release_{otherArch}.exe", + $"{otherProduct}_1.22.0.0_Release_{arch}.exe"); + + var asset = AppUpdateManager.FindInstallerAsset(assets, arch, channel); + + Assert.IsNull(asset); + } + + [TestMethod] + public void FindInstallerAsset_RejectsUnrelatedNamesAndNonInstallerAssets() + { + var assets = CreateAssets( + null, + "setup.exe", + "OtherTableCloth_1.21.1.0_Release_x64.exe", + "TableClothBootstrap_1.21.1.0_Release_x64.exe", + "TableCloth_1.21.1.0_Release_x64_Portable.zip", + "TableCloth_1.21.1.0_Release_x64_symbols.exe", + "TableCloth_1.21.1.0_Release_x64.exe.bak"); + + var asset = AppUpdateManager.FindInstallerAsset(assets, "x64", ReleaseChannel.Retail); + + Assert.IsNull(asset); + } + + [TestMethod] + public void FindInstallerAsset_EmptyRelease_ReturnsNull() + { + var asset = AppUpdateManager.FindInstallerAsset([], "x64", ReleaseChannel.Retail); + + Assert.IsNull(asset); + } + + [TestMethod] + public void FindInstallerAsset_NameComparison_IsCaseInsensitive() + { + var assets = CreateAssets("tablecloth_1.21.1.0_release_X64.EXE"); + + var asset = AppUpdateManager.FindInstallerAsset(assets, "x64", ReleaseChannel.Retail); + + Assert.AreSame(assets[0], asset); + } + + private static AppUpdateManager.GitHubAssetInfo[] CreateAssets(params string?[] names) + => names.Select(name => new AppUpdateManager.GitHubAssetInfo { Name = name }).ToArray(); +}