diff --git a/LanMountainDesktop.Launcher/Services/DataLocationResolver.cs b/LanMountainDesktop.Launcher/Services/DataLocationResolver.cs index 31b3c3c..752cc68 100644 --- a/LanMountainDesktop.Launcher/Services/DataLocationResolver.cs +++ b/LanMountainDesktop.Launcher/Services/DataLocationResolver.cs @@ -3,11 +3,30 @@ using LanMountainDesktop.Launcher.Models; namespace LanMountainDesktop.Launcher.Services; +/// +/// 解析应用数据目录位置。 +/// +/// +/// 安装后的目录结构: +/// +/// {AppRoot}/ ← 应用安装根目录 +/// LanMountainDesktop.Launcher.exe ← Launcher 可执行文件 +/// .Launcher/ ← Launcher 数据目录(日志、状态、配置等) +/// app-{version}/ ← Host 部署目录 +/// LanMountainDesktop.exe +/// ... +/// +/// +/// Launcher 数据目录固定位于应用安装根目录下的 .Launcher 文件夹中, +/// 与 app-* 部署目录同级。此目录不随数据位置模式改变。 +/// +/// Desktop(Host)数据目录则根据用户选择可位于系统目录或便携目录。 +/// internal sealed class DataLocationResolver { private const string ConfigFileName = "data-location.config.json"; - private const string LauncherFolderName = "Launcher"; private const string DesktopFolderName = "Desktop"; + private const string LauncherDataFolderName = ".Launcher"; private readonly string _appRoot; private readonly string _defaultSystemDataPath; @@ -28,13 +47,49 @@ internal sealed class DataLocationResolver public string DefaultSystemDataPath => _defaultSystemDataPath; /// - /// 默认便携模式数据路径(应用目录下的 AppData) + /// 默认便携模式数据路径(应用目录下的 Desktop 文件夹) /// - public string DefaultPortableDataPath => Path.Combine(_appRoot, "AppData"); + public string DefaultPortableDataPath => Path.Combine(_appRoot, DesktopFolderName); - private string ResolveBootstrapLauncherDataPath() + /// + /// Launcher 数据目录,固定位于应用安装根目录下的 .Launcher 文件夹。 + /// 该目录与 app-* 部署目录同级,不随数据位置模式改变。 + /// + public string ResolveLauncherDataPath() { - return Path.Combine(_defaultSystemDataPath, LauncherFolderName); + return Path.Combine(_appRoot, LauncherDataFolderName); + } + + /// + /// 桌面应用数据目录(组件、设置、插件等) + /// + public string ResolveDesktopDataPath() + { + return Path.Combine(ResolveDataRoot(), DesktopFolderName); + } + + /// + /// 数据位置配置文件路径(保存在 Launcher 数据目录下) + /// + public string ResolveConfigPath() + { + return Path.Combine(ResolveLauncherDataPath(), ConfigFileName); + } + + /// + /// 启动器日志目录 + /// + public string ResolveLauncherLogsPath() + { + return Path.Combine(ResolveLauncherDataPath(), "logs"); + } + + /// + /// 启动器状态目录 + /// + public string ResolveLauncherStatePath() + { + return Path.Combine(ResolveLauncherDataPath(), "state"); } /// @@ -55,6 +110,19 @@ internal sealed class DataLocationResolver } } + public DataLocationMode ResolveMode() + { + var config = LoadConfig(); + if (config is null) + { + return DataLocationMode.System; + } + + return string.Equals(config.DataLocationMode, "Portable", StringComparison.OrdinalIgnoreCase) + ? DataLocationMode.Portable + : DataLocationMode.System; + } + /// /// 解析数据根目录(用户选择的位置) /// @@ -84,66 +152,11 @@ internal sealed class DataLocationResolver : _defaultSystemDataPath; } - /// - /// 启动器数据目录(日志、配置、状态等) - /// - public string ResolveLauncherDataPath() - { - return Path.Combine(ResolveDataRoot(), LauncherFolderName); - } - - /// - /// 桌面应用数据目录(组件、设置、插件等) - /// - public string ResolveDesktopDataPath() - { - return Path.Combine(ResolveDataRoot(), DesktopFolderName); - } - - /// - /// 数据位置配置文件路径(保存在 Launcher 目录下) - /// - public string ResolveConfigPath() - { - return Path.Combine(ResolveBootstrapLauncherDataPath(), ConfigFileName); - } - - /// - /// 启动器日志目录 - /// - public string ResolveLauncherLogsPath() - { - return Path.Combine(ResolveLauncherDataPath(), "logs"); - } - - /// - /// 启动器状态目录 - /// - public string ResolveLauncherStatePath() - { - return Path.Combine(ResolveLauncherDataPath(), "state"); - } - - public DataLocationMode ResolveMode() - { - var config = LoadConfig(); - if (config is null) - { - return DataLocationMode.System; - } - - return string.Equals(config.DataLocationMode, "Portable", StringComparison.OrdinalIgnoreCase) - ? DataLocationMode.Portable - : DataLocationMode.System; - } - public DataLocationConfig? LoadConfig() { try { - // 配置文件必须位于默认系统数据路径下的 Launcher 目录中 - // 避免循环依赖:不能调用 ResolveConfigPath() -> ResolveLauncherDataPath() -> ResolveDataRoot() -> LoadConfig() - var configPath = Path.Combine(_defaultSystemDataPath, LauncherFolderName, ConfigFileName); + var configPath = ResolveConfigPath(); if (!File.Exists(configPath)) { return null; @@ -163,8 +176,8 @@ internal sealed class DataLocationResolver { try { - var launcherPath = ResolveBootstrapLauncherDataPath(); - Directory.CreateDirectory(launcherPath); + var launcherDataPath = ResolveLauncherDataPath(); + Directory.CreateDirectory(launcherDataPath); var configPath = ResolveConfigPath(); var json = JsonSerializer.Serialize(config, AppJsonContext.Default.DataLocationConfig); @@ -194,9 +207,8 @@ internal sealed class DataLocationResolver // 先创建目录结构 try { - var resolvedDataRoot = ResolveDataRoot(config); - Directory.CreateDirectory(Path.Combine(resolvedDataRoot, LauncherFolderName)); - Directory.CreateDirectory(Path.Combine(resolvedDataRoot, DesktopFolderName)); + Directory.CreateDirectory(ResolveLauncherDataPath()); + Directory.CreateDirectory(Path.Combine(ResolveDataRoot(config), DesktopFolderName)); } catch (Exception ex) { diff --git a/LanMountainDesktop/Services/LauncherClient.cs b/LanMountainDesktop/Services/LauncherClient.cs index bd68fe7..7e6cebc 100644 --- a/LanMountainDesktop/Services/LauncherClient.cs +++ b/LanMountainDesktop/Services/LauncherClient.cs @@ -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 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(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)) diff --git a/LanMountainDesktop/Services/LauncherPathResolver.cs b/LanMountainDesktop/Services/LauncherPathResolver.cs new file mode 100644 index 0000000..b3630e3 --- /dev/null +++ b/LanMountainDesktop/Services/LauncherPathResolver.cs @@ -0,0 +1,90 @@ +using System; +using System.IO; +using System.Linq; + +namespace LanMountainDesktop.Services; + +/// +/// 统一解析 Launcher 可执行文件路径的工具类。 +/// +/// +/// 安装后的目录结构: +/// +/// {AppRoot}/ ← 应用安装根目录 +/// LanMountainDesktop.Launcher.exe ← Launcher 可执行文件 +/// .Launcher/ ← Launcher 数据目录(日志、状态、配置等) +/// app-{version}/ ← Host 部署目录 +/// LanMountainDesktop.exe +/// ... +/// +/// +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; + + /// + /// 解析 Launcher 可执行文件的完整路径。如果找不到则返回 null。 + /// + 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); + } + + /// + /// 解析 Launcher 数据目录(.Launcher)的路径。 + /// 该目录与 app-* 文件夹同级,位于应用安装根目录下。 + /// + 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; + } + } +} diff --git a/LanMountainDesktop/Services/UpdateWorkflowService.cs b/LanMountainDesktop/Services/UpdateWorkflowService.cs index 461fb72..5444b9f 100644 --- a/LanMountainDesktop/Services/UpdateWorkflowService.cs +++ b/LanMountainDesktop/Services/UpdateWorkflowService.cs @@ -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, diff --git a/LanMountainDesktop/plugins/PluginMarketInstallService.cs b/LanMountainDesktop/plugins/PluginMarketInstallService.cs index fa4e9bd..6c50478 100644 --- a/LanMountainDesktop/plugins/PluginMarketInstallService.cs +++ b/LanMountainDesktop/plugins/PluginMarketInstallService.cs @@ -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