Compare commits

...

4 Commits

Author SHA1 Message Date
lincube
57c5e41a5c 0.5.18 2026-03-12 00:34:49 +08:00
lincube
ce2b218dfa 0.5.17 2026-03-12 00:18:04 +08:00
lincube
efdfa68dab 0.5.16 2026-03-11 17:43:31 +08:00
lincube
87110f1d69 0.5.15
市场插件安装机制修复,然后修复了一大堆东西
2026-03-11 15:14:08 +08:00
20 changed files with 393 additions and 126 deletions

17
.github/FIX_REPORT.md vendored
View File

@@ -8,14 +8,14 @@ MSBUILD : error MSB1003: Specify a project or solution file.
The current working directory does not contain a project or solution file. The current working directory does not contain a project or solution file.
``` ```
**原因**: 项目中缺少 `LanMountainDesktop.sln` 解决方案文件,但工作流尝试执行 `dotnet restore` 而没有指定项目。 **原因**: 项目中缺少 `LanMountainDesktop.slnx` 解决方案文件,但工作流尝试执行 `dotnet restore` 而没有指定项目。
--- ---
## 🔧 已采取的修复 ## 🔧 已采取的修复
### 1. 创建解决方案文件 ### 1. 创建 `.slnx` 解决方案文件
✅ 创建了标准的 `LanMountainDesktop.sln` 文件,包含: ✅ 创建了标准的 `LanMountainDesktop.slnx` 文件,包含:
- `LanMountainDesktop/LanMountainDesktop.csproj` - `LanMountainDesktop/LanMountainDesktop.csproj`
### 2. 验证本地构建工作 ### 2. 验证本地构建工作
@@ -35,10 +35,10 @@ The current working directory does not contain a project or solution file.
## 📋 解决方案文件内容 ## 📋 解决方案文件内容
包含主桌面项目的标准 Visual Studio 解决方案格式: 包含主桌面项目的标准 XML 解决方案格式:
``` ```
LanMountainDesktop.sln LanMountainDesktop.slnx
└── LanMountainDesktop (Desktop UI - Avalonia) └── LanMountainDesktop (Desktop UI - Avalonia)
``` ```
@@ -50,10 +50,11 @@ LanMountainDesktop.sln
```bash ```bash
# 1. 添加新创建的解决方案文件 # 1. 添加新创建的解决方案文件
git add LanMountainDesktop.sln git add LanMountainDesktop.slnx
git add global.json
# 2. 提交 # 2. 提交
git commit -m "Add solution file for desktop project" git commit -m "Migrate desktop solution to .slnx"
# 3. 推送 # 3. 推送
git push origin main git push origin main
@@ -92,7 +93,7 @@ git push origin v1.0.1
| `.github/workflows/code-quality.yml` | 代码质量检查 | ✅ 可用 | | `.github/workflows/code-quality.yml` | 代码质量检查 | ✅ 可用 |
| `.github/workflows/release.yml` | 多平台发布 | ✅ 可用 | | `.github/workflows/release.yml` | 多平台发布 | ✅ 可用 |
| `.github/workflows/issue-management.yml` | Issue自动管理 | ✅ 可用 | | `.github/workflows/issue-management.yml` | Issue自动管理 | ✅ 可用 |
| `LanMountainDesktop.sln` | 解决方案文件 | ✅ 已修复 | | `LanMountainDesktop.slnx` | 解决方案文件 | ✅ 已修复 |
--- ---

3
.github/README.md vendored
View File

@@ -36,9 +36,10 @@
- 扩展契约与字段说明见组件系统文档:`LanMountainDesktop/ComponentSystem/README.md` - 扩展契约与字段说明见组件系统文档:`LanMountainDesktop/ComponentSystem/README.md`
## 当前状态 ## 当前状态
- 项目包含桌面端与推荐后端两个子项目,并在同一 solution 中维护。 - 项目包含桌面端与推荐后端两个子项目,并在同一 `LanMountainDesktop.slnx` 工作区中维护。
- 配置默认写入本地:`%LOCALAPPDATA%\LanMountainDesktop\settings.json` - 配置默认写入本地:`%LOCALAPPDATA%\LanMountainDesktop\settings.json`
- 当前体验以 Windows 为主要目标平台。 - 当前体验以 Windows 为主要目标平台。
- SDK 版本由仓库根目录 `global.json` 锁定。
## 运行说明 ## 运行说明
运行与环境准备已拆分到独立文档:[`run.md`](./run.md) 运行与环境准备已拆分到独立文档:[`run.md`](./run.md)

View File

@@ -9,7 +9,7 @@ on:
env: env:
DOTNET_VERSION: '10.0.x' DOTNET_VERSION: '10.0.x'
Solution_Name: LanMountainDesktop.sln Solution_Name: LanMountainDesktop.slnx
jobs: jobs:
build-windows: build-windows:

View File

@@ -8,7 +8,7 @@ on:
env: env:
DOTNET_VERSION: '10.0.x' DOTNET_VERSION: '10.0.x'
Solution_Name: LanMountainDesktop.sln Solution_Name: LanMountainDesktop.slnx
jobs: jobs:
analyze: analyze:

View File

@@ -18,7 +18,7 @@ on:
env: env:
DOTNET_VERSION: '10.0.x' DOTNET_VERSION: '10.0.x'
Solution_Name: LanMountainDesktop.sln Solution_Name: LanMountainDesktop.slnx
jobs: jobs:
prepare: prepare:

10
.gitignore vendored
View File

@@ -492,3 +492,13 @@ nul
/_build_verify_plugin_services /_build_verify_plugin_services
/LanMountainDesktop.PluginSdk/_build_verify_*/ /LanMountainDesktop.PluginSdk/_build_verify_*/
/_build_obj /_build_obj
# LanMountainDesktop local workspace files
/.arts/
/.knox/
/.lingma/
/.tmp/
/publish-test/
/validator-restore.log
/temp_old_main.axaml
/temp_old_main_utf8.axaml

View File

@@ -6,6 +6,13 @@ using LanMountainDesktop.PluginSdk;
internal static class Program internal static class Program
{ {
private static readonly TimeSpan[] RetryDelays =
[
TimeSpan.FromMilliseconds(120),
TimeSpan.FromMilliseconds(250),
TimeSpan.FromMilliseconds(500)
];
private static async Task<int> Main(string[] args) private static async Task<int> Main(string[] args)
{ {
var result = new HelperResult(); var result = new HelperResult();
@@ -35,10 +42,12 @@ internal static class Program
var manifest = ReadManifestFromPackage(fullSourcePath); var manifest = ReadManifestFromPackage(fullSourcePath);
Directory.CreateDirectory(fullPluginsDirectory); Directory.CreateDirectory(fullPluginsDirectory);
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id);
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id)); var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
File.Copy(fullSourcePath, destinationPath, overwrite: true); var stagingPath = destinationPath + ".incoming";
DeleteFileWithRetry(stagingPath);
CopyWithRetry(fullSourcePath, stagingPath, overwrite: true);
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath);
MoveWithOverwriteRetry(stagingPath, destinationPath);
result = new HelperResult result = new HelperResult
{ {
@@ -123,7 +132,7 @@ internal static class Program
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}"); return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
} }
private static void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId) private static 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), PluginSdkInfo.RuntimeDirectoryName));
foreach (var existingPackagePath in Directory foreach (var existingPackagePath in Directory
@@ -133,13 +142,19 @@ internal static class Program
{ {
try try
{ {
if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase) ||
string.Equals(existingPackagePath, Path.GetFullPath(stagingPath), StringComparison.OrdinalIgnoreCase))
{
continue;
}
var existingManifest = ReadManifestFromPackage(existingPackagePath); var existingManifest = ReadManifestFromPackage(existingPackagePath);
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase)) if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
{ {
continue; continue;
} }
File.Delete(existingPackagePath); DeleteFileWithRetry(existingPackagePath);
} }
catch catch
{ {
@@ -148,6 +163,56 @@ internal static class Program
} }
} }
private static void CopyWithRetry(string sourcePath, string destinationPath, bool overwrite)
{
Retry(() => File.Copy(sourcePath, destinationPath, overwrite));
}
private static void MoveWithOverwriteRetry(string sourcePath, string destinationPath)
{
Retry(() => File.Move(sourcePath, destinationPath, overwrite: true));
}
private static void DeleteFileWithRetry(string filePath)
{
Retry(() =>
{
if (File.Exists(filePath))
{
File.Delete(filePath);
}
});
}
private static void Retry(Action action)
{
Exception? lastException = null;
for (var attempt = 0; attempt <= RetryDelays.Length; attempt++)
{
try
{
action();
return;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
lastException = ex;
if (attempt >= RetryDelays.Length)
{
break;
}
Thread.Sleep(RetryDelays[attempt]);
}
}
if (lastException is not null)
{
throw lastException;
}
}
private static string BuildInstalledPackageFileName(string pluginId) private static string BuildInstalledPackageFileName(string pluginId)
{ {
var invalidChars = Path.GetInvalidFileNameChars(); var invalidChars = Path.GetInvalidFileNameChars();

View File

@@ -1,43 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop", "LanMountainDesktop\LanMountainDesktop.csproj", "{00000001-0000-0000-0000-000000000001}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.SamplePlugin", "LanAirApp\samples\LanMountainDesktop.SamplePlugin\LanMountainDesktop.SamplePlugin.csproj", "{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginPackager", "LanAirApp\tools\LanMountainDesktop.PluginPackager\LanMountainDesktop.PluginPackager.csproj", "{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginSdk", "LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj", "{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginsInstallHelper", "LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj", "{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{00000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{00000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.Build.0 = Release|Any CPU
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Release|Any CPU.Build.0 = Release|Any CPU
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.Build.0 = Debug|Any CPU
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.ActiveCfg = Release|Any CPU
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.Build.0 = Release|Any CPU
{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

7
LanMountainDesktop.slnx Normal file
View File

@@ -0,0 +1,7 @@
<Solution>
<Project Path="LanAirApp/samples/LanMountainDesktop.SamplePlugin/LanMountainDesktop.SamplePlugin.csproj" />
<Project Path="LanAirApp/tools/LanMountainDesktop.PluginPackager/LanMountainDesktop.PluginPackager.csproj" />
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
<Project Path="LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj" />
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
</Solution>

View File

@@ -271,6 +271,10 @@ public partial class App : Application
mainWindow.Activate(); mainWindow.Activate();
mainWindow.Topmost = true; mainWindow.Topmost = true;
mainWindow.Topmost = false; mainWindow.Topmost = false;
if (mainWindow is MainWindow lanMountainMainWindow)
{
lanMountainMainWindow.ShowSingleInstanceNotice();
}
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -743,7 +743,10 @@
"placement.fit": "Fit", "placement.fit": "Fit",
"placement.stretch": "Stretch", "placement.stretch": "Stretch",
"placement.center": "Center", "placement.center": "Center",
"placement.tile": "Tile" "placement.tile": "Tile",
} "single_instance.notice.title": "App already open",
"single_instance.notice.description": "LanMountainDesktop is already running. Switched back to the active desktop.",
"single_instance.notice.button": "Got it"
}

View File

@@ -743,7 +743,10 @@
"placement.fit": "适应", "placement.fit": "适应",
"placement.stretch": "拉伸", "placement.stretch": "拉伸",
"placement.center": "居中", "placement.center": "居中",
"placement.tile": "平铺" "placement.tile": "平铺",
} "single_instance.notice.title": "应用已打开",
"single_instance.notice.description": "阑山桌面已经在运行,已为你切换到当前正在使用的桌面。",
"single_instance.notice.button": "知道了"
}

View File

@@ -1,8 +1,10 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Avalonia; using Avalonia;
using Avalonia.WebView.Desktop; using Avalonia.WebView.Desktop;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
using System;
using System.Threading.Tasks;
namespace LanMountainDesktop; namespace LanMountainDesktop;
@@ -17,12 +19,11 @@ sealed class Program
AppLogger.Initialize(); AppLogger.Initialize();
RegisterGlobalExceptionLogging(); RegisterGlobalExceptionLogging();
using var singleInstance = SingleInstanceService.CreateDefault(); using var singleInstance = AcquireSingleInstance(args);
if (!singleInstance.IsPrimaryInstance) if (!singleInstance.IsPrimaryInstance)
{ {
AppLogger.Warn("Startup", "A secondary launch was blocked because another instance is already running."); AppLogger.Warn("Startup", "A secondary launch was blocked because another instance is already running.");
var notified = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2)); _ = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2));
ShowAlreadyRunningNotice(notified);
return; return;
} }
@@ -72,6 +73,42 @@ sealed class Program
return builder; return builder;
} }
private static SingleInstanceService AcquireSingleInstance(string[] args)
{
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
var singleInstance = SingleInstanceService.CreateDefault();
if (singleInstance.IsPrimaryInstance || restartParentProcessId is null)
{
return singleInstance;
}
AppLogger.Info(
"Startup",
$"Restart relaunch detected. Waiting for previous instance pid={restartParentProcessId.Value} to exit before re-acquiring the single-instance lock.");
singleInstance.Dispose();
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(12);
WaitForRestartParentExit(restartParentProcessId.Value, deadline);
while (DateTime.UtcNow < deadline)
{
var retryInstance = SingleInstanceService.CreateDefault();
if (retryInstance.IsPrimaryInstance)
{
AppLogger.Info("Startup", "Restart relaunch acquired the single-instance lock.");
return retryInstance;
}
retryInstance.Dispose();
Thread.Sleep(150);
}
AppLogger.Warn(
"Startup",
$"Restart relaunch timed out while waiting for the single-instance lock. pid={restartParentProcessId.Value}.");
return SingleInstanceService.CreateDefault();
}
private static string LoadConfiguredRenderMode() private static string LoadConfiguredRenderMode()
{ {
try try
@@ -85,14 +122,25 @@ sealed class Program
} }
} }
private static void ShowAlreadyRunningNotice(bool notifiedPrimaryInstance) private static void WaitForRestartParentExit(int processId, DateTime deadlineUtc)
{ {
const string caption = "LanMountainDesktop"; try
var message = notifiedPrimaryInstance {
? "应用已打开,不需要多开了。\r\n\r\n已为你切换到正在运行的阑山桌面。" using var process = Process.GetProcessById(processId);
: "应用已打开,不需要多开了。\r\n\r\n请切换到正在运行的阑山桌面。"; var remaining = deadlineUtc - DateTime.UtcNow;
if (remaining > TimeSpan.Zero)
WindowsNativeDialogService.ShowInformation(caption, message); {
process.WaitForExit((int)Math.Ceiling(remaining.TotalMilliseconds));
}
}
catch (ArgumentException)
{
// The previous process already exited before we started waiting.
}
catch (Exception ex)
{
AppLogger.Warn("Startup", $"Failed while waiting for restart parent pid={processId} to exit.", ex);
}
} }
private static void RegisterGlobalExceptionLogging() private static void RegisterGlobalExceptionLogging()

View File

@@ -9,6 +9,8 @@ namespace LanMountainDesktop.Services;
public static class AppRestartService public static class AppRestartService
{ {
private const string RestartParentPidArgumentPrefix = "--restart-parent-pid=";
public static bool TryRestartApplication() public static bool TryRestartApplication()
{ {
return App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest( return App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest(
@@ -75,6 +77,21 @@ public static class AppRestartService
return null; return null;
} }
public static int? TryGetRestartParentProcessId(IReadOnlyList<string> commandLineArgs)
{
ArgumentNullException.ThrowIfNull(commandLineArgs);
foreach (var argument in commandLineArgs)
{
if (TryParseRestartParentProcessId(argument, out var processId))
{
return processId;
}
}
return null;
}
private static ProcessStartInfo CreateExecutableStartInfo( private static ProcessStartInfo CreateExecutableStartInfo(
string executablePath, string executablePath,
string? entryAssemblyPath, string? entryAssemblyPath,
@@ -88,6 +105,7 @@ public static class AppRestartService
}; };
AppendArguments(startInfo, commandLineArgs); AppendArguments(startInfo, commandLineArgs);
AppendRestartParentProcessArgument(startInfo);
return startInfo; return startInfo;
} }
@@ -110,6 +128,7 @@ public static class AppRestartService
startInfo.ArgumentList.Add(entryAssemblyPath); startInfo.ArgumentList.Add(entryAssemblyPath);
AppendArguments(startInfo, commandLineArgs); AppendArguments(startInfo, commandLineArgs);
AppendRestartParentProcessArgument(startInfo);
return startInfo; return startInfo;
} }
@@ -117,10 +136,34 @@ public static class AppRestartService
{ {
for (var i = 1; i < commandLineArgs.Count; i++) for (var i = 1; i < commandLineArgs.Count; i++)
{ {
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
{
continue;
}
startInfo.ArgumentList.Add(commandLineArgs[i]); startInfo.ArgumentList.Add(commandLineArgs[i]);
} }
} }
private static void AppendRestartParentProcessArgument(ProcessStartInfo startInfo)
{
startInfo.ArgumentList.Add($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
}
private static bool TryParseRestartParentProcessId(string? argument, out int processId)
{
processId = 0;
if (string.IsNullOrWhiteSpace(argument) ||
!argument.StartsWith(RestartParentPidArgumentPrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
return int.TryParse(
argument[RestartParentPidArgumentPrefix.Length..],
out processId) && processId > 0;
}
private static string? NormalizeExistingPath(string? path) private static string? NormalizeExistingPath(string? path)
{ {
if (string.IsNullOrWhiteSpace(path)) if (string.IsNullOrWhiteSpace(path))

View File

@@ -0,0 +1,59 @@
using System;
using Avalonia.Interactivity;
using Avalonia.Threading;
namespace LanMountainDesktop.Views;
public partial class MainWindow
{
private readonly DispatcherTimer _singleInstanceNoticeTimer = new()
{
Interval = TimeSpan.FromSeconds(6)
};
internal void ShowSingleInstanceNotice()
{
if (Dispatcher.UIThread.CheckAccess())
{
ShowSingleInstanceNoticeCore();
return;
}
Dispatcher.UIThread.Post(ShowSingleInstanceNoticeCore, DispatcherPriority.Send);
}
private void ShowSingleInstanceNoticeCore()
{
SingleInstanceNoticeTitleTextBlock.Text = L(
"single_instance.notice.title",
"App already open");
SingleInstanceNoticeDescriptionTextBlock.Text = L(
"single_instance.notice.description",
"LanMountainDesktop is already running. Switched back to the active desktop.");
SingleInstanceNoticeButtonTextBlock.Text = L(
"single_instance.notice.button",
"Got it");
SingleInstanceNoticeDock.IsVisible = true;
_singleInstanceNoticeTimer.Stop();
_singleInstanceNoticeTimer.Tick -= OnSingleInstanceNoticeTimerTick;
_singleInstanceNoticeTimer.Tick += OnSingleInstanceNoticeTimerTick;
_singleInstanceNoticeTimer.Start();
}
private void OnSingleInstanceNoticeButtonClick(object? sender, RoutedEventArgs e)
{
HideSingleInstanceNotice();
}
private void OnSingleInstanceNoticeTimerTick(object? sender, EventArgs e)
{
HideSingleInstanceNotice();
}
private void HideSingleInstanceNotice()
{
_singleInstanceNoticeTimer.Stop();
SingleInstanceNoticeDock.IsVisible = false;
}
}

View File

@@ -469,51 +469,98 @@
</ScrollViewer> </ScrollViewer>
</ui:NavigationView> </ui:NavigationView>
<Border x:Name="PendingRestartDock" <StackPanel Grid.Row="1"
Grid.Row="1" Spacing="12">
IsVisible="False" <Border x:Name="SingleInstanceNoticeDock"
Classes="glass-panel" IsVisible="False"
CornerRadius="18" Classes="glass-panel"
Padding="14,12"> CornerRadius="18"
<Grid ColumnDefinitions="Auto,*,Auto" Padding="14,12">
ColumnSpacing="12"> <Grid ColumnDefinitions="Auto,*,Auto"
<Border Width="34" ColumnSpacing="12">
Height="34" <Border Width="34"
CornerRadius="17" Height="34"
Background="{DynamicResource AdaptiveAccentBrush}"> CornerRadius="17"
<fi:FluentIcon Icon="ArrowSync" Background="{DynamicResource AdaptiveAccentBrush}">
IconVariant="Regular" <fi:FluentIcon Icon="Alert"
FontSize="16" IconVariant="Regular"
Foreground="White" FontSize="16"
HorizontalAlignment="Center" Foreground="White"
VerticalAlignment="Center" /> HorizontalAlignment="Center"
</Border> VerticalAlignment="Center" />
<StackPanel Grid.Column="1" </Border>
Spacing="2" <StackPanel Grid.Column="1"
VerticalAlignment="Center"> Spacing="2"
<TextBlock x:Name="PendingRestartDockTitleTextBlock" VerticalAlignment="Center">
FontSize="13" <TextBlock x:Name="SingleInstanceNoticeTitleTextBlock"
FontWeight="SemiBold" FontSize="13"
Text="Restart required" /> FontWeight="SemiBold"
<TextBlock x:Name="PendingRestartDockDescriptionTextBlock" Text="App already open" />
TextWrapping="Wrap" <TextBlock x:Name="SingleInstanceNoticeDescriptionTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" TextWrapping="Wrap"
Text="Your changes will apply after restarting the app." /> Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
</StackPanel> Text="LanMountainDesktop is already running. Switched back to the active desktop." />
<Button x:Name="PendingRestartDockButton"
Grid.Column="2"
Padding="14,8"
Click="OnPendingRestartDockButtonClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:FluentIcon Icon="ArrowSync"
IconVariant="Regular" />
<TextBlock x:Name="PendingRestartDockButtonTextBlock"
VerticalAlignment="Center"
Text="Restart app" />
</StackPanel> </StackPanel>
</Button> <Button x:Name="SingleInstanceNoticeButton"
</Grid> Grid.Column="2"
</Border> Padding="14,8"
Click="OnSingleInstanceNoticeButtonClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:FluentIcon Icon="Checkmark"
IconVariant="Regular" />
<TextBlock x:Name="SingleInstanceNoticeButtonTextBlock"
VerticalAlignment="Center"
Text="Got it" />
</StackPanel>
</Button>
</Grid>
</Border>
<Border x:Name="PendingRestartDock"
IsVisible="False"
Classes="glass-panel"
CornerRadius="18"
Padding="14,12">
<Grid ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="12">
<Border Width="34"
Height="34"
CornerRadius="17"
Background="{DynamicResource AdaptiveAccentBrush}">
<fi:FluentIcon Icon="ArrowSync"
IconVariant="Regular"
FontSize="16"
Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1"
Spacing="2"
VerticalAlignment="Center">
<TextBlock x:Name="PendingRestartDockTitleTextBlock"
FontSize="13"
FontWeight="SemiBold"
Text="Restart required" />
<TextBlock x:Name="PendingRestartDockDescriptionTextBlock"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="Your changes will apply after restarting the app." />
</StackPanel>
<Button x:Name="PendingRestartDockButton"
Grid.Column="2"
Padding="14,8"
Click="OnPendingRestartDockButtonClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:FluentIcon Icon="ArrowSync"
IconVariant="Regular" />
<TextBlock x:Name="PendingRestartDockButtonTextBlock"
VerticalAlignment="Center"
Text="Restart app" />
</StackPanel>
</Button>
</Grid>
</Border>
</StackPanel>
</Grid> </Grid>
</Border> </Border>
</Border> </Border>

View File

@@ -78,16 +78,24 @@ internal sealed class AirAppMarketInstallService : IDisposable
} }
} }
await using var hashStream = File.OpenRead(downloadPath); var actualSize = new FileInfo(downloadPath).Length;
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken); string actualHash;
var actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant(); await using (var hashStream = File.OpenRead(downloadPath))
{
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken);
actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
}
if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase)) if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
{ {
AppLogger.Error(
"PluginMarket",
$"SHA-256 verification failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadUrl='{resolvedDownloadUrl}'; DownloadPath='{downloadPath}'; ExpectedHash='{plugin.Sha256}'; ActualHash='{actualHash}'; ExpectedSize='{plugin.PackageSizeBytes}'; ActualSize='{actualSize}'.");
File.Delete(downloadPath); File.Delete(downloadPath);
return new AirAppMarketInstallResult( return new AirAppMarketInstallResult(
false, false,
null, null,
$"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}."); $"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}. Expected size {plugin.PackageSizeBytes}, actual size {actualSize}. Source {resolvedDownloadUrl}.");
} }
PluginManifest manifest; PluginManifest manifest;

View File

@@ -31,6 +31,7 @@
- Windows 是当前主要目标平台。 - Windows 是当前主要目标平台。
- 已提供组件系统、插件系统、主题系统和设置系统。 - 已提供组件系统、插件系统、主题系统和设置系统。
- 中文为主语言,英文为附加扩展语言。 - 中文为主语言,英文为附加扩展语言。
- 仓库主入口解决方案文件已切换为 `LanMountainDesktop.slnx`SDK 版本由根目录 `global.json` 锁定。
### 运行说明 ### 运行说明

6
global.json Normal file
View File

@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.103",
"rollForward": "latestFeature"
}
}

8
run.md
View File

@@ -8,12 +8,14 @@
- 安装 .NET SDK 10。 - 安装 .NET SDK 10。
- 桌面端建议在 Windows 上运行。 - 桌面端建议在 Windows 上运行。
- 仓库主入口解决方案文件为 `LanMountainDesktop.slnx`
- SDK 版本由仓库根目录 `global.json` 锁定。
### 构建 ### 构建
```bash ```bash
dotnet restore dotnet restore
dotnet build LanMountainDesktop.sln -c Debug dotnet build LanMountainDesktop.slnx -c Debug
``` ```
### 运行桌面端 ### 运行桌面端
@@ -41,11 +43,13 @@ dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
This guide explains how to run LanMountainDesktop locally. This guide explains how to run LanMountainDesktop locally.
The repository entry solution is `LanMountainDesktop.slnx`, and the SDK version is pinned by the root `global.json`.
### Build ### Build
```bash ```bash
dotnet restore dotnet restore
dotnet build LanMountainDesktop.sln -c Debug dotnet build LanMountainDesktop.slnx -c Debug
``` ```
### Run ### Run