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