diff --git a/LanDesktopPLONDS.installer/App.axaml.cs b/LanDesktopPLONDS.installer/App.axaml.cs
index f0ce791..ce932ce 100644
--- a/LanDesktopPLONDS.installer/App.axaml.cs
+++ b/LanDesktopPLONDS.installer/App.axaml.cs
@@ -22,10 +22,12 @@ public partial class App : Application
var privacyIdentity = new PrivacyDeviceIdentityProvider();
var installService = OnlineInstallService.CreateDefault(privacyIdentity);
var consentStore = new InstallerPrivacyConsentStore();
- desktop.MainWindow = new MainWindow
+ var mainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(installService, privacyIdentity, consentStore)
};
+ desktop.MainWindow = mainWindow;
+ mainWindow.Show();
}
base.OnFrameworkInitializationCompleted();
diff --git a/LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.csproj b/LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.csproj
index 2c756bb..5733597 100644
--- a/LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.csproj
+++ b/LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.csproj
@@ -10,7 +10,8 @@
$(Version)
true
Assets\logo.ico
- app.manifest
+ app.Debug.manifest
+ app.manifest
diff --git a/LanDesktopPLONDS.installer/Services/InstallerJsonContext.cs b/LanDesktopPLONDS.installer/Services/InstallerJsonContext.cs
index 8beaef4..ed2e98f 100644
--- a/LanDesktopPLONDS.installer/Services/InstallerJsonContext.cs
+++ b/LanDesktopPLONDS.installer/Services/InstallerJsonContext.cs
@@ -2,5 +2,10 @@ using System.Text.Json.Serialization;
namespace LanDesktopPLONDS.Installer.Services;
+[JsonSourceGenerationOptions(
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ ReadCommentHandling = System.Text.Json.JsonCommentHandling.Skip,
+ AllowTrailingCommas = true)]
[JsonSerializable(typeof(InstallerPlondsManifest))]
internal sealed partial class InstallerJsonContext : JsonSerializerContext;
diff --git a/LanDesktopPLONDS.installer/Services/InstallerPlondsClient.cs b/LanDesktopPLONDS.installer/Services/InstallerPlondsClient.cs
index 79175e8..aed75a6 100644
--- a/LanDesktopPLONDS.installer/Services/InstallerPlondsClient.cs
+++ b/LanDesktopPLONDS.installer/Services/InstallerPlondsClient.cs
@@ -10,7 +10,7 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin
{
private const string S3ManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_S3_MANIFEST_URL";
private const string GitHubManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_GITHUB_MANIFEST_URL";
- private const string DefaultS3ManifestUrl = "https://cn-nb1.rains3.com/lmdesktop/plonds/PLONDS.json";
+ private const string DefaultS3ManifestUrl = "https://cn-nb1.rains3.com/lmdesktop/lanmountain/update/plonds/PLONDS.json";
private const string DefaultGitHubManifestUrl = "https://github.com/wwiinnddyy/LanMountainDesktop/releases/latest/download/PLONDS.json";
private static readonly JsonSerializerOptions JsonOptions = new()
@@ -78,30 +78,54 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin
{
var version = ParseVersion(candidate.Manifest.CurrentVersion).ToString();
var packageRoot = Path.Combine(stagingRoot, SanitizePathSegment(version), SanitizePathSegment(candidate.Source.Id), "full");
- if (Directory.Exists(packageRoot))
+ var urls = new[] { candidate.FilesZipUrl }
+ .Concat(InstallerPlondsUrlResolver.ResolveFilesZipUrls(candidate.Manifest, candidate.Source))
+ .DistinctBy(uri => uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ Exception? lastError = null;
+
+ foreach (var filesZipUrl in urls)
{
- Directory.Delete(packageRoot, recursive: true);
+ cancellationToken.ThrowIfCancellationRequested();
+ if (Directory.Exists(packageRoot))
+ {
+ Directory.Delete(packageRoot, recursive: true);
+ }
+
+ Directory.CreateDirectory(packageRoot);
+ var zipPath = Path.Combine(packageRoot, "Files.zip");
+ var extractDirectory = Path.Combine(packageRoot, "Files");
+ Directory.CreateDirectory(extractDirectory);
+ var attempt = candidate with { FilesZipUrl = filesZipUrl };
+
+ try
+ {
+ await DownloadToFileAsync(attempt, zipPath, progress, cancellationToken).ConfigureAwait(false);
+ await VerifyPackageAsync(zipPath, attempt.Manifest, filesZipUrl, cancellationToken).ConfigureAwait(false);
+ ExtractZip(zipPath, extractDirectory);
+
+ progress?.Report(new InstallerDeployProgress(
+ "Files package prepared",
+ version,
+ 1,
+ 0.10,
+ "Files.zip",
+ new FileInfo(zipPath).Length,
+ new FileInfo(zipPath).Length));
+
+ return new PreparedFilesPackage(version, candidate.Source.Id, zipPath, extractDirectory, candidate.Manifest);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ lastError = ex;
+ }
}
- Directory.CreateDirectory(packageRoot);
- var zipPath = Path.Combine(packageRoot, "Files.zip");
- var extractDirectory = Path.Combine(packageRoot, "Files");
- Directory.CreateDirectory(extractDirectory);
-
- await DownloadToFileAsync(candidate, zipPath, progress, cancellationToken).ConfigureAwait(false);
- await VerifyPackageAsync(zipPath, candidate.Manifest, candidate.FilesZipUrl, cancellationToken).ConfigureAwait(false);
- ExtractZip(zipPath, extractDirectory);
-
- progress?.Report(new InstallerDeployProgress(
- "Files package prepared",
- version,
- 1,
- 0.10,
- "Files.zip",
- new FileInfo(zipPath).Length,
- new FileInfo(zipPath).Length));
-
- return new PreparedFilesPackage(version, candidate.Source.Id, zipPath, extractDirectory, candidate.Manifest);
+ throw new InvalidOperationException("Failed to download and prepare the PLONDS Files package.", lastError);
}
public static long EstimateInstallBytes(InstallerPlondsManifest manifest)
@@ -140,33 +164,43 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin
var totalBytes = response.Content.Headers.ContentLength;
var partialPath = $"{destinationPath}.partial";
long downloaded = 0;
- await using (var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
- await using (var target = File.Create(partialPath))
+ try
{
- var buffer = new byte[128 * 1024];
- while (true)
+ await using (var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
+ await using (var target = File.Create(partialPath))
{
- var read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
- if (read == 0)
+ var buffer = new byte[128 * 1024];
+ while (true)
{
- break;
- }
+ var read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
+ if (read == 0)
+ {
+ break;
+ }
- await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
- downloaded += read;
- var fraction = totalBytes is > 0 ? Math.Clamp((double)downloaded / totalBytes.Value, 0, 1) : 0;
- progress?.Report(new InstallerDeployProgress(
- "Downloading Files.zip",
- candidate.Manifest.CurrentVersion,
- fraction,
- 0,
- "Files.zip",
- downloaded,
- totalBytes));
+ await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
+ downloaded += read;
+ var fraction = totalBytes is > 0 ? Math.Clamp((double)downloaded / totalBytes.Value, 0, 1) : 0;
+ progress?.Report(new InstallerDeployProgress(
+ "Downloading Files.zip",
+ candidate.Manifest.CurrentVersion,
+ fraction,
+ 0,
+ "Files.zip",
+ downloaded,
+ totalBytes));
+ }
+ }
+
+ File.Move(partialPath, destinationPath, overwrite: true);
+ }
+ finally
+ {
+ if (File.Exists(partialPath))
+ {
+ File.Delete(partialPath);
}
}
-
- File.Move(partialPath, destinationPath, overwrite: true);
}
private static async Task VerifyPackageAsync(
diff --git a/LanDesktopPLONDS.installer/app.Debug.manifest b/LanDesktopPLONDS.installer/app.Debug.manifest
new file mode 100644
index 0000000..db6b639
--- /dev/null
+++ b/LanDesktopPLONDS.installer/app.Debug.manifest
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LanMountainDesktop.Tests/OnlineInstallerCoreTests.cs b/LanMountainDesktop.Tests/OnlineInstallerCoreTests.cs
index 3504637..493ee20 100644
--- a/LanMountainDesktop.Tests/OnlineInstallerCoreTests.cs
+++ b/LanMountainDesktop.Tests/OnlineInstallerCoreTests.cs
@@ -84,6 +84,45 @@ public sealed class OnlineInstallerCoreTests : IDisposable
Assert.Contains(urls, uri => uri.AbsoluteUri == "https://github.test/Files.zip");
}
+ [Fact]
+ public async Task FindLatest_ParsesCamelCasePlondsManifest()
+ {
+ var client = new InstallerPlondsClient(
+ new HttpClient(new ManifestHandler("""
+ {
+ "formatVersion": "2.0",
+ "currentVersion": "1.2.4",
+ "previousVersion": "1.2.3",
+ "isFullUpdate": false,
+ "requiresCleanInstall": true,
+ "channel": "preview",
+ "platform": "windows-x64",
+ "updatedAt": "2026-06-03T00:00:00Z",
+ "filesMap": {},
+ "changedFilesMap": {},
+ "checksums": {
+ "Files.zip": "md5:00000000000000000000000000000000"
+ },
+ "downloads": {
+ "s3": {
+ "filesZipUrl": "https://s3.test/Files.zip"
+ },
+ "github": {
+ "filesZipUrl": "https://github.test/files-windows-x64.zip"
+ }
+ }
+ }
+ """)),
+ Path.Combine(_tempRoot, "staging"));
+
+ var candidate = await client.FindLatestAsync(CancellationToken.None);
+
+ Assert.Equal("1.2.4", candidate.Manifest.CurrentVersion);
+ Assert.Equal("preview", candidate.Manifest.Channel);
+ Assert.Equal("https://s3.test/Files.zip", candidate.FilesZipUrl.AbsoluteUri);
+ }
+
+
[Theory]
[InlineData("")]
[InlineData("C:\\")]
@@ -141,7 +180,43 @@ public sealed class OnlineInstallerCoreTests : IDisposable
manifest,
new Uri("https://s3.test/Files.zip"));
- await Assert.ThrowsAsync(() => client.DownloadAndPrepareFullPackageAsync(candidate, null, CancellationToken.None));
+ var exception = await Assert.ThrowsAsync(
+ () => client.DownloadAndPrepareFullPackageAsync(candidate, null, CancellationToken.None));
+ Assert.IsType(exception.InnerException);
+ }
+
+ [Fact]
+ public async Task DownloadAndPrepareFullPackage_FallsBackWhenFirstPackageUrlFails()
+ {
+ var zipPath = Path.Combine(_tempRoot, "Files.zip");
+ Directory.CreateDirectory(_tempRoot);
+ using (var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create))
+ {
+ var entry = archive.CreateEntry("LanMountainDesktop.exe");
+ await using var stream = entry.Open();
+ await using var writer = new StreamWriter(stream);
+ await writer.WriteAsync("host");
+ }
+
+ var manifest = CreateManifest(
+ downloads: new InstallerPlondsDownloads(
+ new InstallerPlondsGitHubDownloads(null, null, null, "https://github.test/files-windows-x64.zip"),
+ new InstallerPlondsS3Downloads(null, null, null, null, null, null, null, null, null, "https://s3.test/Files.zip", null, null)),
+ checksums: new Dictionary
+ {
+ ["Files.zip"] = "sha256:" + Sha256(zipPath)
+ });
+ var client = new InstallerPlondsClient(
+ new HttpClient(new FallbackPackageHandler(zipPath)),
+ Path.Combine(_tempRoot, "staging"));
+ var candidate = new InstallerPlondsCandidate(
+ new InstallerPlondsSource("s3", "s3", "https://origin.test/releases/PLONDS.json", 100),
+ manifest,
+ new Uri("https://s3.test/Files.zip"));
+
+ var package = await client.DownloadAndPrepareFullPackageAsync(candidate, null, CancellationToken.None);
+
+ Assert.True(File.Exists(Path.Combine(package.ExtractDirectory, "LanMountainDesktop.exe")));
}
private static InstallerPlondsManifest CreateManifest(
@@ -205,4 +280,33 @@ public sealed class OnlineInstallerCoreTests : IDisposable
return Task.FromResult(response);
}
}
+
+ private sealed class FallbackPackageHandler(string zipPath) : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ if (request.RequestUri?.AbsoluteUri == "https://github.test/files-windows-x64.zip")
+ {
+ var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
+ {
+ Content = new ByteArrayContent(File.ReadAllBytes(zipPath))
+ };
+ response.Content.Headers.ContentLength = new FileInfo(zipPath).Length;
+ return Task.FromResult(response);
+ }
+
+ return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.NotFound));
+ }
+ }
+
+ private sealed class ManifestHandler(string manifestJson) : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)
+ {
+ Content = new StringContent(manifestJson)
+ });
+ }
+ }
}
diff --git a/LanMountainDesktop/Services/Plonds/PlondsClientServiceFactory.cs b/LanMountainDesktop/Services/Plonds/PlondsClientServiceFactory.cs
index 1c0f51d..b2fe4cf 100644
--- a/LanMountainDesktop/Services/Plonds/PlondsClientServiceFactory.cs
+++ b/LanMountainDesktop/Services/Plonds/PlondsClientServiceFactory.cs
@@ -4,7 +4,7 @@ internal static class PlondsClientServiceFactory
{
private const string S3ManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_S3_MANIFEST_URL";
private const string GitHubManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_GITHUB_MANIFEST_URL";
- private const string DefaultS3ManifestUrl = "https://cn-nb1.rains3.com/lmdesktop/plonds/PLONDS.json";
+ private const string DefaultS3ManifestUrl = "https://cn-nb1.rains3.com/lmdesktop/lanmountain/update/plonds/PLONDS.json";
private const string DefaultGitHubManifestUrl = "https://github.com/wwiinnddyy/LanMountainDesktop/releases/latest/download/PLONDS.json";
public static IPlondsService CreateDefault(HttpClient? httpClient = null)