mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efdfa68dab | ||
|
|
87110f1d69 | ||
|
|
e7a03404ce | ||
|
|
2781d7e0d9 |
17
.github/FIX_REPORT.md
vendored
17
.github/FIX_REPORT.md
vendored
@@ -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
3
.github/README.md
vendored
@@ -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)
|
||||||
|
|||||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -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:
|
||||||
|
|||||||
2
.github/workflows/code-quality.yml
vendored
2
.github/workflows/code-quality.yml
vendored
@@ -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:
|
||||||
|
|||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -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:
|
||||||
|
|||||||
@@ -67,6 +67,11 @@
|
|||||||
"capability.message_bus.detail": "This sample plugin uses IPluginMessageBus to push clock ticks and state change notifications into plugin UI surfaces.",
|
"capability.message_bus.detail": "This sample plugin uses IPluginMessageBus to push clock ticks and state change notifications into plugin UI surfaces.",
|
||||||
"capability.widget_context.title": "PluginDesktopComponentContext",
|
"capability.widget_context.title": "PluginDesktopComponentContext",
|
||||||
"capability.widget_context.detail": "Widgets can read ComponentId, PlacementId, CellSize, and call GetService<T>() against the same plugin service container.",
|
"capability.widget_context.detail": "Widgets can read ComponentId, PlacementId, CellSize, and call GetService<T>() against the same plugin service container.",
|
||||||
|
"widget.close_desktop.display_name": "Close Desktop",
|
||||||
|
"widget.close_desktop.text": "Close Desktop",
|
||||||
|
"widget.close_desktop.hint": "Exit LanMountainDesktop on click",
|
||||||
|
"widget.close_desktop.unavailable": "Host lifecycle API is unavailable",
|
||||||
|
"widget.close_desktop.failed": "Host rejected the exit request",
|
||||||
"widget.subtitle.preview": "Preview surface | placed: {0}",
|
"widget.subtitle.preview": "Preview surface | placed: {0}",
|
||||||
"widget.subtitle.placement": "Placement {0} | placed: {1}",
|
"widget.subtitle.placement": "Placement {0} | placed: {1}",
|
||||||
"common.dev": "dev",
|
"common.dev": "dev",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public sealed class SamplePlugin : PluginBase, IDisposable
|
|||||||
var hostName = GetHostProperty(context, PluginHostPropertyKeys.HostApplicationName, "UnknownHost");
|
var hostName = GetHostProperty(context, PluginHostPropertyKeys.HostApplicationName, "UnknownHost");
|
||||||
var hostVersion = GetHostProperty(context, PluginHostPropertyKeys.HostVersion, "UnknownVersion");
|
var hostVersion = GetHostProperty(context, PluginHostPropertyKeys.HostVersion, "UnknownVersion");
|
||||||
var sdkApiVersion = GetHostProperty(context, PluginHostPropertyKeys.PluginSdkApiVersion, "UnknownApiVersion");
|
var sdkApiVersion = GetHostProperty(context, PluginHostPropertyKeys.PluginSdkApiVersion, "UnknownApiVersion");
|
||||||
|
var hostApplicationLifecycle = context.GetService<IHostApplicationLifecycle>();
|
||||||
var messageBus = context.GetService<IPluginMessageBus>()
|
var messageBus = context.GetService<IPluginMessageBus>()
|
||||||
?? throw new InvalidOperationException("Plugin message bus is not available.");
|
?? throw new InvalidOperationException("Plugin message bus is not available.");
|
||||||
|
|
||||||
@@ -74,6 +75,19 @@ public sealed class SamplePlugin : PluginBase, IDisposable
|
|||||||
allowStatusBarPlacement: false,
|
allowStatusBarPlacement: false,
|
||||||
resizeMode: PluginDesktopComponentResizeMode.Proportional,
|
resizeMode: PluginDesktopComponentResizeMode.Proportional,
|
||||||
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.34, 18, 34)));
|
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.34, 18, 34)));
|
||||||
|
|
||||||
|
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
|
||||||
|
"LanMountainDesktop.SamplePlugin.CloseDesktop",
|
||||||
|
localizer.GetString("widget.close_desktop.display_name", "关闭桌面"),
|
||||||
|
widgetContext => new SamplePluginCloseDesktopWidget(widgetContext),
|
||||||
|
iconKey: "DismissCircle",
|
||||||
|
category: localizer.GetString("widget.category", "鎻掍欢"),
|
||||||
|
minWidthCells: 2,
|
||||||
|
minHeightCells: 1,
|
||||||
|
allowDesktopPlacement: true,
|
||||||
|
allowStatusBarPlacement: false,
|
||||||
|
resizeMode: PluginDesktopComponentResizeMode.Free,
|
||||||
|
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.28, 14, 22)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.SamplePlugin;
|
||||||
|
|
||||||
|
internal sealed class SamplePluginCloseDesktopWidget : Border
|
||||||
|
{
|
||||||
|
private readonly PluginLocalizer _localizer;
|
||||||
|
private readonly IHostApplicationLifecycle? _hostApplicationLifecycle;
|
||||||
|
private readonly TextBlock _titleTextBlock;
|
||||||
|
private readonly TextBlock _statusTextBlock;
|
||||||
|
|
||||||
|
public SamplePluginCloseDesktopWidget(PluginDesktopComponentContext context)
|
||||||
|
{
|
||||||
|
_localizer = PluginLocalizer.Create(context);
|
||||||
|
_hostApplicationLifecycle = context.GetService<IHostApplicationLifecycle>();
|
||||||
|
|
||||||
|
_titleTextBlock = new TextBlock
|
||||||
|
{
|
||||||
|
Text = T("widget.close_desktop.text", "关闭桌面"),
|
||||||
|
Foreground = Brushes.White,
|
||||||
|
FontWeight = FontWeight.SemiBold,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
|
||||||
|
_statusTextBlock = new TextBlock
|
||||||
|
{
|
||||||
|
Text = _hostApplicationLifecycle is null
|
||||||
|
? T("widget.close_desktop.unavailable", "宿主未提供退出接口")
|
||||||
|
: T("widget.close_desktop.hint", "点击后退出阑山桌面"),
|
||||||
|
Foreground = new SolidColorBrush(Color.Parse("#FFD4E7F6")),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
|
||||||
|
var contentGrid = new Grid
|
||||||
|
{
|
||||||
|
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
|
||||||
|
ColumnSpacing = 14,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
CreateIconShell(),
|
||||||
|
new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = 2,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
_titleTextBlock,
|
||||||
|
_statusTextBlock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Grid.SetColumn(contentGrid.Children[1], 1);
|
||||||
|
|
||||||
|
var actionButton = new Button
|
||||||
|
{
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
|
VerticalAlignment = VerticalAlignment.Stretch,
|
||||||
|
HorizontalContentAlignment = HorizontalAlignment.Stretch,
|
||||||
|
VerticalContentAlignment = VerticalAlignment.Stretch,
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
BorderThickness = new Thickness(0),
|
||||||
|
Padding = new Thickness(0),
|
||||||
|
IsEnabled = _hostApplicationLifecycle is not null,
|
||||||
|
Content = contentGrid
|
||||||
|
};
|
||||||
|
actionButton.Click += OnButtonClick;
|
||||||
|
|
||||||
|
Background = new LinearGradientBrush
|
||||||
|
{
|
||||||
|
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||||
|
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||||
|
GradientStops =
|
||||||
|
[
|
||||||
|
new GradientStop(Color.Parse("#FF0B1220"), 0),
|
||||||
|
new GradientStop(Color.Parse("#FF172554"), 0.55),
|
||||||
|
new GradientStop(Color.Parse("#FF7F1D1D"), 1)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
BorderBrush = new SolidColorBrush(Color.Parse("#66FB7185"));
|
||||||
|
BorderThickness = new Thickness(1);
|
||||||
|
CornerRadius = new CornerRadius(18);
|
||||||
|
Padding = new Thickness(14, 10);
|
||||||
|
Child = actionButton;
|
||||||
|
|
||||||
|
SizeChanged += OnSizeChanged;
|
||||||
|
ApplyScale();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Border CreateIconShell()
|
||||||
|
{
|
||||||
|
return new Border
|
||||||
|
{
|
||||||
|
Width = 36,
|
||||||
|
Height = 36,
|
||||||
|
CornerRadius = new CornerRadius(999),
|
||||||
|
Background = new SolidColorBrush(Color.Parse("#33F87171")),
|
||||||
|
BorderBrush = new SolidColorBrush(Color.Parse("#88FCA5A5")),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Child = new TextBlock
|
||||||
|
{
|
||||||
|
Text = "⏻",
|
||||||
|
FontSize = 18,
|
||||||
|
Foreground = Brushes.White,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
TextAlignment = TextAlignment.Center
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_hostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest(
|
||||||
|
Source: "SamplePlugin.CloseDesktopWidget",
|
||||||
|
Reason: "User invoked the sample plugin close-desktop widget.")) == true)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_statusTextBlock.Text = T("widget.close_desktop.failed", "宿主未接受退出请求");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||||
|
{
|
||||||
|
ApplyScale();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyScale()
|
||||||
|
{
|
||||||
|
var basis = Bounds.Height > 1 ? Bounds.Height : 72;
|
||||||
|
Padding = new Thickness(Math.Clamp(basis * 0.18, 12, 18), Math.Clamp(basis * 0.14, 8, 14));
|
||||||
|
CornerRadius = new CornerRadius(Math.Clamp(basis * 0.32, 16, 24));
|
||||||
|
|
||||||
|
if (Child is not Button actionButton || actionButton.Content is not Grid contentGrid)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentGrid.Children[0] is Border iconShell)
|
||||||
|
{
|
||||||
|
var iconSize = Math.Clamp(basis * 0.58, 28, 40);
|
||||||
|
iconShell.Width = iconSize;
|
||||||
|
iconShell.Height = iconSize;
|
||||||
|
if (iconShell.Child is TextBlock iconText)
|
||||||
|
{
|
||||||
|
iconText.FontSize = Math.Clamp(iconSize * 0.5, 14, 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_titleTextBlock.FontSize = Math.Clamp(basis * 0.28, 14, 20);
|
||||||
|
_statusTextBlock.FontSize = Math.Clamp(basis * 0.18, 10, 13);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string T(string key, string fallback)
|
||||||
|
{
|
||||||
|
return _localizer.GetString(key, fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
LanMountainDesktop.PluginSdk/IHostApplicationLifecycle.cs
Normal file
12
LanMountainDesktop.PluginSdk/IHostApplicationLifecycle.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed record HostApplicationLifecycleRequest(
|
||||||
|
string? Source = null,
|
||||||
|
string? Reason = null);
|
||||||
|
|
||||||
|
public interface IHostApplicationLifecycle
|
||||||
|
{
|
||||||
|
bool TryExit(HostApplicationLifecycleRequest? request = null);
|
||||||
|
|
||||||
|
bool TryRestart(HostApplicationLifecycleRequest? request = null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
<PackageVersion>$(Version)</PackageVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
242
LanMountainDesktop.PluginsInstallHelper/Program.cs
Normal file
242
LanMountainDesktop.PluginsInstallHelper/Program.cs
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
var result = new HelperResult();
|
||||||
|
string? resultPath = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parsedArgs = ParseArgs(args);
|
||||||
|
if (!parsedArgs.TryGetValue("source", out var sourcePath) ||
|
||||||
|
!parsedArgs.TryGetValue("plugins-dir", out var pluginsDirectory) ||
|
||||||
|
!parsedArgs.TryGetValue("result", out resultPath) ||
|
||||||
|
string.IsNullOrWhiteSpace(sourcePath) ||
|
||||||
|
string.IsNullOrWhiteSpace(pluginsDirectory) ||
|
||||||
|
string.IsNullOrWhiteSpace(resultPath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Required arguments: --source <path> --plugins-dir <path> --result <path>.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullSourcePath = Path.GetFullPath(sourcePath);
|
||||||
|
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
|
||||||
|
resultPath = Path.GetFullPath(resultPath);
|
||||||
|
|
||||||
|
if (!File.Exists(fullSourcePath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest = ReadManifestFromPackage(fullSourcePath);
|
||||||
|
Directory.CreateDirectory(fullPluginsDirectory);
|
||||||
|
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
||||||
|
var stagingPath = destinationPath + ".incoming";
|
||||||
|
DeleteFileWithRetry(stagingPath);
|
||||||
|
CopyWithRetry(fullSourcePath, stagingPath, overwrite: true);
|
||||||
|
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath);
|
||||||
|
MoveWithOverwriteRetry(stagingPath, destinationPath);
|
||||||
|
|
||||||
|
result = new HelperResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
InstalledPackagePath = destinationPath,
|
||||||
|
ManifestId = manifest.Id,
|
||||||
|
ManifestName = manifest.Name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
result = new HelperResult
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
ErrorMessage = ex.Message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(resultPath))
|
||||||
|
{
|
||||||
|
var resultDirectory = Path.GetDirectoryName(resultPath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(resultDirectory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(resultDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
resultPath,
|
||||||
|
JsonSerializer.Serialize(result, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true
|
||||||
|
}),
|
||||||
|
Encoding.UTF8);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Success ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ParseArgs(string[] args)
|
||||||
|
{
|
||||||
|
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
for (var i = 0; i < args.Length; i++)
|
||||||
|
{
|
||||||
|
var current = args[i];
|
||||||
|
if (!current.StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = current[2..];
|
||||||
|
if (string.IsNullOrWhiteSpace(key) || i + 1 >= args.Length)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
values[key] = args[++i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PluginManifest ReadManifestFromPackage(string packagePath)
|
||||||
|
{
|
||||||
|
using var archive = ZipFile.OpenRead(packagePath);
|
||||||
|
var entries = archive.Entries
|
||||||
|
.Where(entry => string.Equals(entry.Name, PluginSdkInfo.ManifestFileName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (entries.Length == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin package '{packagePath}' does not contain '{PluginSdkInfo.ManifestFileName}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.Length > 1)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin package '{packagePath}' contains multiple '{PluginSdkInfo.ManifestFileName}' files.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = entries[0].Open();
|
||||||
|
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
|
||||||
|
{
|
||||||
|
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), PluginSdkInfo.RuntimeDirectoryName));
|
||||||
|
foreach (var existingPackagePath in Directory
|
||||||
|
.EnumerateFiles(pluginsDirectory, "*" + PluginSdkInfo.PackageFileExtension, SearchOption.AllDirectories)
|
||||||
|
.Select(Path.GetFullPath)
|
||||||
|
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(existingPackagePath, Path.GetFullPath(stagingPath), StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingManifest = ReadManifestFromPackage(existingPackagePath);
|
||||||
|
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DeleteFileWithRetry(existingPackagePath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore unrelated or malformed packages while replacing an install target.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
var invalidChars = Path.GetInvalidFileNameChars();
|
||||||
|
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||||
|
return fileName + PluginSdkInfo.PackageFileExtension;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EnsureTrailingSeparator(string path)
|
||||||
|
{
|
||||||
|
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||||||
|
? path
|
||||||
|
: path + Path.DirectorySeparatorChar;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class HelperResult
|
||||||
|
{
|
||||||
|
public bool Success { get; init; }
|
||||||
|
|
||||||
|
public string? InstalledPackagePath { get; init; }
|
||||||
|
|
||||||
|
public string? ManifestId { get; init; }
|
||||||
|
|
||||||
|
public string? ManifestName { get; init; }
|
||||||
|
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +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
|
|
||||||
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
|
|
||||||
EndGlobalSection
|
|
||||||
EndGlobal
|
|
||||||
7
LanMountainDesktop.slnx
Normal file
7
LanMountainDesktop.slnx
Normal 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>
|
||||||
@@ -12,6 +12,7 @@ using LanMountainDesktop.Services;
|
|||||||
using LanMountainDesktop.ViewModels;
|
using LanMountainDesktop.ViewModels;
|
||||||
using LanMountainDesktop.Views;
|
using LanMountainDesktop.Views;
|
||||||
using AvaloniaWebView;
|
using AvaloniaWebView;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
namespace LanMountainDesktop;
|
namespace LanMountainDesktop;
|
||||||
|
|
||||||
@@ -19,12 +20,19 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
private readonly AppSettingsService _appSettingsService = new();
|
private readonly AppSettingsService _appSettingsService = new();
|
||||||
private readonly LocalizationService _localizationService = new();
|
private readonly LocalizationService _localizationService = new();
|
||||||
|
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
|
||||||
|
private bool _exitCleanupCompleted;
|
||||||
|
|
||||||
private SettingsWindow? _traySettingsWindow;
|
private SettingsWindow? _traySettingsWindow;
|
||||||
private TrayIcons? _trayIcons;
|
private TrayIcons? _trayIcons;
|
||||||
private PluginRuntimeService? _pluginRuntimeService;
|
private PluginRuntimeService? _pluginRuntimeService;
|
||||||
|
|
||||||
|
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
||||||
|
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
|
||||||
|
(Current as App)?._hostApplicationLifecycle;
|
||||||
|
|
||||||
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
|
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
|
||||||
|
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
|
||||||
|
|
||||||
public override void Initialize()
|
public override void Initialize()
|
||||||
{
|
{
|
||||||
@@ -51,14 +59,14 @@ public partial class App : Application
|
|||||||
desktop.Exit += (_, _) =>
|
desktop.Exit += (_, _) =>
|
||||||
{
|
{
|
||||||
AppLogger.Info("App", "Desktop lifetime exit triggered.");
|
AppLogger.Info("App", "Desktop lifetime exit triggered.");
|
||||||
AppSettingsService.SettingsSaved -= OnAppSettingsSaved;
|
PerformExitCleanup();
|
||||||
DisposeTrayIcon();
|
|
||||||
};
|
};
|
||||||
desktop.MainWindow = new MainWindow
|
desktop.MainWindow = new MainWindow
|
||||||
{
|
{
|
||||||
DataContext = new MainWindowViewModel(),
|
DataContext = new MainWindowViewModel(),
|
||||||
};
|
};
|
||||||
AppLogger.Info("App", $"Main window created. LogFile={AppLogger.LogFilePath}");
|
AppLogger.Info("App", $"Main window created. LogFile={AppLogger.LogFilePath}");
|
||||||
|
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
@@ -66,12 +74,9 @@ public partial class App : Application
|
|||||||
|
|
||||||
private void OnTrayExitClick(object? sender, EventArgs e)
|
private void OnTrayExitClick(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
DisposeTrayIcon();
|
_ = _hostApplicationLifecycle.TryExit(new HostApplicationLifecycleRequest(
|
||||||
|
Source: "TrayMenu",
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
Reason: "User selected Exit App from the tray menu."));
|
||||||
{
|
|
||||||
desktop.Shutdown();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnTraySettingsClick(object? sender, EventArgs e)
|
private void OnTraySettingsClick(object? sender, EventArgs e)
|
||||||
@@ -114,18 +119,9 @@ public partial class App : Application
|
|||||||
|
|
||||||
private void OnTrayRestartClick(object? sender, EventArgs e)
|
private void OnTrayRestartClick(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
AppRestartService.TryRestartApplication();
|
_ = _hostApplicationLifecycle.TryRestart(new HostApplicationLifecycleRequest(
|
||||||
}
|
Source: "TrayMenu",
|
||||||
|
Reason: "User selected Restart App from the tray menu."));
|
||||||
private void OnAppSettingsSaved(string _)
|
|
||||||
{
|
|
||||||
Dispatcher.UIThread.Post(() =>
|
|
||||||
{
|
|
||||||
if (_trayIcons is not null)
|
|
||||||
{
|
|
||||||
InitializeTrayIcon();
|
|
||||||
}
|
|
||||||
}, DispatcherPriority.Background);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DisableAvaloniaDataAnnotationValidation()
|
private void DisableAvaloniaDataAnnotationValidation()
|
||||||
@@ -246,6 +242,99 @@ public partial class App : Application
|
|||||||
_trayIcons = null;
|
_trayIcons = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ActivateMainWindow()
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (desktop.MainWindow is not Window mainWindow)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!mainWindow.IsVisible)
|
||||||
|
{
|
||||||
|
mainWindow.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainWindow.WindowState == WindowState.Minimized)
|
||||||
|
{
|
||||||
|
mainWindow.WindowState = WindowState.Normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.Activate();
|
||||||
|
mainWindow.Topmost = true;
|
||||||
|
mainWindow.Topmost = false;
|
||||||
|
if (mainWindow is MainWindow lanMountainMainWindow)
|
||||||
|
{
|
||||||
|
lanMountainMainWindow.ShowSingleInstanceNotice();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("SingleInstance", "Failed to activate the existing main window.", ex);
|
||||||
|
}
|
||||||
|
}, DispatcherPriority.Send);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAppSettingsSaved(string _)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
if (_trayIcons is not null)
|
||||||
|
{
|
||||||
|
InitializeTrayIcon();
|
||||||
|
}
|
||||||
|
}, DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PerformExitCleanup()
|
||||||
|
{
|
||||||
|
if (_exitCleanupCompleted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_exitCleanupCompleted = true;
|
||||||
|
AppSettingsService.SettingsSaved -= OnAppSettingsSaved;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_traySettingsWindow?.Close();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("App", "Failed to close tray-opened settings window during shutdown.", ex);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_traySettingsWindow = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_pluginRuntimeService?.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("PluginRuntime", "Failed to dispose plugin runtime during shutdown.", ex);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_pluginRuntimeService = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioRecorderServiceFactory.DisposeSharedServices();
|
||||||
|
StudyAnalyticsServiceFactory.DisposeSharedService();
|
||||||
|
DisposeTrayIcon();
|
||||||
|
}
|
||||||
|
|
||||||
private string L(string key, string fallback)
|
private string L(string key, string fallback)
|
||||||
{
|
{
|
||||||
var snapshot = _appSettingsService.Load();
|
var snapshot = _appSettingsService.Load();
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj"
|
||||||
|
ReferenceOutputAssembly="false" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -56,4 +58,22 @@
|
|||||||
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
|
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
|
||||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Target Name="CopyPluginsInstallHelperToOutput" AfterTargets="Build">
|
||||||
|
<ItemGroup>
|
||||||
|
<PluginsInstallHelperFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Copy SourceFiles="@(PluginsInstallHelperFiles)"
|
||||||
|
DestinationFiles="@(PluginsInstallHelperFiles->'$(OutDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')"
|
||||||
|
SkipUnchangedFiles="true" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<Target Name="CopyPluginsInstallHelperToPublish" AfterTargets="Publish" Condition="'$(PublishDir)' != ''">
|
||||||
|
<ItemGroup>
|
||||||
|
<PluginsInstallHelperPublishFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Copy SourceFiles="@(PluginsInstallHelperPublishFiles)"
|
||||||
|
DestinationFiles="@(PluginsInstallHelperPublishFiles->'$(PublishDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')"
|
||||||
|
SkipUnchangedFiles="true" />
|
||||||
|
</Target>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -251,6 +251,7 @@
|
|||||||
"settings.update.status_launching_installer": "Download complete. Launching installer...",
|
"settings.update.status_launching_installer": "Download complete. Launching installer...",
|
||||||
"settings.update.status_installer_missing": "Installer file was not found after download.",
|
"settings.update.status_installer_missing": "Installer file was not found after download.",
|
||||||
"settings.update.status_installer_started": "Installer started. The app will close for update.",
|
"settings.update.status_installer_started": "Installer started. The app will close for update.",
|
||||||
|
"settings.update.status_elevation_cancelled": "Administrator permission was not granted. Update was cancelled.",
|
||||||
"settings.update.status_launch_failed_format": "Failed to start installer: {0}",
|
"settings.update.status_launch_failed_format": "Failed to start installer: {0}",
|
||||||
"settings.about.title": "About",
|
"settings.about.title": "About",
|
||||||
"settings.about.version_format": "Version: {0}",
|
"settings.about.version_format": "Version: {0}",
|
||||||
@@ -742,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"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -251,6 +251,7 @@
|
|||||||
"settings.update.status_launching_installer": "下载完成,正在启动安装程序...",
|
"settings.update.status_launching_installer": "下载完成,正在启动安装程序...",
|
||||||
"settings.update.status_installer_missing": "下载后未找到安装包文件。",
|
"settings.update.status_installer_missing": "下载后未找到安装包文件。",
|
||||||
"settings.update.status_installer_started": "安装程序已启动,应用将关闭进行更新。",
|
"settings.update.status_installer_started": "安装程序已启动,应用将关闭进行更新。",
|
||||||
|
"settings.update.status_elevation_cancelled": "未授予管理员权限,更新已取消。",
|
||||||
"settings.update.status_launch_failed_format": "启动安装程序失败:{0}",
|
"settings.update.status_launch_failed_format": "启动安装程序失败:{0}",
|
||||||
"settings.about.title": "关于",
|
"settings.about.title": "关于",
|
||||||
"settings.about.version_format": "版本号: {0}",
|
"settings.about.version_format": "版本号: {0}",
|
||||||
@@ -742,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": "知道了"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +19,14 @@ sealed class Program
|
|||||||
AppLogger.Initialize();
|
AppLogger.Initialize();
|
||||||
RegisterGlobalExceptionLogging();
|
RegisterGlobalExceptionLogging();
|
||||||
|
|
||||||
|
using var singleInstance = AcquireSingleInstance(args);
|
||||||
|
if (!singleInstance.IsPrimaryInstance)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("Startup", "A secondary launch was blocked because another instance is already running.");
|
||||||
|
_ = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var diagnostics = StartupDiagnosticsService.Run(args);
|
var diagnostics = StartupDiagnosticsService.Run(args);
|
||||||
StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics);
|
StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics);
|
||||||
|
|
||||||
@@ -24,6 +34,7 @@ sealed class Program
|
|||||||
{
|
{
|
||||||
var renderMode = LoadConfiguredRenderMode();
|
var renderMode = LoadConfiguredRenderMode();
|
||||||
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
|
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
|
||||||
|
App.CurrentSingleInstanceService = singleInstance;
|
||||||
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
|
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
|
||||||
AppLogger.Info("Startup", "Application exited normally.");
|
AppLogger.Info("Startup", "Application exited normally.");
|
||||||
}
|
}
|
||||||
@@ -32,6 +43,10 @@ sealed class Program
|
|||||||
AppLogger.Critical("Startup", "Application terminated during startup.", ex);
|
AppLogger.Critical("Startup", "Application terminated during startup.", ex);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
App.CurrentSingleInstanceService = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avalonia configuration, don't remove; also used by visual designer.
|
// Avalonia configuration, don't remove; also used by visual designer.
|
||||||
@@ -58,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
|
||||||
@@ -71,6 +122,27 @@ sealed class Program
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void WaitForRestartParentExit(int processId, DateTime deadlineUtc)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var process = Process.GetProcessById(processId);
|
||||||
|
var remaining = deadlineUtc - DateTime.UtcNow;
|
||||||
|
if (remaining > TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
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()
|
||||||
{
|
{
|
||||||
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
|
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
|
||||||
|
|||||||
@@ -3,26 +3,19 @@ using System.Collections.Generic;
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Avalonia;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.Services;
|
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()
|
||||||
{
|
{
|
||||||
if (!TryRestartCurrentProcess())
|
return App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest(
|
||||||
{
|
Source: nameof(AppRestartService),
|
||||||
return false;
|
Reason: "Legacy restart entry point invoked.")) == true;
|
||||||
}
|
|
||||||
|
|
||||||
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
|
||||||
{
|
|
||||||
desktop.Shutdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool TryRestartCurrentProcess()
|
public static bool TryRestartCurrentProcess()
|
||||||
@@ -84,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,
|
||||||
@@ -97,6 +105,7 @@ public static class AppRestartService
|
|||||||
};
|
};
|
||||||
|
|
||||||
AppendArguments(startInfo, commandLineArgs);
|
AppendArguments(startInfo, commandLineArgs);
|
||||||
|
AppendRestartParentProcessArgument(startInfo);
|
||||||
return startInfo;
|
return startInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,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))
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||||
|
{
|
||||||
|
public bool TryExit(HostApplicationLifecycleRequest? request = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
AppLogger.Info(
|
||||||
|
"HostLifecycle",
|
||||||
|
$"Exit requested. Source='{request?.Source ?? "Unknown"}'; Reason='{request?.Reason ?? string.Empty}'.");
|
||||||
|
|
||||||
|
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("HostLifecycle", "Exit request ignored because desktop lifetime is unavailable.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Dispatcher.UIThread.CheckAccess())
|
||||||
|
{
|
||||||
|
desktop.Shutdown();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() => desktop.Shutdown(), DispatcherPriority.Send);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("HostLifecycle", "Failed to exit the application.", ex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryRestart(HostApplicationLifecycleRequest? request = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var startInfo = AppRestartService.CreateRestartStartInfo();
|
||||||
|
if (startInfo is null)
|
||||||
|
{
|
||||||
|
AppLogger.Warn(
|
||||||
|
"HostLifecycle",
|
||||||
|
$"Restart request rejected because restart start info could not be resolved. Source='{request?.Source ?? "Unknown"}'.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process.Start(startInfo);
|
||||||
|
var exitRequest = request is null
|
||||||
|
? new HostApplicationLifecycleRequest(Reason: "Restart accepted.")
|
||||||
|
: request with
|
||||||
|
{
|
||||||
|
Reason = string.IsNullOrWhiteSpace(request.Reason)
|
||||||
|
? "Restart accepted."
|
||||||
|
: request.Reason
|
||||||
|
};
|
||||||
|
|
||||||
|
return TryExit(exitRequest);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("HostLifecycle", "Failed to restart the application.", ex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,6 +80,19 @@ public static class AudioRecorderServiceFactory
|
|||||||
{
|
{
|
||||||
return CreateRecorder();
|
return CreateRecorder();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void DisposeSharedServices()
|
||||||
|
{
|
||||||
|
if (SharedRecorderService.IsValueCreated)
|
||||||
|
{
|
||||||
|
SharedRecorderService.Value.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SharedStudyMonitoringService.IsValueCreated)
|
||||||
|
{
|
||||||
|
SharedStudyMonitoringService.Value.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class NoOpAudioRecorderService(string reason) : IAudioRecorderService
|
internal sealed class NoOpAudioRecorderService(string reason) : IAudioRecorderService
|
||||||
|
|||||||
186
LanMountainDesktop/Services/PluginsInstallHelperClient.cs
Normal file
186
LanMountainDesktop/Services/PluginsInstallHelperClient.cs
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
internal sealed class PluginsInstallHelperClient
|
||||||
|
{
|
||||||
|
private const int UserCanceledUacErrorCode = 1223;
|
||||||
|
private const string HelperExecutableName = "LanMountainDesktop.PluginsInstallHelper.exe";
|
||||||
|
|
||||||
|
public async Task<PluginsInstallHelperResult> InstallPackageAsync(
|
||||||
|
string packagePath,
|
||||||
|
string pluginsDirectory,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(pluginsDirectory);
|
||||||
|
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return new PluginsInstallHelperResult(
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
"Elevated helper install is only supported on Windows.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var helperPath = ResolveHelperPath();
|
||||||
|
if (!File.Exists(helperPath))
|
||||||
|
{
|
||||||
|
return new PluginsInstallHelperResult(
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
$"Plugins install helper was not found at '{helperPath}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var resultPath = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"LanMountainDesktop",
|
||||||
|
"PluginInstallResults",
|
||||||
|
$"{Guid.NewGuid():N}.json");
|
||||||
|
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(resultPath)!);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var process = StartHelperProcess(helperPath, packagePath, pluginsDirectory, resultPath);
|
||||||
|
if (process is null)
|
||||||
|
{
|
||||||
|
return new PluginsInstallHelperResult(false, null, "Failed to start plugins install helper.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await process.WaitForExitAsync(cancellationToken);
|
||||||
|
var result = await ReadResultAsync(resultPath, cancellationToken);
|
||||||
|
if (result is not null)
|
||||||
|
{
|
||||||
|
return new PluginsInstallHelperResult(result.Success, result.InstalledPackagePath, result.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.ExitCode == 0)
|
||||||
|
{
|
||||||
|
return new PluginsInstallHelperResult(
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
"Plugins install helper exited without producing a result file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PluginsInstallHelperResult(
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"Plugins install helper exited with code {0}.",
|
||||||
|
process.ExitCode));
|
||||||
|
}
|
||||||
|
catch (Win32Exception ex) when (ex.NativeErrorCode == UserCanceledUacErrorCode)
|
||||||
|
{
|
||||||
|
return new PluginsInstallHelperResult(false, null, "Administrator permission request was canceled.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TryDeleteFile(resultPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Process? StartHelperProcess(
|
||||||
|
string helperPath,
|
||||||
|
string packagePath,
|
||||||
|
string pluginsDirectory,
|
||||||
|
string resultPath)
|
||||||
|
{
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = helperPath,
|
||||||
|
Verb = "runas",
|
||||||
|
UseShellExecute = true,
|
||||||
|
WorkingDirectory = Path.GetDirectoryName(helperPath) ?? AppContext.BaseDirectory,
|
||||||
|
Arguments = string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"--source {QuoteArgument(Path.GetFullPath(packagePath))} --plugins-dir {QuoteArgument(Path.GetFullPath(pluginsDirectory))} --result {QuoteArgument(Path.GetFullPath(resultPath))}")
|
||||||
|
};
|
||||||
|
|
||||||
|
return Process.Start(startInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<HelperResultFile?> ReadResultAsync(string resultPath, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!File.Exists(resultPath))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var stream = File.OpenRead(resultPath);
|
||||||
|
return await JsonSerializer.DeserializeAsync<HelperResultFile>(stream, cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveHelperPath()
|
||||||
|
{
|
||||||
|
return Path.Combine(AppContext.BaseDirectory, "PluginsInstallHelper", HelperExecutableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string QuoteArgument(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
return "\"\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
builder.Append('"');
|
||||||
|
foreach (var ch in value)
|
||||||
|
{
|
||||||
|
if (ch == '"')
|
||||||
|
{
|
||||||
|
builder.Append("\\\"");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
builder.Append(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append('"');
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDeleteFile(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore temp file cleanup failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class HelperResultFile
|
||||||
|
{
|
||||||
|
public bool Success { get; init; }
|
||||||
|
|
||||||
|
public string? InstalledPackagePath { get; init; }
|
||||||
|
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record PluginsInstallHelperResult(
|
||||||
|
bool Success,
|
||||||
|
string? InstalledPackagePath,
|
||||||
|
string? ErrorMessage);
|
||||||
151
LanMountainDesktop/Services/SingleInstanceService.cs
Normal file
151
LanMountainDesktop/Services/SingleInstanceService.cs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO.Pipes;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public sealed class SingleInstanceService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Mutex _mutex;
|
||||||
|
private readonly string _pipeName;
|
||||||
|
private readonly CancellationTokenSource _listenCts = new();
|
||||||
|
private bool _ownsMutex;
|
||||||
|
private bool _disposed;
|
||||||
|
private Task? _listenTask;
|
||||||
|
|
||||||
|
private SingleInstanceService(string mutexName, string pipeName)
|
||||||
|
{
|
||||||
|
_mutex = new Mutex(initiallyOwned: false, mutexName);
|
||||||
|
_pipeName = pipeName;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ownsMutex = _mutex.WaitOne(TimeSpan.Zero, exitContext: false);
|
||||||
|
}
|
||||||
|
catch (AbandonedMutexException)
|
||||||
|
{
|
||||||
|
_ownsMutex = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsPrimaryInstance => _ownsMutex;
|
||||||
|
|
||||||
|
public static SingleInstanceService CreateDefault()
|
||||||
|
{
|
||||||
|
const string appId = "LanMountainDesktop";
|
||||||
|
var userName = Environment.UserName;
|
||||||
|
var scopeSeed = $"{appId}:{userName}";
|
||||||
|
var scopeHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(scopeSeed)));
|
||||||
|
var suffix = scopeHash[..16];
|
||||||
|
var mutexName = OperatingSystem.IsWindows()
|
||||||
|
? $"Local\\{appId}.SingleInstance.{suffix}"
|
||||||
|
: $"{appId}.SingleInstance.{suffix}";
|
||||||
|
return new SingleInstanceService(
|
||||||
|
mutexName,
|
||||||
|
$"{appId}.Activate.{suffix}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StartActivationListener(Action onActivationRequested)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(onActivationRequested);
|
||||||
|
|
||||||
|
if (!_ownsMutex || _disposed || _listenTask is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryNotifyPrimaryInstance(TimeSpan timeout)
|
||||||
|
{
|
||||||
|
if (_ownsMutex || _disposed)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new NamedPipeClientStream(
|
||||||
|
serverName: ".",
|
||||||
|
pipeName: _pipeName,
|
||||||
|
direction: PipeDirection.Out,
|
||||||
|
options: PipeOptions.Asynchronous);
|
||||||
|
|
||||||
|
client.Connect((int)Math.Max(1, timeout.TotalMilliseconds));
|
||||||
|
client.WriteByte(1);
|
||||||
|
client.Flush();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("SingleInstance", "Failed to notify the primary instance.", ex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
_listenCts.Cancel();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_listenTask?.Wait(TimeSpan.FromSeconds(1));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore listener shutdown races during process exit.
|
||||||
|
}
|
||||||
|
|
||||||
|
_listenCts.Dispose();
|
||||||
|
if (_ownsMutex)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_mutex.ReleaseMutex();
|
||||||
|
}
|
||||||
|
catch (ApplicationException)
|
||||||
|
{
|
||||||
|
// Ownership may already be lost during shutdown.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_mutex.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ListenForActivationAsync(Action onActivationRequested, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var server = new NamedPipeServerStream(
|
||||||
|
_pipeName,
|
||||||
|
PipeDirection.In,
|
||||||
|
1,
|
||||||
|
PipeTransmissionMode.Byte,
|
||||||
|
PipeOptions.Asynchronous);
|
||||||
|
|
||||||
|
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await server.ReadAsync(new byte[1], cancellationToken).ConfigureAwait(false);
|
||||||
|
onActivationRequested();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("SingleInstance", "Activation listener failed.", ex);
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,14 @@ public static class StudyAnalyticsServiceFactory
|
|||||||
{
|
{
|
||||||
return SharedService.Value;
|
return SharedService.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void DisposeSharedService()
|
||||||
|
{
|
||||||
|
if (SharedService.IsValueCreated)
|
||||||
|
{
|
||||||
|
SharedService.Value.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||||
@@ -446,6 +454,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
_disposed = true;
|
_disposed = true;
|
||||||
StopTimerLocked();
|
StopTimerLocked();
|
||||||
_samplingTimer.Dispose();
|
_samplingTimer.Dispose();
|
||||||
|
_audioRecorderService.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -759,4 +768,3 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
_lastSessionReport = null;
|
_lastSessionReport = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,20 @@ namespace LanMountainDesktop.Services;
|
|||||||
internal static class WindowsNativeDialogService
|
internal static class WindowsNativeDialogService
|
||||||
{
|
{
|
||||||
private const uint Ok = 0x00000000;
|
private const uint Ok = 0x00000000;
|
||||||
|
private const uint IconInformation = 0x00000040;
|
||||||
private const uint IconWarning = 0x00000030;
|
private const uint IconWarning = 0x00000030;
|
||||||
|
|
||||||
|
public static void ShowInformation(string caption, string message)
|
||||||
|
{
|
||||||
|
Show(caption, message, Ok | IconInformation, "NativeDialog");
|
||||||
|
}
|
||||||
|
|
||||||
public static void ShowWarning(string caption, string message)
|
public static void ShowWarning(string caption, string message)
|
||||||
|
{
|
||||||
|
Show(caption, message, Ok | IconWarning, "StartupDiagnostics");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Show(string caption, string message, uint type, string logCategory)
|
||||||
{
|
{
|
||||||
if (!OperatingSystem.IsWindows())
|
if (!OperatingSystem.IsWindows())
|
||||||
{
|
{
|
||||||
@@ -17,11 +28,11 @@ internal static class WindowsNativeDialogService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_ = MessageBoxW(IntPtr.Zero, message, caption, Ok | IconWarning);
|
_ = MessageBoxW(IntPtr.Zero, message, caption, type);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AppLogger.Warn("StartupDiagnostics", "Failed to show legacy executable warning dialog.", ex);
|
AppLogger.Warn(logCategory, "Failed to show native dialog.", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1195,11 +1195,22 @@ public partial class MainWindow
|
|||||||
|
|
||||||
var restoreButton = new Button
|
var restoreButton = new Button
|
||||||
{
|
{
|
||||||
Content = L("settings.launcher.restore_button", "Unhide"),
|
Width = 36,
|
||||||
MinWidth = 110,
|
Height = 36,
|
||||||
Padding = new Thickness(12, 6),
|
Padding = new Thickness(0),
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
BorderThickness = new Thickness(0),
|
||||||
Tag = new LauncherHiddenItemToken(hiddenItem.Kind, hiddenItem.Key)
|
Tag = new LauncherHiddenItemToken(hiddenItem.Kind, hiddenItem.Key)
|
||||||
};
|
};
|
||||||
|
restoreButton.Content = new FluentIcons.Avalonia.Fluent.SymbolIcon
|
||||||
|
{
|
||||||
|
Symbol = FluentIcons.Common.Symbol.Eye,
|
||||||
|
IconVariant = FluentIcons.Common.IconVariant.Regular,
|
||||||
|
FontSize = 18,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
ToolTip.SetTip(restoreButton, L("settings.launcher.restore_button", "Unhide"));
|
||||||
restoreButton.Click += OnRestoreLauncherHiddenItemClick;
|
restoreButton.Click += OnRestoreLauncherHiddenItemClick;
|
||||||
|
|
||||||
return new SettingsExpanderItem
|
return new SettingsExpanderItem
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Threading.Tasks;
|
|||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using FluentAvalonia.UI.Controls;
|
using FluentAvalonia.UI.Controls;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Views;
|
namespace LanMountainDesktop.Views;
|
||||||
@@ -77,7 +78,9 @@ public partial class MainWindow
|
|||||||
var result = await dialog.ShowAsync(this);
|
var result = await dialog.ShowAsync(this);
|
||||||
if (result == ContentDialogResult.Primary)
|
if (result == ContentDialogResult.Primary)
|
||||||
{
|
{
|
||||||
if (!AppRestartService.TryRestartApplication())
|
if (App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest(
|
||||||
|
Source: nameof(MainWindow),
|
||||||
|
Reason: "User confirmed a pending restart prompt.")) != true)
|
||||||
{
|
{
|
||||||
UpdatePendingRestartDock();
|
UpdatePendingRestartDock();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
|
|
||||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||||
|
UpdateVideoWallpaperPreviewVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnNightModeChecked(object? sender, RoutedEventArgs e)
|
private void OnNightModeChecked(object? sender, RoutedEventArgs e)
|
||||||
@@ -344,6 +345,7 @@ public partial class MainWindow
|
|||||||
var placement = GetSelectedWallpaperPlacement();
|
var placement = GetSelectedWallpaperPlacement();
|
||||||
DesktopWallpaperLayer.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, false);
|
DesktopWallpaperLayer.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, false);
|
||||||
WallpaperPreviewViewport.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, true);
|
WallpaperPreviewViewport.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, true);
|
||||||
|
UpdateVideoWallpaperPreviewVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateWallpaperDisplay()
|
private void UpdateWallpaperDisplay()
|
||||||
@@ -628,12 +630,99 @@ public partial class MainWindow
|
|||||||
EnableHardwareDecoding = false
|
EnableHardwareDecoding = false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (_previewVideoWallpaperPlayer is null && WallpaperPreviewVideoView is not null)
|
private void EnsureDesktopVideoFrameRefreshTimer()
|
||||||
|
{
|
||||||
|
if (_desktopVideoFrameRefreshTimer is not null)
|
||||||
{
|
{
|
||||||
_previewVideoWallpaperPlayer = new MediaPlayer(_libVlc);
|
return;
|
||||||
WallpaperPreviewVideoView.MediaPlayer = _previewVideoWallpaperPlayer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_desktopVideoFrameRefreshTimer = new DispatcherTimer
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromMilliseconds(33)
|
||||||
|
};
|
||||||
|
_desktopVideoFrameRefreshTimer.Tick += OnDesktopVideoFrameRefreshTimerTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartDesktopVideoFrameRefreshTimer()
|
||||||
|
{
|
||||||
|
EnsureDesktopVideoFrameRefreshTimer();
|
||||||
|
if (_desktopVideoFrameRefreshTimer?.IsEnabled == false)
|
||||||
|
{
|
||||||
|
_desktopVideoFrameRefreshTimer.Start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopDesktopVideoFrameRefreshTimer()
|
||||||
|
{
|
||||||
|
if (_desktopVideoFrameRefreshTimer?.IsEnabled == true)
|
||||||
|
{
|
||||||
|
_desktopVideoFrameRefreshTimer.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDesktopVideoFrameRefreshTimerTick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
PushDesktopVideoFrameToWallpaperImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateVideoWallpaperPreviewVisibility()
|
||||||
|
{
|
||||||
|
var shouldShowPreview =
|
||||||
|
_wallpaperMediaType == WallpaperMediaType.Video &&
|
||||||
|
_isSettingsOpen &&
|
||||||
|
SettingsPage.IsVisible &&
|
||||||
|
WallpaperSettingsPanel.IsVisible &&
|
||||||
|
_wallpaperPreviewSnapshotBitmap is not null;
|
||||||
|
|
||||||
|
WallpaperPreviewVideoImage.IsVisible = shouldShowPreview;
|
||||||
|
if (shouldShowPreview && !ReferenceEquals(WallpaperPreviewVideoImage.Source, _wallpaperPreviewSnapshotBitmap))
|
||||||
|
{
|
||||||
|
WallpaperPreviewVideoImage.Source = _wallpaperPreviewSnapshotBitmap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InvalidateVideoWallpaperPreviewSnapshot()
|
||||||
|
{
|
||||||
|
_wallpaperPreviewSnapshotPending = true;
|
||||||
|
_wallpaperPreviewSnapshotBitmap?.Dispose();
|
||||||
|
_wallpaperPreviewSnapshotBitmap = null;
|
||||||
|
WallpaperPreviewVideoImage.Source = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CaptureVideoWallpaperPreviewSnapshotFromStagingBuffer()
|
||||||
|
{
|
||||||
|
if (!_wallpaperPreviewSnapshotPending ||
|
||||||
|
_desktopVideoStagingBuffer is null ||
|
||||||
|
_desktopVideoFrameWidth <= 0 ||
|
||||||
|
_desktopVideoFrameHeight <= 0 ||
|
||||||
|
_desktopVideoFramePitch <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_wallpaperPreviewSnapshotBitmap?.Dispose();
|
||||||
|
_wallpaperPreviewSnapshotBitmap = new WriteableBitmap(
|
||||||
|
new PixelSize(_desktopVideoFrameWidth, _desktopVideoFrameHeight),
|
||||||
|
new Vector(96, 96),
|
||||||
|
PixelFormat.Bgra8888,
|
||||||
|
AlphaFormat.Opaque);
|
||||||
|
|
||||||
|
using var framebuffer = _wallpaperPreviewSnapshotBitmap.Lock();
|
||||||
|
var rows = Math.Min(framebuffer.Size.Height, _desktopVideoFrameHeight);
|
||||||
|
var bytesPerRow = Math.Min(framebuffer.RowBytes, _desktopVideoFramePitch);
|
||||||
|
for (var row = 0; row < rows; row++)
|
||||||
|
{
|
||||||
|
var sourceOffset = row * _desktopVideoFramePitch;
|
||||||
|
var destinationPtr = IntPtr.Add(framebuffer.Address, row * framebuffer.RowBytes);
|
||||||
|
Marshal.Copy(_desktopVideoStagingBuffer, sourceOffset, destinationPtr, bytesPerRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
_wallpaperPreviewSnapshotPending = false;
|
||||||
|
WallpaperPreviewVideoImage.Source = _wallpaperPreviewSnapshotBitmap;
|
||||||
|
UpdateVideoWallpaperPreviewVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool ConfigureDesktopVideoRenderer()
|
private bool ConfigureDesktopVideoRenderer()
|
||||||
@@ -685,6 +774,7 @@ public partial class MainWindow
|
|||||||
(uint)_desktopVideoFrameHeight,
|
(uint)_desktopVideoFrameHeight,
|
||||||
(uint)_desktopVideoFramePitch);
|
(uint)_desktopVideoFramePitch);
|
||||||
DesktopVideoWallpaperImage.Source = _desktopVideoBitmap;
|
DesktopVideoWallpaperImage.Source = _desktopVideoBitmap;
|
||||||
|
InvalidateVideoWallpaperPreviewSnapshot();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -745,31 +835,6 @@ public partial class MainWindow
|
|||||||
private void OnDesktopVideoFrameDisplay(IntPtr opaque, IntPtr picture)
|
private void OnDesktopVideoFrameDisplay(IntPtr opaque, IntPtr picture)
|
||||||
{
|
{
|
||||||
Interlocked.Exchange(ref _desktopVideoFrameDirtyFlag, 1);
|
Interlocked.Exchange(ref _desktopVideoFrameDirtyFlag, 1);
|
||||||
ScheduleDesktopVideoFrameUiRefresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ScheduleDesktopVideoFrameUiRefresh()
|
|
||||||
{
|
|
||||||
if (Interlocked.Exchange(ref _desktopVideoFrameUiRefreshScheduledFlag, 1) == 1)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Dispatcher.UIThread.Post(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
PushDesktopVideoFrameToWallpaperImage();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
Interlocked.Exchange(ref _desktopVideoFrameUiRefreshScheduledFlag, 0);
|
|
||||||
if (Volatile.Read(ref _desktopVideoFrameDirtyFlag) == 1)
|
|
||||||
{
|
|
||||||
ScheduleDesktopVideoFrameUiRefresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, DispatcherPriority.Render);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PushDesktopVideoFrameToWallpaperImage()
|
private void PushDesktopVideoFrameToWallpaperImage()
|
||||||
@@ -812,18 +877,22 @@ public partial class MainWindow
|
|||||||
{
|
{
|
||||||
DesktopVideoWallpaperImage.Source = _desktopVideoBitmap;
|
DesktopVideoWallpaperImage.Source = _desktopVideoBitmap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CaptureVideoWallpaperPreviewSnapshotFromStagingBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ReleaseDesktopVideoRendererResources()
|
private void ReleaseDesktopVideoRendererResources()
|
||||||
{
|
{
|
||||||
Interlocked.Exchange(ref _desktopVideoFrameDirtyFlag, 0);
|
Interlocked.Exchange(ref _desktopVideoFrameDirtyFlag, 0);
|
||||||
Interlocked.Exchange(ref _desktopVideoFrameUiRefreshScheduledFlag, 0);
|
|
||||||
|
|
||||||
if (DesktopVideoWallpaperImage is not null)
|
if (DesktopVideoWallpaperImage is not null)
|
||||||
{
|
{
|
||||||
DesktopVideoWallpaperImage.Source = null;
|
DesktopVideoWallpaperImage.Source = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
InvalidateVideoWallpaperPreviewSnapshot();
|
||||||
|
WallpaperPreviewVideoImage.Source = null;
|
||||||
|
|
||||||
_desktopVideoBitmap?.Dispose();
|
_desktopVideoBitmap?.Dispose();
|
||||||
_desktopVideoBitmap = null;
|
_desktopVideoBitmap = null;
|
||||||
_desktopVideoStagingBuffer = null;
|
_desktopVideoStagingBuffer = null;
|
||||||
@@ -855,10 +924,8 @@ public partial class MainWindow
|
|||||||
{
|
{
|
||||||
EnsureVideoWallpaperPlayers();
|
EnsureVideoWallpaperPlayers();
|
||||||
if (_videoWallpaperPlayer is null ||
|
if (_videoWallpaperPlayer is null ||
|
||||||
_previewVideoWallpaperPlayer is null ||
|
|
||||||
_libVlc is null ||
|
_libVlc is null ||
|
||||||
DesktopVideoWallpaperImage is null ||
|
DesktopVideoWallpaperImage is null)
|
||||||
WallpaperPreviewVideoView is null)
|
|
||||||
{
|
{
|
||||||
_wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable.");
|
_wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable.");
|
||||||
StopVideoWallpaper();
|
StopVideoWallpaper();
|
||||||
@@ -873,15 +940,13 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
|
|
||||||
_videoWallpaperMedia?.Dispose();
|
_videoWallpaperMedia?.Dispose();
|
||||||
_previewVideoWallpaperMedia?.Dispose();
|
|
||||||
_videoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
|
_videoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
|
||||||
_previewVideoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
|
|
||||||
_videoWallpaperMedia.AddOption(":input-repeat=65535");
|
_videoWallpaperMedia.AddOption(":input-repeat=65535");
|
||||||
_previewVideoWallpaperMedia.AddOption(":input-repeat=65535");
|
InvalidateVideoWallpaperPreviewSnapshot();
|
||||||
_videoWallpaperPlayer.Play(_videoWallpaperMedia);
|
_videoWallpaperPlayer.Play(_videoWallpaperMedia);
|
||||||
_previewVideoWallpaperPlayer.Play(_previewVideoWallpaperMedia);
|
StartDesktopVideoFrameRefreshTimer();
|
||||||
DesktopVideoWallpaperImage.IsVisible = true;
|
DesktopVideoWallpaperImage.IsVisible = true;
|
||||||
WallpaperPreviewVideoView.IsVisible = true;
|
UpdateVideoWallpaperPreviewVisibility();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -897,26 +962,17 @@ public partial class MainWindow
|
|||||||
DesktopVideoWallpaperImage.IsVisible = false;
|
DesktopVideoWallpaperImage.IsVisible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (WallpaperPreviewVideoView is not null)
|
WallpaperPreviewVideoImage.IsVisible = false;
|
||||||
{
|
|
||||||
WallpaperPreviewVideoView.IsVisible = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_videoWallpaperPlayer is not null)
|
if (_videoWallpaperPlayer is not null)
|
||||||
{
|
{
|
||||||
_videoWallpaperPlayer.Stop();
|
_videoWallpaperPlayer.Stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_previewVideoWallpaperPlayer is not null)
|
StopDesktopVideoFrameRefreshTimer();
|
||||||
{
|
|
||||||
_previewVideoWallpaperPlayer.Stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
ReleaseDesktopVideoRendererResources();
|
ReleaseDesktopVideoRendererResources();
|
||||||
_videoWallpaperMedia?.Dispose();
|
_videoWallpaperMedia?.Dispose();
|
||||||
_videoWallpaperMedia = null;
|
_videoWallpaperMedia = null;
|
||||||
_previewVideoWallpaperMedia?.Dispose();
|
|
||||||
_previewVideoWallpaperMedia = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PersistSettings()
|
private void PersistSettings()
|
||||||
@@ -2379,7 +2435,14 @@ public partial class MainWindow
|
|||||||
_isSettingsOpen = true;
|
_isSettingsOpen = true;
|
||||||
UpdateDesktopPageAwareComponentContext();
|
UpdateDesktopPageAwareComponentContext();
|
||||||
UpdateAdaptiveTextSystem();
|
UpdateAdaptiveTextSystem();
|
||||||
ApplyWallpaperBrush();
|
if (_wallpaperMediaType == WallpaperMediaType.Video)
|
||||||
|
{
|
||||||
|
UpdateVideoWallpaperPreviewVisibility();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ApplyWallpaperBrush();
|
||||||
|
}
|
||||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||||
if (_settingsContentPanelTransform is not null)
|
if (_settingsContentPanelTransform is not null)
|
||||||
{
|
{
|
||||||
@@ -2387,6 +2450,7 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
SettingsPage.IsVisible = true;
|
SettingsPage.IsVisible = true;
|
||||||
SettingsPage.Opacity = 0;
|
SettingsPage.Opacity = 0;
|
||||||
|
UpdateVideoWallpaperPreviewVisibility();
|
||||||
UpdateSettingsViewportInsets(Math.Max(1, _currentDesktopCellSize));
|
UpdateSettingsViewportInsets(Math.Max(1, _currentDesktopCellSize));
|
||||||
|
|
||||||
UpdateWallpaperPreviewLayout();
|
UpdateWallpaperPreviewLayout();
|
||||||
@@ -2416,7 +2480,11 @@ public partial class MainWindow
|
|||||||
_isSettingsOpen = false;
|
_isSettingsOpen = false;
|
||||||
UpdateDesktopPageAwareComponentContext();
|
UpdateDesktopPageAwareComponentContext();
|
||||||
UpdateAdaptiveTextSystem();
|
UpdateAdaptiveTextSystem();
|
||||||
ApplyWallpaperBrush();
|
UpdateVideoWallpaperPreviewVisibility();
|
||||||
|
if (_wallpaperMediaType != WallpaperMediaType.Video)
|
||||||
|
{
|
||||||
|
ApplyWallpaperBrush();
|
||||||
|
}
|
||||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||||
|
|
||||||
if (immediate)
|
if (immediate)
|
||||||
@@ -2608,7 +2676,7 @@ public partial class MainWindow
|
|||||||
internal Border WallpaperPreviewHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewHost")!;
|
internal Border WallpaperPreviewHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewHost")!;
|
||||||
internal Border WallpaperPreviewFrame => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewFrame")!;
|
internal Border WallpaperPreviewFrame => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewFrame")!;
|
||||||
internal Border WallpaperPreviewViewport => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewViewport")!;
|
internal Border WallpaperPreviewViewport => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewViewport")!;
|
||||||
internal LibVLCSharp.Avalonia.VideoView? WallpaperPreviewVideoView => WallpaperSettingsPanel.FindControl<LibVLCSharp.Avalonia.VideoView>("WallpaperPreviewVideoView");
|
internal Image WallpaperPreviewVideoImage => WallpaperSettingsPanel.FindControl<Image>("WallpaperPreviewVideoImage")!;
|
||||||
internal Grid WallpaperPreviewGrid => WallpaperSettingsPanel.FindControl<Grid>("WallpaperPreviewGrid")!;
|
internal Grid WallpaperPreviewGrid => WallpaperSettingsPanel.FindControl<Grid>("WallpaperPreviewGrid")!;
|
||||||
internal Border WallpaperPreviewTopStatusBarHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewTopStatusBarHost")!;
|
internal Border WallpaperPreviewTopStatusBarHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewTopStatusBarHost")!;
|
||||||
internal StackPanel WallpaperPreviewTopStatusComponentsPanel => WallpaperSettingsPanel.FindControl<StackPanel>("WallpaperPreviewTopStatusComponentsPanel")!;
|
internal StackPanel WallpaperPreviewTopStatusComponentsPanel => WallpaperSettingsPanel.FindControl<StackPanel>("WallpaperPreviewTopStatusComponentsPanel")!;
|
||||||
|
|||||||
59
LanMountainDesktop/Views/MainWindow.SingleInstanceNotice.cs
Normal file
59
LanMountainDesktop/Views/MainWindow.SingleInstanceNotice.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Threading;
|
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Views;
|
namespace LanMountainDesktop.Views;
|
||||||
@@ -357,7 +358,8 @@ public partial class MainWindow
|
|||||||
{
|
{
|
||||||
FileName = installerPath,
|
FileName = installerPath,
|
||||||
WorkingDirectory = Path.GetDirectoryName(installerPath) ?? Environment.CurrentDirectory,
|
WorkingDirectory = Path.GetDirectoryName(installerPath) ?? Environment.CurrentDirectory,
|
||||||
UseShellExecute = true
|
UseShellExecute = true,
|
||||||
|
Verb = "runas"
|
||||||
});
|
});
|
||||||
|
|
||||||
_updateStatusText = L(
|
_updateStatusText = L(
|
||||||
@@ -365,7 +367,16 @@ public partial class MainWindow
|
|||||||
"Installer started. The app will close for update.");
|
"Installer started. The app will close for update.");
|
||||||
UpdateUpdatePanelState();
|
UpdateUpdatePanelState();
|
||||||
|
|
||||||
Dispatcher.UIThread.Post(Close, DispatcherPriority.Background);
|
_ = App.CurrentHostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest(
|
||||||
|
Source: nameof(MainWindow),
|
||||||
|
Reason: "Update installer started successfully."));
|
||||||
|
}
|
||||||
|
catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
|
||||||
|
{
|
||||||
|
_updateStatusText = L(
|
||||||
|
"settings.update.status_elevation_cancelled",
|
||||||
|
"Administrator permission was not granted. Update was cancelled.");
|
||||||
|
UpdateUpdatePanelState();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -124,21 +124,21 @@ public partial class MainWindow : Window
|
|||||||
private LibVLC? _libVlc;
|
private LibVLC? _libVlc;
|
||||||
private MediaPlayer? _videoWallpaperPlayer;
|
private MediaPlayer? _videoWallpaperPlayer;
|
||||||
private Media? _videoWallpaperMedia;
|
private Media? _videoWallpaperMedia;
|
||||||
private MediaPlayer? _previewVideoWallpaperPlayer;
|
|
||||||
private Media? _previewVideoWallpaperMedia;
|
|
||||||
private readonly object _desktopVideoFrameSync = new();
|
private readonly object _desktopVideoFrameSync = new();
|
||||||
private MediaPlayer.LibVLCVideoLockCb? _desktopVideoLockCallback;
|
private MediaPlayer.LibVLCVideoLockCb? _desktopVideoLockCallback;
|
||||||
private MediaPlayer.LibVLCVideoUnlockCb? _desktopVideoUnlockCallback;
|
private MediaPlayer.LibVLCVideoUnlockCb? _desktopVideoUnlockCallback;
|
||||||
private MediaPlayer.LibVLCVideoDisplayCb? _desktopVideoDisplayCallback;
|
private MediaPlayer.LibVLCVideoDisplayCb? _desktopVideoDisplayCallback;
|
||||||
|
private DispatcherTimer? _desktopVideoFrameRefreshTimer;
|
||||||
private IntPtr _desktopVideoFrameBufferPtr;
|
private IntPtr _desktopVideoFrameBufferPtr;
|
||||||
private byte[]? _desktopVideoStagingBuffer;
|
private byte[]? _desktopVideoStagingBuffer;
|
||||||
private WriteableBitmap? _desktopVideoBitmap;
|
private WriteableBitmap? _desktopVideoBitmap;
|
||||||
|
private WriteableBitmap? _wallpaperPreviewSnapshotBitmap;
|
||||||
private int _desktopVideoFrameWidth;
|
private int _desktopVideoFrameWidth;
|
||||||
private int _desktopVideoFrameHeight;
|
private int _desktopVideoFrameHeight;
|
||||||
private int _desktopVideoFramePitch;
|
private int _desktopVideoFramePitch;
|
||||||
private int _desktopVideoFrameBufferSize;
|
private int _desktopVideoFrameBufferSize;
|
||||||
private int _desktopVideoFrameDirtyFlag;
|
private int _desktopVideoFrameDirtyFlag;
|
||||||
private int _desktopVideoFrameUiRefreshScheduledFlag;
|
private bool _wallpaperPreviewSnapshotPending;
|
||||||
private string? _wallpaperPath;
|
private string? _wallpaperPath;
|
||||||
private string _wallpaperStatus = "Current background uses solid color.";
|
private string _wallpaperStatus = "Current background uses solid color.";
|
||||||
private IReadOnlyList<Color> _recommendedColors = Array.Empty<Color>();
|
private IReadOnlyList<Color> _recommendedColors = Array.Empty<Color>();
|
||||||
@@ -384,15 +384,15 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
PersistSettings();
|
PersistSettings();
|
||||||
StopVideoWallpaper();
|
StopVideoWallpaper();
|
||||||
_previewVideoWallpaperMedia?.Dispose();
|
|
||||||
_previewVideoWallpaperMedia = null;
|
|
||||||
_previewVideoWallpaperPlayer?.Dispose();
|
|
||||||
_previewVideoWallpaperPlayer = null;
|
|
||||||
DisposeLauncherResources();
|
DisposeLauncherResources();
|
||||||
_videoWallpaperMedia?.Dispose();
|
_videoWallpaperMedia?.Dispose();
|
||||||
_videoWallpaperMedia = null;
|
_videoWallpaperMedia = null;
|
||||||
_videoWallpaperPlayer?.Dispose();
|
_videoWallpaperPlayer?.Dispose();
|
||||||
_videoWallpaperPlayer = null;
|
_videoWallpaperPlayer = null;
|
||||||
|
_desktopVideoFrameRefreshTimer?.Stop();
|
||||||
|
_desktopVideoFrameRefreshTimer = null;
|
||||||
|
_wallpaperPreviewSnapshotBitmap?.Dispose();
|
||||||
|
_wallpaperPreviewSnapshotBitmap = null;
|
||||||
_libVlc?.Dispose();
|
_libVlc?.Dispose();
|
||||||
_libVlc = null;
|
_libVlc = null;
|
||||||
if (_weatherDataService is IDisposable weatherServiceDisposable)
|
if (_weatherDataService is IDisposable weatherServiceDisposable)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
xmlns:fi="using:FluentIcons.Avalonia"
|
xmlns:fi="using:FluentIcons.Avalonia"
|
||||||
xmlns:ic="using:FluentIcons.Avalonia.Fluent"
|
xmlns:ic="using:FluentIcons.Avalonia.Fluent"
|
||||||
xmlns:comp="using:LanMountainDesktop.Views.Components"
|
xmlns:comp="using:LanMountainDesktop.Views.Components"
|
||||||
xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
|
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="600"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="600"
|
||||||
x:Class="LanMountainDesktop.Views.SettingsPages.WallpaperSettingsPage">
|
x:Class="LanMountainDesktop.Views.SettingsPages.WallpaperSettingsPage">
|
||||||
<Grid x:Name="WallpaperSettingsPanel"
|
<Grid x:Name="WallpaperSettingsPanel"
|
||||||
@@ -37,11 +36,12 @@
|
|||||||
CornerRadius="12"
|
CornerRadius="12"
|
||||||
Background="#30111827">
|
Background="#30111827">
|
||||||
<Grid>
|
<Grid>
|
||||||
<vlc:VideoView x:Name="WallpaperPreviewVideoView"
|
<Image x:Name="WallpaperPreviewVideoImage"
|
||||||
IsVisible="False"
|
IsVisible="False"
|
||||||
IsHitTestVisible="False"
|
IsHitTestVisible="False"
|
||||||
HorizontalAlignment="Stretch"
|
Stretch="UniformToFill"
|
||||||
VerticalAlignment="Stretch" />
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch" />
|
||||||
|
|
||||||
<Grid x:Name="WallpaperPreviewGrid"
|
<Grid x:Name="WallpaperPreviewGrid"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
using LibVLCSharp.Avalonia;
|
|
||||||
namespace LanMountainDesktop.Views;
|
namespace LanMountainDesktop.Views;
|
||||||
|
|
||||||
public partial class SettingsWindow
|
public partial class SettingsWindow
|
||||||
@@ -14,7 +13,7 @@ public partial class SettingsWindow
|
|||||||
internal Border WallpaperPreviewHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewHost")!;
|
internal Border WallpaperPreviewHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewHost")!;
|
||||||
internal Border WallpaperPreviewFrame => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewFrame")!;
|
internal Border WallpaperPreviewFrame => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewFrame")!;
|
||||||
internal Border WallpaperPreviewViewport => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewViewport")!;
|
internal Border WallpaperPreviewViewport => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewViewport")!;
|
||||||
internal LibVLCSharp.Avalonia.VideoView? WallpaperPreviewVideoView => WallpaperSettingsPanel.FindControl<LibVLCSharp.Avalonia.VideoView>("WallpaperPreviewVideoView");
|
internal Image WallpaperPreviewVideoImage => WallpaperSettingsPanel.FindControl<Image>("WallpaperPreviewVideoImage")!;
|
||||||
internal Grid WallpaperPreviewGrid => WallpaperSettingsPanel.FindControl<Grid>("WallpaperPreviewGrid")!;
|
internal Grid WallpaperPreviewGrid => WallpaperSettingsPanel.FindControl<Grid>("WallpaperPreviewGrid")!;
|
||||||
internal Border WallpaperPreviewTopStatusBarHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewTopStatusBarHost")!;
|
internal Border WallpaperPreviewTopStatusBarHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewTopStatusBarHost")!;
|
||||||
internal StackPanel WallpaperPreviewTopStatusComponentsPanel => WallpaperSettingsPanel.FindControl<StackPanel>("WallpaperPreviewTopStatusComponentsPanel")!;
|
internal StackPanel WallpaperPreviewTopStatusComponentsPanel => WallpaperSettingsPanel.FindControl<StackPanel>("WallpaperPreviewTopStatusComponentsPanel")!;
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ public partial class SettingsWindow
|
|||||||
_previewVideoWallpaperPlayer = null;
|
_previewVideoWallpaperPlayer = null;
|
||||||
_previewVideoWallpaperMedia?.Dispose();
|
_previewVideoWallpaperMedia?.Dispose();
|
||||||
_previewVideoWallpaperMedia = null;
|
_previewVideoWallpaperMedia = null;
|
||||||
|
_previewVideoFrameRefreshTimer?.Stop();
|
||||||
|
_previewVideoFrameRefreshTimer = null;
|
||||||
_libVlc?.Dispose();
|
_libVlc?.Dispose();
|
||||||
_libVlc = null;
|
_libVlc = null;
|
||||||
|
|
||||||
@@ -254,6 +256,7 @@ public partial class SettingsWindow
|
|||||||
}
|
}
|
||||||
|
|
||||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||||
|
SyncVideoWallpaperPreviewPlayback();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PersistSettings()
|
private void PersistSettings()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Threading.Tasks;
|
|||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using FluentAvalonia.UI.Controls;
|
using FluentAvalonia.UI.Controls;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Views;
|
namespace LanMountainDesktop.Views;
|
||||||
@@ -77,7 +78,9 @@ public partial class SettingsWindow
|
|||||||
var result = await dialog.ShowAsync(this);
|
var result = await dialog.ShowAsync(this);
|
||||||
if (result == ContentDialogResult.Primary)
|
if (result == ContentDialogResult.Primary)
|
||||||
{
|
{
|
||||||
if (!AppRestartService.TryRestartApplication())
|
if (App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest(
|
||||||
|
Source: nameof(SettingsWindow),
|
||||||
|
Reason: "User confirmed a pending restart prompt from settings.")) != true)
|
||||||
{
|
{
|
||||||
UpdatePendingRestartDock();
|
UpdatePendingRestartDock();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Threading;
|
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Views;
|
namespace LanMountainDesktop.Views;
|
||||||
@@ -250,23 +249,23 @@ public partial class SettingsWindow
|
|||||||
{
|
{
|
||||||
FileName = installerPath,
|
FileName = installerPath,
|
||||||
WorkingDirectory = Path.GetDirectoryName(installerPath) ?? Environment.CurrentDirectory,
|
WorkingDirectory = Path.GetDirectoryName(installerPath) ?? Environment.CurrentDirectory,
|
||||||
UseShellExecute = true
|
UseShellExecute = true,
|
||||||
|
Verb = "runas"
|
||||||
});
|
});
|
||||||
|
|
||||||
_updateStatusText = L("settings.update.status_installer_started", "Installer started. The app will close for update.");
|
_updateStatusText = L("settings.update.status_installer_started", "Installer started. The app will close for update.");
|
||||||
UpdateUpdatePanelState();
|
UpdateUpdatePanelState();
|
||||||
|
|
||||||
Dispatcher.UIThread.Post(() =>
|
_ = App.CurrentHostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest(
|
||||||
{
|
Source: nameof(SettingsWindow),
|
||||||
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
Reason: "Update installer started successfully from settings."));
|
||||||
{
|
}
|
||||||
desktop.Shutdown();
|
catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
|
||||||
}
|
{
|
||||||
else
|
_updateStatusText = L(
|
||||||
{
|
"settings.update.status_elevation_cancelled",
|
||||||
Close();
|
"Administrator permission was not granted. Update was cancelled.");
|
||||||
}
|
UpdateUpdatePanelState();
|
||||||
}, DispatcherPriority.Background);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
@@ -12,6 +13,7 @@ using Avalonia.Media.Imaging;
|
|||||||
using Avalonia.Platform;
|
using Avalonia.Platform;
|
||||||
using Avalonia.Styling;
|
using Avalonia.Styling;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
|
using Avalonia.Threading;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.Theme;
|
using LanMountainDesktop.Theme;
|
||||||
using LibVLCSharp.Shared;
|
using LibVLCSharp.Shared;
|
||||||
@@ -157,7 +159,7 @@ public partial class SettingsWindow
|
|||||||
{
|
{
|
||||||
DesktopWallpaperLayer.Background = Brushes.Transparent;
|
DesktopWallpaperLayer.Background = Brushes.Transparent;
|
||||||
WallpaperPreviewViewport.Background = GetThemeDefaultDesktopBackground();
|
WallpaperPreviewViewport.Background = GetThemeDefaultDesktopBackground();
|
||||||
PlayVideoWallpaper(_wallpaperVideoPath);
|
SyncVideoWallpaperPreviewPlayback();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +177,36 @@ public partial class SettingsWindow
|
|||||||
WallpaperPreviewViewport.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, true);
|
WallpaperPreviewViewport.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SyncVideoWallpaperPreviewPlayback()
|
||||||
|
{
|
||||||
|
var shouldPlay =
|
||||||
|
_wallpaperMediaType == WallpaperMediaType.Video &&
|
||||||
|
!string.IsNullOrWhiteSpace(_wallpaperVideoPath) &&
|
||||||
|
WallpaperSettingsPanel.IsVisible;
|
||||||
|
|
||||||
|
if (!shouldPlay)
|
||||||
|
{
|
||||||
|
if (_previewVideoWallpaperPlayer?.IsPlaying == true)
|
||||||
|
{
|
||||||
|
StopPreviewVideoCapture(clearSnapshot: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
WallpaperPreviewVideoImage.IsVisible = WallpaperPreviewVideoImage.Source is not null && WallpaperSettingsPanel.IsVisible;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WallpaperPreviewVideoImage.Source is not null)
|
||||||
|
{
|
||||||
|
WallpaperPreviewVideoImage.IsVisible = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_previewVideoWallpaperMedia is null || _previewVideoSnapshotPending)
|
||||||
|
{
|
||||||
|
PlayVideoWallpaper(_wallpaperVideoPath!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void UpdateWallpaperDisplay()
|
private void UpdateWallpaperDisplay()
|
||||||
{
|
{
|
||||||
WallpaperPathTextBlock.Text = string.IsNullOrWhiteSpace(_wallpaperPath)
|
WallpaperPathTextBlock.Text = string.IsNullOrWhiteSpace(_wallpaperPath)
|
||||||
@@ -417,11 +449,239 @@ public partial class SettingsWindow
|
|||||||
private void EnsureVideoWallpaperPlayers()
|
private void EnsureVideoWallpaperPlayers()
|
||||||
{
|
{
|
||||||
Core.Initialize();
|
Core.Initialize();
|
||||||
_libVlc ??= new LibVLC();
|
_libVlc ??= new LibVLC("--quiet");
|
||||||
_previewVideoWallpaperPlayer ??= new MediaPlayer(_libVlc);
|
if (_previewVideoWallpaperPlayer is null)
|
||||||
if (WallpaperPreviewVideoView is not null)
|
|
||||||
{
|
{
|
||||||
WallpaperPreviewVideoView.MediaPlayer = _previewVideoWallpaperPlayer;
|
_previewVideoWallpaperPlayer = new MediaPlayer(_libVlc)
|
||||||
|
{
|
||||||
|
EnableHardwareDecoding = false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsurePreviewVideoFrameRefreshTimer()
|
||||||
|
{
|
||||||
|
if (_previewVideoFrameRefreshTimer is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_previewVideoFrameRefreshTimer = new DispatcherTimer
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromMilliseconds(33)
|
||||||
|
};
|
||||||
|
_previewVideoFrameRefreshTimer.Tick += OnPreviewVideoFrameRefreshTimerTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartPreviewVideoFrameRefreshTimer()
|
||||||
|
{
|
||||||
|
EnsurePreviewVideoFrameRefreshTimer();
|
||||||
|
if (_previewVideoFrameRefreshTimer?.IsEnabled == false)
|
||||||
|
{
|
||||||
|
_previewVideoFrameRefreshTimer.Start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopPreviewVideoFrameRefreshTimer()
|
||||||
|
{
|
||||||
|
if (_previewVideoFrameRefreshTimer?.IsEnabled == true)
|
||||||
|
{
|
||||||
|
_previewVideoFrameRefreshTimer.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPreviewVideoFrameRefreshTimerTick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
PushPreviewVideoFrameToWallpaperImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopPreviewVideoCapture(bool clearSnapshot)
|
||||||
|
{
|
||||||
|
WallpaperPreviewVideoImage.IsVisible = false;
|
||||||
|
_previewVideoWallpaperPlayer?.Stop();
|
||||||
|
StopPreviewVideoFrameRefreshTimer();
|
||||||
|
_previewVideoWallpaperMedia?.Dispose();
|
||||||
|
_previewVideoWallpaperMedia = null;
|
||||||
|
_previewVideoSnapshotPending = false;
|
||||||
|
|
||||||
|
if (clearSnapshot)
|
||||||
|
{
|
||||||
|
ReleasePreviewVideoRendererResources();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ConfigurePreviewVideoRenderer()
|
||||||
|
{
|
||||||
|
if (_previewVideoWallpaperPlayer is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hostWidth = Math.Max(1, WallpaperPreviewViewport.Bounds.Width);
|
||||||
|
var hostHeight = Math.Max(1, WallpaperPreviewViewport.Bounds.Height);
|
||||||
|
var pixelWidth = Math.Max(1, (int)Math.Round(hostWidth * RenderScaling));
|
||||||
|
var pixelHeight = Math.Max(1, (int)Math.Round(hostHeight * RenderScaling));
|
||||||
|
const int maxPixelCount = 1280 * 720;
|
||||||
|
var pixelCount = (long)pixelWidth * pixelHeight;
|
||||||
|
if (pixelCount > maxPixelCount)
|
||||||
|
{
|
||||||
|
var scale = Math.Sqrt((double)maxPixelCount / pixelCount);
|
||||||
|
pixelWidth = Math.Max(1, (int)Math.Round(pixelWidth * scale));
|
||||||
|
pixelHeight = Math.Max(1, (int)Math.Round(pixelHeight * scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
var pitch = pixelWidth * 4;
|
||||||
|
var bufferSize = pitch * pixelHeight;
|
||||||
|
if (bufferSize <= 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pixelWidth == _previewVideoFrameWidth &&
|
||||||
|
pixelHeight == _previewVideoFrameHeight &&
|
||||||
|
_previewVideoFrameBufferPtr != IntPtr.Zero &&
|
||||||
|
_previewVideoBitmap is not null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleasePreviewVideoRendererResources();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_previewVideoFrameWidth = pixelWidth;
|
||||||
|
_previewVideoFrameHeight = pixelHeight;
|
||||||
|
_previewVideoFramePitch = pitch;
|
||||||
|
_previewVideoFrameBufferSize = bufferSize;
|
||||||
|
_previewVideoFrameBufferPtr = Marshal.AllocHGlobal(_previewVideoFrameBufferSize);
|
||||||
|
_previewVideoStagingBuffer = new byte[_previewVideoFrameBufferSize];
|
||||||
|
_previewVideoBitmap = new WriteableBitmap(
|
||||||
|
new PixelSize(_previewVideoFrameWidth, _previewVideoFrameHeight),
|
||||||
|
new Vector(96, 96),
|
||||||
|
PixelFormat.Bgra8888,
|
||||||
|
AlphaFormat.Opaque);
|
||||||
|
EnsurePreviewVideoCallbacks();
|
||||||
|
_previewVideoWallpaperPlayer.SetVideoCallbacks(
|
||||||
|
_previewVideoLockCallback!,
|
||||||
|
_previewVideoUnlockCallback!,
|
||||||
|
_previewVideoDisplayCallback!);
|
||||||
|
_previewVideoWallpaperPlayer.SetVideoFormat(
|
||||||
|
"RV32",
|
||||||
|
(uint)_previewVideoFrameWidth,
|
||||||
|
(uint)_previewVideoFrameHeight,
|
||||||
|
(uint)_previewVideoFramePitch);
|
||||||
|
WallpaperPreviewVideoImage.Source = _previewVideoBitmap;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
ReleasePreviewVideoRendererResources();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsurePreviewVideoCallbacks()
|
||||||
|
{
|
||||||
|
_previewVideoLockCallback ??= OnPreviewVideoFrameLock;
|
||||||
|
_previewVideoUnlockCallback ??= OnPreviewVideoFrameUnlock;
|
||||||
|
_previewVideoDisplayCallback ??= OnPreviewVideoFrameDisplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IntPtr OnPreviewVideoFrameLock(IntPtr opaque, IntPtr planes)
|
||||||
|
{
|
||||||
|
Monitor.Enter(_previewVideoFrameSync);
|
||||||
|
if (_previewVideoFrameBufferPtr == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
Marshal.WriteIntPtr(planes, IntPtr.Zero);
|
||||||
|
Monitor.Exit(_previewVideoFrameSync);
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
Marshal.WriteIntPtr(planes, _previewVideoFrameBufferPtr);
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPreviewVideoFrameUnlock(IntPtr opaque, IntPtr picture, IntPtr planes)
|
||||||
|
{
|
||||||
|
if (Monitor.IsEntered(_previewVideoFrameSync))
|
||||||
|
{
|
||||||
|
Monitor.Exit(_previewVideoFrameSync);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPreviewVideoFrameDisplay(IntPtr opaque, IntPtr picture)
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _previewVideoFrameDirtyFlag, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PushPreviewVideoFrameToWallpaperImage()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _previewVideoFrameDirtyFlag, 0) == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_previewVideoBitmap is null ||
|
||||||
|
_previewVideoStagingBuffer is null ||
|
||||||
|
_previewVideoFrameBufferPtr == IntPtr.Zero ||
|
||||||
|
_previewVideoFrameBufferSize <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_previewVideoFrameSync)
|
||||||
|
{
|
||||||
|
if (_previewVideoFrameBufferPtr == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Marshal.Copy(_previewVideoFrameBufferPtr, _previewVideoStagingBuffer, 0, _previewVideoFrameBufferSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var framebuffer = _previewVideoBitmap.Lock();
|
||||||
|
var rows = Math.Min(framebuffer.Size.Height, _previewVideoFrameHeight);
|
||||||
|
var bytesPerRow = Math.Min(framebuffer.RowBytes, _previewVideoFramePitch);
|
||||||
|
for (var row = 0; row < rows; row++)
|
||||||
|
{
|
||||||
|
var sourceOffset = row * _previewVideoFramePitch;
|
||||||
|
var destinationPtr = IntPtr.Add(framebuffer.Address, row * framebuffer.RowBytes);
|
||||||
|
Marshal.Copy(_previewVideoStagingBuffer, sourceOffset, destinationPtr, bytesPerRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ReferenceEquals(WallpaperPreviewVideoImage.Source, _previewVideoBitmap))
|
||||||
|
{
|
||||||
|
WallpaperPreviewVideoImage.Source = _previewVideoBitmap;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_previewVideoSnapshotPending)
|
||||||
|
{
|
||||||
|
_previewVideoSnapshotPending = false;
|
||||||
|
WallpaperPreviewVideoImage.IsVisible = WallpaperSettingsPanel.IsVisible;
|
||||||
|
StopPreviewVideoCapture(clearSnapshot: false);
|
||||||
|
WallpaperPreviewVideoImage.IsVisible = WallpaperSettingsPanel.IsVisible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleasePreviewVideoRendererResources()
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _previewVideoFrameDirtyFlag, 0);
|
||||||
|
WallpaperPreviewVideoImage.Source = null;
|
||||||
|
_previewVideoBitmap?.Dispose();
|
||||||
|
_previewVideoBitmap = null;
|
||||||
|
_previewVideoStagingBuffer = null;
|
||||||
|
_previewVideoFrameWidth = 0;
|
||||||
|
_previewVideoFrameHeight = 0;
|
||||||
|
_previewVideoFramePitch = 0;
|
||||||
|
_previewVideoFrameBufferSize = 0;
|
||||||
|
|
||||||
|
lock (_previewVideoFrameSync)
|
||||||
|
{
|
||||||
|
if (_previewVideoFrameBufferPtr != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
Marshal.FreeHGlobal(_previewVideoFrameBufferPtr);
|
||||||
|
_previewVideoFrameBufferPtr = IntPtr.Zero;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,7 +697,14 @@ public partial class SettingsWindow
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
EnsureVideoWallpaperPlayers();
|
EnsureVideoWallpaperPlayers();
|
||||||
if (_previewVideoWallpaperPlayer is null || _libVlc is null || WallpaperPreviewVideoView is null)
|
if (_previewVideoWallpaperPlayer is null || _libVlc is null)
|
||||||
|
{
|
||||||
|
_wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable.");
|
||||||
|
StopVideoWallpaper();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ConfigurePreviewVideoRenderer())
|
||||||
{
|
{
|
||||||
_wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable.");
|
_wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable.");
|
||||||
StopVideoWallpaper();
|
StopVideoWallpaper();
|
||||||
@@ -447,8 +714,10 @@ public partial class SettingsWindow
|
|||||||
_previewVideoWallpaperMedia?.Dispose();
|
_previewVideoWallpaperMedia?.Dispose();
|
||||||
_previewVideoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
|
_previewVideoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
|
||||||
_previewVideoWallpaperMedia.AddOption(":input-repeat=65535");
|
_previewVideoWallpaperMedia.AddOption(":input-repeat=65535");
|
||||||
|
_previewVideoSnapshotPending = true;
|
||||||
|
WallpaperPreviewVideoImage.IsVisible = false;
|
||||||
_previewVideoWallpaperPlayer.Play(_previewVideoWallpaperMedia);
|
_previewVideoWallpaperPlayer.Play(_previewVideoWallpaperMedia);
|
||||||
WallpaperPreviewVideoView.IsVisible = true;
|
StartPreviewVideoFrameRefreshTimer();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -459,14 +728,7 @@ public partial class SettingsWindow
|
|||||||
|
|
||||||
private void StopVideoWallpaper()
|
private void StopVideoWallpaper()
|
||||||
{
|
{
|
||||||
if (WallpaperPreviewVideoView is not null)
|
StopPreviewVideoCapture(clearSnapshot: true);
|
||||||
{
|
|
||||||
WallpaperPreviewVideoView.IsVisible = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_previewVideoWallpaperPlayer?.Stop();
|
|
||||||
_previewVideoWallpaperMedia?.Dispose();
|
|
||||||
_previewVideoWallpaperMedia = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnRecommendedColorClick(object? sender, RoutedEventArgs e)
|
private void OnRecommendedColorClick(object? sender, RoutedEventArgs e)
|
||||||
|
|||||||
@@ -873,11 +873,22 @@ public partial class SettingsWindow
|
|||||||
|
|
||||||
var restoreButton = new Button
|
var restoreButton = new Button
|
||||||
{
|
{
|
||||||
Content = L("settings.launcher.restore_button", "Unhide"),
|
Width = 36,
|
||||||
MinWidth = 110,
|
Height = 36,
|
||||||
Padding = new Thickness(12, 6),
|
Padding = new Thickness(0),
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
BorderThickness = new Thickness(0),
|
||||||
Tag = new LauncherHiddenItemToken(hiddenItem.Kind, hiddenItem.Key)
|
Tag = new LauncherHiddenItemToken(hiddenItem.Kind, hiddenItem.Key)
|
||||||
};
|
};
|
||||||
|
restoreButton.Content = new FluentIcons.Avalonia.Fluent.SymbolIcon
|
||||||
|
{
|
||||||
|
Symbol = FluentIcons.Common.Symbol.Eye,
|
||||||
|
IconVariant = FluentIcons.Common.IconVariant.Regular,
|
||||||
|
FontSize = 18,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
ToolTip.SetTip(restoreButton, L("settings.launcher.restore_button", "Unhide"));
|
||||||
restoreButton.Click += OnRestoreLauncherHiddenItemClick;
|
restoreButton.Click += OnRestoreLauncherHiddenItemClick;
|
||||||
|
|
||||||
return new SettingsExpanderItem
|
return new SettingsExpanderItem
|
||||||
|
|||||||
@@ -129,6 +129,20 @@ public partial class SettingsWindow : Window
|
|||||||
private MediaPlayer? _previewVideoWallpaperPlayer;
|
private MediaPlayer? _previewVideoWallpaperPlayer;
|
||||||
private Media? _previewVideoWallpaperMedia;
|
private Media? _previewVideoWallpaperMedia;
|
||||||
private LibVLC? _libVlc;
|
private LibVLC? _libVlc;
|
||||||
|
private readonly object _previewVideoFrameSync = new();
|
||||||
|
private MediaPlayer.LibVLCVideoLockCb? _previewVideoLockCallback;
|
||||||
|
private MediaPlayer.LibVLCVideoUnlockCb? _previewVideoUnlockCallback;
|
||||||
|
private MediaPlayer.LibVLCVideoDisplayCb? _previewVideoDisplayCallback;
|
||||||
|
private DispatcherTimer? _previewVideoFrameRefreshTimer;
|
||||||
|
private IntPtr _previewVideoFrameBufferPtr;
|
||||||
|
private byte[]? _previewVideoStagingBuffer;
|
||||||
|
private WriteableBitmap? _previewVideoBitmap;
|
||||||
|
private int _previewVideoFrameWidth;
|
||||||
|
private int _previewVideoFrameHeight;
|
||||||
|
private int _previewVideoFramePitch;
|
||||||
|
private int _previewVideoFrameBufferSize;
|
||||||
|
private int _previewVideoFrameDirtyFlag;
|
||||||
|
private bool _previewVideoSnapshotPending;
|
||||||
private string? _wallpaperPath;
|
private string? _wallpaperPath;
|
||||||
private string _wallpaperStatus = "Current background uses solid color.";
|
private string _wallpaperStatus = "Current background uses solid color.";
|
||||||
private IReadOnlyList<Color> _recommendedColors = Array.Empty<Color>();
|
private IReadOnlyList<Color> _recommendedColors = Array.Empty<Color>();
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ OutputBaseFilename={#MyAppName}-Setup-{#MyAppVersion}-{#MyAppArch}
|
|||||||
Compression=lzma2/ultra64
|
Compression=lzma2/ultra64
|
||||||
SolidCompression=yes
|
SolidCompression=yes
|
||||||
WizardStyle=modern
|
WizardStyle=modern
|
||||||
|
; Leave PrivilegesRequiredOverridesAllowed unset so users cannot downgrade
|
||||||
|
; installation mode via dialog or /ALLUSERS /CURRENTUSER command-line switches.
|
||||||
PrivilegesRequired=admin
|
PrivilegesRequired=admin
|
||||||
CloseApplications=yes
|
CloseApplications=yes
|
||||||
CloseApplicationsFilter={#MyAppExeName}
|
CloseApplicationsFilter={#MyAppExeName}
|
||||||
@@ -113,7 +115,7 @@ Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
|||||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||||
|
|
||||||
[Registry]
|
[Registry]
|
||||||
Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "{#MyAppName}"; ValueData: """{app}\{#MyAppExeName}"""; Tasks: startup; Flags: uninsdeletevalue
|
Root: HKA; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "{#MyAppName}"; ValueData: """{app}\{#MyAppExeName}"""; Tasks: startup; Flags: uninsdeletevalue
|
||||||
|
|
||||||
[Run]
|
[Run]
|
||||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ namespace LanMountainDesktop.Views.SettingsPages;
|
|||||||
internal sealed class AirAppMarketInstallService : IDisposable
|
internal sealed class AirAppMarketInstallService : IDisposable
|
||||||
{
|
{
|
||||||
private readonly PluginRuntimeService _runtime;
|
private readonly PluginRuntimeService _runtime;
|
||||||
|
private readonly PluginsInstallHelperClient _helperClient = new();
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly ResumableDownloadService _downloadService;
|
private readonly ResumableDownloadService _downloadService;
|
||||||
private readonly AirAppMarketReleaseResolverService _releaseResolverService;
|
private readonly AirAppMarketReleaseResolverService _releaseResolverService;
|
||||||
@@ -77,9 +78,13 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await using var hashStream = File.OpenRead(downloadPath);
|
string actualHash;
|
||||||
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken);
|
await using (var hashStream = File.OpenRead(downloadPath))
|
||||||
var actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
|
{
|
||||||
|
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))
|
||||||
{
|
{
|
||||||
File.Delete(downloadPath);
|
File.Delete(downloadPath);
|
||||||
@@ -89,7 +94,28 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
|||||||
$"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}.");
|
$"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var manifest = _runtime.InstallPluginPackage(downloadPath);
|
PluginManifest manifest;
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
var helperResult = await _helperClient.InstallPackageAsync(
|
||||||
|
downloadPath,
|
||||||
|
_runtime.PluginsDirectory,
|
||||||
|
cancellationToken);
|
||||||
|
if (!helperResult.Success || string.IsNullOrWhiteSpace(helperResult.InstalledPackagePath))
|
||||||
|
{
|
||||||
|
return new AirAppMarketInstallResult(
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
helperResult.ErrorMessage ?? "Plugins install helper failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest = _runtime.RegisterInstalledPluginPackage(helperResult.InstalledPackagePath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
manifest = _runtime.InstallPluginPackage(downloadPath);
|
||||||
|
}
|
||||||
|
|
||||||
AppLogger.Info(
|
AppLogger.Info(
|
||||||
"PluginMarket",
|
"PluginMarket",
|
||||||
$"Install staged successfully. PluginId='{manifest.Id}'; InstalledName='{manifest.Name}'; PackagePath='{downloadPath}'.");
|
$"Install staged successfully. PluginId='{manifest.Id}'; InstalledName='{manifest.Name}'; PackagePath='{downloadPath}'.");
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
|
|
||||||
private readonly PluginLoader _loader;
|
private readonly PluginLoader _loader;
|
||||||
private readonly AppSettingsService _appSettingsService = new();
|
private readonly AppSettingsService _appSettingsService = new();
|
||||||
|
private readonly IHostApplicationLifecycle _applicationLifecycle = new HostApplicationLifecycleService();
|
||||||
private readonly IServiceProvider _hostServices;
|
private readonly IServiceProvider _hostServices;
|
||||||
private readonly IPluginPackageManager _packageManager;
|
private readonly IPluginPackageManager _packageManager;
|
||||||
private readonly List<LoadedPlugin> _loadedPlugins = [];
|
private readonly List<LoadedPlugin> _loadedPlugins = [];
|
||||||
@@ -34,7 +35,7 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
{
|
{
|
||||||
PluginsDirectory = Path.Combine(AppContext.BaseDirectory, "Extensions", "Plugins");
|
PluginsDirectory = Path.Combine(AppContext.BaseDirectory, "Extensions", "Plugins");
|
||||||
_packageManager = new PluginRuntimePackageManager(this);
|
_packageManager = new PluginRuntimePackageManager(this);
|
||||||
_hostServices = new PluginHostServiceProvider(_packageManager);
|
_hostServices = new PluginHostServiceProvider(_packageManager, _applicationLifecycle);
|
||||||
_loader = new PluginLoader(CreateOptions());
|
_loader = new PluginLoader(CreateOptions());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +197,14 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public PluginManifest RegisterInstalledPluginPackage(string packagePath)
|
||||||
|
{
|
||||||
|
lock (_packageMutationGate)
|
||||||
|
{
|
||||||
|
return RegisterInstalledPluginPackageCore(packagePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public bool DeleteInstalledPlugin(string pluginId)
|
public bool DeleteInstalledPlugin(string pluginId)
|
||||||
{
|
{
|
||||||
lock (_packageMutationGate)
|
lock (_packageMutationGate)
|
||||||
@@ -288,6 +297,25 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
return new PluginPackageInstallResult(manifest, replacedExisting, RestartRequired: true);
|
return new PluginPackageInstallResult(manifest, replacedExisting, RestartRequired: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private PluginManifest RegisterInstalledPluginPackageCore(string packagePath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
|
||||||
|
|
||||||
|
var fullPackagePath = Path.GetFullPath(packagePath);
|
||||||
|
if (!File.Exists(fullPackagePath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"Plugin package '{fullPackagePath}' was not found.", fullPackagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest = ReadManifestFromPackage(fullPackagePath);
|
||||||
|
AppLogger.Info(
|
||||||
|
"PluginRuntime",
|
||||||
|
$"Registering externally installed package. PluginId='{manifest.Id}'; Source='{fullPackagePath}'.");
|
||||||
|
UpdateCatalogAfterPackageInstall(manifest, fullPackagePath);
|
||||||
|
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
UnloadInstalledPlugins();
|
UnloadInstalledPlugins();
|
||||||
@@ -652,17 +680,29 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
private sealed class PluginHostServiceProvider : IServiceProvider
|
private sealed class PluginHostServiceProvider : IServiceProvider
|
||||||
{
|
{
|
||||||
private readonly IPluginPackageManager _packageManager;
|
private readonly IPluginPackageManager _packageManager;
|
||||||
|
private readonly IHostApplicationLifecycle _applicationLifecycle;
|
||||||
|
|
||||||
public PluginHostServiceProvider(IPluginPackageManager packageManager)
|
public PluginHostServiceProvider(
|
||||||
|
IPluginPackageManager packageManager,
|
||||||
|
IHostApplicationLifecycle applicationLifecycle)
|
||||||
{
|
{
|
||||||
_packageManager = packageManager;
|
_packageManager = packageManager;
|
||||||
|
_applicationLifecycle = applicationLifecycle;
|
||||||
}
|
}
|
||||||
|
|
||||||
public object? GetService(Type serviceType)
|
public object? GetService(Type serviceType)
|
||||||
{
|
{
|
||||||
return serviceType == typeof(IPluginPackageManager)
|
if (serviceType == typeof(IPluginPackageManager))
|
||||||
? _packageManager
|
{
|
||||||
: null;
|
return _packageManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serviceType == typeof(IHostApplicationLifecycle))
|
||||||
|
{
|
||||||
|
return _applicationLifecycle;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
- Windows 是当前主要目标平台。
|
- Windows 是当前主要目标平台。
|
||||||
- 已提供组件系统、插件系统、主题系统和设置系统。
|
- 已提供组件系统、插件系统、主题系统和设置系统。
|
||||||
- 中文为主语言,英文为附加扩展语言。
|
- 中文为主语言,英文为附加扩展语言。
|
||||||
|
- 仓库主入口解决方案文件已切换为 `LanMountainDesktop.slnx`,SDK 版本由根目录 `global.json` 锁定。
|
||||||
|
|
||||||
### 运行说明
|
### 运行说明
|
||||||
|
|
||||||
|
|||||||
6
global.json
Normal file
6
global.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "10.0.103",
|
||||||
|
"rollForward": "latestFeature"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
run.md
8
run.md
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user