mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 17:24:27 +08:00
fix.启动器一定要能够启动
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:sty="using:FluentAvalonia.Styling"
|
||||
x:Class="LanMountainDesktop.Launcher.App"
|
||||
RequestedThemeVariant="Default">
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<sty:FluentAvaloniaTheme />
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
|
||||
@@ -10,13 +10,14 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
<!-- 只引用 Shared.Contracts(IPC 协议) -->
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.12" />
|
||||
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
|
||||
<PackageReference Include="Microsoft.Windows.CsWin32" Version="0.3.106" PrivateAssets="all" />
|
||||
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
|
||||
|
||||
@@ -58,6 +58,8 @@ internal sealed class DeploymentLocator
|
||||
public string? ResolveHostExecutablePath()
|
||||
{
|
||||
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||
|
||||
// 1. 首先查找 app-{version} 目录(生产环境)
|
||||
var currentDeployment = FindCurrentDeploymentDirectory();
|
||||
if (!string.IsNullOrWhiteSpace(currentDeployment))
|
||||
{
|
||||
@@ -68,15 +70,98 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 查找 Launcher 所在目录(开发环境 - 直接运行)
|
||||
var inRoot = Path.Combine(_appRoot, executable);
|
||||
if (File.Exists(inRoot))
|
||||
{
|
||||
return inRoot;
|
||||
}
|
||||
|
||||
// 3. 查找父目录(开发环境 - 从 Launcher 项目运行)
|
||||
var parent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
|
||||
var inParent = Path.Combine(parent, executable);
|
||||
return File.Exists(inParent) ? inParent : null;
|
||||
if (File.Exists(inParent))
|
||||
{
|
||||
return inParent;
|
||||
}
|
||||
|
||||
// 4. 开发模式:如果启用了开发模式,优先扫描开发路径
|
||||
if (Views.ErrorWindow.CheckDevModeEnabled())
|
||||
{
|
||||
var devPath = ScanDevelopmentPaths(executable);
|
||||
if (!string.IsNullOrWhiteSpace(devPath))
|
||||
{
|
||||
return devPath;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 开发模式:查找主程序项目的输出目录
|
||||
var devPaths = GetDevelopmentPaths(executable);
|
||||
foreach (var devPath in devPaths)
|
||||
{
|
||||
if (File.Exists(devPath))
|
||||
{
|
||||
return devPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 扫描开发路径(开发模式)
|
||||
/// </summary>
|
||||
private static string? ScanDevelopmentPaths(string executable)
|
||||
{
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
// 从 Launcher 项目运行
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// 从解决方案根目录运行
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// dev-test 目录
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||
};
|
||||
|
||||
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取开发环境可能的主程序路径
|
||||
/// </summary>
|
||||
private static IEnumerable<string> GetDevelopmentPaths(string executable)
|
||||
{
|
||||
// 获取 Launcher 所在目录
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
|
||||
// 可能的开发目录结构
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
// 从 Launcher 项目运行:..\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// 从解决方案根目录运行:LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// 从 dev-test 目录运行
|
||||
Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||
};
|
||||
|
||||
return possiblePaths.Select(Path.GetFullPath).Distinct();
|
||||
}
|
||||
|
||||
public string GetCurrentVersion()
|
||||
|
||||
109
LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs
Normal file
109
LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Launcher IPC 服务端 - 接收主程序的启动进度报告
|
||||
/// </summary>
|
||||
public class LauncherIpcServer : IDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private NamedPipeServerStream? _pipeServer;
|
||||
private readonly Action<StartupProgressMessage> _onProgress;
|
||||
private Task? _listenTask;
|
||||
|
||||
public LauncherIpcServer(Action<StartupProgressMessage> onProgress)
|
||||
{
|
||||
_onProgress = onProgress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动 IPC 服务端监听
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
_listenTask = Task.Run(async () =>
|
||||
{
|
||||
while (!_cts.Token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
_pipeServer = new NamedPipeServerStream(
|
||||
LauncherIpcConstants.PipeName,
|
||||
PipeDirection.In,
|
||||
1,
|
||||
PipeTransmissionMode.Message);
|
||||
|
||||
await _pipeServer.WaitForConnectionAsync(_cts.Token);
|
||||
|
||||
using var reader = new StreamReader(_pipeServer);
|
||||
var json = await reader.ReadToEndAsync(_cts.Token);
|
||||
|
||||
if (!string.IsNullOrEmpty(json))
|
||||
{
|
||||
try
|
||||
{
|
||||
var message = JsonSerializer.Deserialize<StartupProgressMessage>(json);
|
||||
if (message != null)
|
||||
{
|
||||
_onProgress(message);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_pipeServer.Disconnect();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// 管道断开,继续监听
|
||||
continue;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"IPC error: {ex.Message}");
|
||||
await Task.Delay(100, _cts.Token);
|
||||
}
|
||||
}
|
||||
}, _cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止 IPC 服务端
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
_cts.Cancel();
|
||||
try
|
||||
{
|
||||
_pipeServer?.Disconnect();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
_pipeServer?.Dispose();
|
||||
_cts.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
_listenTask?.Wait(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services.Ipc;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
@@ -39,14 +41,7 @@ internal sealed class LauncherFlowCoordinator
|
||||
// 清理待删除的旧版本
|
||||
_deploymentLocator.CleanupDestroyedDeployments();
|
||||
|
||||
if (_oobeStateService.IsFirstRun())
|
||||
{
|
||||
foreach (var step in _oobeSteps)
|
||||
{
|
||||
await step.RunAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示 Splash 窗口
|
||||
var splashWindow = await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
var window = new SplashWindow();
|
||||
@@ -55,17 +50,29 @@ internal sealed class LauncherFlowCoordinator
|
||||
});
|
||||
|
||||
var reporter = (ISplashStageReporter)splashWindow;
|
||||
|
||||
// 启动 IPC 服务端监听主程序进度
|
||||
using var ipcServer = new LauncherIpcServer(msg =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
reporter.Report(msg.Stage.ToString().ToLower(), msg.Message ?? "");
|
||||
});
|
||||
});
|
||||
ipcServer.Start();
|
||||
|
||||
try
|
||||
{
|
||||
reporter.Report("silentUpdate", "update");
|
||||
// 检查并安装待处理的更新(主程序下载的)
|
||||
reporter.Report("update", "检查更新...");
|
||||
var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
||||
if (!updateResult.Success)
|
||||
{
|
||||
return updateResult;
|
||||
}
|
||||
|
||||
reporter.Report("pluginTasks", "plugins");
|
||||
// 检查并安装待处理的插件更新
|
||||
reporter.Report("plugins", "检查插件更新...");
|
||||
var pluginsDir = _context.GetOption("plugins-dir")
|
||||
?? Path.Combine(_deploymentLocator.GetAppRoot(), "plugins");
|
||||
var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir);
|
||||
@@ -74,13 +81,28 @@ internal sealed class LauncherFlowCoordinator
|
||||
return queueResult;
|
||||
}
|
||||
|
||||
reporter.Report("launchHost", "launch");
|
||||
var hostResult = LaunchHost();
|
||||
// OOBE(首次运行引导)
|
||||
if (_oobeStateService.IsFirstRun())
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Hide());
|
||||
foreach (var step in _oobeSteps)
|
||||
{
|
||||
await step.RunAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Show());
|
||||
}
|
||||
|
||||
// 启动主程序
|
||||
reporter.Report("launch", "正在启动...");
|
||||
var hostResult = await LaunchHostWithIpcAsync();
|
||||
if (!hostResult.Success)
|
||||
{
|
||||
return hostResult;
|
||||
}
|
||||
|
||||
// 等待主程序就绪或超时
|
||||
await Task.Delay(TimeSpan.FromSeconds(30));
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
@@ -107,11 +129,28 @@ internal sealed class LauncherFlowCoordinator
|
||||
}
|
||||
}
|
||||
|
||||
private LauncherResult LaunchHost()
|
||||
private async Task<LauncherResult> LaunchHostWithIpcAsync(string? customHostPath = null)
|
||||
{
|
||||
var hostPath = _deploymentLocator.ResolveHostExecutablePath();
|
||||
// 优先使用自定义路径(调试模式选择的路径)
|
||||
var hostPath = customHostPath ?? _deploymentLocator.ResolveHostExecutablePath();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(hostPath))
|
||||
{
|
||||
// 关闭 Splash 窗口
|
||||
// 显示错误窗口而不是直接退出
|
||||
var (errorResult, selectedPath) = await ShowHostNotFoundErrorAsync();
|
||||
|
||||
if (errorResult == ErrorWindowResult.Retry)
|
||||
{
|
||||
// 用户选择重试,如果有选择路径则使用,否则重新尝试
|
||||
if (!string.IsNullOrWhiteSpace(selectedPath))
|
||||
{
|
||||
return await LaunchHostWithIpcAsync(selectedPath);
|
||||
}
|
||||
return await LaunchHostWithIpcAsync();
|
||||
}
|
||||
|
||||
// 用户选择退出
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
@@ -133,6 +172,12 @@ internal sealed class LauncherFlowCoordinator
|
||||
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot()
|
||||
};
|
||||
|
||||
// 传递环境变量供 IPC 使用
|
||||
processStartInfo.EnvironmentVariables[LauncherIpcConstants.LauncherPidEnvVar] =
|
||||
Environment.ProcessId.ToString();
|
||||
processStartInfo.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar] =
|
||||
_deploymentLocator.GetAppRoot();
|
||||
|
||||
Process.Start(processStartInfo);
|
||||
return new LauncherResult
|
||||
{
|
||||
@@ -143,6 +188,26 @@ internal sealed class LauncherFlowCoordinator
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 显示找不到主程序的错误窗口
|
||||
/// </summary>
|
||||
private async Task<(ErrorWindowResult Result, string? CustomPath)> ShowHostNotFoundErrorAsync()
|
||||
{
|
||||
return await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
var errorWindow = new ErrorWindow();
|
||||
errorWindow.SetErrorMessage("找不到阑山桌面应用程序。");
|
||||
errorWindow.Show();
|
||||
|
||||
var result = await errorWindow.WaitForChoiceAsync();
|
||||
var customPath = errorWindow.GetCustomHostPath();
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() => errorWindow.Close());
|
||||
|
||||
return (result, customPath);
|
||||
});
|
||||
}
|
||||
|
||||
private static void EnsureExecutable(string path)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
using System.IO.Compression;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 插件安装服务 - 简化版,不依赖 PluginSdk
|
||||
/// </summary>
|
||||
internal sealed class PluginInstallerService
|
||||
{
|
||||
private const string ManifestFileName = "manifest.json";
|
||||
private const string PackageFileExtension = ".lmdp";
|
||||
private const string RuntimeDirectoryName = "runtime";
|
||||
|
||||
private static readonly TimeSpan[] RetryDelays =
|
||||
[
|
||||
TimeSpan.FromMilliseconds(120),
|
||||
@@ -48,33 +55,40 @@ internal sealed class PluginInstallerService
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(packagePath);
|
||||
var entries = archive.Entries
|
||||
.Where(entry => string.Equals(entry.Name, PluginSdkInfo.ManifestFileName, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(entry => string.Equals(entry.Name, ManifestFileName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin package '{packagePath}' does not contain '{PluginSdkInfo.ManifestFileName}'.");
|
||||
$"Plugin package '{packagePath}' does not contain '{ManifestFileName}'.");
|
||||
}
|
||||
|
||||
if (entries.Length > 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin package '{packagePath}' contains multiple '{PluginSdkInfo.ManifestFileName}' files.");
|
||||
$"Plugin package '{packagePath}' contains multiple '{ManifestFileName}' files.");
|
||||
}
|
||||
|
||||
using var stream = entries[0].Open();
|
||||
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = reader.ReadToEnd();
|
||||
var manifest = JsonSerializer.Deserialize<PluginManifest>(json);
|
||||
if (manifest == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to deserialize manifest from '{packagePath}'.");
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
private void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
|
||||
{
|
||||
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), PluginSdkInfo.RuntimeDirectoryName));
|
||||
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), RuntimeDirectoryName));
|
||||
var pendingDeletionDir = Path.Combine(pluginsDirectory, ".pending-deletions");
|
||||
Directory.CreateDirectory(pendingDeletionDir);
|
||||
|
||||
foreach (var existingPackagePath in Directory
|
||||
.EnumerateFiles(pluginsDirectory, "*" + PluginSdkInfo.PackageFileExtension, SearchOption.AllDirectories)
|
||||
.EnumerateFiles(pluginsDirectory, "*" + PackageFileExtension, SearchOption.AllDirectories)
|
||||
.Select(Path.GetFullPath)
|
||||
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
@@ -188,7 +202,7 @@ internal sealed class PluginInstallerService
|
||||
{
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||
return fileName + PluginSdkInfo.PackageFileExtension;
|
||||
return fileName + PackageFileExtension;
|
||||
}
|
||||
|
||||
private static string EnsureTrailingSeparator(string path)
|
||||
@@ -198,3 +212,15 @@ internal sealed class PluginInstallerService
|
||||
: path + Path.DirectorySeparatorChar;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 简化的插件清单模型
|
||||
/// </summary>
|
||||
public class PluginManifest
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string Version { get; set; } = "";
|
||||
public string? Description { get; set; }
|
||||
public string? Author { get; set; }
|
||||
}
|
||||
|
||||
@@ -146,7 +146,9 @@ internal sealed class UpdateEngineService
|
||||
var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
|
||||
if (string.IsNullOrWhiteSpace(currentDeployment))
|
||||
{
|
||||
return Failed("update.apply", "no_current_deployment", "Current deployment directory not found.");
|
||||
// 全新安装场景:没有当前部署目录,但有更新包
|
||||
// 这种情况下应该直接应用更新作为首次安装
|
||||
return await ApplyInitialDeploymentAsync(fileMap, archivePath, fileMapPath, signaturePath);
|
||||
}
|
||||
|
||||
var currentVersion = _deploymentLocator.GetCurrentVersion();
|
||||
@@ -258,6 +260,167 @@ internal sealed class UpdateEngineService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全新安装场景:直接应用更新包作为首次部署
|
||||
/// </summary>
|
||||
private async Task<LauncherResult> ApplyInitialDeploymentAsync(
|
||||
SignedFileMap fileMap,
|
||||
string archivePath,
|
||||
string fileMapPath,
|
||||
string signaturePath)
|
||||
{
|
||||
var targetVersion = string.IsNullOrWhiteSpace(fileMap.ToVersion) ? "1.0.0" : fileMap.ToVersion!;
|
||||
var targetDeployment = _deploymentLocator.BuildNextDeploymentDirectory(targetVersion);
|
||||
var partialMarker = Path.Combine(targetDeployment, ".partial");
|
||||
var snapshotPath = Path.Combine(_snapshotsRoot, $"initial-{Guid.NewGuid():N}.json");
|
||||
|
||||
var extractRoot = Path.Combine(_incomingRoot, "extracted");
|
||||
try
|
||||
{
|
||||
// 保存快照(用于回滚,虽然首次安装回滚意义不大)
|
||||
var snapshot = new SnapshotMetadata
|
||||
{
|
||||
SnapshotId = Guid.NewGuid().ToString("N"),
|
||||
SourceVersion = "0.0.0",
|
||||
TargetVersion = targetVersion,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
SourceDirectory = "",
|
||||
TargetDirectory = targetDeployment,
|
||||
Status = "pending"
|
||||
};
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
|
||||
// 清理并解压更新包
|
||||
if (Directory.Exists(extractRoot))
|
||||
{
|
||||
Directory.Delete(extractRoot, true);
|
||||
}
|
||||
Directory.CreateDirectory(extractRoot);
|
||||
ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true);
|
||||
|
||||
// 创建目标部署目录
|
||||
Directory.CreateDirectory(targetDeployment);
|
||||
File.WriteAllText(partialMarker, string.Empty);
|
||||
|
||||
// 应用所有文件(全新安装时,所有文件都是新增或替换)
|
||||
foreach (var file in fileMap.Files)
|
||||
{
|
||||
ApplyInitialFileEntry(file, targetDeployment, extractRoot);
|
||||
}
|
||||
|
||||
// 验证文件哈希
|
||||
foreach (var file in fileMap.Files)
|
||||
{
|
||||
if (!NeedsVerification(file))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(targetDeployment, file.Path);
|
||||
var actualHash = ComputeSha256Hex(fullPath);
|
||||
if (!string.Equals(actualHash, file.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"File hash mismatch for '{file.Path}'.");
|
||||
}
|
||||
}
|
||||
|
||||
// 激活部署(创建 .current 标记,删除 .partial 标记)
|
||||
var currentMarker = Path.Combine(targetDeployment, ".current");
|
||||
File.WriteAllText(currentMarker, string.Empty);
|
||||
if (File.Exists(partialMarker))
|
||||
{
|
||||
File.Delete(partialMarker);
|
||||
}
|
||||
|
||||
// 清理更新包
|
||||
snapshot.Status = "applied";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
CleanupIncomingArtifacts();
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.apply",
|
||||
Code = "ok",
|
||||
Message = $"Initial deployment to {targetVersion}.",
|
||||
CurrentVersion = "0.0.0",
|
||||
TargetVersion = targetVersion
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 清理失败的目标目录
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(targetDeployment))
|
||||
{
|
||||
Directory.Delete(targetDeployment, true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "update.apply",
|
||||
Code = "initial_deploy_failed",
|
||||
Message = "Failed to apply initial deployment.",
|
||||
ErrorMessage = ex.Message,
|
||||
CurrentVersion = "0.0.0",
|
||||
TargetVersion = targetVersion
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(extractRoot))
|
||||
{
|
||||
Directory.Delete(extractRoot, true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 应用初始部署文件(全新安装场景,不需要源目录)
|
||||
/// </summary>
|
||||
private void ApplyInitialFileEntry(UpdateFileEntry file, string targetDeployment, string extractRoot)
|
||||
{
|
||||
var normalizedPath = NormalizeRelativePath(file.Path);
|
||||
|
||||
// 删除操作在全新安装时忽略
|
||||
if (string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var targetPath = Path.Combine(targetDeployment, normalizedPath);
|
||||
EnsurePathWithinRoot(targetPath, targetDeployment);
|
||||
var targetDir = Path.GetDirectoryName(targetPath);
|
||||
if (!string.IsNullOrWhiteSpace(targetDir))
|
||||
{
|
||||
Directory.CreateDirectory(targetDir);
|
||||
}
|
||||
|
||||
// 无论是 add 还是 replace,都从压缩包复制
|
||||
var archiveRelative = string.IsNullOrWhiteSpace(file.ArchivePath) ? normalizedPath : NormalizeRelativePath(file.ArchivePath);
|
||||
var extractedPath = Path.Combine(extractRoot, archiveRelative);
|
||||
EnsurePathWithinRoot(extractedPath, extractRoot);
|
||||
|
||||
if (!File.Exists(extractedPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Archive file '{archiveRelative}' not found for '{file.Path}'.");
|
||||
}
|
||||
|
||||
File.Copy(extractedPath, targetPath, overwrite: true);
|
||||
}
|
||||
|
||||
public LauncherResult RollbackLatest()
|
||||
{
|
||||
if (!Directory.Exists(_snapshotsRoot))
|
||||
|
||||
106
LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml
Normal file
106
LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml
Normal file
@@ -0,0 +1,106 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="420"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.ErrorDebugWindow"
|
||||
x:DataType="views:ErrorDebugWindow"
|
||||
Title="调试模式"
|
||||
Width="420"
|
||||
Height="320"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
TransparencyLevelHint="None">
|
||||
<Design.DataContext>
|
||||
<views:ErrorDebugWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid Margin="24" RowDefinitions="Auto,*,Auto">
|
||||
<!-- 标题 -->
|
||||
<TextBlock Grid.Row="0"
|
||||
Text="调试设置"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
Margin="0,0,0,16" />
|
||||
|
||||
<!-- 设置内容 -->
|
||||
<StackPanel Grid.Row="1" Spacing="16">
|
||||
<!-- 开发模式开关 -->
|
||||
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||
Padding="16,12">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" VerticalAlignment="Center">
|
||||
<TextBlock Text="开发模式"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="启用后自动扫描开发目录"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Margin="0,2,0,0" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch x:Name="DevModeToggle"
|
||||
Grid.Column="1"
|
||||
OnContent="开"
|
||||
OffContent="关" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 应用路径选择 -->
|
||||
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||
Padding="16,12">
|
||||
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0"
|
||||
Text="应用路径"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock x:Name="PathTextBlock"
|
||||
Grid.Row="1" Grid.Column="0"
|
||||
Text="未选择"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Margin="0,4,12,0" />
|
||||
<Button x:Name="BrowseButton"
|
||||
Grid.Row="0" Grid.RowSpan="2" Grid.Column="1"
|
||||
Content="浏览..."
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<Border Background="{DynamicResource SystemFillColorCautionBackgroundBrush}"
|
||||
CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||
Padding="12,10"
|
||||
IsVisible="True">
|
||||
<TextBlock Text="此功能仅供开发人员使用"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource SystemFillColorCautionBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 按钮区域 -->
|
||||
<StackPanel Grid.Row="2"
|
||||
Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
Spacing="12"
|
||||
Margin="0,16,0,0">
|
||||
<Button x:Name="CancelButton"
|
||||
Content="取消"
|
||||
Width="80"
|
||||
Height="32" />
|
||||
<Button x:Name="OkButton"
|
||||
Content="确定"
|
||||
Width="80"
|
||||
Height="32"
|
||||
Theme="{DynamicResource AccentButtonTheme}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
128
LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml.cs
Normal file
128
LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Platform.Storage;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 错误调试窗口 - 开发人员专用调试设置
|
||||
/// </summary>
|
||||
public partial class ErrorDebugWindow : Window
|
||||
{
|
||||
private string? _selectedHostPath;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用了开发模式
|
||||
/// </summary>
|
||||
public bool IsDevModeEnabled { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 选择的主程序路径
|
||||
/// </summary>
|
||||
public string? SelectedHostPath => _selectedHostPath;
|
||||
|
||||
public ErrorDebugWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
InitializeComponents();
|
||||
}
|
||||
|
||||
public ErrorDebugWindow(bool devModeEnabled, string? initialPath) : this()
|
||||
{
|
||||
IsDevModeEnabled = devModeEnabled;
|
||||
_selectedHostPath = initialPath;
|
||||
|
||||
// 设置初始值
|
||||
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
|
||||
if (devModeToggle is not null)
|
||||
{
|
||||
devModeToggle.IsChecked = devModeEnabled;
|
||||
}
|
||||
|
||||
UpdatePathDisplay(initialPath);
|
||||
}
|
||||
|
||||
private void InitializeComponents()
|
||||
{
|
||||
// 开发模式开关
|
||||
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
|
||||
if (devModeToggle is not null)
|
||||
{
|
||||
devModeToggle.IsCheckedChanged += (s, e) =>
|
||||
{
|
||||
IsDevModeEnabled = devModeToggle.IsChecked ?? false;
|
||||
};
|
||||
}
|
||||
|
||||
// 浏览按钮
|
||||
var browseButton = this.FindControl<Button>("BrowseButton");
|
||||
if (browseButton is not null)
|
||||
{
|
||||
browseButton.Click += OnBrowseClick;
|
||||
}
|
||||
|
||||
// 确定按钮
|
||||
var okButton = this.FindControl<Button>("OkButton");
|
||||
if (okButton is not null)
|
||||
{
|
||||
okButton.Click += (s, e) => Close();
|
||||
}
|
||||
|
||||
// 取消按钮
|
||||
var cancelButton = this.FindControl<Button>("CancelButton");
|
||||
if (cancelButton is not null)
|
||||
{
|
||||
cancelButton.Click += (s, e) =>
|
||||
{
|
||||
// 取消时恢复原始状态
|
||||
IsDevModeEnabled = false;
|
||||
_selectedHostPath = null;
|
||||
Close();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 浏览按钮点击
|
||||
/// </summary>
|
||||
private async void OnBrowseClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var storageProvider = StorageProvider;
|
||||
if (storageProvider is null) return;
|
||||
|
||||
var options = new FilePickerOpenOptions
|
||||
{
|
||||
Title = "选择阑山桌面主程序",
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter = new[]
|
||||
{
|
||||
new FilePickerFileType("可执行文件")
|
||||
{
|
||||
Patterns = OperatingSystem.IsWindows()
|
||||
? new[] { "*.exe" }
|
||||
: new[] { "*" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = await storageProvider.OpenFilePickerAsync(options);
|
||||
if (result.Count > 0)
|
||||
{
|
||||
_selectedHostPath = result[0].Path.LocalPath;
|
||||
UpdatePathDisplay(_selectedHostPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新路径显示
|
||||
/// </summary>
|
||||
private void UpdatePathDisplay(string? path)
|
||||
{
|
||||
var pathTextBlock = this.FindControl<TextBlock>("PathTextBlock");
|
||||
if (pathTextBlock is not null)
|
||||
{
|
||||
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "未选择" : path;
|
||||
}
|
||||
}
|
||||
}
|
||||
84
LanMountainDesktop.Launcher/Views/ErrorWindow.axaml
Normal file
84
LanMountainDesktop.Launcher/Views/ErrorWindow.axaml
Normal file
@@ -0,0 +1,84 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="480"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.ErrorWindow"
|
||||
x:DataType="views:ErrorWindow"
|
||||
Title="阑山桌面 - 启动失败"
|
||||
Width="480"
|
||||
Height="320"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
TransparencyLevelHint="None">
|
||||
<Design.DataContext>
|
||||
<views:ErrorWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid Margin="40" RowDefinitions="Auto,*,Auto">
|
||||
<!-- 错误图标和标题 -->
|
||||
<StackPanel Grid.Row="0" HorizontalAlignment="Center">
|
||||
<!-- 错误图标 - 可点击进入调试模式(隐藏功能,无提示) -->
|
||||
<Border x:Name="ErrorIconBorder"
|
||||
Width="64"
|
||||
Height="64"
|
||||
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
|
||||
CornerRadius="32"
|
||||
HorizontalAlignment="Center">
|
||||
<TextBlock x:Name="ErrorIconText"
|
||||
Text="!"
|
||||
FontSize="36"
|
||||
FontWeight="Bold"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="启动失败"
|
||||
FontSize="24"
|
||||
FontWeight="Medium"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,20,0,0" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<StackPanel Grid.Row="1" VerticalAlignment="Center" Margin="0,20">
|
||||
<TextBlock x:Name="ErrorMessageText"
|
||||
Text="找不到阑山桌面应用程序。"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
TextAlignment="Center"
|
||||
LineHeight="22" />
|
||||
|
||||
<TextBlock Text="请确保应用程序已正确安装,或尝试重新安装。"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
TextAlignment="Center"
|
||||
Margin="0,12,0,0"
|
||||
LineHeight="20" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- 按钮区域 -->
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Center" Spacing="12">
|
||||
<Button x:Name="RetryButton"
|
||||
Content="重试"
|
||||
Width="100"
|
||||
Height="36"
|
||||
FontSize="14"
|
||||
Theme="{DynamicResource AccentButtonTheme}" />
|
||||
|
||||
<Button x:Name="ExitButton"
|
||||
Content="退出"
|
||||
Width="100"
|
||||
Height="36"
|
||||
FontSize="14" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
239
LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs
Normal file
239
LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs
Normal file
@@ -0,0 +1,239 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Platform.Storage;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 错误窗口 - 显示启动失败信息,支持调试模式(隐藏入口)
|
||||
/// </summary>
|
||||
public partial class ErrorWindow : Window
|
||||
{
|
||||
private readonly TaskCompletionSource<ErrorWindowResult> _completionSource = new();
|
||||
private int _iconClickCount = 0;
|
||||
private const int DebugModeClickThreshold = 5;
|
||||
private bool _isDebugMode = false;
|
||||
private string? _customHostPath;
|
||||
private bool _devModeEnabled;
|
||||
|
||||
public ErrorWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
// 先加载保存的状态
|
||||
_devModeEnabled = LoadDevModeStateInternal();
|
||||
|
||||
InitializeComponents();
|
||||
}
|
||||
|
||||
private void InitializeComponents()
|
||||
{
|
||||
// 错误图标点击事件(进入调试模式 - 隐藏功能)
|
||||
var errorIconBorder = this.FindControl<Border>("ErrorIconBorder");
|
||||
if (errorIconBorder is not null)
|
||||
{
|
||||
errorIconBorder.PointerPressed += OnErrorIconClick;
|
||||
}
|
||||
|
||||
// 按钮事件
|
||||
var retryButton = this.FindControl<Button>("RetryButton");
|
||||
var exitButton = this.FindControl<Button>("ExitButton");
|
||||
|
||||
if (retryButton is not null)
|
||||
{
|
||||
retryButton.Click += OnRetryClick;
|
||||
}
|
||||
|
||||
if (exitButton is not null)
|
||||
{
|
||||
exitButton.Click += OnExitClick;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置错误消息
|
||||
/// </summary>
|
||||
public void SetErrorMessage(string message)
|
||||
{
|
||||
var errorText = this.FindControl<TextBlock>("ErrorMessageText");
|
||||
if (errorText is not null)
|
||||
{
|
||||
errorText.Text = message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取用户选择的主程序路径
|
||||
/// </summary>
|
||||
public string? GetCustomHostPath() => _customHostPath;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用了开发模式
|
||||
/// </summary>
|
||||
public bool IsDevModeEnabled() => _devModeEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// 等待用户选择
|
||||
/// </summary>
|
||||
public Task<ErrorWindowResult> WaitForChoiceAsync()
|
||||
{
|
||||
return _completionSource.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 错误图标点击事件 - 连续点击 5 次进入调试模式(隐藏功能)
|
||||
/// </summary>
|
||||
private void OnErrorIconClick(object? sender, Avalonia.Input.PointerPressedEventArgs e)
|
||||
{
|
||||
_iconClickCount++;
|
||||
|
||||
if (_iconClickCount >= DebugModeClickThreshold && !_isDebugMode)
|
||||
{
|
||||
EnterDebugMode();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 进入调试模式 - 显示调试窗口
|
||||
/// </summary>
|
||||
private async void EnterDebugMode()
|
||||
{
|
||||
_isDebugMode = true;
|
||||
|
||||
// 创建并显示调试窗口
|
||||
var debugWindow = new ErrorDebugWindow(_devModeEnabled, _customHostPath)
|
||||
{
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner
|
||||
};
|
||||
|
||||
// 订阅调试窗口关闭事件
|
||||
debugWindow.Closed += (s, e) =>
|
||||
{
|
||||
// 更新状态
|
||||
_devModeEnabled = debugWindow.IsDevModeEnabled;
|
||||
_customHostPath = debugWindow.SelectedHostPath;
|
||||
|
||||
// 保存开发模式状态
|
||||
SaveDevModeStateInternal(_devModeEnabled);
|
||||
|
||||
// 如果启用了开发模式且没有选择路径,自动扫描
|
||||
if (_devModeEnabled && string.IsNullOrEmpty(_customHostPath))
|
||||
{
|
||||
ScanDevPaths();
|
||||
}
|
||||
};
|
||||
|
||||
await debugWindow.ShowDialog(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 扫描开发路径
|
||||
/// </summary>
|
||||
private void ScanDevPaths()
|
||||
{
|
||||
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||
};
|
||||
|
||||
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
_customHostPath = path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存开发模式状态(内部方法)
|
||||
/// </summary>
|
||||
private static void SaveDevModeStateInternal(bool enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
var devModeFile = GetDevModeFilePath();
|
||||
var dir = Path.GetDirectoryName(devModeFile);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
File.WriteAllText(devModeFile, enabled ? "1" : "0");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Failed to save dev mode state: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载开发模式状态(内部方法)
|
||||
/// </summary>
|
||||
private static bool LoadDevModeStateInternal()
|
||||
{
|
||||
try
|
||||
{
|
||||
var devModeFile = GetDevModeFilePath();
|
||||
if (File.Exists(devModeFile))
|
||||
{
|
||||
var content = File.ReadAllText(devModeFile).Trim();
|
||||
return content == "1";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Failed to load dev mode state: {ex.Message}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取开发模式状态文件路径
|
||||
/// </summary>
|
||||
private static string GetDevModeFilePath()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
return Path.Combine(appData, "LanMountainDesktop", ".launcher", "devmode.config");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查是否启用了开发模式(静态方法,启动时调用)
|
||||
/// </summary>
|
||||
public static bool CheckDevModeEnabled()
|
||||
{
|
||||
return LoadDevModeStateInternal();
|
||||
}
|
||||
|
||||
private void OnRetryClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_completionSource.TrySetResult(ErrorWindowResult.Retry);
|
||||
}
|
||||
|
||||
private void OnExitClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_completionSource.TrySetResult(ErrorWindowResult.Exit);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 错误窗口用户选择结果
|
||||
/// </summary>
|
||||
public enum ErrorWindowResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 重试
|
||||
/// </summary>
|
||||
Retry,
|
||||
|
||||
/// <summary>
|
||||
/// 退出
|
||||
/// </summary>
|
||||
Exit
|
||||
}
|
||||
@@ -1,22 +1,55 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="420"
|
||||
d:DesignHeight="260"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.OobeWindow"
|
||||
Title="阑山桌面"
|
||||
x:DataType="views:OobeWindow"
|
||||
Title="欢迎使用阑山桌面"
|
||||
Width="420"
|
||||
Height="260"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
<Grid Margin="24" RowDefinitions="*,Auto">
|
||||
<TextBlock Text="欢迎使用阑山桌面"
|
||||
FontSize="26"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center" />
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
TransparencyLevelHint="None">
|
||||
<Design.DataContext>
|
||||
<views:OobeWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid Margin="32" RowDefinitions="*,Auto">
|
||||
<!-- 欢迎文本 -->
|
||||
<StackPanel Grid.Row="0" VerticalAlignment="Center" HorizontalAlignment="Center">
|
||||
<TextBlock Text="欢迎使用"
|
||||
FontSize="18"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Text="阑山桌面"
|
||||
FontSize="32"
|
||||
FontWeight="Light"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,8,0,0" />
|
||||
<TextBlock Text="您的智能桌面助手"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,16,0,0" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- 进入按钮 -->
|
||||
<Button Grid.Row="1"
|
||||
x:Name="EnterButton"
|
||||
HorizontalAlignment="Right"
|
||||
Width="64"
|
||||
Height="40"
|
||||
Content="→"
|
||||
FontSize="18" />
|
||||
Width="80"
|
||||
Height="36"
|
||||
Content="开始使用"
|
||||
FontSize="14"
|
||||
Background="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"
|
||||
CornerRadius="4" />
|
||||
</Grid>
|
||||
</Window>
|
||||
|
||||
@@ -4,13 +4,17 @@ using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
internal partial class OobeWindow : Window
|
||||
/// <summary>
|
||||
/// OOBE(首次使用体验)窗口
|
||||
/// </summary>
|
||||
public partial class OobeWindow : Window
|
||||
{
|
||||
private readonly TaskCompletionSource<bool> _completionSource = new();
|
||||
|
||||
public OobeWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
var enterButton = this.FindControl<Button>("EnterButton");
|
||||
if (enterButton is not null)
|
||||
{
|
||||
@@ -18,6 +22,9 @@ internal partial class OobeWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 等待用户点击开始按钮
|
||||
/// </summary>
|
||||
public Task WaitForEnterAsync() => _completionSource.Task;
|
||||
|
||||
private void OnEnterClick(object? sender, RoutedEventArgs e)
|
||||
|
||||
@@ -1,40 +1,57 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="400"
|
||||
d:DesignHeight="200"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.SplashWindow"
|
||||
x:DataType="views:SplashWindow"
|
||||
Title="阑山桌面"
|
||||
Width="420"
|
||||
Height="240"
|
||||
Width="400"
|
||||
Height="200"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
SystemDecorations="None">
|
||||
<Grid Margin="24" RowDefinitions="*,Auto,Auto,Auto">
|
||||
SystemDecorations="None"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
TransparencyLevelHint="None">
|
||||
<Design.DataContext>
|
||||
<views:SplashWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid RowDefinitions="*,Auto,Auto">
|
||||
<!-- 应用名称 -->
|
||||
<TextBlock x:Name="AppNameText"
|
||||
Text="阑山桌面"
|
||||
FontSize="34"
|
||||
FontSize="36"
|
||||
FontWeight="Light"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
Grid.Row="0" />
|
||||
Grid.Row="0"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
|
||||
<!-- 进度条 -->
|
||||
<ProgressBar x:Name="ProgressIndicator"
|
||||
Grid.Row="1"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="0"
|
||||
Height="4"
|
||||
Margin="0,12,0,0"
|
||||
IsIndeterminate="True" />
|
||||
<TextBlock x:Name="StageText"
|
||||
Height="3"
|
||||
Width="200"
|
||||
Margin="0,20,0,0"
|
||||
IsIndeterminate="True"
|
||||
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Background="{DynamicResource ControlStrokeColorDefaultBrush}" />
|
||||
|
||||
<!-- 状态文本 -->
|
||||
<TextBlock x:Name="StatusText"
|
||||
Grid.Row="2"
|
||||
FontSize="12"
|
||||
Foreground="#999999"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,8,0,0"
|
||||
Text="" />
|
||||
<TextBlock x:Name="DetailText"
|
||||
Grid.Row="3"
|
||||
FontSize="11"
|
||||
Foreground="#BBBBBB"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,2,0,0"
|
||||
Text="" />
|
||||
Margin="0,12,0,24"
|
||||
Text="正在启动..." />
|
||||
</Grid>
|
||||
</Window>
|
||||
|
||||
@@ -1,48 +1,92 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
internal partial class SplashWindow : Window, ISplashStageReporter
|
||||
/// <summary>
|
||||
/// 启动画面窗口 - 简洁设计
|
||||
/// </summary>
|
||||
public partial class SplashWindow : Window, ISplashStageReporter
|
||||
{
|
||||
private static readonly (string Stage, string Label, double Progress)[] StageMap =
|
||||
[
|
||||
("bootstrap", "正在初始化...", 10),
|
||||
("silentUpdate", "正在应用更新...", 35),
|
||||
("pluginTasks", "正在处理插件...", 65),
|
||||
("launchHost", "正在启动...", 90),
|
||||
];
|
||||
|
||||
public SplashWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新进度和状态
|
||||
/// </summary>
|
||||
public void Report(string stage, string message)
|
||||
{
|
||||
var (label, progress) = ResolveStageInfo(stage);
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var statusText = this.GetControl<TextBlock>("StatusText");
|
||||
var progressIndicator = this.GetControl<ProgressBar>("ProgressIndicator");
|
||||
|
||||
var stageText = this.GetControl<TextBlock>("StageText");
|
||||
var detailText = this.GetControl<TextBlock>("DetailText");
|
||||
var progressIndicator = this.GetControl<ProgressBar>("ProgressIndicator");
|
||||
// 更新状态文本
|
||||
statusText.Text = message;
|
||||
|
||||
stageText.Text = label;
|
||||
detailText.Text = message;
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = progress;
|
||||
// 根据阶段更新进度
|
||||
var progress = ResolveProgress(stage);
|
||||
if (progress > 0)
|
||||
{
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = progress;
|
||||
}
|
||||
else
|
||||
{
|
||||
progressIndicator.IsIndeterminate = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static (string Label, double Progress) ResolveStageInfo(string stage)
|
||||
/// <summary>
|
||||
/// 更新进度(0-100)
|
||||
/// </summary>
|
||||
public void UpdateProgress(int percent, string? message = null)
|
||||
{
|
||||
foreach (var (s, label, progress) in StageMap)
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (string.Equals(s, stage, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (label, progress);
|
||||
}
|
||||
}
|
||||
var statusText = this.GetControl<TextBlock>("StatusText");
|
||||
var progressIndicator = this.GetControl<ProgressBar>("ProgressIndicator");
|
||||
|
||||
return (stage, 0);
|
||||
if (!string.IsNullOrEmpty(message))
|
||||
{
|
||||
statusText.Text = message;
|
||||
}
|
||||
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = Math.Clamp(percent, 0, 100);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新状态文本
|
||||
/// </summary>
|
||||
public void UpdateStatus(string message)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var statusText = this.GetControl<TextBlock>("StatusText");
|
||||
statusText.Text = message;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据阶段名称解析进度值
|
||||
/// </summary>
|
||||
private static int ResolveProgress(string stage)
|
||||
{
|
||||
return stage.ToLowerInvariant() switch
|
||||
{
|
||||
"initializing" => 10,
|
||||
"update" => 30,
|
||||
"plugins" => 50,
|
||||
"launch" => 70,
|
||||
"ready" => 100,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user