fix.在线安装器

This commit is contained in:
lincube
2026-06-03 07:30:54 +08:00
parent e888b0423a
commit 75c7aece4f
7 changed files with 211 additions and 47 deletions

View File

@@ -22,10 +22,12 @@ public partial class App : Application
var privacyIdentity = new PrivacyDeviceIdentityProvider(); var privacyIdentity = new PrivacyDeviceIdentityProvider();
var installService = OnlineInstallService.CreateDefault(privacyIdentity); var installService = OnlineInstallService.CreateDefault(privacyIdentity);
var consentStore = new InstallerPrivacyConsentStore(); var consentStore = new InstallerPrivacyConsentStore();
desktop.MainWindow = new MainWindow var mainWindow = new MainWindow
{ {
DataContext = new MainWindowViewModel(installService, privacyIdentity, consentStore) DataContext = new MainWindowViewModel(installService, privacyIdentity, consentStore)
}; };
desktop.MainWindow = mainWindow;
mainWindow.Show();
} }
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();

View File

@@ -10,7 +10,8 @@
<PackageVersion>$(Version)</PackageVersion> <PackageVersion>$(Version)</PackageVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<ApplicationIcon>Assets\logo.ico</ApplicationIcon> <ApplicationIcon>Assets\logo.ico</ApplicationIcon>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest Condition="'$(Configuration)' == 'Debug'">app.Debug.manifest</ApplicationManifest>
<ApplicationManifest Condition="'$(Configuration)' != 'Debug'">app.manifest</ApplicationManifest>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -2,5 +2,10 @@ using System.Text.Json.Serialization;
namespace LanDesktopPLONDS.Installer.Services; namespace LanDesktopPLONDS.Installer.Services;
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
ReadCommentHandling = System.Text.Json.JsonCommentHandling.Skip,
AllowTrailingCommas = true)]
[JsonSerializable(typeof(InstallerPlondsManifest))] [JsonSerializable(typeof(InstallerPlondsManifest))]
internal sealed partial class InstallerJsonContext : JsonSerializerContext; internal sealed partial class InstallerJsonContext : JsonSerializerContext;

View File

@@ -10,7 +10,7 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin
{ {
private const string S3ManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_S3_MANIFEST_URL"; private const string S3ManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_S3_MANIFEST_URL";
private const string GitHubManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_GITHUB_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 const string DefaultGitHubManifestUrl = "https://github.com/wwiinnddyy/LanMountainDesktop/releases/latest/download/PLONDS.json";
private static readonly JsonSerializerOptions JsonOptions = new() 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 version = ParseVersion(candidate.Manifest.CurrentVersion).ToString();
var packageRoot = Path.Combine(stagingRoot, SanitizePathSegment(version), SanitizePathSegment(candidate.Source.Id), "full"); 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); throw new InvalidOperationException("Failed to download and prepare the PLONDS Files package.", lastError);
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);
} }
public static long EstimateInstallBytes(InstallerPlondsManifest manifest) 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 totalBytes = response.Content.Headers.ContentLength;
var partialPath = $"{destinationPath}.partial"; var partialPath = $"{destinationPath}.partial";
long downloaded = 0; long downloaded = 0;
await using (var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false)) try
await using (var target = File.Create(partialPath))
{ {
var buffer = new byte[128 * 1024]; await using (var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
while (true) await using (var target = File.Create(partialPath))
{ {
var read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); var buffer = new byte[128 * 1024];
if (read == 0) 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); await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
downloaded += read; downloaded += read;
var fraction = totalBytes is > 0 ? Math.Clamp((double)downloaded / totalBytes.Value, 0, 1) : 0; var fraction = totalBytes is > 0 ? Math.Clamp((double)downloaded / totalBytes.Value, 0, 1) : 0;
progress?.Report(new InstallerDeployProgress( progress?.Report(new InstallerDeployProgress(
"Downloading Files.zip", "Downloading Files.zip",
candidate.Manifest.CurrentVersion, candidate.Manifest.CurrentVersion,
fraction, fraction,
0, 0,
"Files.zip", "Files.zip",
downloaded, downloaded,
totalBytes)); 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( private static async Task VerifyPackageAsync(

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="0.0.0.0" name="LanDesktopPLONDS.Installer"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

View File

@@ -84,6 +84,45 @@ public sealed class OnlineInstallerCoreTests : IDisposable
Assert.Contains(urls, uri => uri.AbsoluteUri == "https://github.test/Files.zip"); 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] [Theory]
[InlineData("")] [InlineData("")]
[InlineData("C:\\")] [InlineData("C:\\")]
@@ -141,7 +180,43 @@ public sealed class OnlineInstallerCoreTests : IDisposable
manifest, manifest,
new Uri("https://s3.test/Files.zip")); new Uri("https://s3.test/Files.zip"));
await Assert.ThrowsAsync<InvalidDataException>(() => client.DownloadAndPrepareFullPackageAsync(candidate, null, CancellationToken.None)); var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => client.DownloadAndPrepareFullPackageAsync(candidate, null, CancellationToken.None));
Assert.IsType<InvalidDataException>(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<string, string>
{
["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( private static InstallerPlondsManifest CreateManifest(
@@ -205,4 +280,33 @@ public sealed class OnlineInstallerCoreTests : IDisposable
return Task.FromResult(response); return Task.FromResult(response);
} }
} }
private sealed class FallbackPackageHandler(string zipPath) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> 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<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(manifestJson)
});
}
}
} }

View File

@@ -4,7 +4,7 @@ internal static class PlondsClientServiceFactory
{ {
private const string S3ManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_S3_MANIFEST_URL"; private const string S3ManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_S3_MANIFEST_URL";
private const string GitHubManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_GITHUB_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 const string DefaultGitHubManifestUrl = "https://github.com/wwiinnddyy/LanMountainDesktop/releases/latest/download/PLONDS.json";
public static IPlondsService CreateDefault(HttpClient? httpClient = null) public static IPlondsService CreateDefault(HttpClient? httpClient = null)