Add LauncherPathResolver and refactor data paths

Introduce LauncherPathResolver to centralize resolving the launcher executable and .Launcher data directory (with write-permission fallback). Refactor DataLocationResolver to use a fixed ".Launcher" launcher data folder, expose explicit ResolveLauncherDataPath/ResolveDesktopDataPath/ResolveConfigPath/ResolveLauncherLogsPath/ResolveLauncherStatePath methods, and fix config load/save and directory creation to avoid dependency cycles. Update LauncherClient, UpdateWorkflowService and PluginMarketInstallService to use the new resolver (remove duplicated ResolveLauncherPath logic) and improve error messages when the launcher is missing.
This commit is contained in:
lincube
2026-05-01 11:31:40 +08:00
parent fc4d0c4cd8
commit 0348324fa3
5 changed files with 179 additions and 121 deletions

View File

@@ -15,7 +15,6 @@ namespace LanMountainDesktop.Services;
internal sealed class LauncherClient
{
private const int UserCanceledUacErrorCode = 1223;
private const string LauncherExecutableName = "LanMountainDesktop.Launcher.exe";
public async Task<LauncherInstallResult> InstallPackageAsync(
string packagePath,
@@ -34,13 +33,13 @@ internal sealed class LauncherClient
"failed");
}
var launcherPath = ResolveLauncherPath();
if (!File.Exists(launcherPath))
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
{
return new LauncherInstallResult(
false,
null,
$"Launcher executable was not found at '{launcherPath}'.",
"Launcher executable was not found. Expected it to be located in the application root directory (sibling to the app-* deployment folder).",
"failed");
}
@@ -129,21 +128,6 @@ internal sealed class LauncherClient
return await JsonSerializer.DeserializeAsync<HelperResultFile>(stream, cancellationToken: cancellationToken);
}
private static string ResolveLauncherPath()
{
var baseDirectory = AppContext.BaseDirectory;
var candidates = new[]
{
Path.Combine(baseDirectory, "Launcher", LauncherExecutableName),
Path.Combine(baseDirectory, LauncherExecutableName),
Path.GetFullPath(Path.Combine(baseDirectory, "..", "LanMountainDesktop.Launcher", LauncherExecutableName)),
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Debug", "net10.0", LauncherExecutableName)),
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Release", "net10.0", LauncherExecutableName))
};
return candidates.FirstOrDefault(File.Exists) ?? candidates[0];
}
private static string QuoteArgument(string value)
{
if (string.IsNullOrEmpty(value))

View File

@@ -0,0 +1,90 @@
using System;
using System.IO;
using System.Linq;
namespace LanMountainDesktop.Services;
/// <summary>
/// 统一解析 Launcher 可执行文件路径的工具类。
/// </summary>
/// <remarks>
/// 安装后的目录结构:
/// <code>
/// {AppRoot}/ ← 应用安装根目录
/// LanMountainDesktop.Launcher.exe ← Launcher 可执行文件
/// .Launcher/ ← Launcher 数据目录(日志、状态、配置等)
/// app-{version}/ ← Host 部署目录
/// LanMountainDesktop.exe
/// ...
/// </code>
/// </remarks>
internal static class LauncherPathResolver
{
private const string WindowsLauncherExeName = "LanMountainDesktop.Launcher.exe";
private const string UnixLauncherExeName = "LanMountainDesktop.Launcher";
private static string LauncherExecutableName =>
OperatingSystem.IsWindows() ? WindowsLauncherExeName : UnixLauncherExeName;
/// <summary>
/// 解析 Launcher 可执行文件的完整路径。如果找不到则返回 null。
/// </summary>
public static string? ResolveLauncherExecutablePath()
{
var baseDirectory = AppContext.BaseDirectory;
var candidates = new[]
{
// 1. 发布版安装版Host 在 app-* 子目录中Launcher 在父目录(应用根目录)
Path.GetFullPath(Path.Combine(baseDirectory, "..", LauncherExecutableName)),
// 2. 便携版 / 单文件发布Launcher 与 Host 在同一目录
Path.Combine(baseDirectory, LauncherExecutableName),
// 3. 开发环境Launcher 项目输出目录与 Host 项目输出目录同级
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Debug", "net10.0", LauncherExecutableName)),
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Release", "net10.0", LauncherExecutableName)),
};
return candidates
.Select(Path.GetFullPath)
.Distinct(StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(File.Exists);
}
/// <summary>
/// 解析 Launcher 数据目录(.Launcher的路径。
/// 该目录与 app-* 文件夹同级,位于应用安装根目录下。
/// </summary>
public static string ResolveLauncherDataDirectory()
{
var baseDirectory = AppContext.BaseDirectory;
// 优先尝试应用安装根目录Host 的父目录)
var appRootCandidate = Path.GetFullPath(Path.Combine(baseDirectory, ".."));
var launcherDataDir = Path.Combine(appRootCandidate, ".Launcher");
if (Directory.Exists(launcherDataDir) || CanWriteToDirectory(appRootCandidate))
{
return launcherDataDir;
}
// 回退到 Host 所在目录(便携模式或开发环境)
return Path.Combine(baseDirectory, ".Launcher");
}
private static bool CanWriteToDirectory(string path)
{
try
{
var testFile = Path.Combine(path, $".write-test-{Guid.NewGuid():N}.tmp");
File.WriteAllText(testFile, string.Empty);
File.Delete(testFile);
return true;
}
catch
{
return false;
}
}
}

View File

@@ -1431,26 +1431,15 @@ public sealed class UpdateWorkflowService
{
try
{
var launcherExeName = OperatingSystem.IsWindows()
? "LanMountainDesktop.Launcher.exe"
: "LanMountainDesktop.Launcher";
// The Launcher is in the parent directory of the app's base directory
// (app runs from app-{version}/ subdirectory, Launcher is at root)
var appBaseDir = AppContext.BaseDirectory;
var launcherRoot = Path.GetDirectoryName(appBaseDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
if (string.IsNullOrWhiteSpace(launcherRoot))
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
{
launcherRoot = appBaseDir;
}
var launcherPath = Path.Combine(launcherRoot, launcherExeName);
if (!File.Exists(launcherPath))
{
AppLogger.Warn("UpdateWorkflow", $"Launcher executable not found at '{launcherPath}'. Falling back to next-startup apply.");
AppLogger.Warn("UpdateWorkflow", "Launcher executable not found. Falling back to next-startup apply.");
return false;
}
var launcherRoot = Path.GetDirectoryName(launcherPath)!;
var startInfo = new ProcessStartInfo
{
FileName = launcherPath,

View File

@@ -14,8 +14,6 @@ namespace LanMountainDesktop.Services.PluginMarket;
internal sealed class AirAppMarketInstallService : IDisposable
{
private const string LauncherExecutableName = "LanMountainDesktop.Launcher.exe";
private readonly PluginRuntimeService _runtime;
private readonly LauncherClient _launcherClient = new();
private readonly HttpClient _httpClient;
@@ -83,13 +81,13 @@ internal sealed class AirAppMarketInstallService : IDisposable
{
if (OperatingSystem.IsWindows())
{
var launcherPath = ResolveLauncherPath();
if (!File.Exists(launcherPath))
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
{
return new AirAppMarketInstallResult(
false,
null,
$"Launcher executable was not found at '{launcherPath}'.");
"Launcher executable was not found. Expected it to be located in the application root directory (sibling to the app-* deployment folder).");
}
}
@@ -364,21 +362,6 @@ internal sealed class AirAppMarketInstallService : IDisposable
return new AirAppMarketVerificationResult(true, null);
}
private static string ResolveLauncherPath()
{
var baseDirectory = AppContext.BaseDirectory;
var candidates = new[]
{
Path.Combine(baseDirectory, "Launcher", LauncherExecutableName),
Path.Combine(baseDirectory, LauncherExecutableName),
Path.GetFullPath(Path.Combine(baseDirectory, "..", "LanMountainDesktop.Launcher", LauncherExecutableName)),
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Debug", "net10.0", LauncherExecutableName)),
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Release", "net10.0", LauncherExecutableName))
};
return candidates.FirstOrDefault(File.Exists) ?? candidates[0];
}
private static void TryDeleteFile(string path)
{
try