mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33baaa579d | ||
|
|
20cd6041a7 | ||
|
|
65a3cf832a | ||
|
|
5d48a03f57 | ||
|
|
ea8ce1f5ff | ||
|
|
aeae4be060 | ||
|
|
915739ff7b | ||
|
|
cb86ca10e7 | ||
|
|
b3a74aa072 | ||
|
|
b436bfa884 | ||
|
|
081abeb688 | ||
|
|
594a62132f | ||
|
|
15e589aedd | ||
|
|
ac4617f5cf | ||
|
|
0645598753 |
16
.codex/environments/environment.toml
Normal file
16
.codex/environments/environment.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||||
|
version = 1
|
||||||
|
name = "LanMountainDesktop"
|
||||||
|
|
||||||
|
[setup]
|
||||||
|
script = ""
|
||||||
|
|
||||||
|
[[actions]]
|
||||||
|
name = "运行"
|
||||||
|
icon = "run"
|
||||||
|
command = "dotnet run --project 'C:\\Users\\USER693091\\Documents\\GitHub\\LanMountainDesktop\\LanMountainDesktop\\LanMountainDesktop.csproj"
|
||||||
|
|
||||||
|
[[actions]]
|
||||||
|
name = "构建"
|
||||||
|
icon = "tool"
|
||||||
|
command = "dotnet build 'C:\\Users\\USER693091\\Documents\\GitHub\\LanMountainDesktop\\LanMountainDesktop.slnx"
|
||||||
27
.github/workflows/airappmarket-validate.yml
vendored
27
.github/workflows/airappmarket-validate.yml
vendored
@@ -1,27 +0,0 @@
|
|||||||
name: AirAppMarket Validate
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- "airappmarket/**"
|
|
||||||
- ".github/workflows/airappmarket-validate.yml"
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "airappmarket/**"
|
|
||||||
- ".github/workflows/airappmarket-validate.yml"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
validate:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup .NET
|
|
||||||
uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: "10.0.x"
|
|
||||||
|
|
||||||
- name: Validate AirAppMarket index
|
|
||||||
run: dotnet run --project airappmarket/tools/AirAppMarket.Validator -- airappmarket/index.json airappmarket/schema/airappmarket-index.schema.json
|
|
||||||
8
Directory.Build.props
Normal file
8
Directory.Build.props
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
<TargetFramework Condition="'$(TargetFramework)' == ''">net10.0</TargetFramework>
|
||||||
|
<Nullable Condition="'$(Nullable)' == ''">enable</Nullable>
|
||||||
|
<ImplicitUsings Condition="'$(ImplicitUsings)' == ''">enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# LanAirApp (Mirror)
|
|
||||||
|
|
||||||
## 中文
|
|
||||||
|
|
||||||
这里的 `LanAirApp/` 是放在宿主仓库里的镜像副本,只用于本地联调和工作区构建,不是插件市场或插件开发资料的最终权威来源。
|
|
||||||
|
|
||||||
### 这份镜像的角色
|
|
||||||
|
|
||||||
- 提供本地工作区里的 `airappmarket` 索引副本
|
|
||||||
- 提供插件文档、工具和样例镜像,便于和宿主一起联调
|
|
||||||
- 不承担宿主运行时职责
|
|
||||||
|
|
||||||
### 权威来源
|
|
||||||
|
|
||||||
- 插件市场与开发文档:独立 `LanAirApp` 仓库
|
|
||||||
- 权威示例插件:独立 `LanMountainDesktop.SamplePlugin`
|
|
||||||
- 本目录中的 `samples/LanMountainDesktop.SamplePlugin` 只是镜像模板副本
|
|
||||||
|
|
||||||
## English
|
|
||||||
|
|
||||||
This `LanAirApp/` directory is a mirror that lives inside the host repository. It exists for local workspace integration and build convenience only. It is not the final authority for the plugin market or developer-facing plugin materials.
|
|
||||||
|
|
||||||
### Role of this mirror
|
|
||||||
|
|
||||||
- keep a local copy of the `airappmarket` index for workspace integration
|
|
||||||
- keep mirrored docs, tools, and sample templates for local development
|
|
||||||
- avoid duplicating host runtime responsibilities
|
|
||||||
|
|
||||||
### Sources of truth
|
|
||||||
|
|
||||||
- Plugin market and developer docs: standalone `LanAirApp`
|
|
||||||
- Authoritative sample plugin: standalone `LanMountainDesktop.SamplePlugin`
|
|
||||||
- `samples/LanMountainDesktop.SamplePlugin` in this mirror is template/mirror content only
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# 插件开发指南
|
|
||||||
|
|
||||||
## 中文
|
|
||||||
|
|
||||||
使用 `LanMountainDesktop.PluginSdk` 开发插件时,至少需要准备:
|
|
||||||
|
|
||||||
- `plugin.json`
|
|
||||||
- 插件入口程序集
|
|
||||||
- 入口类
|
|
||||||
- 本地化资源
|
|
||||||
|
|
||||||
推荐从示例插件开始,先完成清单、入口、设置页和桌面组件,再逐步扩展业务逻辑。
|
|
||||||
|
|
||||||
## English
|
|
||||||
|
|
||||||
To build a plugin with `LanMountainDesktop.PluginSdk`, prepare the manifest, plugin assembly, entrance class, and localization resources first.
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# 插件打包指南
|
|
||||||
|
|
||||||
## 中文
|
|
||||||
|
|
||||||
阑山桌面插件的标准安装格式为 `.laapp`。插件项目应在仓库根目录提供:
|
|
||||||
|
|
||||||
- `.laapp` 安装包
|
|
||||||
- `README.md`
|
|
||||||
|
|
||||||
官方市场索引只负责记录链接和校验信息。
|
|
||||||
|
|
||||||
## English
|
|
||||||
|
|
||||||
The standard package format is `.laapp`. Plugin repositories should keep the package and `README.md` in the repository root, while the official market index stores metadata and validation data.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<Version>1.0.0</Version>
|
|
||||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
|
||||||
<OutputPath>bin\$(Configuration)\$(TargetFramework)\content\</OutputPath>
|
|
||||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
|
||||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
|
||||||
<PluginPackageOutputDirectory>$(MSBuildThisFileDirectory)artifacts\Packages\</PluginPackageOutputDirectory>
|
|
||||||
<PluginPackagePath>$(PluginPackageOutputDirectory)$(AssemblyName).$(Version).laapp</PluginPackagePath>
|
|
||||||
<LegacyLoosePluginOutputDirectory>$(MSBuildThisFileDirectory)artifacts\Loose\</LegacyLoosePluginOutputDirectory>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\..\..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" Private="false" />
|
|
||||||
<None Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />
|
|
||||||
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<Target Name="CreateLaappPackage" AfterTargets="Build">
|
|
||||||
<MakeDir Directories="$(PluginPackageOutputDirectory)" />
|
|
||||||
<RemoveDir Directories="$(LegacyLoosePluginOutputDirectory)" />
|
|
||||||
<Delete Files="$(PluginPackagePath)" TreatErrorsAsWarnings="true" />
|
|
||||||
<ZipDirectory SourceDirectory="$(OutputPath)" DestinationFile="$(PluginPackagePath)" />
|
|
||||||
</Target>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
{
|
|
||||||
"settings.page_title": "Plugin Status",
|
|
||||||
"plugin.name": "LanMountain Sample Plugin",
|
|
||||||
"plugin.description": "Example plugin used to validate PluginSdk loading, services, communication, and localization.",
|
|
||||||
"widget.display_name": "Sample Plugin Status Clock",
|
|
||||||
"widget.category": "Plugins",
|
|
||||||
"settings.header.title": "Sample Plugin Capability Inspector",
|
|
||||||
"settings.section.info": "Plugin Info",
|
|
||||||
"settings.section.capabilities": "Accessible Capabilities",
|
|
||||||
"settings.section.status": "Live Runtime Status",
|
|
||||||
"settings.info.plugin_name": "Plugin Name",
|
|
||||||
"settings.info.plugin_id": "Plugin Id",
|
|
||||||
"settings.info.version": "Version",
|
|
||||||
"settings.info.author": "Author",
|
|
||||||
"settings.info.description": "Description",
|
|
||||||
"settings.info.plugin_directory": "Plugin Directory",
|
|
||||||
"settings.info.data_directory": "Data Directory",
|
|
||||||
"settings.info.host_application": "Host Application",
|
|
||||||
"settings.info.host_version": "Host Version",
|
|
||||||
"settings.info.sdk_api_version": "SDK API Version",
|
|
||||||
"settings.info.state_service_resolved": "State Service Resolved",
|
|
||||||
"settings.info.clock_service_resolved": "Clock Service Resolved",
|
|
||||||
"settings.info.message_bus_resolved": "Message Bus Resolved",
|
|
||||||
"settings.info.component_placed": "Component Placed",
|
|
||||||
"settings.info.placed_count": "Placed Count",
|
|
||||||
"settings.info.preview_count": "Preview Count",
|
|
||||||
"settings.info.placement_ids": "Placement Ids",
|
|
||||||
"settings.info.last_component_id": "Last Component Id",
|
|
||||||
"settings.info.last_cell_size": "Last Cell Size",
|
|
||||||
"settings.info.clock_service_time": "Clock Service Time",
|
|
||||||
"settings.status.updated_at": "Updated: {0}",
|
|
||||||
"status.frontend.title": "Frontend Status",
|
|
||||||
"status.component.title": "Component Status",
|
|
||||||
"status.backend.title": "Backend Status",
|
|
||||||
"status.service.title": "Clock Service",
|
|
||||||
"status.summary.pending": "Pending",
|
|
||||||
"status.summary.attached": "Attached",
|
|
||||||
"status.summary.healthy": "Healthy",
|
|
||||||
"status.summary.faulted": "Faulted",
|
|
||||||
"status.summary.placed": "Placed",
|
|
||||||
"status.summary.preview": "Preview",
|
|
||||||
"status.frontend.detail.pending": "Waiting for a plugin UI surface to connect.",
|
|
||||||
"status.frontend.detail.settings_connected": "Settings page is connected to plugin services and communication.",
|
|
||||||
"status.frontend.detail.widget_connected": "Widget surface is connected to plugin services and communication.",
|
|
||||||
"status.component.detail.pending": "No component instance has been created yet.",
|
|
||||||
"status.component.detail.none": "No component instance is active.",
|
|
||||||
"status.component.detail.preview": "Preview instances: {0}; no placed desktop instance is active yet.",
|
|
||||||
"status.component.detail.placed": "Placed count: {0}; preview count: {1}; placements: {2}",
|
|
||||||
"status.backend.detail.pending": "Plugin initialization is in progress.",
|
|
||||||
"status.backend.detail.log_written": "Initialization log written to: {0}",
|
|
||||||
"status.backend.detail.log_write_failed": "Initialization log write failed: {0}",
|
|
||||||
"status.service.detail.pending": "Clock service is not attached yet.",
|
|
||||||
"status.service.detail.attached": "Clock service was attached and is waiting for the first tick.",
|
|
||||||
"status.service.detail.running": "Clock service is running. Current service time: {0}",
|
|
||||||
"status.service.detail.write_failed": "Clock state write failed: {0}",
|
|
||||||
"capability.manifest.title": "IPluginContext.Manifest",
|
|
||||||
"capability.manifest.detail": "Readable. Current plugin id: {0}; version: {1}.",
|
|
||||||
"capability.directories.title": "IPluginContext.PluginDirectory / DataDirectory",
|
|
||||||
"capability.directories.detail": "Readable. Plugin directory: {0}; data directory: {1}.",
|
|
||||||
"capability.properties.title": "IPluginContext.Properties",
|
|
||||||
"capability.properties.detail": "Readable. Host properties currently exposed: {0}.",
|
|
||||||
"capability.get_service.title": "IPluginContext.GetService<T>()",
|
|
||||||
"capability.get_service.detail": "Callable. State service resolved: {0}; clock service resolved: {1}; message bus resolved: {2}.",
|
|
||||||
"capability.register_service.title": "IPluginContext.RegisterService<TService>()",
|
|
||||||
"capability.register_service.detail": "Callable during plugin initialization. This sample plugin registers SamplePluginRuntimeStateService and SamplePluginClockService into the plugin service container.",
|
|
||||||
"capability.message_bus.title": "Plugin Communication Bus",
|
|
||||||
"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.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.placement": "Placement {0} | placed: {1}",
|
|
||||||
"common.dev": "dev",
|
|
||||||
"common.none": "(none)",
|
|
||||||
"common.unknown": "(unknown)",
|
|
||||||
"common.true": "true",
|
|
||||||
"common.false": "false",
|
|
||||||
"common.yes": "Yes",
|
|
||||||
"common.no": "No"
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
{
|
|
||||||
"settings.page_title": "插件状态",
|
|
||||||
"plugin.name": "阑山示例插件",
|
|
||||||
"plugin.description": "用于验证 PluginSdk 加载、服务、通信与本地化能力的示例插件。",
|
|
||||||
"widget.display_name": "示例插件状态时钟",
|
|
||||||
"widget.category": "插件",
|
|
||||||
"settings.header.title": "示例插件能力检查器",
|
|
||||||
"settings.section.info": "插件信息",
|
|
||||||
"settings.section.capabilities": "可访问能力",
|
|
||||||
"settings.section.status": "实时运行状态",
|
|
||||||
"settings.info.plugin_name": "插件名称",
|
|
||||||
"settings.info.plugin_id": "插件 Id",
|
|
||||||
"settings.info.version": "版本",
|
|
||||||
"settings.info.author": "作者",
|
|
||||||
"settings.info.description": "描述",
|
|
||||||
"settings.info.plugin_directory": "插件目录",
|
|
||||||
"settings.info.data_directory": "数据目录",
|
|
||||||
"settings.info.host_application": "宿主应用",
|
|
||||||
"settings.info.host_version": "宿主版本",
|
|
||||||
"settings.info.sdk_api_version": "SDK API 版本",
|
|
||||||
"settings.info.state_service_resolved": "状态服务已解析",
|
|
||||||
"settings.info.clock_service_resolved": "时钟服务已解析",
|
|
||||||
"settings.info.message_bus_resolved": "消息总线已解析",
|
|
||||||
"settings.info.component_placed": "组件是否已放置",
|
|
||||||
"settings.info.placed_count": "已放置数量",
|
|
||||||
"settings.info.preview_count": "预览数量",
|
|
||||||
"settings.info.placement_ids": "放置位置 Id",
|
|
||||||
"settings.info.last_component_id": "最近组件 Id",
|
|
||||||
"settings.info.last_cell_size": "最近单元尺寸",
|
|
||||||
"settings.info.clock_service_time": "时钟服务时间",
|
|
||||||
"settings.status.updated_at": "更新时间:{0}",
|
|
||||||
"status.frontend.title": "前端状态",
|
|
||||||
"status.component.title": "组件状态",
|
|
||||||
"status.backend.title": "后端状态",
|
|
||||||
"status.service.title": "时钟服务",
|
|
||||||
"status.summary.pending": "等待中",
|
|
||||||
"status.summary.attached": "已挂接",
|
|
||||||
"status.summary.healthy": "正常",
|
|
||||||
"status.summary.faulted": "异常",
|
|
||||||
"status.summary.placed": "已放置",
|
|
||||||
"status.summary.preview": "预览中",
|
|
||||||
"status.frontend.detail.pending": "等待插件界面接入。",
|
|
||||||
"status.frontend.detail.settings_connected": "设置页已接入插件服务与通信。",
|
|
||||||
"status.frontend.detail.widget_connected": "组件界面已接入插件服务与通信。",
|
|
||||||
"status.component.detail.pending": "当前还没有创建组件实例。",
|
|
||||||
"status.component.detail.none": "当前没有活动中的组件实例。",
|
|
||||||
"status.component.detail.preview": "当前预览实例数量:{0};尚未有已放置的桌面实例。",
|
|
||||||
"status.component.detail.placed": "已放置数量:{0};预览数量:{1};放置位置:{2}",
|
|
||||||
"status.backend.detail.pending": "插件初始化进行中。",
|
|
||||||
"status.backend.detail.log_written": "初始化日志已写入:{0}",
|
|
||||||
"status.backend.detail.log_write_failed": "初始化日志写入失败:{0}",
|
|
||||||
"status.service.detail.pending": "时钟服务尚未挂接。",
|
|
||||||
"status.service.detail.attached": "时钟服务已挂接,正在等待第一次心跳。",
|
|
||||||
"status.service.detail.running": "时钟服务运行中,当前服务时间:{0}",
|
|
||||||
"status.service.detail.write_failed": "时钟状态写入失败:{0}",
|
|
||||||
"capability.manifest.title": "IPluginContext.Manifest",
|
|
||||||
"capability.manifest.detail": "可读取。当前插件 id:{0};版本:{1}。",
|
|
||||||
"capability.directories.title": "IPluginContext.PluginDirectory / DataDirectory",
|
|
||||||
"capability.directories.detail": "可读取。插件目录:{0};数据目录:{1}。",
|
|
||||||
"capability.properties.title": "IPluginContext.Properties",
|
|
||||||
"capability.properties.detail": "可读取。宿主当前暴露的属性:{0}。",
|
|
||||||
"capability.get_service.title": "IPluginContext.GetService<T>()",
|
|
||||||
"capability.get_service.detail": "可调用。状态服务已解析:{0};时钟服务已解析:{1};消息总线已解析:{2}。",
|
|
||||||
"capability.register_service.title": "IPluginContext.RegisterService<TService>()",
|
|
||||||
"capability.register_service.detail": "可在插件初始化阶段调用。这个示例插件会把 SamplePluginRuntimeStateService 和 SamplePluginClockService 注册进插件服务容器。",
|
|
||||||
"capability.message_bus.title": "插件通信总线",
|
|
||||||
"capability.message_bus.detail": "这个示例插件通过 IPluginMessageBus 向插件 UI 推送时钟心跳和状态变化通知。",
|
|
||||||
"capability.widget_context.title": "PluginDesktopComponentContext",
|
|
||||||
"capability.widget_context.detail": "组件可以读取 ComponentId、PlacementId、CellSize,并能在同一个插件服务容器上调用 GetService<T>()。",
|
|
||||||
"widget.subtitle.preview": "预览界面 | 已放置:{0}",
|
|
||||||
"widget.subtitle.placement": "位置 {0} | 已放置:{1}",
|
|
||||||
"common.dev": "开发版",
|
|
||||||
"common.none": "(无)",
|
|
||||||
"common.unknown": "(未知)",
|
|
||||||
"common.true": "是",
|
|
||||||
"common.false": "否",
|
|
||||||
"common.yes": "是",
|
|
||||||
"common.no": "否"
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# LanMountainDesktop.SamplePlugin
|
|
||||||
|
|
||||||
## 中文
|
|
||||||
|
|
||||||
这是阑山桌面的标准示例插件,用于演示插件清单、设置页、桌面组件、服务注册、本地化和 `.laapp` 打包流程。
|
|
||||||
|
|
||||||
## English
|
|
||||||
|
|
||||||
This is the standard sample plugin used to demonstrate manifests, settings pages, desktop components, service registration, localization, and `.laapp` packaging.
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
using LanMountainDesktop.PluginSdk;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.SamplePlugin;
|
|
||||||
|
|
||||||
[PluginEntrance]
|
|
||||||
public sealed class SamplePlugin : PluginBase, IDisposable
|
|
||||||
{
|
|
||||||
private SamplePluginRuntimeStateService? _stateService;
|
|
||||||
private SamplePluginClockService? _clockService;
|
|
||||||
|
|
||||||
public override void Initialize(IPluginContext context)
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(context.DataDirectory);
|
|
||||||
var localizer = PluginLocalizer.Create(context);
|
|
||||||
|
|
||||||
var hostName = GetHostProperty(context, PluginHostPropertyKeys.HostApplicationName, "UnknownHost");
|
|
||||||
var hostVersion = GetHostProperty(context, PluginHostPropertyKeys.HostVersion, "UnknownVersion");
|
|
||||||
var sdkApiVersion = GetHostProperty(context, PluginHostPropertyKeys.PluginSdkApiVersion, "UnknownApiVersion");
|
|
||||||
var messageBus = context.GetService<IPluginMessageBus>()
|
|
||||||
?? throw new InvalidOperationException("Plugin message bus is not available.");
|
|
||||||
|
|
||||||
_stateService = new SamplePluginRuntimeStateService(
|
|
||||||
context.Manifest,
|
|
||||||
context.PluginDirectory,
|
|
||||||
context.DataDirectory,
|
|
||||||
hostName,
|
|
||||||
hostVersion,
|
|
||||||
sdkApiVersion,
|
|
||||||
messageBus,
|
|
||||||
localizer);
|
|
||||||
context.RegisterService(_stateService);
|
|
||||||
|
|
||||||
_clockService = new SamplePluginClockService(context.DataDirectory, _stateService, messageBus, localizer);
|
|
||||||
context.RegisterService(_clockService);
|
|
||||||
_stateService.AttachClockService(_clockService);
|
|
||||||
|
|
||||||
var logPath = Path.Combine(context.DataDirectory, "sample-plugin.log");
|
|
||||||
var initMessage =
|
|
||||||
$"[{DateTimeOffset.UtcNow:O}] {context.Manifest.Name} initialized in {hostName} (plugin version {context.Manifest.Version ?? "dev"}).";
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
File.AppendAllText(logPath, initMessage + Environment.NewLine);
|
|
||||||
_stateService.MarkBackendReady(localizer.Format(
|
|
||||||
"status.backend.detail.log_written",
|
|
||||||
"Initialization log written: {0}",
|
|
||||||
logPath));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_stateService.MarkBackendFaulted(localizer.Format(
|
|
||||||
"status.backend.detail.log_write_failed",
|
|
||||||
"Initialization log failed: {0}",
|
|
||||||
ex.Message));
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
|
|
||||||
_clockService.Start();
|
|
||||||
|
|
||||||
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
|
|
||||||
"LanMountainDesktop.SamplePlugin.StatusClock",
|
|
||||||
localizer.GetString("widget.display_name", "Sample Plugin Status Clock"),
|
|
||||||
widgetContext => new SamplePluginStatusClockWidget(widgetContext),
|
|
||||||
iconKey: "PuzzlePiece",
|
|
||||||
category: localizer.GetString("widget.category", "Plugins"),
|
|
||||||
minWidthCells: 4,
|
|
||||||
minHeightCells: 4,
|
|
||||||
allowDesktopPlacement: true,
|
|
||||||
allowStatusBarPlacement: false,
|
|
||||||
resizeMode: PluginDesktopComponentResizeMode.Proportional,
|
|
||||||
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.34, 18, 34)));
|
|
||||||
|
|
||||||
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
|
|
||||||
"LanMountainDesktop.SamplePlugin.CloseDesktop",
|
|
||||||
localizer.GetString("widget.close_desktop.display_name", "Close Desktop"),
|
|
||||||
widgetContext => new SamplePluginCloseDesktopWidget(widgetContext),
|
|
||||||
iconKey: "DismissCircle",
|
|
||||||
category: localizer.GetString("widget.category", "Plugins"),
|
|
||||||
minWidthCells: 2,
|
|
||||||
minHeightCells: 1,
|
|
||||||
allowDesktopPlacement: true,
|
|
||||||
allowStatusBarPlacement: false,
|
|
||||||
resizeMode: PluginDesktopComponentResizeMode.Free,
|
|
||||||
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.28, 14, 22)));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_clockService?.Dispose();
|
|
||||||
_clockService = null;
|
|
||||||
_stateService = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetHostProperty(IPluginContext context, string key, string fallback)
|
|
||||||
{
|
|
||||||
return context.TryGetProperty<string>(key, out var value) && !string.IsNullOrWhiteSpace(value)
|
|
||||||
? value
|
|
||||||
: fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,524 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using LanMountainDesktop.PluginSdk;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.SamplePlugin;
|
|
||||||
|
|
||||||
internal enum SamplePluginHealthState
|
|
||||||
{
|
|
||||||
Healthy,
|
|
||||||
Pending,
|
|
||||||
Faulted
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed record SamplePluginStatusEntry(
|
|
||||||
string Key,
|
|
||||||
string Title,
|
|
||||||
SamplePluginHealthState State,
|
|
||||||
string Summary,
|
|
||||||
string Detail,
|
|
||||||
DateTimeOffset UpdatedAt);
|
|
||||||
|
|
||||||
internal sealed record SamplePluginCapabilityItem(
|
|
||||||
string Title,
|
|
||||||
string Detail);
|
|
||||||
|
|
||||||
internal sealed record SamplePluginRuntimeSnapshot(
|
|
||||||
PluginManifest Manifest,
|
|
||||||
string PluginDirectory,
|
|
||||||
string DataDirectory,
|
|
||||||
string HostApplicationName,
|
|
||||||
string HostVersion,
|
|
||||||
string SdkApiVersion,
|
|
||||||
IReadOnlyList<SamplePluginStatusEntry> StatusEntries,
|
|
||||||
bool HasPlacedComponent,
|
|
||||||
int PlacedCount,
|
|
||||||
int PreviewCount,
|
|
||||||
IReadOnlyList<string> PlacementIds,
|
|
||||||
string? LastComponentId,
|
|
||||||
double LastCellSize,
|
|
||||||
DateTimeOffset? ServiceClockTime);
|
|
||||||
|
|
||||||
internal sealed record SamplePluginClockTickMessage(DateTimeOffset CurrentTime);
|
|
||||||
|
|
||||||
internal sealed record SamplePluginStateChangedMessage(string Reason);
|
|
||||||
|
|
||||||
internal sealed record SamplePluginComponentInstance(
|
|
||||||
string ComponentId,
|
|
||||||
string? PlacementId,
|
|
||||||
double CellSize)
|
|
||||||
{
|
|
||||||
public bool IsPlaced => !string.IsNullOrWhiteSpace(PlacementId);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class SamplePluginRuntimeStateService
|
|
||||||
{
|
|
||||||
private readonly object _gate = new();
|
|
||||||
private readonly IPluginMessageBus _messageBus;
|
|
||||||
private readonly Dictionary<string, SamplePluginComponentInstance> _componentInstances =
|
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
private readonly PluginManifest _manifest;
|
|
||||||
private readonly string _pluginDirectory;
|
|
||||||
private readonly string _dataDirectory;
|
|
||||||
private readonly string _hostApplicationName;
|
|
||||||
private readonly string _hostVersion;
|
|
||||||
private readonly string _sdkApiVersion;
|
|
||||||
private readonly PluginLocalizer _localizer;
|
|
||||||
|
|
||||||
private SamplePluginStatusEntry _frontend;
|
|
||||||
private SamplePluginStatusEntry _component;
|
|
||||||
private SamplePluginStatusEntry _backend;
|
|
||||||
private SamplePluginStatusEntry _service;
|
|
||||||
private string? _lastComponentId;
|
|
||||||
private double _lastCellSize;
|
|
||||||
private DateTimeOffset? _serviceClockTime;
|
|
||||||
|
|
||||||
public SamplePluginRuntimeStateService(
|
|
||||||
PluginManifest manifest,
|
|
||||||
string pluginDirectory,
|
|
||||||
string dataDirectory,
|
|
||||||
string hostApplicationName,
|
|
||||||
string hostVersion,
|
|
||||||
string sdkApiVersion,
|
|
||||||
IPluginMessageBus messageBus,
|
|
||||||
PluginLocalizer localizer)
|
|
||||||
{
|
|
||||||
_manifest = manifest;
|
|
||||||
_pluginDirectory = pluginDirectory;
|
|
||||||
_dataDirectory = dataDirectory;
|
|
||||||
_hostApplicationName = hostApplicationName;
|
|
||||||
_hostVersion = hostVersion;
|
|
||||||
_sdkApiVersion = sdkApiVersion;
|
|
||||||
_messageBus = messageBus;
|
|
||||||
_localizer = localizer;
|
|
||||||
|
|
||||||
_frontend = CreateEntry(
|
|
||||||
"frontend",
|
|
||||||
T("status.frontend.title", "前端状态"),
|
|
||||||
SamplePluginHealthState.Pending,
|
|
||||||
T("status.summary.pending", "等待中"),
|
|
||||||
T("status.frontend.detail.pending", "等待插件界面接入。"));
|
|
||||||
|
|
||||||
_component = CreateEntry(
|
|
||||||
"component",
|
|
||||||
T("status.component.title", "组件状态"),
|
|
||||||
SamplePluginHealthState.Pending,
|
|
||||||
T("status.summary.pending", "等待中"),
|
|
||||||
T("status.component.detail.pending", "当前还没有创建组件实例。"));
|
|
||||||
|
|
||||||
_backend = CreateEntry(
|
|
||||||
"backend",
|
|
||||||
T("status.backend.title", "后端状态"),
|
|
||||||
SamplePluginHealthState.Pending,
|
|
||||||
T("status.summary.pending", "等待中"),
|
|
||||||
T("status.backend.detail.pending", "插件初始化进行中。"));
|
|
||||||
|
|
||||||
_service = CreateEntry(
|
|
||||||
"service",
|
|
||||||
T("status.service.title", "时钟服务"),
|
|
||||||
SamplePluginHealthState.Pending,
|
|
||||||
T("status.summary.pending", "等待中"),
|
|
||||||
T("status.service.detail.pending", "时钟服务尚未挂接。"));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void AttachClockService(SamplePluginClockService clockService)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(clockService);
|
|
||||||
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
_serviceClockTime = clockService.CurrentTime;
|
|
||||||
_service = CreateEntry(
|
|
||||||
"service",
|
|
||||||
T("status.service.title", "时钟服务"),
|
|
||||||
SamplePluginHealthState.Pending,
|
|
||||||
T("status.summary.attached", "已挂接"),
|
|
||||||
T("status.service.detail.attached", "时钟服务已挂接,正在等待第一次心跳。"));
|
|
||||||
}
|
|
||||||
|
|
||||||
PublishStateChanged("Clock service attached");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void MarkFrontendReady(string detail)
|
|
||||||
{
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
_frontend = CreateEntry(
|
|
||||||
"frontend",
|
|
||||||
T("status.frontend.title", "前端状态"),
|
|
||||||
SamplePluginHealthState.Healthy,
|
|
||||||
T("status.summary.healthy", "正常"),
|
|
||||||
detail);
|
|
||||||
}
|
|
||||||
|
|
||||||
PublishStateChanged("Frontend updated");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void MarkBackendReady(string detail)
|
|
||||||
{
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
_backend = CreateEntry(
|
|
||||||
"backend",
|
|
||||||
T("status.backend.title", "后端状态"),
|
|
||||||
SamplePluginHealthState.Healthy,
|
|
||||||
T("status.summary.healthy", "正常"),
|
|
||||||
detail);
|
|
||||||
}
|
|
||||||
|
|
||||||
PublishStateChanged("Backend updated");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void MarkBackendFaulted(string detail)
|
|
||||||
{
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
_backend = CreateEntry(
|
|
||||||
"backend",
|
|
||||||
T("status.backend.title", "后端状态"),
|
|
||||||
SamplePluginHealthState.Faulted,
|
|
||||||
T("status.summary.faulted", "异常"),
|
|
||||||
detail);
|
|
||||||
}
|
|
||||||
|
|
||||||
PublishStateChanged("Backend faulted");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void MarkClockServiceTick(DateTimeOffset currentTime)
|
|
||||||
{
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
_serviceClockTime = currentTime;
|
|
||||||
_service = CreateEntry(
|
|
||||||
"service",
|
|
||||||
T("status.service.title", "时钟服务"),
|
|
||||||
SamplePluginHealthState.Healthy,
|
|
||||||
T("status.summary.healthy", "正常"),
|
|
||||||
Tf(
|
|
||||||
"status.service.detail.running",
|
|
||||||
"时钟服务运行中,当前服务时间:{0}",
|
|
||||||
currentTime.LocalDateTime.ToString("HH:mm:ss")));
|
|
||||||
}
|
|
||||||
|
|
||||||
PublishStateChanged("Clock service tick");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void MarkClockServiceFaulted(string detail)
|
|
||||||
{
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
_service = CreateEntry(
|
|
||||||
"service",
|
|
||||||
T("status.service.title", "时钟服务"),
|
|
||||||
SamplePluginHealthState.Faulted,
|
|
||||||
T("status.summary.faulted", "异常"),
|
|
||||||
detail);
|
|
||||||
}
|
|
||||||
|
|
||||||
PublishStateChanged("Clock service faulted");
|
|
||||||
}
|
|
||||||
|
|
||||||
public string RegisterComponentInstance(string componentId, string? placementId, double cellSize)
|
|
||||||
{
|
|
||||||
var instanceId = Guid.NewGuid().ToString("N");
|
|
||||||
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
_componentInstances[instanceId] = new SamplePluginComponentInstance(componentId, placementId, cellSize);
|
|
||||||
_lastComponentId = componentId;
|
|
||||||
_lastCellSize = cellSize;
|
|
||||||
UpdateComponentStatusNoLock();
|
|
||||||
}
|
|
||||||
|
|
||||||
PublishStateChanged("Component attached");
|
|
||||||
return instanceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void UnregisterComponentInstance(string instanceId)
|
|
||||||
{
|
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(instanceId);
|
|
||||||
|
|
||||||
var removed = false;
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
removed = _componentInstances.Remove(instanceId);
|
|
||||||
if (removed)
|
|
||||||
{
|
|
||||||
UpdateComponentStatusNoLock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (removed)
|
|
||||||
{
|
|
||||||
PublishStateChanged("Component detached");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public SamplePluginRuntimeSnapshot GetSnapshot()
|
|
||||||
{
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
var placementIds = _componentInstances.Values
|
|
||||||
.Where(instance => instance.IsPlaced)
|
|
||||||
.Select(instance => instance.PlacementId!)
|
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
||||||
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
var previewCount = _componentInstances.Values.Count(instance => !instance.IsPlaced);
|
|
||||||
|
|
||||||
return new SamplePluginRuntimeSnapshot(
|
|
||||||
_manifest,
|
|
||||||
_pluginDirectory,
|
|
||||||
_dataDirectory,
|
|
||||||
_hostApplicationName,
|
|
||||||
_hostVersion,
|
|
||||||
_sdkApiVersion,
|
|
||||||
[_frontend, _component, _backend, _service],
|
|
||||||
placementIds.Length > 0,
|
|
||||||
placementIds.Length,
|
|
||||||
previewCount,
|
|
||||||
placementIds,
|
|
||||||
_lastComponentId,
|
|
||||||
_lastCellSize,
|
|
||||||
_serviceClockTime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public IReadOnlyList<SamplePluginCapabilityItem> GetCapabilities(
|
|
||||||
IPluginContext context,
|
|
||||||
bool hasStateService,
|
|
||||||
bool hasClockService,
|
|
||||||
bool hasMessageBus)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(context);
|
|
||||||
|
|
||||||
var propertyNames = context.Properties.Count == 0
|
|
||||||
? T("common.none", "(无)")
|
|
||||||
: string.Join(", ", context.Properties.Keys.OrderBy(key => key, StringComparer.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
return
|
|
||||||
[
|
|
||||||
new SamplePluginCapabilityItem(
|
|
||||||
T("capability.manifest.title", "IPluginContext.Manifest"),
|
|
||||||
Tf(
|
|
||||||
"capability.manifest.detail",
|
|
||||||
"可读取。当前插件 id:{0};版本:{1}。",
|
|
||||||
context.Manifest.Id,
|
|
||||||
context.Manifest.Version ?? T("common.dev", "开发版"))),
|
|
||||||
new SamplePluginCapabilityItem(
|
|
||||||
T("capability.directories.title", "IPluginContext.PluginDirectory / DataDirectory"),
|
|
||||||
Tf(
|
|
||||||
"capability.directories.detail",
|
|
||||||
"可读取。插件目录:{0};数据目录:{1}。",
|
|
||||||
context.PluginDirectory,
|
|
||||||
context.DataDirectory)),
|
|
||||||
new SamplePluginCapabilityItem(
|
|
||||||
T("capability.properties.title", "IPluginContext.Properties"),
|
|
||||||
Tf(
|
|
||||||
"capability.properties.detail",
|
|
||||||
"可读取。宿主当前暴露的属性:{0}。",
|
|
||||||
propertyNames)),
|
|
||||||
new SamplePluginCapabilityItem(
|
|
||||||
T("capability.get_service.title", "IPluginContext.GetService<T>()"),
|
|
||||||
Tf(
|
|
||||||
"capability.get_service.detail",
|
|
||||||
"可调用。状态服务已解析:{0};时钟服务已解析:{1};消息总线已解析:{2}。",
|
|
||||||
FormatBoolean(hasStateService),
|
|
||||||
FormatBoolean(hasClockService),
|
|
||||||
FormatBoolean(hasMessageBus))),
|
|
||||||
new SamplePluginCapabilityItem(
|
|
||||||
T("capability.register_service.title", "IPluginContext.RegisterService<TService>()"),
|
|
||||||
T(
|
|
||||||
"capability.register_service.detail",
|
|
||||||
"可在插件初始化阶段调用。这个示例插件会把 SamplePluginRuntimeStateService 和 SamplePluginClockService 注册进插件服务容器。")),
|
|
||||||
new SamplePluginCapabilityItem(
|
|
||||||
T("capability.message_bus.title", "插件通信总线"),
|
|
||||||
T(
|
|
||||||
"capability.message_bus.detail",
|
|
||||||
"这个示例插件通过 IPluginMessageBus 向插件 UI 推送时钟心跳和状态变化通知。")),
|
|
||||||
new SamplePluginCapabilityItem(
|
|
||||||
T("capability.widget_context.title", "PluginDesktopComponentContext"),
|
|
||||||
T(
|
|
||||||
"capability.widget_context.detail",
|
|
||||||
"组件可以读取 ComponentId、PlacementId、CellSize,并能在同一个插件服务容器上调用 GetService<T>()。"))
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateComponentStatusNoLock()
|
|
||||||
{
|
|
||||||
var placementIds = _componentInstances.Values
|
|
||||||
.Where(instance => instance.IsPlaced)
|
|
||||||
.Select(instance => instance.PlacementId!)
|
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
||||||
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
var previewCount = _componentInstances.Values.Count(instance => !instance.IsPlaced);
|
|
||||||
|
|
||||||
if (placementIds.Length > 0)
|
|
||||||
{
|
|
||||||
_component = CreateEntry(
|
|
||||||
"component",
|
|
||||||
T("status.component.title", "组件状态"),
|
|
||||||
SamplePluginHealthState.Healthy,
|
|
||||||
T("status.summary.placed", "已放置"),
|
|
||||||
Tf(
|
|
||||||
"status.component.detail.placed",
|
|
||||||
"已放置数量:{0};预览数量:{1};放置位置:{2}",
|
|
||||||
placementIds.Length,
|
|
||||||
previewCount,
|
|
||||||
string.Join(", ", placementIds)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previewCount > 0)
|
|
||||||
{
|
|
||||||
_component = CreateEntry(
|
|
||||||
"component",
|
|
||||||
T("status.component.title", "组件状态"),
|
|
||||||
SamplePluginHealthState.Healthy,
|
|
||||||
T("status.summary.preview", "预览中"),
|
|
||||||
Tf(
|
|
||||||
"status.component.detail.preview",
|
|
||||||
"当前预览实例数量:{0};尚未有已放置的桌面实例。",
|
|
||||||
previewCount));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_component = CreateEntry(
|
|
||||||
"component",
|
|
||||||
T("status.component.title", "组件状态"),
|
|
||||||
SamplePluginHealthState.Pending,
|
|
||||||
T("status.summary.pending", "等待中"),
|
|
||||||
T("status.component.detail.none", "当前没有活动中的组件实例。"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void PublishStateChanged(string reason)
|
|
||||||
{
|
|
||||||
_messageBus.Publish(new SamplePluginStateChangedMessage(reason));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static SamplePluginStatusEntry CreateEntry(
|
|
||||||
string key,
|
|
||||||
string title,
|
|
||||||
SamplePluginHealthState state,
|
|
||||||
string summary,
|
|
||||||
string detail)
|
|
||||||
{
|
|
||||||
return new SamplePluginStatusEntry(
|
|
||||||
key,
|
|
||||||
title,
|
|
||||||
state,
|
|
||||||
summary,
|
|
||||||
detail,
|
|
||||||
DateTimeOffset.Now);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string T(string key, string fallback)
|
|
||||||
{
|
|
||||||
return _localizer.GetString(key, fallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string Tf(string key, string fallback, params object[] args)
|
|
||||||
{
|
|
||||||
return _localizer.Format(key, fallback, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatBoolean(bool value)
|
|
||||||
{
|
|
||||||
return value
|
|
||||||
? T("common.true", "是")
|
|
||||||
: T("common.false", "否");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class SamplePluginClockService : IDisposable
|
|
||||||
{
|
|
||||||
private readonly object _gate = new();
|
|
||||||
private readonly string _clockStateFilePath;
|
|
||||||
private readonly SamplePluginRuntimeStateService _stateService;
|
|
||||||
private readonly IPluginMessageBus _messageBus;
|
|
||||||
private readonly PluginLocalizer _localizer;
|
|
||||||
private readonly Timer _timer;
|
|
||||||
private DateTimeOffset _currentTime = DateTimeOffset.Now;
|
|
||||||
private int _disposed;
|
|
||||||
|
|
||||||
public SamplePluginClockService(
|
|
||||||
string dataDirectory,
|
|
||||||
SamplePluginRuntimeStateService stateService,
|
|
||||||
IPluginMessageBus messageBus,
|
|
||||||
PluginLocalizer localizer)
|
|
||||||
{
|
|
||||||
_clockStateFilePath = Path.Combine(dataDirectory, "clock-service.txt");
|
|
||||||
_stateService = stateService;
|
|
||||||
_messageBus = messageBus;
|
|
||||||
_localizer = localizer;
|
|
||||||
_timer = new Timer(OnTimerTick);
|
|
||||||
}
|
|
||||||
|
|
||||||
public DateTimeOffset CurrentTime
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
return _currentTime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Start()
|
|
||||||
{
|
|
||||||
PublishTick();
|
|
||||||
_timer.Change(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_timer.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnTimerTick(object? state)
|
|
||||||
{
|
|
||||||
PublishTick();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void PublishTick()
|
|
||||||
{
|
|
||||||
if (Volatile.Read(ref _disposed) != 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var now = DateTimeOffset.Now;
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
_currentTime = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
File.WriteAllText(
|
|
||||||
_clockStateFilePath,
|
|
||||||
now.ToString("O", CultureInfo.InvariantCulture));
|
|
||||||
_stateService.MarkClockServiceTick(now);
|
|
||||||
_messageBus.Publish(new SamplePluginClockTickMessage(now));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_stateService.MarkClockServiceFaulted(_localizer.Format(
|
|
||||||
"status.service.detail.write_failed",
|
|
||||||
"时钟状态写入失败:{0}",
|
|
||||||
ex.Message));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,374 +0,0 @@
|
|||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Layout;
|
|
||||||
using Avalonia.Media;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using LanMountainDesktop.PluginSdk;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.SamplePlugin;
|
|
||||||
|
|
||||||
internal sealed class SamplePluginSettingsView : UserControl
|
|
||||||
{
|
|
||||||
private readonly IPluginContext _context;
|
|
||||||
private readonly PluginLocalizer _localizer;
|
|
||||||
private readonly SamplePluginRuntimeStateService _stateService;
|
|
||||||
private readonly SamplePluginClockService _clockService;
|
|
||||||
private readonly IPluginMessageBus _messageBus;
|
|
||||||
private readonly StackPanel _pluginInfoPanel = new() { Spacing = 8 };
|
|
||||||
private readonly StackPanel _capabilityPanel = new() { Spacing = 8 };
|
|
||||||
private readonly StackPanel _statusPanel = new() { Spacing = 10 };
|
|
||||||
private readonly List<IDisposable> _subscriptions = [];
|
|
||||||
|
|
||||||
public SamplePluginSettingsView(IPluginContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
_localizer = PluginLocalizer.Create(context);
|
|
||||||
_stateService = context.GetService<SamplePluginRuntimeStateService>()
|
|
||||||
?? throw new InvalidOperationException("SamplePluginRuntimeStateService is not available.");
|
|
||||||
_clockService = context.GetService<SamplePluginClockService>()
|
|
||||||
?? throw new InvalidOperationException("SamplePluginClockService is not available.");
|
|
||||||
_messageBus = context.GetService<IPluginMessageBus>()
|
|
||||||
?? throw new InvalidOperationException("IPluginMessageBus is not available.");
|
|
||||||
|
|
||||||
_stateService.MarkFrontendReady(T(
|
|
||||||
"status.frontend.detail.settings_connected",
|
|
||||||
"设置页已接入插件服务与通信。"));
|
|
||||||
|
|
||||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
|
||||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
|
||||||
|
|
||||||
Content = new Border
|
|
||||||
{
|
|
||||||
Background = new LinearGradientBrush
|
|
||||||
{
|
|
||||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
|
||||||
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
|
||||||
GradientStops =
|
|
||||||
[
|
|
||||||
new GradientStop(Color.Parse("#1F0B1120"), 0),
|
|
||||||
new GradientStop(Color.Parse("#260C4A6E"), 1)
|
|
||||||
]
|
|
||||||
},
|
|
||||||
BorderBrush = new SolidColorBrush(Color.Parse("#6628B2FF")),
|
|
||||||
BorderThickness = new Thickness(1),
|
|
||||||
CornerRadius = new CornerRadius(18),
|
|
||||||
Padding = new Thickness(18),
|
|
||||||
Child = new StackPanel
|
|
||||||
{
|
|
||||||
Spacing = 14,
|
|
||||||
Children =
|
|
||||||
{
|
|
||||||
new TextBlock
|
|
||||||
{
|
|
||||||
Text = T("settings.header.title", "示例插件能力检查器"),
|
|
||||||
FontSize = 22,
|
|
||||||
FontWeight = FontWeight.SemiBold,
|
|
||||||
Foreground = Brushes.White
|
|
||||||
},
|
|
||||||
CreateSection(T("settings.section.info", "插件信息"), _pluginInfoPanel),
|
|
||||||
CreateSection(T("settings.section.capabilities", "可访问能力"), _capabilityPanel),
|
|
||||||
CreateSection(T("settings.section.status", "实时运行状态"), _statusPanel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
RefreshView();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
|
||||||
{
|
|
||||||
SubscribeToPluginBus();
|
|
||||||
RefreshView();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
|
||||||
{
|
|
||||||
foreach (var subscription in _subscriptions)
|
|
||||||
{
|
|
||||||
subscription.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
_subscriptions.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SubscribeToPluginBus()
|
|
||||||
{
|
|
||||||
if (_subscriptions.Count > 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_subscriptions.Add(_messageBus.Subscribe<SamplePluginClockTickMessage>(_ =>
|
|
||||||
Dispatcher.UIThread.Post(RefreshView)));
|
|
||||||
|
|
||||||
_subscriptions.Add(_messageBus.Subscribe<SamplePluginStateChangedMessage>(_ =>
|
|
||||||
Dispatcher.UIThread.Post(RefreshView)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RefreshView()
|
|
||||||
{
|
|
||||||
var snapshot = _stateService.GetSnapshot();
|
|
||||||
RefreshPluginInfo(snapshot);
|
|
||||||
RefreshCapabilities();
|
|
||||||
RefreshStatuses(snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RefreshPluginInfo(SamplePluginRuntimeSnapshot snapshot)
|
|
||||||
{
|
|
||||||
_pluginInfoPanel.Children.Clear();
|
|
||||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
|
||||||
T("settings.info.plugin_name", "插件名称"),
|
|
||||||
T("plugin.name", snapshot.Manifest.Name)));
|
|
||||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.plugin_id", "插件 Id"), snapshot.Manifest.Id));
|
|
||||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.version", "版本"), snapshot.Manifest.Version ?? T("common.dev", "开发版")));
|
|
||||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.author", "作者"), snapshot.Manifest.Author ?? T("common.none", "(无)")));
|
|
||||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
|
||||||
T("settings.info.description", "描述"),
|
|
||||||
T("plugin.description", snapshot.Manifest.Description ?? T("common.none", "(无)"))));
|
|
||||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.plugin_directory", "插件目录"), snapshot.PluginDirectory));
|
|
||||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.data_directory", "数据目录"), snapshot.DataDirectory));
|
|
||||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.host_application", "宿主应用"), snapshot.HostApplicationName));
|
|
||||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.host_version", "宿主版本"), snapshot.HostVersion));
|
|
||||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.sdk_api_version", "SDK API 版本"), snapshot.SdkApiVersion));
|
|
||||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
|
||||||
T("settings.info.state_service_resolved", "状态服务已解析"),
|
|
||||||
FormatBoolean(_context.GetService<SamplePluginRuntimeStateService>() is not null)));
|
|
||||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
|
||||||
T("settings.info.clock_service_resolved", "时钟服务已解析"),
|
|
||||||
FormatBoolean(_context.GetService<SamplePluginClockService>() is not null)));
|
|
||||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
|
||||||
T("settings.info.message_bus_resolved", "消息总线已解析"),
|
|
||||||
FormatBoolean(_context.GetService<IPluginMessageBus>() is not null)));
|
|
||||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
|
||||||
T("settings.info.component_placed", "组件是否已放置"),
|
|
||||||
snapshot.HasPlacedComponent ? T("common.yes", "是") : T("common.no", "否")));
|
|
||||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.placed_count", "已放置数量"), snapshot.PlacedCount.ToString()));
|
|
||||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.preview_count", "预览数量"), snapshot.PreviewCount.ToString()));
|
|
||||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
|
||||||
T("settings.info.placement_ids", "放置位置 Id"),
|
|
||||||
snapshot.PlacementIds.Count == 0 ? T("common.none", "(无)") : string.Join(", ", snapshot.PlacementIds)));
|
|
||||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
|
||||||
T("settings.info.last_component_id", "最近组件 Id"),
|
|
||||||
snapshot.LastComponentId ?? T("common.none", "(无)")));
|
|
||||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
|
||||||
T("settings.info.last_cell_size", "最近单元尺寸"),
|
|
||||||
snapshot.LastCellSize > 0 ? $"{snapshot.LastCellSize:F0}px" : T("common.unknown", "(未知)")));
|
|
||||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
|
||||||
T("settings.info.clock_service_time", "时钟服务时间"),
|
|
||||||
_clockService.CurrentTime.LocalDateTime.ToString("HH:mm:ss")));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RefreshCapabilities()
|
|
||||||
{
|
|
||||||
var capabilities = _stateService.GetCapabilities(
|
|
||||||
_context,
|
|
||||||
_context.GetService<SamplePluginRuntimeStateService>() is not null,
|
|
||||||
_context.GetService<SamplePluginClockService>() is not null,
|
|
||||||
_context.GetService<IPluginMessageBus>() is not null);
|
|
||||||
|
|
||||||
_capabilityPanel.Children.Clear();
|
|
||||||
foreach (var capability in capabilities)
|
|
||||||
{
|
|
||||||
_capabilityPanel.Children.Add(CreateCapabilityCard(capability));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RefreshStatuses(SamplePluginRuntimeSnapshot snapshot)
|
|
||||||
{
|
|
||||||
_statusPanel.Children.Clear();
|
|
||||||
|
|
||||||
foreach (var entry in snapshot.StatusEntries)
|
|
||||||
{
|
|
||||||
var palette = GetPalette(entry.State);
|
|
||||||
_statusPanel.Children.Add(new Border
|
|
||||||
{
|
|
||||||
Background = new SolidColorBrush(palette.Background),
|
|
||||||
BorderBrush = new SolidColorBrush(palette.Border),
|
|
||||||
BorderThickness = new Thickness(1),
|
|
||||||
CornerRadius = new CornerRadius(12),
|
|
||||||
Padding = new Thickness(12, 10),
|
|
||||||
Child = new StackPanel
|
|
||||||
{
|
|
||||||
Spacing = 4,
|
|
||||||
Children =
|
|
||||||
{
|
|
||||||
CreateStatusHeader(entry, palette),
|
|
||||||
new TextBlock
|
|
||||||
{
|
|
||||||
Text = entry.Detail,
|
|
||||||
Foreground = new SolidColorBrush(Color.Parse("#FFE0F2FE")),
|
|
||||||
TextWrapping = TextWrapping.Wrap
|
|
||||||
},
|
|
||||||
new TextBlock
|
|
||||||
{
|
|
||||||
Text = Tf("settings.status.updated_at", "更新时间:{0}", entry.UpdatedAt.LocalDateTime.ToString("HH:mm:ss")),
|
|
||||||
Foreground = new SolidColorBrush(Color.Parse("#FF93C5FD"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Border CreateSection(string title, Control content)
|
|
||||||
{
|
|
||||||
return new Border
|
|
||||||
{
|
|
||||||
Background = new SolidColorBrush(Color.Parse("#14000000")),
|
|
||||||
BorderBrush = new SolidColorBrush(Color.Parse("#3328B2FF")),
|
|
||||||
BorderThickness = new Thickness(1),
|
|
||||||
CornerRadius = new CornerRadius(14),
|
|
||||||
Padding = new Thickness(14),
|
|
||||||
Child = new StackPanel
|
|
||||||
{
|
|
||||||
Spacing = 12,
|
|
||||||
Children =
|
|
||||||
{
|
|
||||||
new TextBlock
|
|
||||||
{
|
|
||||||
Text = title,
|
|
||||||
FontSize = 16,
|
|
||||||
FontWeight = FontWeight.SemiBold,
|
|
||||||
Foreground = Brushes.White
|
|
||||||
},
|
|
||||||
content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private Control CreateInfoLine(string label, string value)
|
|
||||||
{
|
|
||||||
var grid = new Grid
|
|
||||||
{
|
|
||||||
ColumnDefinitions = new ColumnDefinitions("180,*"),
|
|
||||||
ColumnSpacing = 10
|
|
||||||
};
|
|
||||||
|
|
||||||
var labelText = new TextBlock
|
|
||||||
{
|
|
||||||
Text = label,
|
|
||||||
Foreground = new SolidColorBrush(Color.Parse("#FFBAE6FD")),
|
|
||||||
FontWeight = FontWeight.SemiBold,
|
|
||||||
TextWrapping = TextWrapping.Wrap
|
|
||||||
};
|
|
||||||
var valueText = new TextBlock
|
|
||||||
{
|
|
||||||
Text = value,
|
|
||||||
Foreground = Brushes.White,
|
|
||||||
TextWrapping = TextWrapping.Wrap
|
|
||||||
};
|
|
||||||
|
|
||||||
grid.Children.Add(labelText);
|
|
||||||
grid.Children.Add(valueText);
|
|
||||||
Grid.SetColumn(valueText, 1);
|
|
||||||
return grid;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Control CreateCapabilityCard(SamplePluginCapabilityItem item)
|
|
||||||
{
|
|
||||||
return new Border
|
|
||||||
{
|
|
||||||
Background = new SolidColorBrush(Color.Parse("#0F082F49")),
|
|
||||||
BorderBrush = new SolidColorBrush(Color.Parse("#3338BDF8")),
|
|
||||||
BorderThickness = new Thickness(1),
|
|
||||||
CornerRadius = new CornerRadius(12),
|
|
||||||
Padding = new Thickness(12, 10),
|
|
||||||
Child = new StackPanel
|
|
||||||
{
|
|
||||||
Spacing = 4,
|
|
||||||
Children =
|
|
||||||
{
|
|
||||||
new TextBlock
|
|
||||||
{
|
|
||||||
Text = item.Title,
|
|
||||||
Foreground = Brushes.White,
|
|
||||||
FontWeight = FontWeight.SemiBold
|
|
||||||
},
|
|
||||||
new TextBlock
|
|
||||||
{
|
|
||||||
Text = item.Detail,
|
|
||||||
Foreground = new SolidColorBrush(Color.Parse("#FFE0F2FE")),
|
|
||||||
TextWrapping = TextWrapping.Wrap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Control CreateStatusHeader(
|
|
||||||
SamplePluginStatusEntry entry,
|
|
||||||
(Color Background, Color Border, Color Dot) palette)
|
|
||||||
{
|
|
||||||
var grid = new Grid
|
|
||||||
{
|
|
||||||
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
|
|
||||||
ColumnSpacing = 8
|
|
||||||
};
|
|
||||||
|
|
||||||
var dot = new Border
|
|
||||||
{
|
|
||||||
Width = 10,
|
|
||||||
Height = 10,
|
|
||||||
CornerRadius = new CornerRadius(999),
|
|
||||||
Background = new SolidColorBrush(palette.Dot),
|
|
||||||
VerticalAlignment = VerticalAlignment.Center
|
|
||||||
};
|
|
||||||
var title = new TextBlock
|
|
||||||
{
|
|
||||||
Text = entry.Title,
|
|
||||||
FontSize = 15,
|
|
||||||
FontWeight = FontWeight.SemiBold,
|
|
||||||
Foreground = Brushes.White
|
|
||||||
};
|
|
||||||
var summary = new TextBlock
|
|
||||||
{
|
|
||||||
Text = entry.Summary,
|
|
||||||
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Right
|
|
||||||
};
|
|
||||||
|
|
||||||
grid.Children.Add(dot);
|
|
||||||
grid.Children.Add(title);
|
|
||||||
grid.Children.Add(summary);
|
|
||||||
Grid.SetColumn(title, 1);
|
|
||||||
Grid.SetColumn(summary, 2);
|
|
||||||
return grid;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static (Color Background, Color Border, Color Dot) GetPalette(SamplePluginHealthState state)
|
|
||||||
{
|
|
||||||
return state switch
|
|
||||||
{
|
|
||||||
SamplePluginHealthState.Healthy => (
|
|
||||||
Color.Parse("#1F115E59"),
|
|
||||||
Color.Parse("#665EEAD4"),
|
|
||||||
Color.Parse("#5EEAD4")),
|
|
||||||
SamplePluginHealthState.Faulted => (
|
|
||||||
Color.Parse("#291B1B"),
|
|
||||||
Color.Parse("#66F87171"),
|
|
||||||
Color.Parse("#F87171")),
|
|
||||||
_ => (
|
|
||||||
Color.Parse("#2B3A2A0D"),
|
|
||||||
Color.Parse("#66FBBF24"),
|
|
||||||
Color.Parse("#FBBF24"))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private string T(string key, string fallback)
|
|
||||||
{
|
|
||||||
return _localizer.GetString(key, fallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string Tf(string key, string fallback, params object[] args)
|
|
||||||
{
|
|
||||||
return _localizer.Format(key, fallback, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatBoolean(bool value)
|
|
||||||
{
|
|
||||||
return value
|
|
||||||
? T("common.true", "是")
|
|
||||||
: T("common.false", "否");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Layout;
|
|
||||||
using Avalonia.Media;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using LanMountainDesktop.PluginSdk;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.SamplePlugin;
|
|
||||||
|
|
||||||
internal sealed class SamplePluginStatusClockWidget : Border
|
|
||||||
{
|
|
||||||
private readonly PluginDesktopComponentContext _context;
|
|
||||||
private readonly PluginLocalizer _localizer;
|
|
||||||
private readonly SamplePluginRuntimeStateService _stateService;
|
|
||||||
private readonly SamplePluginClockService _clockService;
|
|
||||||
private readonly IPluginMessageBus _messageBus;
|
|
||||||
private readonly TextBlock _timeTextBlock;
|
|
||||||
private readonly TextBlock _subtitleTextBlock;
|
|
||||||
private readonly StackPanel _statusPanel;
|
|
||||||
private readonly Border _statusHost;
|
|
||||||
private readonly List<IDisposable> _subscriptions = [];
|
|
||||||
private string? _instanceId;
|
|
||||||
|
|
||||||
public SamplePluginStatusClockWidget(PluginDesktopComponentContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
_localizer = PluginLocalizer.Create(context);
|
|
||||||
_stateService = context.GetService<SamplePluginRuntimeStateService>()
|
|
||||||
?? throw new InvalidOperationException("SamplePluginRuntimeStateService is not available.");
|
|
||||||
_clockService = context.GetService<SamplePluginClockService>()
|
|
||||||
?? throw new InvalidOperationException("SamplePluginClockService is not available.");
|
|
||||||
_messageBus = context.GetService<IPluginMessageBus>()
|
|
||||||
?? throw new InvalidOperationException("IPluginMessageBus is not available.");
|
|
||||||
|
|
||||||
_timeTextBlock = new TextBlock
|
|
||||||
{
|
|
||||||
Foreground = Brushes.White,
|
|
||||||
FontWeight = FontWeight.Bold,
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Left
|
|
||||||
};
|
|
||||||
_subtitleTextBlock = new TextBlock
|
|
||||||
{
|
|
||||||
Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF")),
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Left,
|
|
||||||
TextWrapping = TextWrapping.Wrap
|
|
||||||
};
|
|
||||||
_statusPanel = new StackPanel
|
|
||||||
{
|
|
||||||
Spacing = 8
|
|
||||||
};
|
|
||||||
_statusHost = new Border
|
|
||||||
{
|
|
||||||
Background = new SolidColorBrush(Color.Parse("#1F082F49")),
|
|
||||||
BorderBrush = new SolidColorBrush(Color.Parse("#5538BDF8")),
|
|
||||||
BorderThickness = new Thickness(1),
|
|
||||||
Child = _statusPanel
|
|
||||||
};
|
|
||||||
|
|
||||||
Background = new LinearGradientBrush
|
|
||||||
{
|
|
||||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
|
||||||
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
|
||||||
GradientStops =
|
|
||||||
[
|
|
||||||
new GradientStop(Color.Parse("#FF07111F"), 0),
|
|
||||||
new GradientStop(Color.Parse("#FF0C4A6E"), 0.55),
|
|
||||||
new GradientStop(Color.Parse("#FF0EA5E9"), 1)
|
|
||||||
]
|
|
||||||
};
|
|
||||||
BorderBrush = new SolidColorBrush(Color.Parse("#6648C7FF"));
|
|
||||||
BorderThickness = new Thickness(1);
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Stretch;
|
|
||||||
VerticalAlignment = VerticalAlignment.Stretch;
|
|
||||||
Child = new Grid
|
|
||||||
{
|
|
||||||
RowDefinitions = new RowDefinitions("Auto,*"),
|
|
||||||
RowSpacing = 14,
|
|
||||||
Children =
|
|
||||||
{
|
|
||||||
new StackPanel
|
|
||||||
{
|
|
||||||
Spacing = 4,
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Left,
|
|
||||||
Children =
|
|
||||||
{
|
|
||||||
_timeTextBlock,
|
|
||||||
_subtitleTextBlock
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_statusHost
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Grid.SetRow(((Grid)Child).Children[1], 1);
|
|
||||||
|
|
||||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
|
||||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
|
||||||
SizeChanged += OnSizeChanged;
|
|
||||||
|
|
||||||
RefreshClock(_clockService.CurrentTime);
|
|
||||||
UpdateSubtitle();
|
|
||||||
RefreshStatusPanel();
|
|
||||||
ApplyScale();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(_instanceId))
|
|
||||||
{
|
|
||||||
_instanceId = _stateService.RegisterComponentInstance(
|
|
||||||
_context.ComponentId,
|
|
||||||
_context.PlacementId,
|
|
||||||
_context.CellSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
_stateService.MarkFrontendReady(T(
|
|
||||||
"status.frontend.detail.widget_connected",
|
|
||||||
"组件界面已接入插件服务与通信。"));
|
|
||||||
SubscribeToPluginBus();
|
|
||||||
|
|
||||||
RefreshClock(_clockService.CurrentTime);
|
|
||||||
UpdateSubtitle();
|
|
||||||
RefreshStatusPanel();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
|
||||||
{
|
|
||||||
foreach (var subscription in _subscriptions)
|
|
||||||
{
|
|
||||||
subscription.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
_subscriptions.Clear();
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(_instanceId))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_stateService.UnregisterComponentInstance(_instanceId);
|
|
||||||
_instanceId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
|
||||||
{
|
|
||||||
ApplyScale();
|
|
||||||
RefreshStatusPanel();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SubscribeToPluginBus()
|
|
||||||
{
|
|
||||||
if (_subscriptions.Count > 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_subscriptions.Add(_messageBus.Subscribe<SamplePluginClockTickMessage>(message =>
|
|
||||||
Dispatcher.UIThread.Post(() => RefreshClock(message.CurrentTime))));
|
|
||||||
|
|
||||||
_subscriptions.Add(_messageBus.Subscribe<SamplePluginStateChangedMessage>(_ =>
|
|
||||||
Dispatcher.UIThread.Post(() =>
|
|
||||||
{
|
|
||||||
UpdateSubtitle();
|
|
||||||
RefreshStatusPanel();
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RefreshClock(DateTimeOffset currentTime)
|
|
||||||
{
|
|
||||||
_timeTextBlock.Text = currentTime.LocalDateTime.ToString("HH:mm:ss");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateSubtitle()
|
|
||||||
{
|
|
||||||
var snapshot = _stateService.GetSnapshot();
|
|
||||||
_subtitleTextBlock.Text = string.IsNullOrWhiteSpace(_context.PlacementId)
|
|
||||||
? Tf("widget.subtitle.preview", "预览界面 | 已放置:{0}", snapshot.PlacedCount)
|
|
||||||
: Tf("widget.subtitle.placement", "位置 {0} | 已放置:{1}", _context.PlacementId!, snapshot.PlacedCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RefreshStatusPanel()
|
|
||||||
{
|
|
||||||
_statusPanel.Children.Clear();
|
|
||||||
|
|
||||||
var snapshot = _stateService.GetSnapshot();
|
|
||||||
var basis = GetLayoutBasis();
|
|
||||||
var titleSize = Math.Clamp(basis * 0.068, 11, 16);
|
|
||||||
var detailSize = Math.Clamp(basis * 0.052, 9, 13);
|
|
||||||
|
|
||||||
foreach (var entry in snapshot.StatusEntries)
|
|
||||||
{
|
|
||||||
var palette = GetPalette(entry.State);
|
|
||||||
_statusPanel.Children.Add(new Border
|
|
||||||
{
|
|
||||||
Background = new SolidColorBrush(palette.Background),
|
|
||||||
BorderBrush = new SolidColorBrush(palette.Border),
|
|
||||||
BorderThickness = new Thickness(1),
|
|
||||||
CornerRadius = new CornerRadius(12),
|
|
||||||
Padding = new Thickness(10, 8),
|
|
||||||
Child = new Grid
|
|
||||||
{
|
|
||||||
RowDefinitions = new RowDefinitions("Auto,Auto"),
|
|
||||||
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
|
|
||||||
ColumnSpacing = 8,
|
|
||||||
Children =
|
|
||||||
{
|
|
||||||
new Border
|
|
||||||
{
|
|
||||||
Width = Math.Clamp(basis * 0.038, 8, 11),
|
|
||||||
Height = Math.Clamp(basis * 0.038, 8, 11),
|
|
||||||
CornerRadius = new CornerRadius(999),
|
|
||||||
Background = new SolidColorBrush(palette.Dot),
|
|
||||||
VerticalAlignment = VerticalAlignment.Center
|
|
||||||
},
|
|
||||||
new TextBlock
|
|
||||||
{
|
|
||||||
Text = entry.Title,
|
|
||||||
FontSize = titleSize,
|
|
||||||
FontWeight = FontWeight.SemiBold,
|
|
||||||
Foreground = Brushes.White,
|
|
||||||
TextWrapping = TextWrapping.Wrap
|
|
||||||
},
|
|
||||||
new TextBlock
|
|
||||||
{
|
|
||||||
Text = entry.Summary,
|
|
||||||
FontSize = detailSize,
|
|
||||||
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Right,
|
|
||||||
TextAlignment = TextAlignment.Right,
|
|
||||||
VerticalAlignment = VerticalAlignment.Center
|
|
||||||
},
|
|
||||||
new TextBlock
|
|
||||||
{
|
|
||||||
Text = entry.Detail,
|
|
||||||
FontSize = detailSize,
|
|
||||||
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
|
|
||||||
TextWrapping = TextWrapping.Wrap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var row = (Grid)((Border)_statusPanel.Children[^1]).Child!;
|
|
||||||
Grid.SetColumn(row.Children[1], 1);
|
|
||||||
Grid.SetColumn(row.Children[2], 2);
|
|
||||||
Grid.SetColumnSpan(row.Children[3], 3);
|
|
||||||
Grid.SetRow(row.Children[3], 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ApplyScale()
|
|
||||||
{
|
|
||||||
var basis = GetLayoutBasis();
|
|
||||||
Padding = new Thickness(Math.Clamp(basis * 0.09, 16, 26));
|
|
||||||
CornerRadius = new CornerRadius(Math.Clamp(basis * 0.14, 20, 34));
|
|
||||||
_timeTextBlock.FontSize = Math.Clamp(basis * 0.22, 30, 58);
|
|
||||||
_subtitleTextBlock.FontSize = Math.Clamp(basis * 0.062, 11, 17);
|
|
||||||
_statusHost.Padding = new Thickness(Math.Clamp(basis * 0.045, 10, 18));
|
|
||||||
_statusHost.CornerRadius = new CornerRadius(Math.Clamp(basis * 0.09, 14, 22));
|
|
||||||
_statusPanel.Spacing = Math.Clamp(basis * 0.024, 6, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
private double GetLayoutBasis()
|
|
||||||
{
|
|
||||||
var width = Bounds.Width > 1 ? Bounds.Width : _context.CellSize * 4;
|
|
||||||
var height = Bounds.Height > 1 ? Bounds.Height : _context.CellSize * 4;
|
|
||||||
return Math.Max(_context.CellSize * 4, Math.Min(width, height));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static (Color Background, Color Border, Color Dot) GetPalette(SamplePluginHealthState state)
|
|
||||||
{
|
|
||||||
return state switch
|
|
||||||
{
|
|
||||||
SamplePluginHealthState.Healthy => (
|
|
||||||
Color.Parse("#1F0F766E"),
|
|
||||||
Color.Parse("#4D5EEAD4"),
|
|
||||||
Color.Parse("#5EEAD4")),
|
|
||||||
SamplePluginHealthState.Faulted => (
|
|
||||||
Color.Parse("#29B91C1C"),
|
|
||||||
Color.Parse("#66F87171"),
|
|
||||||
Color.Parse("#F87171")),
|
|
||||||
_ => (
|
|
||||||
Color.Parse("#1F7C2D12"),
|
|
||||||
Color.Parse("#66FDBA74"),
|
|
||||||
Color.Parse("#FDBA74"))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private string T(string key, string fallback)
|
|
||||||
{
|
|
||||||
return _localizer.GetString(key, fallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string Tf(string key, string fallback, params object[] args)
|
|
||||||
{
|
|
||||||
return _localizer.Format(key, fallback, args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "LanMountainDesktop.SamplePlugin",
|
|
||||||
"name": "LanMountain Sample Plugin",
|
|
||||||
"description": "Example plugin used to validate PluginSdk loading and isolation.",
|
|
||||||
"author": "LanMountainDesktop",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"apiVersion": "1.0.0",
|
|
||||||
"entranceAssembly": "LanMountainDesktop.SamplePlugin.dll"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# 示例插件目录
|
|
||||||
|
|
||||||
## 中文
|
|
||||||
|
|
||||||
本目录用于存放阑山桌面的示例插件和参考实现。
|
|
||||||
|
|
||||||
当前标准示例为 `LanMountainDesktop.SamplePlugin`。
|
|
||||||
|
|
||||||
## English
|
|
||||||
|
|
||||||
This directory stores sample plugins and reference implementations. The current standard sample is `LanMountainDesktop.SamplePlugin`.
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# 插件标准说明
|
|
||||||
|
|
||||||
## 中文
|
|
||||||
|
|
||||||
本目录存放插件开发需要遵循的基础约定,包括 `.laapp`、`plugin.json`、`Localization/` 以及仓库根目录 README 和安装包等要求。
|
|
||||||
|
|
||||||
## English
|
|
||||||
|
|
||||||
This directory stores the baseline conventions for plugin development, including `.laapp`, `plugin.json`, `Localization/`, and repository-root deliverables.
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "LanMountainDesktop.YourPlugin",
|
|
||||||
"name": "Your Plugin",
|
|
||||||
"description": "Describe what your plugin adds to LanMountainDesktop.",
|
|
||||||
"author": "Your Name",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"apiVersion": "1.0.0",
|
|
||||||
"entranceAssembly": "LanMountainDesktop.YourPlugin.dll"
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
using System.IO.Compression;
|
|
||||||
using LanMountainDesktop.PluginSdk;
|
|
||||||
|
|
||||||
return await RunAsync(args);
|
|
||||||
|
|
||||||
static async Task<int> RunAsync(string[] args)
|
|
||||||
{
|
|
||||||
if (args.Length == 0 || args.Any(arg => string.Equals(arg, "--help", StringComparison.OrdinalIgnoreCase)))
|
|
||||||
{
|
|
||||||
PrintUsage();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
string? inputDirectory = null;
|
|
||||||
string? outputPath = null;
|
|
||||||
var overwrite = false;
|
|
||||||
|
|
||||||
for (var i = 0; i < args.Length; i++)
|
|
||||||
{
|
|
||||||
switch (args[i])
|
|
||||||
{
|
|
||||||
case "--input":
|
|
||||||
inputDirectory = ReadValue(args, ref i, "--input");
|
|
||||||
break;
|
|
||||||
case "--output":
|
|
||||||
outputPath = ReadValue(args, ref i, "--output");
|
|
||||||
break;
|
|
||||||
case "--overwrite":
|
|
||||||
overwrite = true;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new InvalidOperationException($"Unknown argument '{args[i]}'.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(inputDirectory))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Missing required argument '--input'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var fullInputDirectory = Path.GetFullPath(inputDirectory);
|
|
||||||
if (!Directory.Exists(fullInputDirectory))
|
|
||||||
{
|
|
||||||
throw new DirectoryNotFoundException($"Plugin build directory '{fullInputDirectory}' was not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var manifestPath = Path.Combine(fullInputDirectory, PluginSdkInfo.ManifestFileName);
|
|
||||||
if (!File.Exists(manifestPath))
|
|
||||||
{
|
|
||||||
throw new FileNotFoundException(
|
|
||||||
$"Plugin build directory '{fullInputDirectory}' does not contain '{PluginSdkInfo.ManifestFileName}'.",
|
|
||||||
manifestPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
var manifest = PluginManifest.Load(manifestPath);
|
|
||||||
var entranceAssemblyPath = manifest.ResolveEntranceAssemblyPath(manifestPath);
|
|
||||||
if (!File.Exists(entranceAssemblyPath))
|
|
||||||
{
|
|
||||||
throw new FileNotFoundException(
|
|
||||||
$"The entrance assembly declared by '{PluginSdkInfo.ManifestFileName}' was not found.",
|
|
||||||
entranceAssemblyPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
outputPath ??= Path.Combine(
|
|
||||||
Path.GetDirectoryName(fullInputDirectory) ?? fullInputDirectory,
|
|
||||||
BuildPackageFileName(manifest.Id));
|
|
||||||
|
|
||||||
var fullOutputPath = Path.GetFullPath(outputPath);
|
|
||||||
var inputDirectoryWithSeparator = EnsureTrailingSeparator(fullInputDirectory);
|
|
||||||
if (fullOutputPath.StartsWith(inputDirectoryWithSeparator, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("The output .laapp path cannot be placed inside the source directory.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var destinationDirectory = Path.GetDirectoryName(fullOutputPath);
|
|
||||||
if (string.IsNullOrWhiteSpace(destinationDirectory))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Failed to determine the output directory for the .laapp package.");
|
|
||||||
}
|
|
||||||
|
|
||||||
Directory.CreateDirectory(destinationDirectory);
|
|
||||||
if (File.Exists(fullOutputPath))
|
|
||||||
{
|
|
||||||
if (!overwrite)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"The output package '{fullOutputPath}' already exists. Pass '--overwrite' to replace it.");
|
|
||||||
}
|
|
||||||
|
|
||||||
File.Delete(fullOutputPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Run(() => ZipFile.CreateFromDirectory(
|
|
||||||
fullInputDirectory,
|
|
||||||
fullOutputPath,
|
|
||||||
CompressionLevel.Optimal,
|
|
||||||
includeBaseDirectory: false));
|
|
||||||
|
|
||||||
Console.WriteLine($"Packaged '{manifest.Name}' to '{fullOutputPath}'.");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
static string ReadValue(IReadOnlyList<string> args, ref int index, string optionName)
|
|
||||||
{
|
|
||||||
var nextIndex = index + 1;
|
|
||||||
if (nextIndex >= args.Count)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Missing value for '{optionName}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
index = nextIndex;
|
|
||||||
return args[nextIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
static string BuildPackageFileName(string pluginId)
|
|
||||||
{
|
|
||||||
var invalidChars = Path.GetInvalidFileNameChars();
|
|
||||||
var safeName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
|
||||||
return safeName + PluginSdkInfo.PackageFileExtension;
|
|
||||||
}
|
|
||||||
|
|
||||||
static string EnsureTrailingSeparator(string path)
|
|
||||||
{
|
|
||||||
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
|
||||||
? path
|
|
||||||
: path + Path.DirectorySeparatorChar;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void PrintUsage()
|
|
||||||
{
|
|
||||||
Console.WriteLine("LanMountainDesktop.PluginPackager");
|
|
||||||
Console.WriteLine("Usage:");
|
|
||||||
Console.WriteLine(" --input <plugin build directory> Required");
|
|
||||||
Console.WriteLine(" --output <path to .laapp> Optional");
|
|
||||||
Console.WriteLine(" --overwrite Optional");
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using LanMountainDesktop.Settings.Core;
|
||||||
|
using LanMountainDesktop.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Appearance;
|
||||||
|
|
||||||
|
public static class AppearanceCornerRadiusTokenFactory
|
||||||
|
{
|
||||||
|
public static AppearanceCornerRadiusTokens Create(double scale)
|
||||||
|
{
|
||||||
|
var normalizedScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(scale);
|
||||||
|
return new AppearanceCornerRadiusTokens(
|
||||||
|
Radius(6, normalizedScale),
|
||||||
|
Radius(10, normalizedScale),
|
||||||
|
Radius(14, normalizedScale),
|
||||||
|
Radius(18, normalizedScale),
|
||||||
|
Radius(24, normalizedScale),
|
||||||
|
Radius(30, normalizedScale),
|
||||||
|
Radius(36, normalizedScale));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CornerRadius Radius(double value, double scale)
|
||||||
|
{
|
||||||
|
var scaled = Math.Round(value * scale * 2, MidpointRounding.AwayFromZero) / 2d;
|
||||||
|
return new CornerRadius(scaled);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Settings.Core\LanMountainDesktop.Settings.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Host.Abstractions\LanMountainDesktop.Host.Abstractions.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Settings.Core\LanMountainDesktop.Settings.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Appearance\LanMountainDesktop.Appearance.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Host.Abstractions\LanMountainDesktop.Host.Abstractions.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
27
LanMountainDesktop.DesktopHost/DesktopBootstrap.cs
Normal file
27
LanMountainDesktop.DesktopHost/DesktopBootstrap.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.DesktopHost;
|
||||||
|
|
||||||
|
public static class DesktopBootstrap
|
||||||
|
{
|
||||||
|
public static void InitializeStartupServices(Action initializeDeviceId, Action initializeCrashReporting, Action initializeUserBehaviorAnalytics, Action scheduleStartupCleanup)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(initializeDeviceId);
|
||||||
|
ArgumentNullException.ThrowIfNull(initializeCrashReporting);
|
||||||
|
ArgumentNullException.ThrowIfNull(initializeUserBehaviorAnalytics);
|
||||||
|
ArgumentNullException.ThrowIfNull(scheduleStartupCleanup);
|
||||||
|
|
||||||
|
initializeDeviceId();
|
||||||
|
initializeCrashReporting();
|
||||||
|
initializeUserBehaviorAnalytics();
|
||||||
|
scheduleStartupCleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void InitializeApplication(Application application, Action initializeShell)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(application);
|
||||||
|
ArgumentNullException.ThrowIfNull(initializeShell);
|
||||||
|
initializeShell();
|
||||||
|
}
|
||||||
|
}
|
||||||
55
LanMountainDesktop.DesktopHost/DesktopShellHost.cs
Normal file
55
LanMountainDesktop.DesktopHost/DesktopShellHost.cs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using LanMountainDesktop.Host.Abstractions;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.DesktopHost;
|
||||||
|
|
||||||
|
public sealed class DesktopShellHost : IDesktopShellHost
|
||||||
|
{
|
||||||
|
private readonly Action _initializePluginRuntime;
|
||||||
|
private readonly Action _initializeTrayIcon;
|
||||||
|
private readonly Action<IClassicDesktopStyleApplicationLifetime> _createAndAssignMainWindow;
|
||||||
|
private readonly Action _performExitCleanup;
|
||||||
|
private readonly Action _startActivationListener;
|
||||||
|
private readonly Action _startWeatherRefresh;
|
||||||
|
|
||||||
|
public DesktopShellHost(
|
||||||
|
Action initializePluginRuntime,
|
||||||
|
Action initializeTrayIcon,
|
||||||
|
Action<IClassicDesktopStyleApplicationLifetime> createAndAssignMainWindow,
|
||||||
|
Action performExitCleanup,
|
||||||
|
Action startActivationListener,
|
||||||
|
Action startWeatherRefresh)
|
||||||
|
{
|
||||||
|
_initializePluginRuntime = initializePluginRuntime;
|
||||||
|
_initializeTrayIcon = initializeTrayIcon;
|
||||||
|
_createAndAssignMainWindow = createAndAssignMainWindow;
|
||||||
|
_performExitCleanup = performExitCleanup;
|
||||||
|
_startActivationListener = startActivationListener;
|
||||||
|
_startWeatherRefresh = startWeatherRefresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Initialize()
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("An application instance is required to initialize the desktop shell.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Initialize(Application application)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(application);
|
||||||
|
|
||||||
|
_initializePluginRuntime();
|
||||||
|
_initializeTrayIcon();
|
||||||
|
|
||||||
|
if (application.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
desktop.Exit += (_, _) => _performExitCleanup();
|
||||||
|
_createAndAssignMainWindow(desktop);
|
||||||
|
_startActivationListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
_startWeatherRefresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
15
LanMountainDesktop.DesktopHost/DesktopStartupCoordinator.cs
Normal file
15
LanMountainDesktop.DesktopHost/DesktopStartupCoordinator.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.DesktopHost;
|
||||||
|
|
||||||
|
public sealed class DesktopStartupCoordinator
|
||||||
|
{
|
||||||
|
private readonly Action _restoreWorkspaceState;
|
||||||
|
|
||||||
|
public DesktopStartupCoordinator(Action restoreWorkspaceState)
|
||||||
|
{
|
||||||
|
_restoreWorkspaceState = restoreWorkspaceState ?? throw new ArgumentNullException(nameof(restoreWorkspaceState));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Restore() => _restoreWorkspaceState();
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Settings.Core\LanMountainDesktop.Settings.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Appearance\LanMountainDesktop.Appearance.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.DesktopComponents.Runtime\LanMountainDesktop.DesktopComponents.Runtime.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Host.Abstractions\LanMountainDesktop.Host.Abstractions.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
18
LanMountainDesktop.DesktopHost/SettingsWindowHost.cs
Normal file
18
LanMountainDesktop.DesktopHost/SettingsWindowHost.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.DesktopHost;
|
||||||
|
|
||||||
|
public sealed class SettingsWindowHost
|
||||||
|
{
|
||||||
|
private readonly Action<string, string?> _openSettingsWindow;
|
||||||
|
|
||||||
|
public SettingsWindowHost(Action<string, string?> openSettingsWindow)
|
||||||
|
{
|
||||||
|
_openSettingsWindow = openSettingsWindow ?? throw new ArgumentNullException(nameof(openSettingsWindow));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Open(string source, string? pageId = null)
|
||||||
|
{
|
||||||
|
_openSettingsWindow(source, pageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
LanMountainDesktop.DesktopHost/ShutdownCoordinator.cs
Normal file
19
LanMountainDesktop.DesktopHost/ShutdownCoordinator.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.DesktopHost;
|
||||||
|
|
||||||
|
public sealed class ShutdownCoordinator
|
||||||
|
{
|
||||||
|
private readonly Action<bool, string> _prepareForShutdown;
|
||||||
|
private readonly Action<string> _resetShutdownIntent;
|
||||||
|
|
||||||
|
public ShutdownCoordinator(Action<bool, string> prepareForShutdown, Action<string> resetShutdownIntent)
|
||||||
|
{
|
||||||
|
_prepareForShutdown = prepareForShutdown ?? throw new ArgumentNullException(nameof(prepareForShutdown));
|
||||||
|
_resetShutdownIntent = resetShutdownIntent ?? throw new ArgumentNullException(nameof(resetShutdownIntent));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Prepare(bool isRestart, string source) => _prepareForShutdown(isRestart, source);
|
||||||
|
|
||||||
|
public void Reset(string source) => _resetShutdownIntent(source);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Host.Abstractions;
|
||||||
|
|
||||||
|
public sealed record ComponentChromeContext(
|
||||||
|
string ComponentId,
|
||||||
|
string? PlacementId,
|
||||||
|
double CellSize,
|
||||||
|
double GlobalCornerRadiusScale,
|
||||||
|
AppearanceCornerRadiusTokens CornerRadiusTokens,
|
||||||
|
SettingsScope Scope = SettingsScope.App);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace LanMountainDesktop.Host.Abstractions;
|
||||||
|
|
||||||
|
public interface IDesktopShellHost
|
||||||
|
{
|
||||||
|
void Initialize();
|
||||||
|
}
|
||||||
@@ -1,15 +1,10 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<Version>1.0.0</Version>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
10
LanMountainDesktop.PluginSdk/IPluginAppearanceContext.cs
Normal file
10
LanMountainDesktop.PluginSdk/IPluginAppearanceContext.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public interface IPluginAppearanceContext
|
||||||
|
{
|
||||||
|
PluginAppearanceSnapshot Snapshot { get; }
|
||||||
|
|
||||||
|
double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null);
|
||||||
|
|
||||||
|
double ResolveCornerRadius(PluginCornerRadiusPreset preset, double? minimum = null, double? maximum = null);
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ public interface IPluginRuntimeContext
|
|||||||
|
|
||||||
IReadOnlyDictionary<string, object?> Properties { get; }
|
IReadOnlyDictionary<string, object?> Properties { get; }
|
||||||
|
|
||||||
|
IPluginAppearanceContext Appearance { get; }
|
||||||
|
|
||||||
T? GetService<T>();
|
T? GetService<T>();
|
||||||
|
|
||||||
bool TryGetProperty<T>(string key, out T? value);
|
bool TryGetProperty<T>(string key, out T? value);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
49
LanMountainDesktop.PluginSdk/PluginAppearanceContext.cs
Normal file
49
LanMountainDesktop.PluginSdk/PluginAppearanceContext.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed class PluginAppearanceContext : IPluginAppearanceContext
|
||||||
|
{
|
||||||
|
public PluginAppearanceContext(PluginAppearanceSnapshot snapshot)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshot);
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshot.CornerRadiusTokens);
|
||||||
|
|
||||||
|
Snapshot = snapshot with
|
||||||
|
{
|
||||||
|
GlobalCornerRadiusScale = Math.Max(0d, snapshot.GlobalCornerRadiusScale),
|
||||||
|
ThemeVariant = string.IsNullOrWhiteSpace(snapshot.ThemeVariant)
|
||||||
|
? "Unknown"
|
||||||
|
: snapshot.ThemeVariant.Trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginAppearanceSnapshot Snapshot { get; }
|
||||||
|
|
||||||
|
public double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null)
|
||||||
|
{
|
||||||
|
var scale = Snapshot.GlobalCornerRadiusScale;
|
||||||
|
var scaled = Math.Max(0d, baseRadius) * scale;
|
||||||
|
var scaledMin = minimum.HasValue ? minimum.Value * scale : scaled;
|
||||||
|
var scaledMax = maximum.HasValue ? maximum.Value * scale : scaled;
|
||||||
|
return minimum.HasValue || maximum.HasValue
|
||||||
|
? Math.Clamp(scaled, scaledMin, scaledMax)
|
||||||
|
: scaled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double ResolveCornerRadius(PluginCornerRadiusPreset preset, double? minimum = null, double? maximum = null)
|
||||||
|
{
|
||||||
|
var resolved = Math.Max(0d, Snapshot.CornerRadiusTokens.Get(preset));
|
||||||
|
if (!minimum.HasValue && !maximum.HasValue)
|
||||||
|
{
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
var clampedMin = minimum ?? resolved;
|
||||||
|
var clampedMax = maximum ?? resolved;
|
||||||
|
if (clampedMin > clampedMax)
|
||||||
|
{
|
||||||
|
(clampedMin, clampedMax) = (clampedMax, clampedMin);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.Clamp(resolved, clampedMin, clampedMax);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
LanMountainDesktop.PluginSdk/PluginAppearanceSnapshot.cs
Normal file
6
LanMountainDesktop.PluginSdk/PluginAppearanceSnapshot.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed record PluginAppearanceSnapshot(
|
||||||
|
double GlobalCornerRadiusScale,
|
||||||
|
PluginCornerRadiusTokens CornerRadiusTokens,
|
||||||
|
string ThemeVariant);
|
||||||
13
LanMountainDesktop.PluginSdk/PluginCornerRadiusPreset.cs
Normal file
13
LanMountainDesktop.PluginSdk/PluginCornerRadiusPreset.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public enum PluginCornerRadiusPreset
|
||||||
|
{
|
||||||
|
Default = 0,
|
||||||
|
Micro = 1,
|
||||||
|
Xs = 2,
|
||||||
|
Sm = 3,
|
||||||
|
Md = 4,
|
||||||
|
Lg = 5,
|
||||||
|
Xl = 6,
|
||||||
|
Island = 7
|
||||||
|
}
|
||||||
49
LanMountainDesktop.PluginSdk/PluginCornerRadiusTokens.cs
Normal file
49
LanMountainDesktop.PluginSdk/PluginCornerRadiusTokens.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using LanMountainDesktop.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed record PluginCornerRadiusTokens(
|
||||||
|
double Micro,
|
||||||
|
double Xs,
|
||||||
|
double Sm,
|
||||||
|
double Md,
|
||||||
|
double Lg,
|
||||||
|
double Xl,
|
||||||
|
double Island)
|
||||||
|
{
|
||||||
|
public double Get(PluginCornerRadiusPreset preset)
|
||||||
|
{
|
||||||
|
return preset switch
|
||||||
|
{
|
||||||
|
PluginCornerRadiusPreset.Default => Md,
|
||||||
|
PluginCornerRadiusPreset.Micro => Micro,
|
||||||
|
PluginCornerRadiusPreset.Xs => Xs,
|
||||||
|
PluginCornerRadiusPreset.Sm => Sm,
|
||||||
|
PluginCornerRadiusPreset.Md => Md,
|
||||||
|
PluginCornerRadiusPreset.Lg => Lg,
|
||||||
|
PluginCornerRadiusPreset.Xl => Xl,
|
||||||
|
PluginCornerRadiusPreset.Island => Island,
|
||||||
|
_ => Md
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public CornerRadius ToCornerRadius(PluginCornerRadiusPreset preset)
|
||||||
|
{
|
||||||
|
return new CornerRadius(Get(preset));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PluginCornerRadiusTokens FromShared(AppearanceCornerRadiusTokens tokens)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(tokens);
|
||||||
|
|
||||||
|
return new PluginCornerRadiusTokens(
|
||||||
|
tokens.Micro.TopLeft,
|
||||||
|
tokens.Xs.TopLeft,
|
||||||
|
tokens.Sm.TopLeft,
|
||||||
|
tokens.Md.TopLeft,
|
||||||
|
tokens.Lg.TopLeft,
|
||||||
|
tokens.Xl.TopLeft,
|
||||||
|
tokens.Island.TopLeft);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ public sealed class PluginDesktopComponentContext
|
|||||||
string componentId,
|
string componentId,
|
||||||
string? placementId,
|
string? placementId,
|
||||||
double cellSize,
|
double cellSize,
|
||||||
|
IPluginAppearanceContext appearance,
|
||||||
IPluginSettingsService? pluginSettings = null)
|
IPluginSettingsService? pluginSettings = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(manifest);
|
ArgumentNullException.ThrowIfNull(manifest);
|
||||||
@@ -19,6 +20,7 @@ public sealed class PluginDesktopComponentContext
|
|||||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||||
ArgumentNullException.ThrowIfNull(services);
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
ArgumentNullException.ThrowIfNull(properties);
|
ArgumentNullException.ThrowIfNull(properties);
|
||||||
|
ArgumentNullException.ThrowIfNull(appearance);
|
||||||
|
|
||||||
Manifest = manifest;
|
Manifest = manifest;
|
||||||
PluginDirectory = pluginDirectory;
|
PluginDirectory = pluginDirectory;
|
||||||
@@ -28,6 +30,7 @@ public sealed class PluginDesktopComponentContext
|
|||||||
ComponentId = componentId.Trim();
|
ComponentId = componentId.Trim();
|
||||||
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
|
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
|
||||||
CellSize = Math.Max(1, cellSize);
|
CellSize = Math.Max(1, cellSize);
|
||||||
|
Appearance = appearance;
|
||||||
PluginSettings = pluginSettings;
|
PluginSettings = pluginSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,8 +50,24 @@ public sealed class PluginDesktopComponentContext
|
|||||||
|
|
||||||
public double CellSize { get; }
|
public double CellSize { get; }
|
||||||
|
|
||||||
|
public IPluginAppearanceContext Appearance { get; }
|
||||||
|
|
||||||
|
public double GlobalCornerRadiusScale => Appearance.Snapshot.GlobalCornerRadiusScale;
|
||||||
|
|
||||||
|
public PluginCornerRadiusTokens CornerRadiusTokens => Appearance.Snapshot.CornerRadiusTokens;
|
||||||
|
|
||||||
public IPluginSettingsService? PluginSettings { get; }
|
public IPluginSettingsService? PluginSettings { get; }
|
||||||
|
|
||||||
|
public double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null)
|
||||||
|
{
|
||||||
|
return Appearance.ResolveScaledCornerRadius(baseRadius, minimum, maximum);
|
||||||
|
}
|
||||||
|
|
||||||
|
public double ResolveCornerRadius(PluginCornerRadiusPreset preset, double? minimum = null, double? maximum = null)
|
||||||
|
{
|
||||||
|
return Appearance.ResolveCornerRadius(preset, minimum, maximum);
|
||||||
|
}
|
||||||
|
|
||||||
public T? GetService<T>()
|
public T? GetService<T>()
|
||||||
{
|
{
|
||||||
return (T?)Services.GetService(typeof(T));
|
return (T?)Services.GetService(typeof(T));
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed class PluginDesktopComponentOptions
|
||||||
|
{
|
||||||
|
public required string ComponentId { get; init; }
|
||||||
|
|
||||||
|
public required string DisplayName { get; init; }
|
||||||
|
|
||||||
|
public string IconKey { get; init; } = "PuzzlePiece";
|
||||||
|
|
||||||
|
public string Category { get; init; } = "Plugins";
|
||||||
|
|
||||||
|
public int MinWidthCells { get; init; } = 2;
|
||||||
|
|
||||||
|
public int MinHeightCells { get; init; } = 2;
|
||||||
|
|
||||||
|
public bool AllowDesktopPlacement { get; init; } = true;
|
||||||
|
|
||||||
|
public bool AllowStatusBarPlacement { get; init; }
|
||||||
|
|
||||||
|
public PluginDesktopComponentResizeMode ResizeMode { get; init; } = PluginDesktopComponentResizeMode.Proportional;
|
||||||
|
|
||||||
|
public string? DisplayNameLocalizationKey { get; init; }
|
||||||
|
|
||||||
|
public PluginCornerRadiusPreset CornerRadiusPreset { get; init; } = PluginCornerRadiusPreset.Default;
|
||||||
|
|
||||||
|
public Func<IPluginAppearanceContext, double, double>? CornerRadiusResolver { get; init; }
|
||||||
|
}
|
||||||
@@ -5,67 +5,37 @@ namespace LanMountainDesktop.PluginSdk;
|
|||||||
public sealed class PluginDesktopComponentRegistration
|
public sealed class PluginDesktopComponentRegistration
|
||||||
{
|
{
|
||||||
public PluginDesktopComponentRegistration(
|
public PluginDesktopComponentRegistration(
|
||||||
string componentId,
|
|
||||||
string displayName,
|
|
||||||
Func<IServiceProvider, PluginDesktopComponentContext, Control> controlFactory,
|
Func<IServiceProvider, PluginDesktopComponentContext, Control> controlFactory,
|
||||||
string iconKey = "PuzzlePiece",
|
PluginDesktopComponentOptions options)
|
||||||
string category = "Plugins",
|
|
||||||
int minWidthCells = 2,
|
|
||||||
int minHeightCells = 2,
|
|
||||||
bool allowDesktopPlacement = true,
|
|
||||||
bool allowStatusBarPlacement = false,
|
|
||||||
PluginDesktopComponentResizeMode resizeMode = PluginDesktopComponentResizeMode.Proportional,
|
|
||||||
string? displayNameLocalizationKey = null,
|
|
||||||
Func<double, double>? cornerRadiusResolver = null)
|
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(displayName);
|
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(iconKey);
|
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(category);
|
|
||||||
ArgumentNullException.ThrowIfNull(controlFactory);
|
ArgumentNullException.ThrowIfNull(controlFactory);
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(options.ComponentId);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(options.DisplayName);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(options.IconKey);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(options.Category);
|
||||||
|
|
||||||
ComponentId = componentId.Trim();
|
ComponentId = options.ComponentId.Trim();
|
||||||
DisplayName = displayName.Trim();
|
DisplayName = options.DisplayName.Trim();
|
||||||
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(displayNameLocalizationKey)
|
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(options.DisplayNameLocalizationKey)
|
||||||
? null
|
? null
|
||||||
: displayNameLocalizationKey.Trim();
|
: options.DisplayNameLocalizationKey.Trim();
|
||||||
ControlFactory = controlFactory;
|
ControlFactory = controlFactory;
|
||||||
IconKey = iconKey.Trim();
|
IconKey = options.IconKey.Trim();
|
||||||
Category = category.Trim();
|
Category = options.Category.Trim();
|
||||||
MinWidthCells = Math.Max(1, minWidthCells);
|
MinWidthCells = Math.Max(1, options.MinWidthCells);
|
||||||
MinHeightCells = Math.Max(1, minHeightCells);
|
MinHeightCells = Math.Max(1, options.MinHeightCells);
|
||||||
AllowDesktopPlacement = allowDesktopPlacement;
|
AllowDesktopPlacement = options.AllowDesktopPlacement;
|
||||||
AllowStatusBarPlacement = allowStatusBarPlacement;
|
AllowStatusBarPlacement = options.AllowStatusBarPlacement;
|
||||||
ResizeMode = resizeMode;
|
ResizeMode = options.ResizeMode;
|
||||||
CornerRadiusResolver = cornerRadiusResolver;
|
CornerRadiusPreset = options.CornerRadiusPreset;
|
||||||
|
CornerRadiusResolver = options.CornerRadiusResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
public PluginDesktopComponentRegistration(
|
public PluginDesktopComponentRegistration(
|
||||||
string componentId,
|
|
||||||
string displayName,
|
|
||||||
Func<PluginDesktopComponentContext, Control> controlFactory,
|
Func<PluginDesktopComponentContext, Control> controlFactory,
|
||||||
string iconKey = "PuzzlePiece",
|
PluginDesktopComponentOptions options)
|
||||||
string category = "Plugins",
|
: this((_, context) => controlFactory(context), options)
|
||||||
int minWidthCells = 2,
|
|
||||||
int minHeightCells = 2,
|
|
||||||
bool allowDesktopPlacement = true,
|
|
||||||
bool allowStatusBarPlacement = false,
|
|
||||||
PluginDesktopComponentResizeMode resizeMode = PluginDesktopComponentResizeMode.Proportional,
|
|
||||||
string? displayNameLocalizationKey = null,
|
|
||||||
Func<double, double>? cornerRadiusResolver = null)
|
|
||||||
: this(
|
|
||||||
componentId,
|
|
||||||
displayName,
|
|
||||||
(_, context) => controlFactory(context),
|
|
||||||
iconKey,
|
|
||||||
category,
|
|
||||||
minWidthCells,
|
|
||||||
minHeightCells,
|
|
||||||
allowDesktopPlacement,
|
|
||||||
allowStatusBarPlacement,
|
|
||||||
resizeMode,
|
|
||||||
displayNameLocalizationKey,
|
|
||||||
cornerRadiusResolver)
|
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,5 +61,25 @@ public sealed class PluginDesktopComponentRegistration
|
|||||||
|
|
||||||
public PluginDesktopComponentResizeMode ResizeMode { get; }
|
public PluginDesktopComponentResizeMode ResizeMode { get; }
|
||||||
|
|
||||||
public Func<double, double>? CornerRadiusResolver { get; }
|
public PluginCornerRadiusPreset CornerRadiusPreset { get; }
|
||||||
|
|
||||||
|
public Func<IPluginAppearanceContext, double, double>? CornerRadiusResolver { get; }
|
||||||
|
|
||||||
|
public double ResolveCornerRadius(IPluginAppearanceContext appearance, double cellSize)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(appearance);
|
||||||
|
|
||||||
|
var resolved = CornerRadiusResolver is not null
|
||||||
|
? CornerRadiusResolver(appearance, Math.Max(1d, cellSize))
|
||||||
|
: CornerRadiusPreset == PluginCornerRadiusPreset.Default
|
||||||
|
? appearance.ResolveScaledCornerRadius(
|
||||||
|
Math.Clamp(Math.Max(1d, cellSize) * 0.22, 8, 18),
|
||||||
|
8,
|
||||||
|
18)
|
||||||
|
: appearance.ResolveCornerRadius(CornerRadiusPreset);
|
||||||
|
|
||||||
|
return double.IsFinite(resolved)
|
||||||
|
? Math.Max(0d, resolved)
|
||||||
|
: appearance.ResolveCornerRadius(PluginCornerRadiusPreset.Default);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ public sealed record PluginManifest(
|
|||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}' (major {requestedVersion.Major}), " +
|
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}' (major {requestedVersion.Major}), " +
|
||||||
$"but the host provides '{PluginSdkInfo.ApiVersion}' (major {currentVersion.Major}). " +
|
$"but the host provides '{PluginSdkInfo.ApiVersion}' (major {currentVersion.Major}). " +
|
||||||
$"This host only supports v{currentVersion.Major}.x plugins. " +
|
$"This host only supports v{currentVersion.Major}.x plugins and rejects v{requestedVersion.Major}.x packages by default. " +
|
||||||
$"Migrate the plugin to API {PluginSdkInfo.ApiVersion} and rebuild the package.");
|
$"Migrate the plugin manifest and code to API {PluginSdkInfo.ApiVersion}, then rebuild and republish the package.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ namespace LanMountainDesktop.PluginSdk;
|
|||||||
|
|
||||||
public static class PluginSdkInfo
|
public static class PluginSdkInfo
|
||||||
{
|
{
|
||||||
public const string ApiVersion = "3.0.0";
|
public const string ApiVersion = "4.0.0";
|
||||||
public const string ManifestFileName = "plugin.json";
|
public const string ManifestFileName = "plugin.json";
|
||||||
public const string PackageFileExtension = ".laapp";
|
public const string PackageFileExtension = ".laapp";
|
||||||
public const string DataDirectoryName = "Data";
|
public const string DataDirectoryName = "Data";
|
||||||
|
|||||||
@@ -30,34 +30,15 @@ public static class PluginServiceCollectionExtensions
|
|||||||
|
|
||||||
public static IServiceCollection AddPluginDesktopComponent<TControl>(
|
public static IServiceCollection AddPluginDesktopComponent<TControl>(
|
||||||
this IServiceCollection services,
|
this IServiceCollection services,
|
||||||
string componentId,
|
PluginDesktopComponentOptions options)
|
||||||
string displayName,
|
|
||||||
string iconKey = "PuzzlePiece",
|
|
||||||
string category = "Plugins",
|
|
||||||
int minWidthCells = 2,
|
|
||||||
int minHeightCells = 2,
|
|
||||||
bool allowDesktopPlacement = true,
|
|
||||||
bool allowStatusBarPlacement = false,
|
|
||||||
PluginDesktopComponentResizeMode resizeMode = PluginDesktopComponentResizeMode.Proportional,
|
|
||||||
string? displayNameLocalizationKey = null,
|
|
||||||
Func<double, double>? cornerRadiusResolver = null)
|
|
||||||
where TControl : Control
|
where TControl : Control
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(services);
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
services.AddSingleton(new PluginDesktopComponentRegistration(
|
services.AddSingleton(new PluginDesktopComponentRegistration(
|
||||||
componentId,
|
|
||||||
displayName,
|
|
||||||
(provider, context) => ActivatorUtilities.CreateInstance<TControl>(provider, context),
|
(provider, context) => ActivatorUtilities.CreateInstance<TControl>(provider, context),
|
||||||
iconKey,
|
options));
|
||||||
category,
|
|
||||||
minWidthCells,
|
|
||||||
minHeightCells,
|
|
||||||
allowDesktopPlacement,
|
|
||||||
allowStatusBarPlacement,
|
|
||||||
resizeMode,
|
|
||||||
displayNameLocalizationKey,
|
|
||||||
cornerRadiusResolver));
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
18
LanMountainDesktop.Settings.Core/GlobalAppearanceSettings.cs
Normal file
18
LanMountainDesktop.Settings.Core/GlobalAppearanceSettings.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace LanMountainDesktop.Settings.Core;
|
||||||
|
|
||||||
|
public static class GlobalAppearanceSettings
|
||||||
|
{
|
||||||
|
public const double DefaultCornerRadiusScale = 1.0;
|
||||||
|
public const double MinimumCornerRadiusScale = 0.0;
|
||||||
|
public const double MaximumCornerRadiusScale = 2.50;
|
||||||
|
|
||||||
|
public static double NormalizeCornerRadiusScale(double value)
|
||||||
|
{
|
||||||
|
if (double.IsNaN(value) || double.IsInfinity(value))
|
||||||
|
{
|
||||||
|
return DefaultCornerRadiusScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.Clamp(value, MinimumCornerRadiusScale, MaximumCornerRadiusScale);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using Avalonia;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Shared.Contracts;
|
||||||
|
|
||||||
|
public sealed record AppearanceCornerRadiusTokens(
|
||||||
|
CornerRadius Micro,
|
||||||
|
CornerRadius Xs,
|
||||||
|
CornerRadius Sm,
|
||||||
|
CornerRadius Md,
|
||||||
|
CornerRadius Lg,
|
||||||
|
CornerRadius Xl,
|
||||||
|
CornerRadius Island);
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<Version>1.0.0</Version>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia;
|
||||||
|
using LanMountainDesktop.Appearance;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Host.Abstractions;
|
||||||
|
using LanMountainDesktop.Views.Components;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class BuiltInDesktopHostCornerRadiusBaselineTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(80d, 0d)]
|
||||||
|
[InlineData(120d, 1d)]
|
||||||
|
[InlineData(160d, 2.5d)]
|
||||||
|
public void BuiltInDesktopHosts_ResolveToTheUnifiedLgBaseline(double cellSize, double globalScale)
|
||||||
|
{
|
||||||
|
var registry = new DesktopComponentRuntimeRegistry(
|
||||||
|
ComponentRegistry.CreateDefault(),
|
||||||
|
DesktopComponentRuntimeRegistry.GetDefaultRegistrations());
|
||||||
|
var expected = AppearanceCornerRadiusTokenFactory.Create(globalScale).Lg.TopLeft;
|
||||||
|
|
||||||
|
foreach (var descriptor in registry.GetDesktopComponents())
|
||||||
|
{
|
||||||
|
var resolved = descriptor.ResolveCornerRadius(CreateChromeContext(descriptor.Definition.Id, cellSize, globalScale));
|
||||||
|
Assert.Equal(expected, resolved, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ComponentChromeContext CreateChromeContext(
|
||||||
|
string componentId,
|
||||||
|
double cellSize,
|
||||||
|
double globalScale)
|
||||||
|
{
|
||||||
|
return new ComponentChromeContext(
|
||||||
|
componentId,
|
||||||
|
null,
|
||||||
|
cellSize,
|
||||||
|
globalScale,
|
||||||
|
AppearanceCornerRadiusTokenFactory.Create(globalScale));
|
||||||
|
}
|
||||||
|
}
|
||||||
91
LanMountainDesktop.Tests/CornerRadiusScaleTests.cs
Normal file
91
LanMountainDesktop.Tests/CornerRadiusScaleTests.cs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Settings.Core;
|
||||||
|
using LanMountainDesktop.Shared.Contracts;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class CornerRadiusScaleTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(-1d, 0d)]
|
||||||
|
[InlineData(0d, 0d)]
|
||||||
|
[InlineData(0.33d, 0.33d)]
|
||||||
|
[InlineData(1.234d, 1.234d)]
|
||||||
|
[InlineData(2.5d, 2.5d)]
|
||||||
|
[InlineData(3d, 2.5d)]
|
||||||
|
public void NormalizeCornerRadiusScale_ClampsWithoutSnapping(double input, double expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, GlobalAppearanceSettings.NormalizeCornerRadiusScale(input), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeCornerRadiusScale_UsesDefaultForInvalidValues()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
GlobalAppearanceSettings.DefaultCornerRadiusScale,
|
||||||
|
GlobalAppearanceSettings.NormalizeCornerRadiusScale(double.NaN),
|
||||||
|
3);
|
||||||
|
Assert.Equal(
|
||||||
|
GlobalAppearanceSettings.DefaultCornerRadiusScale,
|
||||||
|
GlobalAppearanceSettings.NormalizeCornerRadiusScale(double.PositiveInfinity),
|
||||||
|
3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PluginDesktopComponentContext_AllowsZeroRadiusScaling()
|
||||||
|
{
|
||||||
|
var appearanceContext = new PluginAppearanceContext(new PluginAppearanceSnapshot(
|
||||||
|
GlobalCornerRadiusScale: 0d,
|
||||||
|
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(new AppearanceCornerRadiusTokens(
|
||||||
|
new CornerRadius(6),
|
||||||
|
new CornerRadius(10),
|
||||||
|
new CornerRadius(14),
|
||||||
|
new CornerRadius(18),
|
||||||
|
new CornerRadius(24),
|
||||||
|
new CornerRadius(30),
|
||||||
|
new CornerRadius(36))),
|
||||||
|
ThemeVariant: "Unknown"));
|
||||||
|
|
||||||
|
var context = new PluginDesktopComponentContext(
|
||||||
|
new PluginManifest("plugin.id", "Plugin Name", "plugin.dll"),
|
||||||
|
"C:\\Plugins\\plugin.id",
|
||||||
|
"C:\\Data\\plugin.id",
|
||||||
|
new NullServiceProvider(),
|
||||||
|
new Dictionary<string, object?>(),
|
||||||
|
"component-1",
|
||||||
|
null,
|
||||||
|
96d,
|
||||||
|
appearanceContext);
|
||||||
|
|
||||||
|
Assert.Equal(0d, context.GlobalCornerRadiusScale, 3);
|
||||||
|
Assert.Equal(0d, context.ResolveScaledCornerRadius(12d), 3);
|
||||||
|
Assert.Equal(0d, context.ResolveScaledCornerRadius(12d, 8d, 18d), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PluginAppearanceContext_ResolveCornerRadius_DoesNotDoubleScalePresetTokens()
|
||||||
|
{
|
||||||
|
var context = new PluginAppearanceContext(new PluginAppearanceSnapshot(
|
||||||
|
GlobalCornerRadiusScale: 2d,
|
||||||
|
CornerRadiusTokens: new PluginCornerRadiusTokens(
|
||||||
|
Micro: 12d,
|
||||||
|
Xs: 20d,
|
||||||
|
Sm: 28d,
|
||||||
|
Md: 36d,
|
||||||
|
Lg: 48d,
|
||||||
|
Xl: 60d,
|
||||||
|
Island: 72d),
|
||||||
|
ThemeVariant: "Light"));
|
||||||
|
|
||||||
|
Assert.Equal(36d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md), 3);
|
||||||
|
Assert.Equal(36d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md, maximum: 40d), 3);
|
||||||
|
Assert.Equal(36d, context.ResolveScaledCornerRadius(18d), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullServiceProvider : IServiceProvider
|
||||||
|
{
|
||||||
|
public object? GetService(Type serviceType) => null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using LanMountainDesktop.Host.Abstractions;
|
||||||
|
using LanMountainDesktop.Shared.Contracts;
|
||||||
|
using LanMountainDesktop.Views.Components;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class DesktopComponentRuntimeRegistrationCornerRadiusTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void LegacyCellSizeResolver_AppliesGlobalCornerRadiusScale()
|
||||||
|
{
|
||||||
|
var registration = new DesktopComponentRuntimeRegistration(
|
||||||
|
componentId: "test.component",
|
||||||
|
displayNameLocalizationKey: null,
|
||||||
|
controlFactory: () => new Border(),
|
||||||
|
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.30, 10, 40));
|
||||||
|
|
||||||
|
var resolver = Assert.IsType<Func<ComponentChromeContext, double>>(registration.CornerRadiusResolver);
|
||||||
|
var resolved = resolver(CreateChromeContext(cellSize: 120, globalScale: 2.0));
|
||||||
|
|
||||||
|
Assert.Equal(72.0, resolved, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChromeContextResolver_IsNotDoubleScaledByRegistrationWrapper()
|
||||||
|
{
|
||||||
|
var registration = new DesktopComponentRuntimeRegistration(
|
||||||
|
componentId: "test.component",
|
||||||
|
displayNameLocalizationKey: null,
|
||||||
|
controlFactory: _ => new Border(),
|
||||||
|
cornerRadiusResolver: chromeContext => chromeContext.CellSize + chromeContext.GlobalCornerRadiusScale);
|
||||||
|
|
||||||
|
var resolver = Assert.IsType<Func<ComponentChromeContext, double>>(registration.CornerRadiusResolver);
|
||||||
|
var resolved = resolver(CreateChromeContext(cellSize: 50, globalScale: 2.5));
|
||||||
|
|
||||||
|
Assert.Equal(52.5, resolved, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ComponentChromeContext CreateChromeContext(double cellSize, double globalScale)
|
||||||
|
{
|
||||||
|
return new ComponentChromeContext(
|
||||||
|
ComponentId: "test.component",
|
||||||
|
PlacementId: null,
|
||||||
|
CellSize: cellSize,
|
||||||
|
GlobalCornerRadiusScale: globalScale,
|
||||||
|
CornerRadiusTokens: new AppearanceCornerRadiusTokens(
|
||||||
|
new CornerRadius(6),
|
||||||
|
new CornerRadius(10),
|
||||||
|
new CornerRadius(14),
|
||||||
|
new CornerRadius(18),
|
||||||
|
new CornerRadius(24),
|
||||||
|
new CornerRadius(30),
|
||||||
|
new CornerRadius(36)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using LanMountainDesktop.Appearance;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Host.Abstractions;
|
||||||
|
using LanMountainDesktop.Views.Components;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class InfoRecommendationHostCornerRadiusTests
|
||||||
|
{
|
||||||
|
private static readonly string[] WideInfoComponentIds =
|
||||||
|
[
|
||||||
|
BuiltInComponentIds.DesktopDailyPoetry,
|
||||||
|
BuiltInComponentIds.DesktopDailyArtwork,
|
||||||
|
BuiltInComponentIds.DesktopDailyWord,
|
||||||
|
BuiltInComponentIds.DesktopCnrDailyNews,
|
||||||
|
BuiltInComponentIds.DesktopIfengNews,
|
||||||
|
BuiltInComponentIds.DesktopBilibiliHotSearch,
|
||||||
|
BuiltInComponentIds.DesktopBaiduHotSearch,
|
||||||
|
BuiltInComponentIds.DesktopStcn24Forum
|
||||||
|
];
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(80d)]
|
||||||
|
[InlineData(120d)]
|
||||||
|
[InlineData(160d)]
|
||||||
|
public void InfoHostRegistrations_ResolveToTheUnifiedLgBaseline(double cellSize)
|
||||||
|
{
|
||||||
|
var registry = new DesktopComponentRuntimeRegistry(
|
||||||
|
ComponentRegistry.CreateDefault(),
|
||||||
|
DesktopComponentRuntimeRegistry.GetDefaultRegistrations());
|
||||||
|
|
||||||
|
foreach (var componentId in WideInfoComponentIds)
|
||||||
|
{
|
||||||
|
AssertResolved(registry, componentId, cellSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
AssertResolved(registry, BuiltInComponentIds.DesktopDailyWord2x2, cellSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertResolved(
|
||||||
|
DesktopComponentRuntimeRegistry registry,
|
||||||
|
string componentId,
|
||||||
|
double cellSize)
|
||||||
|
{
|
||||||
|
Assert.True(
|
||||||
|
registry.TryGetDescriptor(componentId, out var descriptor),
|
||||||
|
$"Missing runtime registration for '{componentId}'.");
|
||||||
|
|
||||||
|
var zero = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, 0d));
|
||||||
|
var unit = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, 1d));
|
||||||
|
var max = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, 2.5d));
|
||||||
|
|
||||||
|
Assert.Equal(0d, zero, 3);
|
||||||
|
Assert.Equal(24d, unit, 3);
|
||||||
|
Assert.Equal(60d, max, 3);
|
||||||
|
Assert.True(zero <= unit && unit <= max);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ComponentChromeContext CreateChromeContext(
|
||||||
|
string componentId,
|
||||||
|
double cellSize,
|
||||||
|
double globalScale)
|
||||||
|
{
|
||||||
|
return new ComponentChromeContext(
|
||||||
|
componentId,
|
||||||
|
null,
|
||||||
|
cellSize,
|
||||||
|
globalScale,
|
||||||
|
AppearanceCornerRadiusTokenFactory.Create(globalScale));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class WhiteboardNotePersistenceServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SaveNote_ThenLoadNote_RoundTripsSnapshot()
|
||||||
|
{
|
||||||
|
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||||
|
var service = sandbox.CreateService();
|
||||||
|
var snapshot = CreateSampleSnapshot();
|
||||||
|
|
||||||
|
service.SaveNote("DesktopWhiteboard", "whiteboard-1", snapshot, retentionDays: 15);
|
||||||
|
|
||||||
|
var loaded = service.LoadNote("DesktopWhiteboard", "whiteboard-1", retentionDays: 15);
|
||||||
|
|
||||||
|
Assert.Single(loaded.Strokes);
|
||||||
|
Assert.Equal(2, loaded.Strokes[0].Points.Count);
|
||||||
|
Assert.Equal("#FF112233", loaded.Strokes[0].Color);
|
||||||
|
Assert.True(loaded.SavedUtc > DateTimeOffset.MinValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadNote_RemovesExpiredSnapshot_WhenRetentionExceeded()
|
||||||
|
{
|
||||||
|
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||||
|
var service = sandbox.CreateService();
|
||||||
|
|
||||||
|
service.SaveNote("DesktopWhiteboard", "expired-board", CreateSampleSnapshot(), retentionDays: 7);
|
||||||
|
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "expired-board", DateTimeOffset.UtcNow.AddDays(-10), retentionDays: 7);
|
||||||
|
|
||||||
|
var loaded = service.LoadNote("DesktopWhiteboard", "expired-board", retentionDays: 7);
|
||||||
|
|
||||||
|
Assert.Empty(loaded.Strokes);
|
||||||
|
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-board"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeleteExpiredNotesBatch_RemovesExpiredRows_AndKeepsFreshRows()
|
||||||
|
{
|
||||||
|
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||||
|
var service = sandbox.CreateService();
|
||||||
|
|
||||||
|
service.SaveNote("DesktopWhiteboard", "expired-a", CreateSampleSnapshot(), retentionDays: 7);
|
||||||
|
service.SaveNote("DesktopWhiteboard", "expired-b", CreateSampleSnapshot(), retentionDays: 7);
|
||||||
|
service.SaveNote("DesktopWhiteboard", "fresh-c", CreateSampleSnapshot(), retentionDays: 15);
|
||||||
|
|
||||||
|
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "expired-a", DateTimeOffset.UtcNow.AddDays(-9), retentionDays: 7);
|
||||||
|
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "expired-b", DateTimeOffset.UtcNow.AddDays(-8), retentionDays: 7);
|
||||||
|
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "fresh-c", DateTimeOffset.UtcNow.AddDays(-2), retentionDays: 15);
|
||||||
|
|
||||||
|
var deletedCount = service.DeleteExpiredNotesBatch(batchSize: 10);
|
||||||
|
|
||||||
|
Assert.Equal(2, deletedCount);
|
||||||
|
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-a"));
|
||||||
|
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-b"));
|
||||||
|
Assert.True(sandbox.Exists("DesktopWhiteboard", "fresh-c"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WhiteboardNoteSnapshot CreateSampleSnapshot()
|
||||||
|
{
|
||||||
|
return new WhiteboardNoteSnapshot
|
||||||
|
{
|
||||||
|
Strokes =
|
||||||
|
[
|
||||||
|
new WhiteboardStrokeSnapshot
|
||||||
|
{
|
||||||
|
Color = "#FF112233",
|
||||||
|
InkThickness = 3.5d,
|
||||||
|
IgnorePressure = true,
|
||||||
|
Points =
|
||||||
|
[
|
||||||
|
new WhiteboardStylusPointSnapshot { X = 12, Y = 34, Pressure = 0.4d, Width = 2, Height = 2 },
|
||||||
|
new WhiteboardStylusPointSnapshot { X = 48, Y = 64, Pressure = 0.7d, Width = 2, Height = 2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class WhiteboardNotePersistenceSandbox : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _directoryPath = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"LanMountainDesktop.WhiteboardNoteTests",
|
||||||
|
Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
private readonly string _databasePath;
|
||||||
|
|
||||||
|
public WhiteboardNotePersistenceSandbox()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_directoryPath);
|
||||||
|
_databasePath = Path.Combine(_directoryPath, "whiteboard-tests.db");
|
||||||
|
}
|
||||||
|
|
||||||
|
public WhiteboardNotePersistenceService CreateService()
|
||||||
|
{
|
||||||
|
return new WhiteboardNotePersistenceService(new AppDatabaseService(_databasePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OverrideSavedTimestamp(string componentId, string placementId, DateTimeOffset savedUtc, int retentionDays)
|
||||||
|
{
|
||||||
|
var expiresUtc = savedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
|
||||||
|
using var connection = new AppDatabaseService(_databasePath).OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
UPDATE whiteboard_notes
|
||||||
|
SET saved_at_utc_ms = $savedAtUtcMs,
|
||||||
|
expires_at_utc_ms = $expiresAtUtcMs,
|
||||||
|
updated_at_utc_ms = $updatedAtUtcMs
|
||||||
|
WHERE component_id = $componentId
|
||||||
|
AND placement_id = $placementId;
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$savedAtUtcMs", savedUtc.ToUnixTimeMilliseconds());
|
||||||
|
command.Parameters.AddWithValue("$expiresAtUtcMs", expiresUtc.ToUnixTimeMilliseconds());
|
||||||
|
command.Parameters.AddWithValue("$updatedAtUtcMs", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||||
|
command.Parameters.AddWithValue("$componentId", componentId);
|
||||||
|
command.Parameters.AddWithValue("$placementId", placementId);
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Exists(string componentId, string placementId)
|
||||||
|
{
|
||||||
|
using var connection = new AppDatabaseService(_databasePath).OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM whiteboard_notes
|
||||||
|
WHERE component_id = $componentId
|
||||||
|
AND placement_id = $placementId;
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$componentId", componentId);
|
||||||
|
command.Parameters.AddWithValue("$placementId", placementId);
|
||||||
|
return Convert.ToInt32(command.ExecuteScalar()) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_directoryPath))
|
||||||
|
{
|
||||||
|
Directory.Delete(_directoryPath, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Temporary test directories are best-effort cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Project Path="LanAirApp/tools/LanMountainDesktop.PluginPackager/LanMountainDesktop.PluginPackager.csproj" />
|
<Project Path="LanMountainDesktop.Host.Abstractions/LanMountainDesktop.Host.Abstractions.csproj" />
|
||||||
|
<Project Path="LanMountainDesktop.Shared.Contracts/LanMountainDesktop.Shared.Contracts.csproj" />
|
||||||
|
<Project Path="LanMountainDesktop.Settings.Core/LanMountainDesktop.Settings.Core.csproj" />
|
||||||
|
<Project Path="LanMountainDesktop.Appearance/LanMountainDesktop.Appearance.csproj" />
|
||||||
|
<Project Path="LanMountainDesktop.DesktopComponents.Runtime/LanMountainDesktop.DesktopComponents.Runtime.csproj" />
|
||||||
|
<Project Path="LanMountainDesktop.DesktopHost/LanMountainDesktop.DesktopHost.csproj" />
|
||||||
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
|
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
|
||||||
<Project Path="LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj" />
|
<Project Path="LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj" />
|
||||||
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
|
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ using Avalonia.Styling;
|
|||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using AvaloniaWebView;
|
using AvaloniaWebView;
|
||||||
using LanMountainDesktop.ComponentSystem;
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.DesktopHost;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
@@ -61,6 +62,7 @@ public partial class App : Application
|
|||||||
private MainWindow? _mainWindow;
|
private MainWindow? _mainWindow;
|
||||||
private bool _mainWindowClosed;
|
private bool _mainWindowClosed;
|
||||||
private bool _uiUnhandledExceptionHooked;
|
private bool _uiUnhandledExceptionHooked;
|
||||||
|
private DesktopShellHost? _desktopShellHost;
|
||||||
|
|
||||||
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
||||||
internal static (UserBehaviorAnalyticsService?, CrashReportService?) AnalyticsServices { get; set; }
|
internal static (UserBehaviorAnalyticsService?, CrashReportService?) AnalyticsServices { get; set; }
|
||||||
@@ -116,28 +118,32 @@ public partial class App : Application
|
|||||||
AppLogger.Info("App", "Framework initialization completed.");
|
AppLogger.Info("App", "Framework initialization completed.");
|
||||||
RegisterUiUnhandledExceptionGuard();
|
RegisterUiUnhandledExceptionGuard();
|
||||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||||
InitializePluginRuntime();
|
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
|
||||||
InitializeTrayIcon();
|
|
||||||
|
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
base.OnFrameworkInitializationCompleted();
|
||||||
{
|
}
|
||||||
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
|
|
||||||
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
|
private void InitializeDesktopShell()
|
||||||
DisableAvaloniaDataAnnotationValidation();
|
{
|
||||||
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown;
|
_desktopShellHost ??= new DesktopShellHost(
|
||||||
desktop.Exit += (_, _) =>
|
InitializePluginRuntime,
|
||||||
|
InitializeTrayIcon,
|
||||||
|
desktop =>
|
||||||
|
{
|
||||||
|
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
|
||||||
|
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
|
||||||
|
DisableAvaloniaDataAnnotationValidation();
|
||||||
|
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown;
|
||||||
|
CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
|
||||||
|
},
|
||||||
|
() =>
|
||||||
{
|
{
|
||||||
AppLogger.Info("App", "Desktop lifetime exit triggered.");
|
AppLogger.Info("App", "Desktop lifetime exit triggered.");
|
||||||
PerformExitCleanup();
|
PerformExitCleanup();
|
||||||
};
|
},
|
||||||
|
() => CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow),
|
||||||
CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
|
StartWeatherLocationRefreshIfNeeded);
|
||||||
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
|
_desktopShellHost.Initialize(this);
|
||||||
}
|
|
||||||
|
|
||||||
StartWeatherLocationRefreshIfNeeded();
|
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnTrayExitClick(object? sender, EventArgs e)
|
private void OnTrayExitClick(object? sender, EventArgs e)
|
||||||
@@ -493,6 +499,7 @@ public partial class App : Application
|
|||||||
refreshAll ||
|
refreshAll ||
|
||||||
changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) ||
|
changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) ||
|
||||||
changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) ||
|
changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) ||
|
||||||
|
changedKeys.Contains(nameof(AppSettingsSnapshot.GlobalCornerRadiusScale), StringComparer.OrdinalIgnoreCase) ||
|
||||||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) &&
|
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) &&
|
||||||
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) ||
|
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) ||
|
||||||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&
|
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&
|
||||||
@@ -510,6 +517,8 @@ public partial class App : Application
|
|||||||
|
|
||||||
if (languageChanged)
|
if (languageChanged)
|
||||||
{
|
{
|
||||||
|
// 清除本地化缓存,强制重新加载语言文件
|
||||||
|
_localizationService.ClearCache();
|
||||||
ApplyCurrentCultureFromSettings();
|
ApplyCurrentCultureFromSettings();
|
||||||
if (_trayIcons is not null)
|
if (_trayIcons is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# LanMountainDesktop 隐私政策
|
# LanMountainDesktop 隐私政策
|
||||||
|
|
||||||
**最后更新日期:2024年**
|
**最后更新日期:2026年3月17日**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -321,6 +321,6 @@ a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**感谢您信任 LanMountainDesktop!**
|
**感谢您信任阑山桌面LanMountainDesktop!**
|
||||||
|
|
||||||
我们承诺保护您的隐私,并持续改进我们的隐私保护措施。
|
我们承诺保护您的隐私,并持续改进我们的隐私保护措施。
|
||||||
|
|||||||
@@ -41,4 +41,5 @@ public static class BuiltInComponentIds
|
|||||||
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
||||||
public const string DesktopBrowser = "DesktopBrowser";
|
public const string DesktopBrowser = "DesktopBrowser";
|
||||||
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
|
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
|
||||||
|
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,6 +336,15 @@ public sealed class ComponentRegistry
|
|||||||
MinHeightCells: 2,
|
MinHeightCells: 2,
|
||||||
AllowStatusBarPlacement: false,
|
AllowStatusBarPlacement: false,
|
||||||
AllowDesktopPlacement: true),
|
AllowDesktopPlacement: true),
|
||||||
|
new DesktopComponentDefinition(
|
||||||
|
BuiltInComponentIds.DesktopRemovableStorage,
|
||||||
|
"Removable Storage",
|
||||||
|
"Storage",
|
||||||
|
"File",
|
||||||
|
MinWidthCells: 2,
|
||||||
|
MinHeightCells: 2,
|
||||||
|
AllowStatusBarPlacement: false,
|
||||||
|
AllowDesktopPlacement: true),
|
||||||
new DesktopComponentDefinition(
|
new DesktopComponentDefinition(
|
||||||
BuiltInComponentIds.Date,
|
BuiltInComponentIds.Date,
|
||||||
"Calendar",
|
"Calendar",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using LanMountainDesktop.Host.Abstractions;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
@@ -10,5 +11,6 @@ public sealed record DesktopComponentRuntimeContext(
|
|||||||
ISettingsFacadeService SettingsFacade,
|
ISettingsFacadeService SettingsFacade,
|
||||||
ISettingsService SettingsService,
|
ISettingsService SettingsService,
|
||||||
IAppearanceThemeService AppearanceTheme,
|
IAppearanceThemeService AppearanceTheme,
|
||||||
|
ComponentChromeContext Chrome,
|
||||||
IComponentSettingsAccessor ComponentSettingsAccessor,
|
IComponentSettingsAccessor ComponentSettingsAccessor,
|
||||||
IComponentInstanceSettingsStore ComponentSettingsStore);
|
IComponentInstanceSettingsStore ComponentSettingsStore);
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using LanMountainDesktop.Host.Abstractions;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.ComponentSystem;
|
||||||
|
|
||||||
|
public interface IComponentChromeContextAware
|
||||||
|
{
|
||||||
|
void SetComponentChromeContext(ComponentChromeContext context);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
@@ -21,12 +21,20 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="Models\" />
|
<Folder Include="Models\" />
|
||||||
<AvaloniaResource Include="Assets\**" />
|
<AvaloniaResource Include="Assets\**" />
|
||||||
|
<AvaloniaResource Include="Localization\**" />
|
||||||
<EmbeddedResource Include="Assets\Documents\Privacy.md" />
|
<EmbeddedResource Include="Assets\Documents\Privacy.md" />
|
||||||
|
<EmbeddedResource Include="Localization\*.json" />
|
||||||
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
|
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
<None Include="Extensions\Components\*.json" CopyToOutputDirectory="PreserveNewest" />
|
<None Include="Extensions\Components\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Host.Abstractions\LanMountainDesktop.Host.Abstractions.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Settings.Core\LanMountainDesktop.Settings.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Appearance\LanMountainDesktop.Appearance.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.DesktopComponents.Runtime\LanMountainDesktop.DesktopComponents.Runtime.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.DesktopHost\LanMountainDesktop.DesktopHost.csproj" />
|
||||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||||
<ProjectReference Include="..\LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj" ReferenceOutputAssembly="false" />
|
<ProjectReference Include="..\LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj" ReferenceOutputAssembly="false" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
@@ -53,6 +61,10 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
|
||||||
|
<PackageReference Include="MudTools.OfficeInterop" Version="2.0.8" />
|
||||||
|
<PackageReference Include="MudTools.OfficeInterop.Word" Version="2.0.8" />
|
||||||
|
<PackageReference Include="MudTools.OfficeInterop.Excel" Version="2.0.8" />
|
||||||
|
<PackageReference Include="MudTools.OfficeInterop.PowerPoint" Version="2.0.8" />
|
||||||
|
|
||||||
<PackageReference Include="PortAudioSharp2" Version="1.0.6" />
|
<PackageReference Include="PortAudioSharp2" Version="1.0.6" />
|
||||||
<PackageReference Include="MaterialColorUtilities" Version="0.3.0" />
|
<PackageReference Include="MaterialColorUtilities" Version="0.3.0" />
|
||||||
|
|||||||
@@ -7,7 +7,12 @@
|
|||||||
"tray.menu.restart": "Restart App",
|
"tray.menu.restart": "Restart App",
|
||||||
"tray.menu.exit": "Exit App",
|
"tray.menu.exit": "Exit App",
|
||||||
"button.back_to_windows": "Back to Windows",
|
"button.back_to_windows": "Back to Windows",
|
||||||
|
"button.back_to_platform": "Back to {0}",
|
||||||
"tooltip.back_to_windows": "Back to Windows",
|
"tooltip.back_to_windows": "Back to Windows",
|
||||||
|
"tooltip.back_to_platform": "Back to {0}",
|
||||||
|
"platform.windows": "Windows",
|
||||||
|
"platform.linux": "Linux",
|
||||||
|
"platform.macos": "macOS",
|
||||||
"tooltip.open_settings": "Settings",
|
"tooltip.open_settings": "Settings",
|
||||||
"settings.title": "Settings",
|
"settings.title": "Settings",
|
||||||
"settings.shell.title": "Settings",
|
"settings.shell.title": "Settings",
|
||||||
@@ -20,7 +25,7 @@
|
|||||||
"settings.nav.group_system": "System",
|
"settings.nav.group_system": "System",
|
||||||
"settings.nav.group_extensions": "Extensions",
|
"settings.nav.group_extensions": "Extensions",
|
||||||
"settings.nav.wallpaper": "Wallpaper",
|
"settings.nav.wallpaper": "Wallpaper",
|
||||||
"settings.nav.grid": "Grid",
|
"settings.nav.grid": "Components",
|
||||||
"settings.nav.color": "Color",
|
"settings.nav.color": "Color",
|
||||||
"settings.nav.status_bar": "Status Bar",
|
"settings.nav.status_bar": "Status Bar",
|
||||||
"settings.nav.weather": "Weather",
|
"settings.nav.weather": "Weather",
|
||||||
@@ -86,6 +91,8 @@
|
|||||||
"settings.status_bar.description": "Choose which components appear on the top status bar.",
|
"settings.status_bar.description": "Choose which components appear on the top status bar.",
|
||||||
"settings.status_bar.clock_header": "Clock Component",
|
"settings.status_bar.clock_header": "Clock Component",
|
||||||
"settings.status_bar.clock_description": "Display a clock on the top status bar.",
|
"settings.status_bar.clock_description": "Display a clock on the top status bar.",
|
||||||
|
"settings.status_bar.clock_transparent_background_label": "Transparent background",
|
||||||
|
"settings.status_bar.clock_transparent_background_desc": "Remove the capsule background and keep only the clock text.",
|
||||||
"settings.status_bar.spacing_header": "Component Spacing",
|
"settings.status_bar.spacing_header": "Component Spacing",
|
||||||
"settings.status_bar.spacing_desc": "Adjust spacing between status bar components.",
|
"settings.status_bar.spacing_desc": "Adjust spacing between status bar components.",
|
||||||
"settings.status_bar.spacing_mode_compact": "Compact",
|
"settings.status_bar.spacing_mode_compact": "Compact",
|
||||||
@@ -296,8 +303,17 @@
|
|||||||
"settings.status_bar.clock_format.hm": "Hour:Minute",
|
"settings.status_bar.clock_format.hm": "Hour:Minute",
|
||||||
"settings.status_bar.clock_format.hms": "Hour:Minute:Second",
|
"settings.status_bar.clock_format.hms": "Hour:Minute:Second",
|
||||||
"settings.components.title": "Components",
|
"settings.components.title": "Components",
|
||||||
"settings.components.description": "Adjust desktop grid density and widget placement.",
|
"settings.components.description": "Adjust component layout and corner design.",
|
||||||
"settings.components.grid_header": "Grid Layout",
|
"settings.components.grid_header": "Grid Settings",
|
||||||
|
"settings.components.header": "Grid Settings",
|
||||||
|
"settings.components.short_side_label": "Short Side Cells",
|
||||||
|
"settings.components.edge_inset_label": "Screen Inset",
|
||||||
|
"settings.components.spacing_label": "Component Spacing",
|
||||||
|
"settings.components.spacing_compact": "Compact",
|
||||||
|
"settings.components.spacing_relaxed": "Relaxed",
|
||||||
|
"settings.components.corner_radius.header": "Corner Design",
|
||||||
|
"settings.components.corner_radius.label": "Component Corner Radius",
|
||||||
|
"settings.components.corner_radius.description": "Adjust the shared corner radius from a square edge to a capsule-like shape, and expand the internal safe area with it.",
|
||||||
"settings.update.title": "Update",
|
"settings.update.title": "Update",
|
||||||
"settings.update.current_version_label": "Current Version",
|
"settings.update.current_version_label": "Current Version",
|
||||||
"settings.update.latest_version_label": "Latest Release",
|
"settings.update.latest_version_label": "Latest Release",
|
||||||
@@ -403,6 +419,7 @@
|
|||||||
"common.monet": "Monet",
|
"common.monet": "Monet",
|
||||||
"desktop.page_index_format": "Desktop {0}",
|
"desktop.page_index_format": "Desktop {0}",
|
||||||
"launcher.title": "App Launcher",
|
"launcher.title": "App Launcher",
|
||||||
|
"launcher.folder": "Folder",
|
||||||
"launcher.subtitle": "Apps and folders from Windows Start Menu",
|
"launcher.subtitle": "Apps and folders from Windows Start Menu",
|
||||||
"launcher.subtitle_linux": "Installed apps discovered from Linux desktop entries",
|
"launcher.subtitle_linux": "Installed apps discovered from Linux desktop entries",
|
||||||
"launcher.empty": "No Start Menu entries found.",
|
"launcher.empty": "No Start Menu entries found.",
|
||||||
@@ -559,6 +576,7 @@
|
|||||||
"component_category.info": "Info",
|
"component_category.info": "Info",
|
||||||
"component_category.calculator": "Calculator",
|
"component_category.calculator": "Calculator",
|
||||||
"component_category.study": "Study",
|
"component_category.study": "Study",
|
||||||
|
"component_category.file": "File",
|
||||||
"component.date": "Calendar",
|
"component.date": "Calendar",
|
||||||
"component.month_calendar": "Month Calendar",
|
"component.month_calendar": "Month Calendar",
|
||||||
"component.lunar_calendar": "Lunar Calendar",
|
"component.lunar_calendar": "Lunar Calendar",
|
||||||
@@ -587,6 +605,19 @@
|
|||||||
"component.blackboard_landscape": "Blackboard (Landscape)",
|
"component.blackboard_landscape": "Blackboard (Landscape)",
|
||||||
"component.browser": "Browser",
|
"component.browser": "Browser",
|
||||||
"component.office_recent_documents": "Recent Documents",
|
"component.office_recent_documents": "Recent Documents",
|
||||||
|
"whiteboard.settings.desc": "Each blackboard keeps its own note history and saves it independently.",
|
||||||
|
"whiteboard.settings.retention.title": "Note retention",
|
||||||
|
"whiteboard.settings.retention.desc": "Choose how long this blackboard should keep saved notes before expired data is removed automatically.",
|
||||||
|
"whiteboard.settings.retention.option": "{0} days",
|
||||||
|
"whiteboard.settings.instance_scope": "This retention setting is stored per blackboard component instance.",
|
||||||
|
"office_recent_documents.settings.desc": "Choose which Windows and Office sources this widget should scan for recent documents.",
|
||||||
|
"office_recent_documents.settings.sources_title": "Recent document sources",
|
||||||
|
"office_recent_documents.settings.sources_desc": "You can combine multiple sources. Registry selection also keeps the Office interop MRU fallback available.",
|
||||||
|
"office_recent_documents.settings.source.registry": "Office registry MRU",
|
||||||
|
"office_recent_documents.settings.source.recent_folders": "Windows Recent folders",
|
||||||
|
"office_recent_documents.settings.source.jump_lists": "Windows Jump Lists",
|
||||||
|
"office_recent_documents.settings.hint": "If you disable all sources, this widget will stay empty until at least one source is enabled again.",
|
||||||
|
"component.removable_storage": "Removable Storage",
|
||||||
"component.holiday_calendar": "Holiday Calendar",
|
"component.holiday_calendar": "Holiday Calendar",
|
||||||
"component.study_environment": "Environment",
|
"component.study_environment": "Environment",
|
||||||
"component.study_session_control": "Study Session Control",
|
"component.study_session_control": "Study Session Control",
|
||||||
@@ -788,6 +819,20 @@
|
|||||||
"study.environment.settings.show_display_db": "Show display dB",
|
"study.environment.settings.show_display_db": "Show display dB",
|
||||||
"study.environment.settings.show_dbfs": "Show dBFS",
|
"study.environment.settings.show_dbfs": "Show dBFS",
|
||||||
"study.environment.settings.hint": "At least one display mode must stay enabled.",
|
"study.environment.settings.hint": "At least one display mode must stay enabled.",
|
||||||
|
"removable_storage.settings.desc": "Show a connected USB drive with quick open and eject actions.",
|
||||||
|
"removable_storage.settings.behavior_title": "Behavior",
|
||||||
|
"removable_storage.settings.behavior_desc": "The widget automatically watches for removable drives and switches to the newest inserted USB drive.",
|
||||||
|
"removable_storage.action.open": "Open",
|
||||||
|
"removable_storage.action.eject": "Eject",
|
||||||
|
"removable_storage.widget.default_name": "Removable Drive",
|
||||||
|
"removable_storage.widget.empty_title": "No device inserted",
|
||||||
|
"removable_storage.widget.empty_subtitle": "Insert a USB drive to show it here.",
|
||||||
|
"removable_storage.widget.empty_hint": "Buttons stay disabled until a removable device is inserted.",
|
||||||
|
"removable_storage.widget.ready": "Ready to open or eject.",
|
||||||
|
"removable_storage.widget.ejecting": "Ejecting drive...",
|
||||||
|
"removable_storage.widget.eject_failed": "Could not eject this drive. Close any files on it and try again.",
|
||||||
|
"removable_storage.widget.open_failed": "Failed to open this drive.",
|
||||||
|
"removable_storage.widget.refresh_failed": "Drive list refresh failed.",
|
||||||
"study.session_control.action.start": "Start Study Session",
|
"study.session_control.action.start": "Start Study Session",
|
||||||
"study.session_control.action.stop": "Stop Study Session",
|
"study.session_control.action.stop": "Stop Study Session",
|
||||||
"study.session_control.idle_hint": "Tap the right button to start",
|
"study.session_control.idle_hint": "Tap the right button to start",
|
||||||
@@ -892,5 +937,7 @@
|
|||||||
"placement.tile": "Tile",
|
"placement.tile": "Tile",
|
||||||
"single_instance.notice.title": "App already running",
|
"single_instance.notice.title": "App already running",
|
||||||
"single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.",
|
"single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.",
|
||||||
"single_instance.notice.button": "OK"
|
"single_instance.notice.button": "OK",
|
||||||
|
"market.status.install_success_restart_format": "✓ Plugin '{0}' installed successfully! Please restart the application to activate it.",
|
||||||
|
"market.dialog.restart_message_format": "Plugin '{0}' has been installed successfully.\n\nTo use this plugin, you need to restart the application now.\n\nWould you like to restart?"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,12 @@
|
|||||||
"tray.menu.restart": "重启应用",
|
"tray.menu.restart": "重启应用",
|
||||||
"tray.menu.exit": "退出应用",
|
"tray.menu.exit": "退出应用",
|
||||||
"button.back_to_windows": "回到Windows",
|
"button.back_to_windows": "回到Windows",
|
||||||
|
"button.back_to_platform": "回到{0}",
|
||||||
"tooltip.back_to_windows": "回到Windows",
|
"tooltip.back_to_windows": "回到Windows",
|
||||||
|
"tooltip.back_to_platform": "回到{0}",
|
||||||
|
"platform.windows": "Windows",
|
||||||
|
"platform.linux": "Linux",
|
||||||
|
"platform.macos": "macOS",
|
||||||
"tooltip.open_settings": "设置",
|
"tooltip.open_settings": "设置",
|
||||||
"settings.title": "设置",
|
"settings.title": "设置",
|
||||||
"settings.shell.title": "设置",
|
"settings.shell.title": "设置",
|
||||||
@@ -20,7 +25,7 @@
|
|||||||
"settings.nav.group_system": "系统",
|
"settings.nav.group_system": "系统",
|
||||||
"settings.nav.group_extensions": "扩展",
|
"settings.nav.group_extensions": "扩展",
|
||||||
"settings.nav.wallpaper": "壁纸",
|
"settings.nav.wallpaper": "壁纸",
|
||||||
"settings.nav.grid": "网格",
|
"settings.nav.grid": "组件",
|
||||||
"settings.nav.color": "颜色",
|
"settings.nav.color": "颜色",
|
||||||
"settings.nav.status_bar": "状态栏",
|
"settings.nav.status_bar": "状态栏",
|
||||||
"settings.nav.weather": "天气",
|
"settings.nav.weather": "天气",
|
||||||
@@ -85,6 +90,8 @@
|
|||||||
"settings.status_bar.description": "选择顶部状态栏显示的组件。",
|
"settings.status_bar.description": "选择顶部状态栏显示的组件。",
|
||||||
"settings.status_bar.clock_header": "时间组件",
|
"settings.status_bar.clock_header": "时间组件",
|
||||||
"settings.status_bar.clock_description": "在顶部状态栏显示时钟。",
|
"settings.status_bar.clock_description": "在顶部状态栏显示时钟。",
|
||||||
|
"settings.status_bar.clock_transparent_background_label": "透明背景",
|
||||||
|
"settings.status_bar.clock_transparent_background_desc": "移除胶囊背景,仅保留时钟文字。",
|
||||||
"settings.status_bar.spacing_header": "组件间距",
|
"settings.status_bar.spacing_header": "组件间距",
|
||||||
"settings.status_bar.spacing_desc": "调整状态栏组件之间的间距。",
|
"settings.status_bar.spacing_desc": "调整状态栏组件之间的间距。",
|
||||||
"settings.status_bar.spacing_mode_compact": "紧凑",
|
"settings.status_bar.spacing_mode_compact": "紧凑",
|
||||||
@@ -294,9 +301,18 @@
|
|||||||
"settings.status_bar.clock_format_label": "时钟格式",
|
"settings.status_bar.clock_format_label": "时钟格式",
|
||||||
"settings.status_bar.clock_format.hm": "时:分",
|
"settings.status_bar.clock_format.hm": "时:分",
|
||||||
"settings.status_bar.clock_format.hms": "时:分:秒",
|
"settings.status_bar.clock_format.hms": "时:分:秒",
|
||||||
"settings.components.title": "网格",
|
"settings.components.title": "组件",
|
||||||
"settings.components.description": "调整桌面网格与布局。",
|
"settings.components.description": "调整组件布局与圆角设计。",
|
||||||
"settings.components.grid_header": "网格布局",
|
"settings.components.grid_header": "网格设置",
|
||||||
|
"settings.components.header": "网格设置",
|
||||||
|
"settings.components.short_side_label": "短边格数",
|
||||||
|
"settings.components.edge_inset_label": "屏幕边距",
|
||||||
|
"settings.components.spacing_label": "组件间距",
|
||||||
|
"settings.components.spacing_compact": "紧凑",
|
||||||
|
"settings.components.spacing_relaxed": "宽松",
|
||||||
|
"settings.components.corner_radius.header": "圆角设计",
|
||||||
|
"settings.components.corner_radius.label": "组件圆角",
|
||||||
|
"settings.components.corner_radius.description": "将组件容器圆角从直角连续调到接近胶囊的形态,并随圆角增大同步扩展内部安全区。",
|
||||||
"settings.update.title": "更新",
|
"settings.update.title": "更新",
|
||||||
"settings.update.current_version_label": "当前版本",
|
"settings.update.current_version_label": "当前版本",
|
||||||
"settings.update.latest_version_label": "最新发布",
|
"settings.update.latest_version_label": "最新发布",
|
||||||
@@ -401,6 +417,7 @@
|
|||||||
"common.monet": "莫奈",
|
"common.monet": "莫奈",
|
||||||
"desktop.page_index_format": "桌面 {0}",
|
"desktop.page_index_format": "桌面 {0}",
|
||||||
"launcher.title": "应用启动台",
|
"launcher.title": "应用启动台",
|
||||||
|
"launcher.folder": "文件夹",
|
||||||
"launcher.subtitle": "按 Windows 开始菜单结构显示所有应用与文件夹",
|
"launcher.subtitle": "按 Windows 开始菜单结构显示所有应用与文件夹",
|
||||||
"launcher.subtitle_linux": "显示从 Linux .desktop 条目扫描到的已安装应用",
|
"launcher.subtitle_linux": "显示从 Linux .desktop 条目扫描到的已安装应用",
|
||||||
"launcher.empty": "未找到开始菜单条目。",
|
"launcher.empty": "未找到开始菜单条目。",
|
||||||
@@ -557,6 +574,7 @@
|
|||||||
"component_category.info": "信息推荐",
|
"component_category.info": "信息推荐",
|
||||||
"component_category.calculator": "计算器",
|
"component_category.calculator": "计算器",
|
||||||
"component_category.study": "自习",
|
"component_category.study": "自习",
|
||||||
|
"component_category.file": "文件",
|
||||||
"component.date": "日历",
|
"component.date": "日历",
|
||||||
"component.month_calendar": "月历",
|
"component.month_calendar": "月历",
|
||||||
"component.lunar_calendar": "农历",
|
"component.lunar_calendar": "农历",
|
||||||
@@ -585,6 +603,18 @@
|
|||||||
"component.blackboard_landscape": "横向小黑板",
|
"component.blackboard_landscape": "横向小黑板",
|
||||||
"component.browser": "浏览器",
|
"component.browser": "浏览器",
|
||||||
"component.office_recent_documents": "最近文档",
|
"component.office_recent_documents": "最近文档",
|
||||||
|
"whiteboard.settings.desc": "每个小黑板都会独立保存自己的笔记历史。",
|
||||||
|
"whiteboard.settings.retention.title": "笔记保留时间",
|
||||||
|
"whiteboard.settings.retention.desc": "选择这个小黑板在过期笔记被自动删除前,应当保留已保存笔记多久。",
|
||||||
|
"whiteboard.settings.retention.option": "{0} 天",
|
||||||
|
"whiteboard.settings.instance_scope": "这个保留时间设置会按每个小黑板组件实例单独存储。",
|
||||||
|
"office_recent_documents.settings.desc": "选择此小组件需要扫描的 Windows 和 Office 最近文档来源。",
|
||||||
|
"office_recent_documents.settings.sources_title": "最近文档来源",
|
||||||
|
"office_recent_documents.settings.sources_desc": "可以同时选择多个来源。勾选注册表来源时,还会保留 Office interop 的 MRU 回退。",
|
||||||
|
"office_recent_documents.settings.source.registry": "Office 注册表 MRU",
|
||||||
|
"office_recent_documents.settings.source.recent_folders": "Windows 最近文件夹",
|
||||||
|
"office_recent_documents.settings.source.jump_lists": "Windows 跳转列表",
|
||||||
|
"office_recent_documents.settings.hint": "如果关闭全部来源,此小组件会保持空白,直到再次至少启用一个来源。",
|
||||||
"component.holiday_calendar": "节假日日历",
|
"component.holiday_calendar": "节假日日历",
|
||||||
"component.study_environment": "环境",
|
"component.study_environment": "环境",
|
||||||
"component.study_session_control": "自习时段控制",
|
"component.study_session_control": "自习时段控制",
|
||||||
@@ -781,6 +811,21 @@
|
|||||||
"study.environment.value.unavailable": "--",
|
"study.environment.value.unavailable": "--",
|
||||||
"study.environment.value.display_format": "{0:F1} dB",
|
"study.environment.value.display_format": "{0:F1} dB",
|
||||||
"study.environment.value.dbfs_format": "{0:F1} dBFS",
|
"study.environment.value.dbfs_format": "{0:F1} dBFS",
|
||||||
|
"component.removable_storage": "可移动存储",
|
||||||
|
"removable_storage.settings.desc": "在桌面上显示已连接的 U 盘,并提供打开与弹出操作。",
|
||||||
|
"removable_storage.settings.behavior_title": "行为",
|
||||||
|
"removable_storage.settings.behavior_desc": "组件会自动监听可移动存储设备,并优先显示最新插入的 U 盘。",
|
||||||
|
"removable_storage.action.open": "打开",
|
||||||
|
"removable_storage.action.eject": "弹出",
|
||||||
|
"removable_storage.widget.default_name": "可移动磁盘",
|
||||||
|
"removable_storage.widget.empty_title": "未插入设备",
|
||||||
|
"removable_storage.widget.empty_subtitle": "插入 U 盘后会自动显示在这里。",
|
||||||
|
"removable_storage.widget.empty_hint": "在插入可移动设备之前,底部按钮会保持置灰不可点击。",
|
||||||
|
"removable_storage.widget.ready": "已准备好,可直接打开或弹出。",
|
||||||
|
"removable_storage.widget.ejecting": "正在弹出设备...",
|
||||||
|
"removable_storage.widget.eject_failed": "无法弹出该设备,请先关闭正在占用它的文件后再试。",
|
||||||
|
"removable_storage.widget.open_failed": "打开该设备失败。",
|
||||||
|
"removable_storage.widget.refresh_failed": "刷新可移动存储列表失败。",
|
||||||
"study.environment.settings.title": "环境组件设置",
|
"study.environment.settings.title": "环境组件设置",
|
||||||
"study.environment.settings.desc": "配置右侧实时噪音值显示内容。",
|
"study.environment.settings.desc": "配置右侧实时噪音值显示内容。",
|
||||||
"study.environment.settings.show_display_db": "显示 display dB",
|
"study.environment.settings.show_display_db": "显示 display dB",
|
||||||
@@ -890,5 +935,7 @@
|
|||||||
"placement.tile": "平铺",
|
"placement.tile": "平铺",
|
||||||
"single_instance.notice.title": "应用已经运行",
|
"single_instance.notice.title": "应用已经运行",
|
||||||
"single_instance.notice.description": "应用已经运行,无需多次点击打开。",
|
"single_instance.notice.description": "应用已经运行,无需多次点击打开。",
|
||||||
"single_instance.notice.button": "确定"
|
"single_instance.notice.button": "确定",
|
||||||
|
"market.status.install_success_restart_format": "✓ 插件'{0}'安装成功!请重启应用以激活它。",
|
||||||
|
"market.dialog.restart_message_format": "插件'{0}'已成功安装。\n\n要使用此插件,您需要立即重启应用。\n\n是否立即重启?"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using LanMountainDesktop.Settings.Core;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Models;
|
namespace LanMountainDesktop.Models;
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@ public sealed class AppSettingsSnapshot
|
|||||||
|
|
||||||
public bool UseSystemChrome { get; set; }
|
public bool UseSystemChrome { get; set; }
|
||||||
|
|
||||||
|
public double GlobalCornerRadiusScale { get; set; } = GlobalAppearanceSettings.DefaultCornerRadiusScale;
|
||||||
|
|
||||||
public string ThemeColorMode { get; set; } = "default_neutral";
|
public string ThemeColorMode { get; set; } = "default_neutral";
|
||||||
|
|
||||||
public string SystemMaterialMode { get; set; } = "none";
|
public string SystemMaterialMode { get; set; } = "none";
|
||||||
@@ -101,6 +104,8 @@ public sealed class AppSettingsSnapshot
|
|||||||
|
|
||||||
public string ClockDisplayFormat { get; set; } = "HourMinuteSecond";
|
public string ClockDisplayFormat { get; set; } = "HourMinuteSecond";
|
||||||
|
|
||||||
|
public bool StatusBarClockTransparentBackground { get; set; }
|
||||||
|
|
||||||
public string StatusBarSpacingMode { get; set; } = "Relaxed";
|
public string StatusBarSpacingMode { get; set; } = "Relaxed";
|
||||||
|
|
||||||
public int StatusBarCustomSpacingPercent { get; set; } = 12;
|
public int StatusBarCustomSpacingPercent { get; set; } = 12;
|
||||||
|
|||||||
@@ -58,12 +58,16 @@ public sealed class ComponentSettingsSnapshot
|
|||||||
|
|
||||||
public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12;
|
public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12;
|
||||||
|
|
||||||
|
public int WhiteboardNoteRetentionDays { get; set; } = 15;
|
||||||
|
|
||||||
public bool Stcn24ForumAutoRefreshEnabled { get; set; } = true;
|
public bool Stcn24ForumAutoRefreshEnabled { get; set; } = true;
|
||||||
|
|
||||||
public int Stcn24ForumAutoRefreshIntervalMinutes { get; set; } = 20;
|
public int Stcn24ForumAutoRefreshIntervalMinutes { get; set; } = 20;
|
||||||
|
|
||||||
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
|
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
|
||||||
|
|
||||||
|
public List<string>? OfficeRecentDocumentsEnabledSources { get; set; }
|
||||||
|
|
||||||
public ComponentSettingsSnapshot Clone()
|
public ComponentSettingsSnapshot Clone()
|
||||||
{
|
{
|
||||||
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
|
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
|
||||||
@@ -91,6 +95,9 @@ public sealed class ComponentSettingsSnapshot
|
|||||||
clone.WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 }
|
clone.WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 }
|
||||||
? new List<string>(WorldClockTimeZoneIds)
|
? new List<string>(WorldClockTimeZoneIds)
|
||||||
: [];
|
: [];
|
||||||
|
clone.OfficeRecentDocumentsEnabledSources = OfficeRecentDocumentsEnabledSources is not null
|
||||||
|
? new List<string>(OfficeRecentDocumentsEnabledSources)
|
||||||
|
: null;
|
||||||
|
|
||||||
return clone;
|
return clone;
|
||||||
}
|
}
|
||||||
|
|||||||
53
LanMountainDesktop/Models/OfficeRecentDocumentSourceTypes.cs
Normal file
53
LanMountainDesktop/Models/OfficeRecentDocumentSourceTypes.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
public static class OfficeRecentDocumentSourceTypes
|
||||||
|
{
|
||||||
|
public const string Registry = "registry";
|
||||||
|
public const string RecentFolders = "recent_folders";
|
||||||
|
public const string JumpLists = "jump_lists";
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> SupportedValues { get; } =
|
||||||
|
[
|
||||||
|
Registry,
|
||||||
|
RecentFolders,
|
||||||
|
JumpLists
|
||||||
|
];
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> DefaultValues => SupportedValues;
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> NormalizeValues(IEnumerable<string>? values, bool useDefaultWhenEmpty)
|
||||||
|
{
|
||||||
|
if (values is null)
|
||||||
|
{
|
||||||
|
return useDefaultWhenEmpty ? DefaultValues : Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = values
|
||||||
|
.Select(NormalizeValue)
|
||||||
|
.OfType<string>()
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (normalized.Length == 0 && useDefaultWhenEmpty)
|
||||||
|
{
|
||||||
|
return DefaultValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeValue(string? value)
|
||||||
|
{
|
||||||
|
return value?.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
Registry => Registry,
|
||||||
|
RecentFolders => RecentFolders,
|
||||||
|
JumpLists => JumpLists,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
23
LanMountainDesktop/Models/WhiteboardNoteRetentionPolicy.cs
Normal file
23
LanMountainDesktop/Models/WhiteboardNoteRetentionPolicy.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
namespace LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
public static class WhiteboardNoteRetentionPolicy
|
||||||
|
{
|
||||||
|
public const int MinimumDays = 7;
|
||||||
|
public const int MaximumDays = 15;
|
||||||
|
public const int DefaultDays = MaximumDays;
|
||||||
|
|
||||||
|
public static int NormalizeDays(int days)
|
||||||
|
{
|
||||||
|
if (days < MinimumDays)
|
||||||
|
{
|
||||||
|
return MinimumDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (days > MaximumDays)
|
||||||
|
{
|
||||||
|
return MaximumDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
LanMountainDesktop/Models/WhiteboardNoteSnapshot.cs
Normal file
60
LanMountainDesktop/Models/WhiteboardNoteSnapshot.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
public sealed class WhiteboardNoteSnapshot
|
||||||
|
{
|
||||||
|
public int Version { get; set; } = 1;
|
||||||
|
|
||||||
|
public DateTimeOffset SavedUtc { get; set; }
|
||||||
|
|
||||||
|
public List<WhiteboardStrokeSnapshot> Strokes { get; set; } = [];
|
||||||
|
|
||||||
|
public WhiteboardNoteSnapshot Clone()
|
||||||
|
{
|
||||||
|
var clone = (WhiteboardNoteSnapshot)MemberwiseClone();
|
||||||
|
clone.Strokes = Strokes is { Count: > 0 }
|
||||||
|
? new List<WhiteboardStrokeSnapshot>(Strokes.ConvertAll(stroke => stroke?.Clone() ?? new WhiteboardStrokeSnapshot()))
|
||||||
|
: [];
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WhiteboardStrokeSnapshot
|
||||||
|
{
|
||||||
|
public string Color { get; set; } = "#FF000000";
|
||||||
|
|
||||||
|
public double InkThickness { get; set; } = 2.5d;
|
||||||
|
|
||||||
|
public bool IgnorePressure { get; set; } = true;
|
||||||
|
|
||||||
|
public List<WhiteboardStylusPointSnapshot> Points { get; set; } = [];
|
||||||
|
|
||||||
|
public WhiteboardStrokeSnapshot Clone()
|
||||||
|
{
|
||||||
|
var clone = (WhiteboardStrokeSnapshot)MemberwiseClone();
|
||||||
|
clone.Points = Points is { Count: > 0 }
|
||||||
|
? new List<WhiteboardStylusPointSnapshot>(Points.ConvertAll(point => point?.Clone() ?? new WhiteboardStylusPointSnapshot()))
|
||||||
|
: [];
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WhiteboardStylusPointSnapshot
|
||||||
|
{
|
||||||
|
public double X { get; set; }
|
||||||
|
|
||||||
|
public double Y { get; set; }
|
||||||
|
|
||||||
|
public double Pressure { get; set; } = 0.5d;
|
||||||
|
|
||||||
|
public double Width { get; set; }
|
||||||
|
|
||||||
|
public double Height { get; set; }
|
||||||
|
|
||||||
|
public WhiteboardStylusPointSnapshot Clone()
|
||||||
|
{
|
||||||
|
return (WhiteboardStylusPointSnapshot)MemberwiseClone();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.WebView.Desktop;
|
using Avalonia.WebView.Desktop;
|
||||||
|
using LanMountainDesktop.DesktopHost;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
@@ -20,9 +21,11 @@ sealed class Program
|
|||||||
{
|
{
|
||||||
AppLogger.Initialize();
|
AppLogger.Initialize();
|
||||||
RegisterGlobalExceptionLogging();
|
RegisterGlobalExceptionLogging();
|
||||||
InitializeDeviceId();
|
DesktopBootstrap.InitializeStartupServices(
|
||||||
InitializeCrashReporting();
|
InitializeDeviceId,
|
||||||
InitializeUserBehaviorAnalytics();
|
InitializeCrashReporting,
|
||||||
|
InitializeUserBehaviorAnalytics,
|
||||||
|
ScheduleWhiteboardNoteStartupCleanup);
|
||||||
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
|
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
|
||||||
|
|
||||||
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
|
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
|
||||||
@@ -88,6 +91,25 @@ sealed class Program
|
|||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ScheduleWhiteboardNoteStartupCleanup()
|
||||||
|
{
|
||||||
|
_ = Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var deletedCount = new WhiteboardNotePersistenceService().DeleteExpiredNotesBatch(batchSize: 512);
|
||||||
|
if (deletedCount > 0)
|
||||||
|
{
|
||||||
|
AppLogger.Info("Startup", $"Deleted {deletedCount} expired whiteboard notes during startup maintenance.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("Startup", "Failed to run whiteboard note startup maintenance.", ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private static SingleInstanceService AcquireSingleInstance(int? restartParentProcessId)
|
private static SingleInstanceService AcquireSingleInstance(int? restartParentProcessId)
|
||||||
{
|
{
|
||||||
var singleInstance = SingleInstanceService.CreateDefault();
|
var singleInstance = SingleInstanceService.CreateDefault();
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ public sealed class AppDatabaseService
|
|||||||
_databasePath = Path.Combine(dataDirectory, "app.db");
|
_databasePath = Path.Combine(dataDirectory, "app.db");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public AppDatabaseService(string databasePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(databasePath))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Database path cannot be null or whitespace.", nameof(databasePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
_databasePath = databasePath;
|
||||||
|
}
|
||||||
|
|
||||||
public SqliteConnection OpenConnection()
|
public SqliteConnection OpenConnection()
|
||||||
{
|
{
|
||||||
var directory = Path.GetDirectoryName(_databasePath);
|
var directory = Path.GetDirectoryName(_databasePath);
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ using Avalonia.Media;
|
|||||||
using Avalonia.Styling;
|
using Avalonia.Styling;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
|
using LanMountainDesktop.Appearance;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using LanMountainDesktop.Settings.Core;
|
||||||
|
using LanMountainDesktop.Shared.Contracts;
|
||||||
using LanMountainDesktop.Theme;
|
using LanMountainDesktop.Theme;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
|
|
||||||
@@ -41,6 +44,8 @@ public sealed record AppearanceThemeSnapshot(
|
|||||||
string ThemeColorMode,
|
string ThemeColorMode,
|
||||||
string? UserThemeColor,
|
string? UserThemeColor,
|
||||||
string? SelectedWallpaperSeed,
|
string? SelectedWallpaperSeed,
|
||||||
|
double GlobalCornerRadiusScale,
|
||||||
|
AppearanceCornerRadiusTokens CornerRadiusTokens,
|
||||||
string ResolvedSeedSource,
|
string ResolvedSeedSource,
|
||||||
MonetPalette MonetPalette,
|
MonetPalette MonetPalette,
|
||||||
Color AccentColor,
|
Color AccentColor,
|
||||||
@@ -464,6 +469,13 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
|||||||
var context = CreateThemeContext(snapshot);
|
var context = CreateThemeContext(snapshot);
|
||||||
ThemeColorSystemService.ApplyThemeResources(resources, context);
|
ThemeColorSystemService.ApplyThemeResources(resources, context);
|
||||||
GlassEffectService.ApplyGlassResources(resources, context);
|
GlassEffectService.ApplyGlassResources(resources, context);
|
||||||
|
resources["DesignCornerRadiusMicro"] = snapshot.CornerRadiusTokens.Micro;
|
||||||
|
resources["DesignCornerRadiusXs"] = snapshot.CornerRadiusTokens.Xs;
|
||||||
|
resources["DesignCornerRadiusSm"] = snapshot.CornerRadiusTokens.Sm;
|
||||||
|
resources["DesignCornerRadiusMd"] = snapshot.CornerRadiusTokens.Md;
|
||||||
|
resources["DesignCornerRadiusLg"] = snapshot.CornerRadiusTokens.Lg;
|
||||||
|
resources["DesignCornerRadiusXl"] = snapshot.CornerRadiusTokens.Xl;
|
||||||
|
resources["DesignCornerRadiusIsland"] = snapshot.CornerRadiusTokens.Island;
|
||||||
}
|
}
|
||||||
|
|
||||||
public AppearanceMaterialSurface GetMaterialSurface(MaterialSurfaceRole role)
|
public AppearanceMaterialSurface GetMaterialSurface(MaterialSurfaceRole role)
|
||||||
@@ -538,6 +550,7 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
|||||||
if (!refreshAll &&
|
if (!refreshAll &&
|
||||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) &&
|
!changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) &&
|
||||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) &&
|
!changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) &&
|
||||||
|
!changedKeys.Contains(nameof(AppSettingsSnapshot.GlobalCornerRadiusScale), StringComparer.OrdinalIgnoreCase) &&
|
||||||
!(respondsToThemeColor &&
|
!(respondsToThemeColor &&
|
||||||
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) &&
|
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) &&
|
||||||
!(respondsToWallpaper &&
|
!(respondsToWallpaper &&
|
||||||
@@ -559,6 +572,8 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
|||||||
bool queueWallpaperPaletteBuild)
|
bool queueWallpaperPaletteBuild)
|
||||||
{
|
{
|
||||||
var availableModes = _windowMaterialService.GetAvailableModes();
|
var availableModes = _windowMaterialService.GetAvailableModes();
|
||||||
|
var globalCornerRadiusScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(themeState.GlobalCornerRadiusScale);
|
||||||
|
var cornerRadiusTokens = AppearanceCornerRadiusTokenFactory.Create(globalCornerRadiusScale);
|
||||||
MonetPalette palette;
|
MonetPalette palette;
|
||||||
IReadOnlyList<Color> wallpaperSeedCandidates;
|
IReadOnlyList<Color> wallpaperSeedCandidates;
|
||||||
Color effectiveSeedColor;
|
Color effectiveSeedColor;
|
||||||
@@ -598,6 +613,8 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
|||||||
themeColorMode,
|
themeColorMode,
|
||||||
themeState.ThemeColor,
|
themeState.ThemeColor,
|
||||||
selectedWallpaperSeed,
|
selectedWallpaperSeed,
|
||||||
|
globalCornerRadiusScale,
|
||||||
|
cornerRadiusTokens,
|
||||||
resolvedSeedSource,
|
resolvedSeedSource,
|
||||||
palette,
|
palette,
|
||||||
ResolveAccentColor(themeColorMode, themeState.ThemeColor, palette),
|
ResolveAccentColor(themeColorMode, themeState.ThemeColor, palette),
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
|||||||
|
|
||||||
public void DeleteForComponent(string componentId, string? placementId)
|
public void DeleteForComponent(string componentId, string? placementId)
|
||||||
{
|
{
|
||||||
|
_ = new WhiteboardNotePersistenceService().DeleteNote(componentId, placementId);
|
||||||
|
|
||||||
if (_settingsService is not null)
|
if (_settingsService is not null)
|
||||||
{
|
{
|
||||||
_settingsService.SaveSnapshot(
|
_settingsService.SaveSnapshot(
|
||||||
|
|||||||
@@ -72,6 +72,18 @@ public static class DesktopComponentEditorRegistryFactory
|
|||||||
[BuiltInComponentIds.DesktopStudyEnvironment] = new(
|
[BuiltInComponentIds.DesktopStudyEnvironment] = new(
|
||||||
BuiltInComponentIds.DesktopStudyEnvironment,
|
BuiltInComponentIds.DesktopStudyEnvironment,
|
||||||
context => new StudyEnvironmentComponentEditor(context)),
|
context => new StudyEnvironmentComponentEditor(context)),
|
||||||
|
[BuiltInComponentIds.DesktopRemovableStorage] = new(
|
||||||
|
BuiltInComponentIds.DesktopRemovableStorage,
|
||||||
|
context => new RemovableStorageComponentEditor(context)),
|
||||||
|
[BuiltInComponentIds.DesktopWhiteboard] = new(
|
||||||
|
BuiltInComponentIds.DesktopWhiteboard,
|
||||||
|
context => new WhiteboardComponentEditor(context)),
|
||||||
|
[BuiltInComponentIds.DesktopBlackboardLandscape] = new(
|
||||||
|
BuiltInComponentIds.DesktopBlackboardLandscape,
|
||||||
|
context => new WhiteboardComponentEditor(context)),
|
||||||
|
[BuiltInComponentIds.DesktopOfficeRecentDocuments] = new(
|
||||||
|
BuiltInComponentIds.DesktopOfficeRecentDocuments,
|
||||||
|
context => new OfficeRecentDocumentsComponentEditor(context)),
|
||||||
[BuiltInComponentIds.DesktopWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeather),
|
[BuiltInComponentIds.DesktopWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeather),
|
||||||
[BuiltInComponentIds.DesktopWeatherClock] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeatherClock),
|
[BuiltInComponentIds.DesktopWeatherClock] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeatherClock),
|
||||||
[BuiltInComponentIds.DesktopHourlyWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopHourlyWeather),
|
[BuiltInComponentIds.DesktopHourlyWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopHourlyWeather),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using Avalonia.Layout;
|
|||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using LanMountainDesktop.ComponentSystem;
|
using LanMountainDesktop.ComponentSystem;
|
||||||
using LanMountainDesktop.ComponentSystem.Extensions;
|
using LanMountainDesktop.ComponentSystem.Extensions;
|
||||||
|
using LanMountainDesktop.Host.Abstractions;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
using LanMountainDesktop.Views.Components;
|
using LanMountainDesktop.Views.Components;
|
||||||
@@ -62,7 +63,11 @@ public static class DesktopComponentRegistryFactory
|
|||||||
registration.ComponentId,
|
registration.ComponentId,
|
||||||
registration.DisplayNameLocalizationKey,
|
registration.DisplayNameLocalizationKey,
|
||||||
factoryContext => CreatePluginControl(contribution, factoryContext),
|
factoryContext => CreatePluginControl(contribution, factoryContext),
|
||||||
registration.CornerRadiusResolver));
|
chromeContext =>
|
||||||
|
{
|
||||||
|
var appearanceContext = CreatePluginAppearanceContext(chromeContext);
|
||||||
|
return registration.ResolveCornerRadius(appearanceContext, chromeContext.CellSize);
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +127,11 @@ public static class DesktopComponentRegistryFactory
|
|||||||
var pluginSettings = new PluginScopedSettingsService(
|
var pluginSettings = new PluginScopedSettingsService(
|
||||||
contribution.Plugin.Manifest.Id,
|
contribution.Plugin.Manifest.Id,
|
||||||
settingsService);
|
settingsService);
|
||||||
|
var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent();
|
||||||
|
var pluginAppearance = new PluginAppearanceContext(new PluginAppearanceSnapshot(
|
||||||
|
GlobalCornerRadiusScale: appearanceSnapshot.GlobalCornerRadiusScale,
|
||||||
|
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(appearanceSnapshot.CornerRadiusTokens),
|
||||||
|
ThemeVariant: appearanceSnapshot.IsNightMode ? "Dark" : "Light"));
|
||||||
var pluginContext = new PluginDesktopComponentContext(
|
var pluginContext = new PluginDesktopComponentContext(
|
||||||
contribution.Plugin.Manifest,
|
contribution.Plugin.Manifest,
|
||||||
contribution.Plugin.Context.PluginDirectory,
|
contribution.Plugin.Context.PluginDirectory,
|
||||||
@@ -131,6 +141,7 @@ public static class DesktopComponentRegistryFactory
|
|||||||
contribution.Registration.ComponentId,
|
contribution.Registration.ComponentId,
|
||||||
context.PlacementId,
|
context.PlacementId,
|
||||||
context.CellSize,
|
context.CellSize,
|
||||||
|
pluginAppearance,
|
||||||
pluginSettings);
|
pluginSettings);
|
||||||
|
|
||||||
return contribution.Registration.ControlFactory(contribution.Plugin.Services, pluginContext);
|
return contribution.Registration.ControlFactory(contribution.Plugin.Services, pluginContext);
|
||||||
@@ -143,6 +154,14 @@ public static class DesktopComponentRegistryFactory
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IPluginAppearanceContext CreatePluginAppearanceContext(ComponentChromeContext chromeContext)
|
||||||
|
{
|
||||||
|
return new PluginAppearanceContext(new PluginAppearanceSnapshot(
|
||||||
|
GlobalCornerRadiusScale: chromeContext.GlobalCornerRadiusScale,
|
||||||
|
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(chromeContext.CornerRadiusTokens),
|
||||||
|
ThemeVariant: "Unknown"));
|
||||||
|
}
|
||||||
|
|
||||||
private static Control CreatePluginErrorControl(
|
private static Control CreatePluginErrorControl(
|
||||||
PluginDesktopComponentContribution contribution,
|
PluginDesktopComponentContribution contribution,
|
||||||
Exception exception)
|
Exception exception)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public sealed record ComponentLibraryCategoryEntry(
|
|||||||
|
|
||||||
public sealed record ComponentLibraryCreateContext(
|
public sealed record ComponentLibraryCreateContext(
|
||||||
double CellSize,
|
double CellSize,
|
||||||
|
double GlobalCornerRadiusScale,
|
||||||
TimeZoneService TimeZoneService,
|
TimeZoneService TimeZoneService,
|
||||||
IWeatherInfoService WeatherInfoService,
|
IWeatherInfoService WeatherInfoService,
|
||||||
IRecommendationInfoService RecommendationInfoService,
|
IRecommendationInfoService RecommendationInfoService,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public interface IWhiteboardNotePersistenceService
|
||||||
|
{
|
||||||
|
WhiteboardNoteSnapshot LoadNote(string componentId, string? placementId, int retentionDays);
|
||||||
|
|
||||||
|
void SaveNote(string componentId, string? placementId, WhiteboardNoteSnapshot snapshot, int retentionDays);
|
||||||
|
|
||||||
|
bool DeleteNote(string componentId, string? placementId);
|
||||||
|
|
||||||
|
bool TryDeleteExpiredNote(string componentId, string? placementId, int retentionDays);
|
||||||
|
|
||||||
|
bool IsExpired(WhiteboardNoteSnapshot snapshot, int retentionDays, DateTimeOffset? now = null);
|
||||||
|
|
||||||
|
DateTimeOffset? GetExpirationUtc(WhiteboardNoteSnapshot snapshot, int retentionDays);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Reflection;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Services;
|
namespace LanMountainDesktop.Services;
|
||||||
@@ -16,6 +17,23 @@ public sealed class LocalizationService
|
|||||||
private readonly Dictionary<string, Dictionary<string, string>> _cache =
|
private readonly Dictionary<string, Dictionary<string, string>> _cache =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 清除指定语言代码的缓存,强制下次重新加载。
|
||||||
|
/// 在语言切换时调用此方法以确保加载最新的语言文件。
|
||||||
|
/// </summary>
|
||||||
|
public void ClearCache(string? languageCode = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(languageCode))
|
||||||
|
{
|
||||||
|
_cache.Clear();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var normalizedCode = NormalizeLanguageCode(languageCode);
|
||||||
|
_cache.Remove(normalizedCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public string NormalizeLanguageCode(string? languageCode)
|
public string NormalizeLanguageCode(string? languageCode)
|
||||||
{
|
{
|
||||||
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
|
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
|
||||||
@@ -42,14 +60,17 @@ public sealed class LocalizationService
|
|||||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var filePath = Path.Combine(AppContext.BaseDirectory, "Localization", $"{languageCode}.json");
|
var json = TryLoadFromFileSystem(languageCode);
|
||||||
if (File.Exists(filePath))
|
if (string.IsNullOrEmpty(json))
|
||||||
|
{
|
||||||
|
json = TryLoadFromEmbeddedResource(languageCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(json))
|
||||||
{
|
{
|
||||||
var json = File.ReadAllText(filePath);
|
|
||||||
// Defensive: tolerate accidentally duplicated UTF-8 BOM characters at file start.
|
|
||||||
json = json.TrimStart('\uFEFF');
|
json = json.TrimStart('\uFEFF');
|
||||||
var data = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions);
|
var data = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions);
|
||||||
if (data is not null)
|
if (data is not null && data.Count > 0)
|
||||||
{
|
{
|
||||||
result = new Dictionary<string, string>(data, StringComparer.OrdinalIgnoreCase);
|
result = new Dictionary<string, string>(data, StringComparer.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
@@ -60,7 +81,48 @@ public sealed class LocalizationService
|
|||||||
// Keep empty table for resilience.
|
// Keep empty table for resilience.
|
||||||
}
|
}
|
||||||
|
|
||||||
_cache[languageCode] = result;
|
// 只有当语言表非空时才缓存,这样如果加载失败可以下次重试
|
||||||
|
if (result.Count > 0)
|
||||||
|
{
|
||||||
|
_cache[languageCode] = result;
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string? TryLoadFromFileSystem(string languageCode)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var filePath = Path.Combine(AppContext.BaseDirectory, "Localization", $"{languageCode}.json");
|
||||||
|
if (File.Exists(filePath))
|
||||||
|
{
|
||||||
|
return File.ReadAllText(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Continue to next method
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? TryLoadFromEmbeddedResource(string languageCode)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var assembly = Assembly.GetExecutingAssembly();
|
||||||
|
var resourceName = $"LanMountainDesktop.Localization.{languageCode}.json";
|
||||||
|
using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||||
|
if (stream != null)
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
return reader.ReadToEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Continue to next method
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.RegularExpressions;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using System.Threading;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using MudTools.OfficeInterop;
|
||||||
|
using MudTools.OfficeInterop.Excel;
|
||||||
|
using MudTools.OfficeInterop.Word;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Services;
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
public interface IOfficeRecentDocumentsService
|
public interface IOfficeRecentDocumentsService
|
||||||
{
|
{
|
||||||
List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20);
|
List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20, IReadOnlyCollection<string>? enabledSources = null);
|
||||||
void OpenDocument(string filePath);
|
void OpenDocument(string filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,78 +31,67 @@ public sealed class OfficeRecentDocument
|
|||||||
public DateTime LastModifiedTime { get; set; }
|
public DateTime LastModifiedTime { get; set; }
|
||||||
public long FileSizeBytes { get; set; }
|
public long FileSizeBytes { get; set; }
|
||||||
public string IconGlyph { get; set; } = string.Empty;
|
public string IconGlyph { get; set; } = string.Empty;
|
||||||
|
internal DateTime? RecentAccessTime { get; set; }
|
||||||
|
internal int SourcePriority { get; set; }
|
||||||
|
internal int SourceOrder { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
|
public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
|
||||||
{
|
{
|
||||||
|
private const string LogCategory = "OfficeRecentDocs";
|
||||||
private static readonly string[] OfficeExtensions = { ".doc", ".docx", ".dot", ".dotx", ".rtf" };
|
private static readonly string[] OfficeExtensions = { ".doc", ".docx", ".dot", ".dotx", ".rtf" };
|
||||||
private static readonly string[] ExcelExtensions = { ".xls", ".xlsx", ".xlsm", ".xlsb", ".csv" };
|
private static readonly string[] ExcelExtensions = { ".xls", ".xlsx", ".xlsm", ".xlsb", ".csv" };
|
||||||
private static readonly string[] PowerPointExtensions = { ".ppt", ".pptx", ".pptm", ".pps", ".ppsx" };
|
private static readonly string[] PowerPointExtensions = { ".ppt", ".pptx", ".pptm", ".pps", ".ppsx" };
|
||||||
|
private static readonly Regex OfficeFilePathRegex = new(
|
||||||
|
@"(?:[A-Z]:\\|\\\\)[^\x00-\x1F""<>|]+?\.(?:docx?|dotx?|rtf|xlsx?|xlsm|xlsb|csv|pptx?|pptm|ppsx?)",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex OfficeMruTimestampRegex = new(
|
||||||
|
@"\[T(?<filetime>[0-9A-F]+)\]",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
public List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20)
|
public List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20, IReadOnlyCollection<string>? enabledSources = null)
|
||||||
{
|
{
|
||||||
var documents = new List<OfficeRecentDocument>();
|
var documents = new List<OfficeRecentDocument>();
|
||||||
var recentPaths = GetRecentFolders();
|
var normalizedSources = OfficeRecentDocumentSourceTypes.NormalizeValues(
|
||||||
|
enabledSources,
|
||||||
|
useDefaultWhenEmpty: enabledSources is null);
|
||||||
|
|
||||||
foreach (var recentPath in recentPaths)
|
if (!OperatingSystem.IsWindows() || normalizedSources.Count == 0)
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(recentPath))
|
return documents;
|
||||||
{
|
}
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
var useRegistry = normalizedSources.Contains(OfficeRecentDocumentSourceTypes.Registry, StringComparer.OrdinalIgnoreCase);
|
||||||
{
|
var useRecentFolders = normalizedSources.Contains(OfficeRecentDocumentSourceTypes.RecentFolders, StringComparer.OrdinalIgnoreCase);
|
||||||
var files = Directory.GetFiles(recentPath, "*.lnk");
|
var useJumpLists = normalizedSources.Contains(OfficeRecentDocumentSourceTypes.JumpLists, StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var lnkPath in files)
|
|
||||||
{
|
|
||||||
var targetPath = GetShortcutTarget(lnkPath);
|
|
||||||
if (string.IsNullOrEmpty(targetPath))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var extension = Path.GetExtension(targetPath).ToLowerInvariant();
|
if (useRegistry)
|
||||||
if (!IsOfficeFile(extension))
|
{
|
||||||
{
|
TryGetFromRegistry(documents);
|
||||||
continue;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!System.IO.File.Exists(targetPath))
|
if (useRecentFolders)
|
||||||
{
|
{
|
||||||
continue;
|
TryGetFromRecentFolders(documents);
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
if (useJumpLists)
|
||||||
{
|
{
|
||||||
var fileInfo = new FileInfo(targetPath);
|
TryGetFromJumpLists(documents);
|
||||||
var doc = new OfficeRecentDocument
|
}
|
||||||
{
|
|
||||||
FileName = Path.GetFileNameWithoutExtension(targetPath),
|
|
||||||
FilePath = targetPath,
|
|
||||||
Extension = extension,
|
|
||||||
LastModifiedTime = fileInfo.LastWriteTime,
|
|
||||||
FileSizeBytes = fileInfo.Length,
|
|
||||||
IconGlyph = GetIconGlyph(extension)
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!documents.Any(d => d.FilePath == targetPath))
|
if (useRegistry && documents.Count < maxCount)
|
||||||
{
|
{
|
||||||
documents.Add(doc);
|
TryGetFromMudToolsInterop(documents);
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return documents
|
return documents
|
||||||
.OrderByDescending(d => d.LastModifiedTime)
|
.GroupBy(d => d.FilePath, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(MergeDocuments)
|
||||||
|
.OrderByDescending(d => d.RecentAccessTime ?? DateTime.MinValue)
|
||||||
|
.ThenBy(d => d.SourcePriority)
|
||||||
|
.ThenBy(d => d.SourceOrder)
|
||||||
|
.ThenByDescending(d => d.LastModifiedTime)
|
||||||
.Take(maxCount)
|
.Take(maxCount)
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
@@ -109,30 +105,587 @@ public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
|
|||||||
FileName = filePath,
|
FileName = filePath,
|
||||||
UseShellExecute = true
|
UseShellExecute = true
|
||||||
};
|
};
|
||||||
|
|
||||||
Process.Start(startInfo);
|
Process.Start(startInfo);
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn(LogCategory, $"Failed to open Office document '{filePath}'.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OfficeRecentDocument MergeDocuments(IGrouping<string, OfficeRecentDocument> group)
|
||||||
|
{
|
||||||
|
var preferred = group
|
||||||
|
.OrderByDescending(d => d.RecentAccessTime ?? DateTime.MinValue)
|
||||||
|
.ThenBy(d => d.SourcePriority)
|
||||||
|
.ThenBy(d => d.SourceOrder)
|
||||||
|
.ThenByDescending(d => d.LastModifiedTime)
|
||||||
|
.First();
|
||||||
|
|
||||||
|
return new OfficeRecentDocument
|
||||||
|
{
|
||||||
|
FileName = preferred.FileName,
|
||||||
|
FilePath = preferred.FilePath,
|
||||||
|
Extension = preferred.Extension,
|
||||||
|
LastModifiedTime = group.Max(d => d.LastModifiedTime),
|
||||||
|
FileSizeBytes = preferred.FileSizeBytes,
|
||||||
|
IconGlyph = preferred.IconGlyph,
|
||||||
|
RecentAccessTime = group
|
||||||
|
.Where(d => d.RecentAccessTime.HasValue)
|
||||||
|
.Select(d => d.RecentAccessTime)
|
||||||
|
.Max(),
|
||||||
|
SourcePriority = preferred.SourcePriority,
|
||||||
|
SourceOrder = preferred.SourceOrder
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
private void TryGetFromMudToolsInterop(List<OfficeRecentDocument> documents)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RunOnStaThread(() =>
|
||||||
|
{
|
||||||
|
var sourceOrder = 0;
|
||||||
|
TryGetFromWordInterop(documents, ref sourceOrder);
|
||||||
|
TryGetFromExcelInterop(documents, ref sourceOrder);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn(LogCategory, "MudTools.OfficeInterop recent-document read failed.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
private void TryGetFromWordInterop(List<OfficeRecentDocument> documents, ref int sourceOrder)
|
||||||
|
{
|
||||||
|
if (!TryGetOfficeApplication("Word.Application", out var comObject, out var createdNew))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
object? application = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
application = WordFactory.Connection(comObject!);
|
||||||
|
|
||||||
|
if (createdNew)
|
||||||
|
{
|
||||||
|
TrySetProperty(comObject, "Visible", false);
|
||||||
|
TrySetProperty(application, "DisplayAlerts", WdAlertLevel.wdAlertsNone);
|
||||||
|
}
|
||||||
|
|
||||||
|
AddInteropRecentFiles(documents, GetPropertyValue(application, "RecentFiles"), 0, ref sourceOrder);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn(LogCategory, "Failed to read Word recent files via MudTools.OfficeInterop.", ex);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CleanupOfficeApplication(application, comObject, createdNew);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
private void TryGetFromExcelInterop(List<OfficeRecentDocument> documents, ref int sourceOrder)
|
||||||
|
{
|
||||||
|
if (!TryGetOfficeApplication("Excel.Application", out var comObject, out var createdNew))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
object? application = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
application = ExcelFactory.Connection(comObject!);
|
||||||
|
|
||||||
|
if (createdNew)
|
||||||
|
{
|
||||||
|
TrySetProperty(comObject, "Visible", false);
|
||||||
|
TrySetProperty(application, "DisplayAlerts", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
AddInteropRecentFiles(documents, GetPropertyValue(application, "RecentFiles"), 0, ref sourceOrder);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn(LogCategory, "Failed to read Excel recent files via MudTools.OfficeInterop.", ex);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CleanupOfficeApplication(application, comObject, createdNew);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddInteropRecentFiles(
|
||||||
|
List<OfficeRecentDocument> documents,
|
||||||
|
object? recentFiles,
|
||||||
|
int sourcePriority,
|
||||||
|
ref int sourceOrder)
|
||||||
|
{
|
||||||
|
if (recentFiles == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var count = GetIntProperty(recentFiles, "Count");
|
||||||
|
var itemProperty = recentFiles.GetType().GetProperty("Item");
|
||||||
|
if (count <= 0 || itemProperty == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var index = 1; index <= count; index++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var recentFile = itemProperty.GetValue(recentFiles, new object[] { index });
|
||||||
|
var filePath = GetStringProperty(recentFile, "Path");
|
||||||
|
AddDocumentIfExists(documents, filePath, sourcePriority, sourceOrder++, null);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore a single malformed MRU entry and keep processing the rest.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
private static bool TryGetOfficeApplication(string progId, out object? comObject, out bool createdNew)
|
||||||
|
{
|
||||||
|
comObject = null;
|
||||||
|
createdNew = false;
|
||||||
|
|
||||||
|
var applicationType = Type.GetTypeFromProgID(progId, throwOnError: false);
|
||||||
|
if (applicationType == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
comObject = Activator.CreateInstance(applicationType);
|
||||||
|
createdNew = comObject != null;
|
||||||
|
return comObject != null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
private static void CleanupOfficeApplication(object? application, object? comObject, bool createdNew)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (createdNew && application != null)
|
||||||
|
{
|
||||||
|
InvokeParameterlessMethod(application, "Quit");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (application is IDisposable disposable)
|
||||||
|
{
|
||||||
|
disposable.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleaseComObject(application);
|
||||||
|
if (!ReferenceEquals(application, comObject))
|
||||||
|
{
|
||||||
|
ReleaseComObject(comObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
private static void ReleaseComObject(object? instance)
|
||||||
|
{
|
||||||
|
if (instance == null || !Marshal.IsComObject(instance))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Marshal.FinalReleaseComObject(instance);
|
||||||
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<string> GetRecentFolders()
|
[SupportedOSPlatform("windows")]
|
||||||
|
private static void RunOnStaThread(Action action)
|
||||||
{
|
{
|
||||||
var folders = new List<string>();
|
Exception? exception = null;
|
||||||
|
using var finished = new ManualResetEventSlim();
|
||||||
|
|
||||||
|
var thread = new Thread(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
exception = ex;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
finished.Set();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
thread.IsBackground = true;
|
||||||
|
thread.SetApartmentState(ApartmentState.STA);
|
||||||
|
thread.Start();
|
||||||
|
finished.Wait();
|
||||||
|
|
||||||
|
if (exception != null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Failed to run Office interop on STA thread.", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
private void TryGetFromRegistry(List<OfficeRecentDocument> documents)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var officeRoot = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Office");
|
||||||
|
if (officeRoot == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var versions = officeRoot
|
||||||
|
.GetSubKeyNames()
|
||||||
|
.Where(IsOfficeVersionKey)
|
||||||
|
.OrderByDescending(ParseVersionKey)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var sourceOrder = 0;
|
||||||
|
foreach (var version in versions)
|
||||||
|
{
|
||||||
|
TryGetFromRegistryApp(documents, version, "Word", ref sourceOrder);
|
||||||
|
TryGetFromRegistryApp(documents, version, "Excel", ref sourceOrder);
|
||||||
|
TryGetFromRegistryApp(documents, version, "PowerPoint", ref sourceOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn(LogCategory, "Failed to read Office MRU entries from the registry.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
private void TryGetFromRegistryApp(List<OfficeRecentDocument> documents, string version, string appName, ref int sourceOrder)
|
||||||
|
{
|
||||||
|
TryGetFromRegistryMruKey(documents, $@"Software\Microsoft\Office\{version}\{appName}\File MRU", ref sourceOrder);
|
||||||
|
|
||||||
|
using var userMruRoot = Registry.CurrentUser.OpenSubKey($@"Software\Microsoft\Office\{version}\{appName}\User MRU");
|
||||||
|
if (userMruRoot == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var identityKey in userMruRoot.GetSubKeyNames())
|
||||||
|
{
|
||||||
|
TryGetFromRegistryMruKey(
|
||||||
|
documents,
|
||||||
|
$@"Software\Microsoft\Office\{version}\{appName}\User MRU\{identityKey}\File MRU",
|
||||||
|
ref sourceOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
private void TryGetFromRegistryMruKey(List<OfficeRecentDocument> documents, string registryPath, ref int sourceOrder)
|
||||||
|
{
|
||||||
|
using var key = Registry.CurrentUser.OpenSubKey(registryPath);
|
||||||
|
if (key == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries = key
|
||||||
|
.GetValueNames()
|
||||||
|
.Where(name => name.StartsWith("Item ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(name => new
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Order = ParseMruItemOrder(name),
|
||||||
|
Value = key.GetValue(name) as string
|
||||||
|
})
|
||||||
|
.Where(entry => !string.IsNullOrWhiteSpace(entry.Value))
|
||||||
|
.OrderBy(entry => entry.Order);
|
||||||
|
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
var (filePath, recentAccessTime) = ParseOfficeMruValue(entry.Value!);
|
||||||
|
AddDocumentIfExists(documents, filePath, 1, sourceOrder++, recentAccessTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryGetFromRecentFolders(List<OfficeRecentDocument> documents)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var linkFiles = GetRecentFolders()
|
||||||
|
.Where(Directory.Exists)
|
||||||
|
.SelectMany(path => Directory.EnumerateFiles(path, "*.lnk"))
|
||||||
|
.Select(path => new FileInfo(path))
|
||||||
|
.OrderByDescending(info => info.LastWriteTimeUtc)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var sourceOrder = 0;
|
||||||
|
foreach (var linkFile in linkFiles)
|
||||||
|
{
|
||||||
|
var targetPath = GetShortcutTarget(linkFile.FullName);
|
||||||
|
AddDocumentIfExists(documents, targetPath, 2, sourceOrder++, linkFile.LastWriteTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn(LogCategory, "Failed to read Windows Recent shortcut folders.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryGetFromJumpLists(List<OfficeRecentDocument> documents)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var jumpListFiles = GetJumpListFolders()
|
||||||
|
.Where(Directory.Exists)
|
||||||
|
.SelectMany(path => Directory.EnumerateFiles(path, "*.automaticDestinations-ms")
|
||||||
|
.Concat(Directory.EnumerateFiles(path, "*.customDestinations-ms")))
|
||||||
|
.Select(path => new FileInfo(path))
|
||||||
|
.OrderByDescending(info => info.LastWriteTimeUtc)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var sourceOrder = 0;
|
||||||
|
foreach (var jumpListFile in jumpListFiles)
|
||||||
|
{
|
||||||
|
TryParseJumpListFile(jumpListFile, documents, ref sourceOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn(LogCategory, "Failed to read Windows Jump Lists for Office documents.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryParseJumpListFile(FileInfo jumpListFile, List<OfficeRecentDocument> documents, ref int sourceOrder)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bytes = File.ReadAllBytes(jumpListFile.FullName);
|
||||||
|
foreach (var filePath in ExtractPossiblePaths(bytes))
|
||||||
|
{
|
||||||
|
AddDocumentIfExists(documents, filePath, 3, sourceOrder++, jumpListFile.LastWriteTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore a single Jump List file and keep scanning the rest.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> ExtractPossiblePaths(byte[] bytes)
|
||||||
|
{
|
||||||
|
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var text in new[]
|
||||||
|
{
|
||||||
|
Encoding.Unicode.GetString(bytes),
|
||||||
|
Encoding.Latin1.GetString(bytes)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
foreach (Match match in OfficeFilePathRegex.Matches(text))
|
||||||
|
{
|
||||||
|
var normalizedPath = NormalizeFilePath(match.Value);
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalizedPath))
|
||||||
|
{
|
||||||
|
paths.Add(normalizedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddDocumentIfExists(
|
||||||
|
List<OfficeRecentDocument> documents,
|
||||||
|
string? filePath,
|
||||||
|
int sourcePriority,
|
||||||
|
int sourceOrder,
|
||||||
|
DateTime? recentAccessTime)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var normalizedPath = NormalizeFilePath(filePath);
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var extension = Path.GetExtension(normalizedPath).ToLowerInvariant();
|
||||||
|
if (!IsOfficeFile(extension) || !File.Exists(normalizedPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileInfo = new FileInfo(normalizedPath);
|
||||||
|
documents.Add(new OfficeRecentDocument
|
||||||
|
{
|
||||||
|
FileName = Path.GetFileNameWithoutExtension(normalizedPath),
|
||||||
|
FilePath = normalizedPath,
|
||||||
|
Extension = extension,
|
||||||
|
LastModifiedTime = fileInfo.LastWriteTime,
|
||||||
|
FileSizeBytes = fileInfo.Length,
|
||||||
|
IconGlyph = GetIconGlyph(extension),
|
||||||
|
RecentAccessTime = recentAccessTime,
|
||||||
|
SourcePriority = sourcePriority,
|
||||||
|
SourceOrder = sourceOrder
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore a single file and keep processing the rest of the MRU list.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> GetRecentFolders()
|
||||||
|
{
|
||||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||||
folders.Add(Path.Combine(appData, "Microsoft", "Word", "Recent"));
|
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
folders.Add(Path.Combine(appData, "Microsoft", "Excel", "Recent"));
|
|
||||||
folders.Add(Path.Combine(appData, "Microsoft", "PowerPoint", "Recent"));
|
|
||||||
|
|
||||||
return folders;
|
return new[]
|
||||||
|
{
|
||||||
|
Path.Combine(appData, "Microsoft", "Windows", "Recent"),
|
||||||
|
Path.Combine(appData, "Microsoft", "Word", "Recent"),
|
||||||
|
Path.Combine(appData, "Microsoft", "Excel", "Recent"),
|
||||||
|
Path.Combine(appData, "Microsoft", "PowerPoint", "Recent"),
|
||||||
|
Path.Combine(localAppData, "Microsoft", "Office", "Word", "Recent"),
|
||||||
|
Path.Combine(localAppData, "Microsoft", "Office", "Excel", "Recent"),
|
||||||
|
Path.Combine(localAppData, "Microsoft", "Office", "PowerPoint", "Recent")
|
||||||
|
}.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> GetJumpListFolders()
|
||||||
|
{
|
||||||
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||||
|
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
|
||||||
|
return new[]
|
||||||
|
{
|
||||||
|
Path.Combine(appData, "Microsoft", "Windows", "Recent", "AutomaticDestinations"),
|
||||||
|
Path.Combine(appData, "Microsoft", "Windows", "Recent", "CustomDestinations"),
|
||||||
|
Path.Combine(localAppData, "Microsoft", "Windows", "Recent", "AutomaticDestinations"),
|
||||||
|
Path.Combine(localAppData, "Microsoft", "Windows", "Recent", "CustomDestinations")
|
||||||
|
}.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsOfficeVersionKey(string keyName)
|
||||||
|
{
|
||||||
|
return Version.TryParse(keyName, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Version ParseVersionKey(string keyName)
|
||||||
|
{
|
||||||
|
return Version.TryParse(keyName, out var version) ? version : new Version(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ParseMruItemOrder(string valueName)
|
||||||
|
{
|
||||||
|
var numberText = valueName["Item ".Length..];
|
||||||
|
return int.TryParse(numberText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number)
|
||||||
|
? number
|
||||||
|
: int.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string? FilePath, DateTime? RecentAccessTime) ParseOfficeMruValue(string rawValue)
|
||||||
|
{
|
||||||
|
var filePath = ExtractOfficeFilePath(rawValue);
|
||||||
|
DateTime? recentAccessTime = null;
|
||||||
|
|
||||||
|
var timestampMatch = OfficeMruTimestampRegex.Match(rawValue);
|
||||||
|
if (timestampMatch.Success &&
|
||||||
|
long.TryParse(timestampMatch.Groups["filetime"].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var fileTime) &&
|
||||||
|
fileTime > 0)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
recentAccessTime = DateTime.FromFileTimeUtc(fileTime).ToLocalTime();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
recentAccessTime = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (filePath, recentAccessTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractOfficeFilePath(string rawValue)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rawValue))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var markerIndex = rawValue.LastIndexOf('*');
|
||||||
|
var candidate = markerIndex >= 0
|
||||||
|
? rawValue[(markerIndex + 1)..]
|
||||||
|
: rawValue;
|
||||||
|
|
||||||
|
var normalizedCandidate = NormalizeFilePath(candidate);
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalizedCandidate) && IsOfficeFile(Path.GetExtension(normalizedCandidate)))
|
||||||
|
{
|
||||||
|
return normalizedCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
var match = OfficeFilePathRegex.Match(rawValue);
|
||||||
|
return match.Success ? NormalizeFilePath(match.Value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeFilePath(string? rawPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rawPath))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidate = rawPath.Trim('\0', ' ', '"');
|
||||||
|
candidate = Environment.ExpandEnvironmentVariables(candidate);
|
||||||
|
|
||||||
|
if (Uri.TryCreate(candidate, UriKind.Absolute, out var uri) && uri.IsFile)
|
||||||
|
{
|
||||||
|
candidate = uri.LocalPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate = candidate.Replace('/', '\\');
|
||||||
|
return string.IsNullOrWhiteSpace(candidate) ? null : candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsOfficeFile(string extension)
|
private static bool IsOfficeFile(string extension)
|
||||||
{
|
{
|
||||||
return OfficeExtensions.Contains(extension) ||
|
return OfficeExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
|
||||||
ExcelExtensions.Contains(extension) ||
|
ExcelExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
|
||||||
PowerPointExtensions.Contains(extension);
|
PowerPointExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetIconGlyph(string extension)
|
private static string GetIconGlyph(string extension)
|
||||||
@@ -150,4 +703,40 @@ public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
|
|||||||
{
|
{
|
||||||
return ShortcutHelper.GetShortcutTarget(lnkPath);
|
return ShortcutHelper.GetShortcutTarget(lnkPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static object? GetPropertyValue(object? instance, string propertyName)
|
||||||
|
{
|
||||||
|
return instance?.GetType().GetProperty(propertyName)?.GetValue(instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetStringProperty(object? instance, string propertyName)
|
||||||
|
{
|
||||||
|
return GetPropertyValue(instance, propertyName) as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetIntProperty(object instance, string propertyName)
|
||||||
|
{
|
||||||
|
var value = GetPropertyValue(instance, propertyName);
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
int intValue => intValue,
|
||||||
|
short shortValue => shortValue,
|
||||||
|
long longValue => (int)longValue,
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TrySetProperty(object? instance, string propertyName, object value)
|
||||||
|
{
|
||||||
|
var property = instance?.GetType().GetProperty(propertyName);
|
||||||
|
if (property?.CanWrite == true)
|
||||||
|
{
|
||||||
|
property.SetValue(instance, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void InvokeParameterlessMethod(object instance, string methodName)
|
||||||
|
{
|
||||||
|
instance.GetType().GetMethod(methodName, Type.EmptyTypes)?.Invoke(instance, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
310
LanMountainDesktop/Services/RemovableStorageService.cs
Normal file
310
LanMountainDesktop/Services/RemovableStorageService.cs
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public sealed record RemovableStorageDrive(
|
||||||
|
string RootPath,
|
||||||
|
string DriveLetter,
|
||||||
|
string? VolumeLabel);
|
||||||
|
|
||||||
|
public interface IRemovableStorageService
|
||||||
|
{
|
||||||
|
IReadOnlyList<RemovableStorageDrive> GetConnectedDrives();
|
||||||
|
|
||||||
|
bool OpenDrive(string rootPath);
|
||||||
|
|
||||||
|
bool EjectDrive(string rootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RemovableStorageService : IRemovableStorageService
|
||||||
|
{
|
||||||
|
public IReadOnlyList<RemovableStorageDrive> GetConnectedDrives()
|
||||||
|
{
|
||||||
|
var drives = new List<RemovableStorageDrive>();
|
||||||
|
|
||||||
|
foreach (var drive in DriveInfo.GetDrives())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (drive.DriveType != DriveType.Removable || !drive.IsReady)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rootPath = NormalizeRootPath(drive.Name);
|
||||||
|
if (string.IsNullOrWhiteSpace(rootPath))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var driveLetter = rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
var volumeLabel = string.IsNullOrWhiteSpace(drive.VolumeLabel)
|
||||||
|
? null
|
||||||
|
: drive.VolumeLabel.Trim();
|
||||||
|
|
||||||
|
drives.Add(new RemovableStorageDrive(rootPath, driveLetter, volumeLabel));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("RemovableStorage", $"Failed to inspect drive '{drive.Name}'.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return drives
|
||||||
|
.OrderBy(drive => drive.DriveLetter, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool OpenDrive(string rootPath)
|
||||||
|
{
|
||||||
|
var normalizedRootPath = NormalizeRootPath(rootPath);
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedRootPath))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = normalizedRootPath,
|
||||||
|
UseShellExecute = true
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("RemovableStorage", $"Failed to open drive '{normalizedRootPath}'.", ex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool EjectDrive(string rootPath)
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedRootPath = NormalizeRootPath(rootPath);
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedRootPath))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
object? shellApplication = null;
|
||||||
|
object? computerFolder = null;
|
||||||
|
object? driveItem = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var shellType = Type.GetTypeFromProgID("Shell.Application");
|
||||||
|
if (shellType is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
shellApplication = Activator.CreateInstance(shellType);
|
||||||
|
if (shellApplication is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
computerFolder = shellType.InvokeMember(
|
||||||
|
"NameSpace",
|
||||||
|
BindingFlags.InvokeMethod,
|
||||||
|
binder: null,
|
||||||
|
target: shellApplication,
|
||||||
|
args: [17]);
|
||||||
|
if (computerFolder is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var driveToken = normalizedRootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
driveItem = computerFolder.GetType().InvokeMember(
|
||||||
|
"ParseName",
|
||||||
|
BindingFlags.InvokeMethod,
|
||||||
|
binder: null,
|
||||||
|
target: computerFolder,
|
||||||
|
args: [driveToken]);
|
||||||
|
if (driveItem is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryInvokeVerb(driveItem, "Eject"))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TryInvokeLocalizedEjectVerb(driveItem);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("RemovableStorage", $"Failed to eject drive '{normalizedRootPath}'.", ex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ReleaseComObject(driveItem);
|
||||||
|
ReleaseComObject(computerFolder);
|
||||||
|
ReleaseComObject(shellApplication);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryInvokeLocalizedEjectVerb(object driveItem)
|
||||||
|
{
|
||||||
|
object? verbs = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
verbs = driveItem.GetType().InvokeMember(
|
||||||
|
"Verbs",
|
||||||
|
BindingFlags.InvokeMethod,
|
||||||
|
binder: null,
|
||||||
|
target: driveItem,
|
||||||
|
args: null);
|
||||||
|
if (verbs is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var verbsType = verbs.GetType();
|
||||||
|
var countObject = verbsType.InvokeMember(
|
||||||
|
"Count",
|
||||||
|
BindingFlags.GetProperty,
|
||||||
|
binder: null,
|
||||||
|
target: verbs,
|
||||||
|
args: null);
|
||||||
|
var count = countObject is null
|
||||||
|
? 0
|
||||||
|
: Convert.ToInt32(countObject, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
for (var index = 0; index < count; index++)
|
||||||
|
{
|
||||||
|
object? verb = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
verb = verbsType.InvokeMember(
|
||||||
|
"Item",
|
||||||
|
BindingFlags.InvokeMethod,
|
||||||
|
binder: null,
|
||||||
|
target: verbs,
|
||||||
|
args: [index]);
|
||||||
|
if (verb is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var verbNameObject = verb.GetType().InvokeMember(
|
||||||
|
"Name",
|
||||||
|
BindingFlags.GetProperty,
|
||||||
|
binder: null,
|
||||||
|
target: verb,
|
||||||
|
args: null);
|
||||||
|
var verbName = Convert.ToString(verbNameObject, CultureInfo.InvariantCulture);
|
||||||
|
if (!IsEjectVerbName(verbName))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
verb.GetType().InvokeMember(
|
||||||
|
"DoIt",
|
||||||
|
BindingFlags.InvokeMethod,
|
||||||
|
binder: null,
|
||||||
|
target: verb,
|
||||||
|
args: null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ReleaseComObject(verb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ReleaseComObject(verbs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryInvokeVerb(object driveItem, string verbName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
driveItem.GetType().InvokeMember(
|
||||||
|
"InvokeVerb",
|
||||||
|
BindingFlags.InvokeMethod,
|
||||||
|
binder: null,
|
||||||
|
target: driveItem,
|
||||||
|
args: [verbName]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsEjectVerbName(string? verbName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(verbName))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = string.Concat(
|
||||||
|
verbName
|
||||||
|
.Where(character => !char.IsWhiteSpace(character) && character != '&'))
|
||||||
|
.Trim();
|
||||||
|
|
||||||
|
return normalized.Contains("Eject", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.Contains("弹出", StringComparison.Ordinal) ||
|
||||||
|
normalized.Contains("安全删除", StringComparison.Ordinal) ||
|
||||||
|
normalized.Contains("卸载", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeRootPath(string? rootPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rootPath))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = rootPath.Trim();
|
||||||
|
if (trimmed.Length == 1 && char.IsLetter(trimmed[0]))
|
||||||
|
{
|
||||||
|
return string.Create(CultureInfo.InvariantCulture, $"{trimmed}:{Path.DirectorySeparatorChar}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.Length == 2 && char.IsLetter(trimmed[0]) && trimmed[1] == ':')
|
||||||
|
{
|
||||||
|
return trimmed + Path.DirectorySeparatorChar;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = trimmed.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
|
||||||
|
var resolvedRoot = Path.GetPathRoot(normalized);
|
||||||
|
return string.IsNullOrWhiteSpace(resolvedRoot)
|
||||||
|
? normalized
|
||||||
|
: resolvedRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ReleaseComObject(object? value)
|
||||||
|
{
|
||||||
|
if (value is not null && Marshal.IsComObject(value))
|
||||||
|
{
|
||||||
|
Marshal.FinalReleaseComObject(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ internal sealed class SettingsCatalogService : ISettingsCatalog
|
|||||||
[
|
[
|
||||||
new SettingsSectionDefinition("general", SettingsCategories.General, SettingsScope.App, "settings.general.title", iconKey: "Settings", sortOrder: 0),
|
new SettingsSectionDefinition("general", SettingsCategories.General, SettingsScope.App, "settings.general.title", iconKey: "Settings", sortOrder: 0),
|
||||||
new SettingsSectionDefinition("appearance", SettingsCategories.Appearance, SettingsScope.App, "settings.appearance.title", iconKey: "DesignIdeas", sortOrder: 10),
|
new SettingsSectionDefinition("appearance", SettingsCategories.Appearance, SettingsScope.App, "settings.appearance.title", iconKey: "DesignIdeas", sortOrder: 10),
|
||||||
new SettingsSectionDefinition("components", SettingsCategories.Components, SettingsScope.ComponentInstance, "settings.components.title", iconKey: "GridDots", sortOrder: 20),
|
new SettingsSectionDefinition("components", SettingsCategories.Components, SettingsScope.ComponentInstance, "settings.components.title", iconKey: "Apps", sortOrder: 20),
|
||||||
new SettingsSectionDefinition("plugins", SettingsCategories.Plugins, SettingsScope.Plugin, "settings.plugins.title", iconKey: "PuzzlePiece", sortOrder: 30),
|
new SettingsSectionDefinition("plugins", SettingsCategories.Plugins, SettingsScope.Plugin, "settings.plugins.title", iconKey: "PuzzlePiece", sortOrder: 30),
|
||||||
new SettingsSectionDefinition("about", SettingsCategories.About, SettingsScope.App, "settings.about.title", iconKey: "Info", sortOrder: 40)
|
new SettingsSectionDefinition("about", SettingsCategories.About, SettingsScope.App, "settings.about.title", iconKey: "Info", sortOrder: 40)
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
|
|||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Settings.Core;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Services.Settings;
|
namespace LanMountainDesktop.Services.Settings;
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ public sealed record ThemeAppearanceSettingsState(
|
|||||||
bool IsNightMode,
|
bool IsNightMode,
|
||||||
string? ThemeColor,
|
string? ThemeColor,
|
||||||
bool UseSystemChrome,
|
bool UseSystemChrome,
|
||||||
|
double GlobalCornerRadiusScale = GlobalAppearanceSettings.DefaultCornerRadiusScale,
|
||||||
string ThemeColorMode = ThemeAppearanceValues.ColorModeDefaultNeutral,
|
string ThemeColorMode = ThemeAppearanceValues.ColorModeDefaultNeutral,
|
||||||
string SystemMaterialMode = ThemeAppearanceValues.MaterialNone,
|
string SystemMaterialMode = ThemeAppearanceValues.MaterialNone,
|
||||||
string? SelectedWallpaperSeed = null);
|
string? SelectedWallpaperSeed = null);
|
||||||
@@ -29,6 +31,7 @@ public sealed record StatusBarSettingsState(
|
|||||||
bool EnableDynamicTaskbarActions,
|
bool EnableDynamicTaskbarActions,
|
||||||
string TaskbarLayoutMode,
|
string TaskbarLayoutMode,
|
||||||
string ClockDisplayFormat,
|
string ClockDisplayFormat,
|
||||||
|
bool ClockTransparentBackground,
|
||||||
string SpacingMode,
|
string SpacingMode,
|
||||||
int CustomSpacingPercent);
|
int CustomSpacingPercent);
|
||||||
public sealed record WeatherSettingsState(
|
public sealed record WeatherSettingsState(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using Avalonia.Media.Imaging;
|
|||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Settings.Core;
|
||||||
using LanMountainDesktop.Services.PluginMarket;
|
using LanMountainDesktop.Services.PluginMarket;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Services.Settings;
|
namespace LanMountainDesktop.Services.Settings;
|
||||||
@@ -242,6 +243,7 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
|
|||||||
snapshot.IsNightMode ?? false,
|
snapshot.IsNightMode ?? false,
|
||||||
snapshot.ThemeColor,
|
snapshot.ThemeColor,
|
||||||
snapshot.UseSystemChrome,
|
snapshot.UseSystemChrome,
|
||||||
|
GlobalAppearanceSettings.NormalizeCornerRadiusScale(snapshot.GlobalCornerRadiusScale),
|
||||||
ThemeAppearanceValues.NormalizeThemeColorMode(snapshot.ThemeColorMode, snapshot.ThemeColor),
|
ThemeAppearanceValues.NormalizeThemeColorMode(snapshot.ThemeColorMode, snapshot.ThemeColor),
|
||||||
ThemeAppearanceValues.NormalizeSystemMaterialMode(snapshot.SystemMaterialMode),
|
ThemeAppearanceValues.NormalizeSystemMaterialMode(snapshot.SystemMaterialMode),
|
||||||
snapshot.SelectedWallpaperSeed);
|
snapshot.SelectedWallpaperSeed);
|
||||||
@@ -252,6 +254,7 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
|
|||||||
var snapshot = _settingsService.Load();
|
var snapshot = _settingsService.Load();
|
||||||
var changedKeys = new List<string>();
|
var changedKeys = new List<string>();
|
||||||
var normalizedThemeColor = string.IsNullOrWhiteSpace(state.ThemeColor) ? null : state.ThemeColor;
|
var normalizedThemeColor = string.IsNullOrWhiteSpace(state.ThemeColor) ? null : state.ThemeColor;
|
||||||
|
var normalizedCornerRadiusScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(state.GlobalCornerRadiusScale);
|
||||||
var normalizedThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(state.ThemeColorMode, state.ThemeColor);
|
var normalizedThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(state.ThemeColorMode, state.ThemeColor);
|
||||||
var normalizedSystemMaterialMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(state.SystemMaterialMode);
|
var normalizedSystemMaterialMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(state.SystemMaterialMode);
|
||||||
var normalizedSelectedWallpaperSeed = string.IsNullOrWhiteSpace(state.SelectedWallpaperSeed)
|
var normalizedSelectedWallpaperSeed = string.IsNullOrWhiteSpace(state.SelectedWallpaperSeed)
|
||||||
@@ -276,6 +279,12 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
|
|||||||
changedKeys.Add(nameof(AppSettingsSnapshot.UseSystemChrome));
|
changedKeys.Add(nameof(AppSettingsSnapshot.UseSystemChrome));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Math.Abs(GlobalAppearanceSettings.NormalizeCornerRadiusScale(snapshot.GlobalCornerRadiusScale) - normalizedCornerRadiusScale) > 0.0001d)
|
||||||
|
{
|
||||||
|
snapshot.GlobalCornerRadiusScale = normalizedCornerRadiusScale;
|
||||||
|
changedKeys.Add(nameof(AppSettingsSnapshot.GlobalCornerRadiusScale));
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.Equals(snapshot.ThemeColorMode, normalizedThemeColorMode, StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(snapshot.ThemeColorMode, normalizedThemeColorMode, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
snapshot.ThemeColorMode = normalizedThemeColorMode;
|
snapshot.ThemeColorMode = normalizedThemeColorMode;
|
||||||
@@ -361,6 +370,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
|
|||||||
snapshot.EnableDynamicTaskbarActions,
|
snapshot.EnableDynamicTaskbarActions,
|
||||||
snapshot.TaskbarLayoutMode,
|
snapshot.TaskbarLayoutMode,
|
||||||
snapshot.ClockDisplayFormat,
|
snapshot.ClockDisplayFormat,
|
||||||
|
snapshot.StatusBarClockTransparentBackground,
|
||||||
snapshot.StatusBarSpacingMode,
|
snapshot.StatusBarSpacingMode,
|
||||||
snapshot.StatusBarCustomSpacingPercent);
|
snapshot.StatusBarCustomSpacingPercent);
|
||||||
}
|
}
|
||||||
@@ -373,6 +383,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
|
|||||||
snapshot.EnableDynamicTaskbarActions = state.EnableDynamicTaskbarActions;
|
snapshot.EnableDynamicTaskbarActions = state.EnableDynamicTaskbarActions;
|
||||||
snapshot.TaskbarLayoutMode = state.TaskbarLayoutMode;
|
snapshot.TaskbarLayoutMode = state.TaskbarLayoutMode;
|
||||||
snapshot.ClockDisplayFormat = state.ClockDisplayFormat;
|
snapshot.ClockDisplayFormat = state.ClockDisplayFormat;
|
||||||
|
snapshot.StatusBarClockTransparentBackground = state.ClockTransparentBackground;
|
||||||
snapshot.StatusBarSpacingMode = state.SpacingMode;
|
snapshot.StatusBarSpacingMode = state.SpacingMode;
|
||||||
snapshot.StatusBarCustomSpacingPercent = state.CustomSpacingPercent;
|
snapshot.StatusBarCustomSpacingPercent = state.CustomSpacingPercent;
|
||||||
_settingsService.SaveSnapshot(
|
_settingsService.SaveSnapshot(
|
||||||
@@ -385,6 +396,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
|
|||||||
nameof(AppSettingsSnapshot.EnableDynamicTaskbarActions),
|
nameof(AppSettingsSnapshot.EnableDynamicTaskbarActions),
|
||||||
nameof(AppSettingsSnapshot.TaskbarLayoutMode),
|
nameof(AppSettingsSnapshot.TaskbarLayoutMode),
|
||||||
nameof(AppSettingsSnapshot.ClockDisplayFormat),
|
nameof(AppSettingsSnapshot.ClockDisplayFormat),
|
||||||
|
nameof(AppSettingsSnapshot.StatusBarClockTransparentBackground),
|
||||||
nameof(AppSettingsSnapshot.StatusBarSpacingMode),
|
nameof(AppSettingsSnapshot.StatusBarSpacingMode),
|
||||||
nameof(AppSettingsSnapshot.StatusBarCustomSpacingPercent)
|
nameof(AppSettingsSnapshot.StatusBarCustomSpacingPercent)
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -294,6 +294,8 @@ internal sealed class SettingsWindowService : ISettingsWindowService
|
|||||||
if (languageChanged)
|
if (languageChanged)
|
||||||
{
|
{
|
||||||
var regionState = _settingsFacade.Region.Get();
|
var regionState = _settingsFacade.Region.Get();
|
||||||
|
// 清除本地化缓存,强制重新加载语言文件
|
||||||
|
_localizationService.ClearCache();
|
||||||
_viewModel.RefreshLanguage(regionState.LanguageCode);
|
_viewModel.RefreshLanguage(regionState.LanguageCode);
|
||||||
_pageRegistry.Rebuild();
|
_pageRegistry.Rebuild();
|
||||||
_window.ReloadPages(_viewModel.CurrentPageId);
|
_window.ReloadPages(_viewModel.CurrentPageId);
|
||||||
|
|||||||
338
LanMountainDesktop/Services/WhiteboardNotePersistenceService.cs
Normal file
338
LanMountainDesktop/Services/WhiteboardNotePersistenceService.cs
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenceService
|
||||||
|
{
|
||||||
|
private const int DefaultCleanupBatchSize = 256;
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly object _schemaSyncRoot = new();
|
||||||
|
private readonly AppDatabaseService _databaseService;
|
||||||
|
private bool _schemaInitialized;
|
||||||
|
|
||||||
|
public WhiteboardNotePersistenceService(AppDatabaseService? databaseService = null)
|
||||||
|
{
|
||||||
|
_databaseService = databaseService ?? AppDatabaseServiceFactory.CreateDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public WhiteboardNoteSnapshot LoadNote(string componentId, string? placementId, int retentionDays)
|
||||||
|
{
|
||||||
|
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
|
||||||
|
{
|
||||||
|
return new WhiteboardNoteSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
DeleteExpiredInternal(
|
||||||
|
connection,
|
||||||
|
normalizedComponentId,
|
||||||
|
normalizedPlacementId,
|
||||||
|
WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays),
|
||||||
|
DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
SELECT note_json, saved_at_utc_ms
|
||||||
|
FROM whiteboard_notes
|
||||||
|
WHERE component_id = $componentId
|
||||||
|
AND placement_id = $placementId
|
||||||
|
LIMIT 1;
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$componentId", normalizedComponentId);
|
||||||
|
command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
|
||||||
|
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
if (!reader.Read() || reader.IsDBNull(0))
|
||||||
|
{
|
||||||
|
return new WhiteboardNoteSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = reader.GetString(0);
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
{
|
||||||
|
return new WhiteboardNoteSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = JsonSerializer.Deserialize<WhiteboardNoteSnapshot>(json, JsonOptions) ?? new WhiteboardNoteSnapshot();
|
||||||
|
if (!reader.IsDBNull(1))
|
||||||
|
{
|
||||||
|
snapshot.SavedUtc = DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsExpired(snapshot, retentionDays))
|
||||||
|
{
|
||||||
|
DeleteNote(normalizedComponentId, normalizedPlacementId);
|
||||||
|
return new WhiteboardNoteSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot.Clone();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new WhiteboardNoteSnapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SaveNote(string componentId, string? placementId, WhiteboardNoteSnapshot snapshot, int retentionDays)
|
||||||
|
{
|
||||||
|
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var nowUtc = DateTimeOffset.UtcNow;
|
||||||
|
var persistedSnapshot = snapshot?.Clone() ?? new WhiteboardNoteSnapshot();
|
||||||
|
persistedSnapshot.SavedUtc = nowUtc;
|
||||||
|
var expiresUtc = GetExpirationUtc(persistedSnapshot, retentionDays) ?? nowUtc.AddDays(WhiteboardNoteRetentionPolicy.DefaultDays);
|
||||||
|
var json = JsonSerializer.Serialize(persistedSnapshot, JsonOptions);
|
||||||
|
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
INSERT INTO whiteboard_notes(
|
||||||
|
component_id,
|
||||||
|
placement_id,
|
||||||
|
note_json,
|
||||||
|
saved_at_utc_ms,
|
||||||
|
expires_at_utc_ms,
|
||||||
|
updated_at_utc_ms)
|
||||||
|
VALUES(
|
||||||
|
$componentId,
|
||||||
|
$placementId,
|
||||||
|
$noteJson,
|
||||||
|
$savedAtUtcMs,
|
||||||
|
$expiresAtUtcMs,
|
||||||
|
$updatedAtUtcMs)
|
||||||
|
ON CONFLICT(component_id, placement_id) DO UPDATE SET
|
||||||
|
note_json = excluded.note_json,
|
||||||
|
saved_at_utc_ms = excluded.saved_at_utc_ms,
|
||||||
|
expires_at_utc_ms = excluded.expires_at_utc_ms,
|
||||||
|
updated_at_utc_ms = excluded.updated_at_utc_ms;
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$componentId", normalizedComponentId);
|
||||||
|
command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
|
||||||
|
command.Parameters.AddWithValue("$noteJson", json);
|
||||||
|
command.Parameters.AddWithValue("$savedAtUtcMs", persistedSnapshot.SavedUtc.ToUnixTimeMilliseconds());
|
||||||
|
command.Parameters.AddWithValue("$expiresAtUtcMs", expiresUtc.ToUnixTimeMilliseconds());
|
||||||
|
command.Parameters.AddWithValue("$updatedAtUtcMs", nowUtc.ToUnixTimeMilliseconds());
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Keep whiteboard usable even when persistence is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool DeleteNote(string componentId, string? placementId)
|
||||||
|
{
|
||||||
|
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
DELETE FROM whiteboard_notes
|
||||||
|
WHERE component_id = $componentId
|
||||||
|
AND placement_id = $placementId;
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$componentId", normalizedComponentId);
|
||||||
|
command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
|
||||||
|
return command.ExecuteNonQuery() > 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryDeleteExpiredNote(string componentId, string? placementId, int retentionDays)
|
||||||
|
{
|
||||||
|
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
return DeleteExpiredInternal(
|
||||||
|
connection,
|
||||||
|
normalizedComponentId,
|
||||||
|
normalizedPlacementId,
|
||||||
|
WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays),
|
||||||
|
DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int DeleteExpiredNotesBatch(int batchSize = DefaultCleanupBatchSize, DateTimeOffset? now = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
DELETE FROM whiteboard_notes
|
||||||
|
WHERE rowid IN (
|
||||||
|
SELECT rowid
|
||||||
|
FROM whiteboard_notes
|
||||||
|
WHERE expires_at_utc_ms <= $nowUtcMs
|
||||||
|
ORDER BY expires_at_utc_ms ASC
|
||||||
|
LIMIT $batchSize
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$nowUtcMs", (now ?? DateTimeOffset.UtcNow).ToUnixTimeMilliseconds());
|
||||||
|
command.Parameters.AddWithValue("$batchSize", NormalizeBatchSize(batchSize));
|
||||||
|
return command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsExpired(WhiteboardNoteSnapshot snapshot, int retentionDays, DateTimeOffset? now = null)
|
||||||
|
{
|
||||||
|
if (snapshot is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expirationUtc = GetExpirationUtc(snapshot, retentionDays);
|
||||||
|
if (!expirationUtc.HasValue)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return expirationUtc.Value <= (now ?? DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTimeOffset? GetExpirationUtc(WhiteboardNoteSnapshot snapshot, int retentionDays)
|
||||||
|
{
|
||||||
|
if (snapshot is null || snapshot.SavedUtc == default)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot.SavedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
|
||||||
|
}
|
||||||
|
|
||||||
|
private SqliteConnection OpenConnection()
|
||||||
|
{
|
||||||
|
var connection = _databaseService.OpenConnection();
|
||||||
|
EnsureSchema(connection);
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureSchema(SqliteConnection connection)
|
||||||
|
{
|
||||||
|
if (_schemaInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_schemaSyncRoot)
|
||||||
|
{
|
||||||
|
if (_schemaInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
CREATE TABLE IF NOT EXISTS whiteboard_notes (
|
||||||
|
component_id TEXT NOT NULL,
|
||||||
|
placement_id TEXT NOT NULL,
|
||||||
|
note_json TEXT NOT NULL,
|
||||||
|
saved_at_utc_ms INTEGER NOT NULL,
|
||||||
|
expires_at_utc_ms INTEGER NOT NULL,
|
||||||
|
updated_at_utc_ms INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (component_id, placement_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_whiteboard_notes_expires_at
|
||||||
|
ON whiteboard_notes(expires_at_utc_ms);
|
||||||
|
""";
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
_schemaInitialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool DeleteExpiredInternal(
|
||||||
|
SqliteConnection connection,
|
||||||
|
string componentId,
|
||||||
|
string placementId,
|
||||||
|
int retentionDays,
|
||||||
|
DateTimeOffset nowUtc)
|
||||||
|
{
|
||||||
|
using var selectCommand = connection.CreateCommand();
|
||||||
|
selectCommand.CommandText = """
|
||||||
|
SELECT saved_at_utc_ms
|
||||||
|
FROM whiteboard_notes
|
||||||
|
WHERE component_id = $componentId
|
||||||
|
AND placement_id = $placementId
|
||||||
|
LIMIT 1;
|
||||||
|
""";
|
||||||
|
selectCommand.Parameters.AddWithValue("$componentId", componentId);
|
||||||
|
selectCommand.Parameters.AddWithValue("$placementId", placementId);
|
||||||
|
|
||||||
|
var scalar = selectCommand.ExecuteScalar();
|
||||||
|
if (scalar is not long savedAtUtcMs)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var savedUtc = DateTimeOffset.FromUnixTimeMilliseconds(savedAtUtcMs);
|
||||||
|
var expiresUtc = savedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
|
||||||
|
if (expiresUtc > nowUtc)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var deleteCommand = connection.CreateCommand();
|
||||||
|
deleteCommand.CommandText = """
|
||||||
|
DELETE FROM whiteboard_notes
|
||||||
|
WHERE component_id = $componentId
|
||||||
|
AND placement_id = $placementId;
|
||||||
|
""";
|
||||||
|
deleteCommand.Parameters.AddWithValue("$componentId", componentId);
|
||||||
|
deleteCommand.Parameters.AddWithValue("$placementId", placementId);
|
||||||
|
return deleteCommand.ExecuteNonQuery() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryNormalizeKeys(
|
||||||
|
string componentId,
|
||||||
|
string? placementId,
|
||||||
|
out string normalizedComponentId,
|
||||||
|
out string normalizedPlacementId)
|
||||||
|
{
|
||||||
|
normalizedComponentId = componentId?.Trim() ?? string.Empty;
|
||||||
|
normalizedPlacementId = placementId?.Trim() ?? string.Empty;
|
||||||
|
return !string.IsNullOrWhiteSpace(normalizedComponentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int NormalizeBatchSize(int batchSize)
|
||||||
|
{
|
||||||
|
return batchSize <= 0
|
||||||
|
? DefaultCleanupBatchSize
|
||||||
|
: Math.Clamp(batchSize, 1, 4096);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<Styles xmlns="https://github.com/avaloniaui"
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:assists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles">
|
||||||
<Style Selector="Window.component-editor-window">
|
<Style Selector="Window.component-editor-window">
|
||||||
<Setter Property="Background" Value="{DynamicResource EditorWindowBackgroundBrush}" />
|
<Setter Property="Background" Value="{DynamicResource EditorWindowBackgroundBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldBackgroundBrush}" />
|
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldBackgroundBrush}" />
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineBrush}" />
|
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="CornerRadius" Value="18" />
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
|
||||||
<Setter Property="Padding" Value="16,14,12,14" />
|
<Setter Property="Padding" Value="16,14,12,14" />
|
||||||
<Setter Property="MinHeight" Value="56" />
|
<Setter Property="MinHeight" Value="56" />
|
||||||
<Setter Property="FontSize" Value="14" />
|
<Setter Property="FontSize" Value="14" />
|
||||||
@@ -39,7 +40,7 @@
|
|||||||
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldBackgroundBrush}" />
|
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldBackgroundBrush}" />
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineBrush}" />
|
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="CornerRadius" Value="18" />
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
|
||||||
<Setter Property="Padding" Value="16,14,12,14" />
|
<Setter Property="Padding" Value="16,14,12,14" />
|
||||||
<Setter Property="MinHeight" Value="56" />
|
<Setter Property="MinHeight" Value="56" />
|
||||||
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
|
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
|
||||||
@@ -60,7 +61,7 @@
|
|||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<Setter Property="Padding" Value="16,12" />
|
<Setter Property="Padding" Value="16,12" />
|
||||||
<Setter Property="Margin" Value="6,4" />
|
<Setter Property="Margin" Value="6,4" />
|
||||||
<Setter Property="CornerRadius" Value="14" />
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
|
||||||
<Setter Property="MinHeight" Value="44" />
|
<Setter Property="MinHeight" Value="44" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
@@ -74,7 +75,21 @@
|
|||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Window.component-editor-window RadioButton">
|
<Style Selector="Window.component-editor-window RadioButton">
|
||||||
|
<Setter Property="Theme" Value="{StaticResource MaterialRadioButton}" />
|
||||||
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
|
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
|
||||||
|
<Setter Property="Margin" Value="0,2" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="assists:SelectionControlAssist.Size" Value="20" />
|
||||||
|
<Setter Property="assists:SelectionControlAssist.Foreground" Value="{DynamicResource ComponentEditorSecondaryTextBrush}" />
|
||||||
|
<Setter Property="assists:SelectionControlAssist.InnerForeground" Value="{DynamicResource EditorPrimaryBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Window.component-editor-window RadioButton:pointerover">
|
||||||
|
<Setter Property="assists:SelectionControlAssist.Foreground" Value="{DynamicResource EditorSelectOutlineStrongBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Window.component-editor-window RadioButton:checked">
|
||||||
|
<Setter Property="assists:SelectionControlAssist.Foreground" Value="{DynamicResource EditorPrimaryBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Window.component-editor-window ToggleSwitch">
|
<Style Selector="Window.component-editor-window ToggleSwitch">
|
||||||
@@ -85,7 +100,7 @@
|
|||||||
<Setter Property="Background" Value="{DynamicResource EditorSurfaceContainerHighBrush}" />
|
<Setter Property="Background" Value="{DynamicResource EditorSurfaceContainerHighBrush}" />
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineBrush}" />
|
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="CornerRadius" Value="20" />
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusLg}" />
|
||||||
<Setter Property="Padding" Value="4" />
|
<Setter Property="Padding" Value="4" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
@@ -93,7 +108,7 @@
|
|||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<Setter Property="BorderBrush" Value="Transparent" />
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
<Setter Property="CornerRadius" Value="16" />
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
|
||||||
<Setter Property="Padding" Value="18,12" />
|
<Setter Property="Padding" Value="18,12" />
|
||||||
<Setter Property="MinHeight" Value="48" />
|
<Setter Property="MinHeight" Value="48" />
|
||||||
<Setter Property="FontSize" Value="14" />
|
<Setter Property="FontSize" Value="14" />
|
||||||
@@ -124,14 +139,14 @@
|
|||||||
<Setter Property="Background" Value="{DynamicResource ComponentEditorHeroBackgroundBrush}" />
|
<Setter Property="Background" Value="{DynamicResource ComponentEditorHeroBackgroundBrush}" />
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource ComponentEditorCardBorderBrush}" />
|
<Setter Property="BorderBrush" Value="{DynamicResource ComponentEditorCardBorderBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="CornerRadius" Value="28" />
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXl}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Border.component-editor-card">
|
<Style Selector="Border.component-editor-card">
|
||||||
<Setter Property="Background" Value="{DynamicResource ComponentEditorCardBackgroundBrush}" />
|
<Setter Property="Background" Value="{DynamicResource ComponentEditorCardBackgroundBrush}" />
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource ComponentEditorCardBorderBrush}" />
|
<Setter Property="BorderBrush" Value="{DynamicResource ComponentEditorCardBorderBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="CornerRadius" Value="24" />
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusLg}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="TextBlock.component-editor-headline">
|
<Style Selector="TextBlock.component-editor-headline">
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
|
|
||||||
<Styles.Resources>
|
<Styles.Resources>
|
||||||
<!-- Unified corner radius tokens used across settings and widget panels -->
|
<!-- Unified corner radius tokens used across settings and widget panels -->
|
||||||
|
<CornerRadius x:Key="DesignCornerRadiusMicro">6</CornerRadius>
|
||||||
<CornerRadius x:Key="DesignCornerRadiusXl">32</CornerRadius>
|
<CornerRadius x:Key="DesignCornerRadiusXl">32</CornerRadius>
|
||||||
<CornerRadius x:Key="DesignCornerRadiusLg">28</CornerRadius>
|
<CornerRadius x:Key="DesignCornerRadiusLg">28</CornerRadius>
|
||||||
<CornerRadius x:Key="DesignCornerRadiusMd">20</CornerRadius>
|
<CornerRadius x:Key="DesignCornerRadiusMd">20</CornerRadius>
|
||||||
<CornerRadius x:Key="DesignCornerRadiusSm">14</CornerRadius>
|
<CornerRadius x:Key="DesignCornerRadiusSm">14</CornerRadius>
|
||||||
<CornerRadius x:Key="DesignCornerRadiusXs">12</CornerRadius>
|
<CornerRadius x:Key="DesignCornerRadiusXs">12</CornerRadius>
|
||||||
|
<CornerRadius x:Key="DesignCornerRadiusIsland">36</CornerRadius>
|
||||||
</Styles.Resources>
|
</Styles.Resources>
|
||||||
|
|
||||||
<Style Selector="TextBlock">
|
<Style Selector="TextBlock">
|
||||||
@@ -19,7 +21,7 @@
|
|||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonBackgroundBrush}" />
|
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonBackgroundBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveButtonBorderBrush}" />
|
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveButtonBorderBrush}" />
|
||||||
<Setter Property="CornerRadius" Value="20" />
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
|
||||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||||
<Setter Property="FontSize" Value="14" />
|
<Setter Property="FontSize" Value="14" />
|
||||||
<Setter Property="Padding" Value="16,10" />
|
<Setter Property="Padding" Value="16,10" />
|
||||||
@@ -155,7 +157,7 @@
|
|||||||
|
|
||||||
<Style Selector="Button.swatch-button">
|
<Style Selector="Button.swatch-button">
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
<Setter Property="CornerRadius" Value="16" />
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXs}" />
|
||||||
<Setter Property="Opacity" Value="0.88" />
|
<Setter Property="Opacity" Value="0.88" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
@@ -165,29 +167,35 @@
|
|||||||
<Setter Property="RenderTransform" Value="scale(1.05)" />
|
<Setter Property="RenderTransform" Value="scale(1.05)" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Border.glass-panel">
|
<!--
|
||||||
|
半透明表面样式类
|
||||||
|
注意:这些样式使用纯色半透明画刷模拟玻璃效果,并非真正的 Mica/Acrylic 模糊材质。
|
||||||
|
真正的 Mica/Acrylic 效果仅通过 WindowTransparencyLevel 在独立窗口上应用。
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Style Selector="Border.surface-translucent-panel">
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBackgroundBrush}" />
|
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBackgroundBrush}" />
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="1.2" />
|
<Setter Property="BorderThickness" Value="1.2" />
|
||||||
<Setter Property="CornerRadius" Value="28" />
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusLg}" />
|
||||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassPanelOpacity}" />
|
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassPanelOpacity}" />
|
||||||
<Setter Property="BoxShadow" Value="0 4 12 #1A000000" />
|
<Setter Property="BoxShadow" Value="0 4 12 #1A000000" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Border.glass-strong">
|
<Style Selector="Border.surface-translucent-strong">
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
|
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassStrongBorderBrush}" />
|
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassStrongBorderBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="1.5" />
|
<Setter Property="BorderThickness" Value="1.5" />
|
||||||
<Setter Property="CornerRadius" Value="32" />
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXl}" />
|
||||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
|
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
|
||||||
<Setter Property="BoxShadow" Value="0 8 24 #26000000" />
|
<Setter Property="BoxShadow" Value="0 8 24 #26000000" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Border.glass-island">
|
<Style Selector="Border.surface-translucent-island">
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveDockGlassBackgroundBrush}" />
|
<Setter Property="Background" Value="{DynamicResource AdaptiveDockGlassBackgroundBrush}" />
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveDockGlassBorderBrush}" />
|
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveDockGlassBorderBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="1.5" />
|
<Setter Property="BorderThickness" Value="1.5" />
|
||||||
<Setter Property="CornerRadius" Value="36" />
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusIsland}" />
|
||||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
|
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
|
||||||
<Setter Property="BoxShadow" Value="0 12 32 #33000000" />
|
<Setter Property="BoxShadow" Value="0 12 32 #33000000" />
|
||||||
<Setter Property="Transitions">
|
<Setter Property="Transitions">
|
||||||
@@ -197,19 +205,26 @@
|
|||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Border.mica-strong">
|
<Style Selector="Border.surface-solid-strong">
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
|
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
<Setter Property="CornerRadius" Value="36" />
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusIsland}" />
|
||||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
|
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
|
||||||
<Setter Property="BoxShadow" Value="0 8 22 #2A000000" />
|
<Setter Property="BoxShadow" Value="0 8 22 #2A000000" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Border.glass-overlay">
|
<Style Selector="Border.surface-translucent-overlay">
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassOverlayBackgroundBrush}" />
|
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassOverlayBackgroundBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
<Setter Property="CornerRadius" Value="0" />
|
<Setter Property="CornerRadius" Value="0" />
|
||||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassOverlayOpacity}" />
|
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassOverlayOpacity}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- 向后兼容的旧样式类(已弃用) -->
|
||||||
|
<Style Selector="Border.glass-panel" />
|
||||||
|
<Style Selector="Border.glass-strong" />
|
||||||
|
<Style Selector="Border.glass-island" />
|
||||||
|
<Style Selector="Border.mica-strong" />
|
||||||
|
<Style Selector="Border.glass-overlay" />
|
||||||
|
|
||||||
</Styles>
|
</Styles>
|
||||||
|
|||||||
@@ -48,21 +48,21 @@
|
|||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Border.settings-section-card">
|
<Style Selector="Border.settings-section-card">
|
||||||
<Setter Property="CornerRadius" Value="18" />
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusLg}" />
|
||||||
<Setter Property="Padding" Value="20" />
|
<Setter Property="Padding" Value="20" />
|
||||||
<Setter Property="Margin" Value="0,0,0,14" />
|
<Setter Property="Margin" Value="0,0,0,14" />
|
||||||
<Setter Property="BoxShadow" Value="0 2 8 #12000000" />
|
<Setter Property="BoxShadow" Value="0 2 8 #12000000" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Border.settings-option-card">
|
<Style Selector="Border.settings-option-card">
|
||||||
<Setter Property="CornerRadius" Value="14" />
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
|
||||||
<Setter Property="Padding" Value="16" />
|
<Setter Property="Padding" Value="16" />
|
||||||
<Setter Property="Margin" Value="0,0,0,12" />
|
<Setter Property="Margin" Value="0,0,0,12" />
|
||||||
<Setter Property="BoxShadow" Value="0 1 4 #0F000000" />
|
<Setter Property="BoxShadow" Value="0 1 4 #0F000000" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Border.settings-list-item">
|
<Style Selector="Border.settings-list-item">
|
||||||
<Setter Property="CornerRadius" Value="14" />
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
|
||||||
<Setter Property="Padding" Value="16" />
|
<Setter Property="Padding" Value="16" />
|
||||||
<Setter Property="Margin" Value="0,0,0,10" />
|
<Setter Property="Margin" Value="0,0,0,10" />
|
||||||
<Setter Property="BoxShadow" Value="0 1 4 #0F000000" />
|
<Setter Property="BoxShadow" Value="0 1 4 #0F000000" />
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonBackgroundBrush}" />
|
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonBackgroundBrush}" />
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveButtonBorderBrush}" />
|
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveButtonBorderBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="CornerRadius" Value="12" />
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXs}" />
|
||||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||||
<Setter Property="VerticalAlignment" Value="Top" />
|
<Setter Property="VerticalAlignment" Value="Top" />
|
||||||
</Style>
|
</Style>
|
||||||
@@ -201,7 +201,7 @@
|
|||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
|
<Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="CornerRadius" Value="12" />
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXs}" />
|
||||||
<Setter Property="Padding" Value="14,12" />
|
<Setter Property="Padding" Value="14,12" />
|
||||||
<Setter Property="Margin" Value="0,0,0,8" />
|
<Setter Property="Margin" Value="0,0,0,8" />
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentBrush}" />
|
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentBrush}" />
|
||||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveOnAccentBrush}" />
|
<Setter Property="Foreground" Value="{DynamicResource AdaptiveOnAccentBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
<Setter Property="CornerRadius" Value="10" />
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXs}" />
|
||||||
<Setter Property="Padding" Value="16,10" />
|
<Setter Property="Padding" Value="16,10" />
|
||||||
<Setter Property="MinHeight" Value="36" />
|
<Setter Property="MinHeight" Value="36" />
|
||||||
</Style>
|
</Style>
|
||||||
@@ -254,7 +254,7 @@
|
|||||||
<Setter Property="Width" Value="36" />
|
<Setter Property="Width" Value="36" />
|
||||||
<Setter Property="Height" Value="36" />
|
<Setter Property="Height" Value="36" />
|
||||||
<Setter Property="Padding" Value="0" />
|
<Setter Property="Padding" Value="0" />
|
||||||
<Setter Property="CornerRadius" Value="10" />
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXs}" />
|
||||||
<Setter Property="MinHeight" Value="36" />
|
<Setter Property="MinHeight" Value="36" />
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonBackgroundBrush}" />
|
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonBackgroundBrush}" />
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveButtonBorderBrush}" />
|
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveButtonBorderBrush}" />
|
||||||
|
|||||||
@@ -564,11 +564,19 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
RefreshInstalledSnapshot();
|
RefreshInstalledSnapshot();
|
||||||
RefreshItemStates();
|
RefreshItemStates();
|
||||||
|
|
||||||
|
// 设置更明显的状态消息
|
||||||
|
var pluginName = result.PluginName ?? item.Name;
|
||||||
StatusMessage = string.Format(
|
StatusMessage = string.Format(
|
||||||
CultureInfo.CurrentCulture,
|
CultureInfo.CurrentCulture,
|
||||||
L("market.status.install_success_format", "Plugin '{0}' has been staged. Restart the app to apply it."),
|
L("market.status.install_success_restart_format", "✓ Plugin '{0}' installed successfully! Please restart the application to activate it."),
|
||||||
result.PluginName ?? item.Name);
|
pluginName);
|
||||||
RestartRequested?.Invoke(RestartRequiredMessage);
|
|
||||||
|
// 触发重启提醒
|
||||||
|
RestartRequested?.Invoke(string.Format(
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
L("market.dialog.restart_message_format", "Plugin '{0}' has been installed successfully.\n\nTo use this plugin, you need to restart the application now.\n\nWould you like to restart?"),
|
||||||
|
pluginName));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using LanMountainDesktop.Models;
|
|||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using LanMountainDesktop.Settings.Core;
|
||||||
|
|
||||||
namespace LanMountainDesktop.ViewModels;
|
namespace LanMountainDesktop.ViewModels;
|
||||||
|
|
||||||
@@ -268,12 +269,17 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase
|
|||||||
|
|
||||||
partial void OnSelectedLanguageChanged(SelectionOption value)
|
partial void OnSelectedLanguageChanged(SelectionOption value)
|
||||||
{
|
{
|
||||||
RefreshPreview();
|
|
||||||
if (_isInitializing || value is null)
|
if (_isInitializing || value is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新语言代码并刷新UI文本
|
||||||
|
_languageCode = _localizationService.NormalizeLanguageCode(value.Value);
|
||||||
|
RefreshLocalizedText();
|
||||||
|
RefreshPreview();
|
||||||
|
|
||||||
|
// 保存设置
|
||||||
_settingsFacade.Region.Save(new RegionSettingsState(
|
_settingsFacade.Region.Save(new RegionSettingsState(
|
||||||
value.Value,
|
value.Value,
|
||||||
NormalizeTimeZoneId(SelectedTimeZone?.Id)));
|
NormalizeTimeZoneId(SelectedTimeZone?.Id)));
|
||||||
@@ -476,6 +482,9 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _useSystemChrome;
|
private bool _useSystemChrome;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private double _globalCornerRadiusScale = GlobalAppearanceSettings.DefaultCornerRadiusScale;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private SelectionOption _selectedThemeColorMode = new(ThemeAppearanceValues.ColorModeSeedMonet, "User theme color Monet");
|
private SelectionOption _selectedThemeColorMode = new(ThemeAppearanceValues.ColorModeSeedMonet, "User theme color Monet");
|
||||||
|
|
||||||
@@ -542,6 +551,12 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _systemMaterialLabel = string.Empty;
|
private string _systemMaterialLabel = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _globalCornerRadiusLabel = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _globalCornerRadiusDescription = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _themeHeader = string.Empty;
|
private string _themeHeader = string.Empty;
|
||||||
|
|
||||||
@@ -663,6 +678,32 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
|||||||
PersistCurrentState(restartRequired: false);
|
PersistCurrentState(restartRequired: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnGlobalCornerRadiusScaleChanged(double value)
|
||||||
|
{
|
||||||
|
if (_isInitializing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = GlobalAppearanceSettings.NormalizeCornerRadiusScale(value);
|
||||||
|
if (Math.Abs(normalized - value) > 0.0001d)
|
||||||
|
{
|
||||||
|
_isInitializing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
GlobalCornerRadiusScale = normalized;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isInitializing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PersistCurrentState(restartRequired: false);
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnSelectedThemeColorModeChanged(SelectionOption value)
|
partial void OnSelectedThemeColorModeChanged(SelectionOption value)
|
||||||
{
|
{
|
||||||
if (_isInitializing || value is null)
|
if (_isInitializing || value is null)
|
||||||
@@ -727,6 +768,8 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
|||||||
ThemeColorLabel = L("settings.color.theme_color_label", "Theme Accent Color");
|
ThemeColorLabel = L("settings.color.theme_color_label", "Theme Accent Color");
|
||||||
ThemeColorModeLabel = L("settings.appearance.theme_color_mode_label", "Theme color source");
|
ThemeColorModeLabel = L("settings.appearance.theme_color_mode_label", "Theme color source");
|
||||||
SystemMaterialLabel = L("settings.appearance.system_material_label", "System material");
|
SystemMaterialLabel = L("settings.appearance.system_material_label", "System material");
|
||||||
|
GlobalCornerRadiusLabel = L("settings.appearance.corner_radius.label", "Global corner radius");
|
||||||
|
GlobalCornerRadiusDescription = L("settings.appearance.corner_radius.description", "Adjust the shared radius scale used by cards, panels, and component containers.");
|
||||||
ThemeSourceNeutralText = L("settings.appearance.theme_color_mode.neutral", "Default neutral");
|
ThemeSourceNeutralText = L("settings.appearance.theme_color_mode.neutral", "Default neutral");
|
||||||
ThemeSourceUserColorText = L("settings.appearance.theme_color_mode.user", "User theme color Monet");
|
ThemeSourceUserColorText = L("settings.appearance.theme_color_mode.user", "User theme color Monet");
|
||||||
ThemeSourceWallpaperText = L("settings.appearance.theme_color_mode.wallpaper", "Wallpaper Monet");
|
ThemeSourceWallpaperText = L("settings.appearance.theme_color_mode.wallpaper", "Wallpaper Monet");
|
||||||
@@ -771,6 +814,7 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
|||||||
IsNightMode = theme.IsNightMode;
|
IsNightMode = theme.IsNightMode;
|
||||||
ThemeColor = theme.ThemeColor ?? string.Empty;
|
ThemeColor = theme.ThemeColor ?? string.Empty;
|
||||||
UseSystemChrome = theme.UseSystemChrome;
|
UseSystemChrome = theme.UseSystemChrome;
|
||||||
|
GlobalCornerRadiusScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(theme.GlobalCornerRadiusScale);
|
||||||
_selectedWallpaperSeed = theme.SelectedWallpaperSeed;
|
_selectedWallpaperSeed = theme.SelectedWallpaperSeed;
|
||||||
SyncCustomSeedPickerWithSavedThemeColor();
|
SyncCustomSeedPickerWithSavedThemeColor();
|
||||||
|
|
||||||
@@ -820,6 +864,7 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
|||||||
IsNightMode,
|
IsNightMode,
|
||||||
themeColor,
|
themeColor,
|
||||||
UseSystemChrome,
|
UseSystemChrome,
|
||||||
|
GlobalAppearanceSettings.NormalizeCornerRadiusScale(GlobalCornerRadiusScale),
|
||||||
themeColorMode,
|
themeColorMode,
|
||||||
ThemeAppearanceValues.NormalizeSystemMaterialMode(SelectedSystemMaterialMode?.Value),
|
ThemeAppearanceValues.NormalizeSystemMaterialMode(SelectedSystemMaterialMode?.Value),
|
||||||
_selectedWallpaperSeed);
|
_selectedWallpaperSeed);
|
||||||
@@ -951,7 +996,7 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
|
|||||||
private string _pageDescription = string.Empty;
|
private string _pageDescription = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _gridHeader = string.Empty;
|
private string _componentsHeader = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _shortSideCellsLabel = string.Empty;
|
private string _shortSideCellsLabel = string.Empty;
|
||||||
@@ -962,6 +1007,22 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _spacingPresetLabel = string.Empty;
|
private string _spacingPresetLabel = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private double _globalCornerRadiusScale = GlobalAppearanceSettings.DefaultCornerRadiusScale;
|
||||||
|
|
||||||
|
public double GlobalCornerRadiusMinimum => GlobalAppearanceSettings.MinimumCornerRadiusScale;
|
||||||
|
|
||||||
|
public double GlobalCornerRadiusMaximum => GlobalAppearanceSettings.MaximumCornerRadiusScale;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _componentRadiusHeader = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _globalCornerRadiusLabel = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _globalCornerRadiusDescription = string.Empty;
|
||||||
|
|
||||||
public void Load()
|
public void Load()
|
||||||
{
|
{
|
||||||
var state = _settingsFacade.Grid.Get();
|
var state = _settingsFacade.Grid.Get();
|
||||||
@@ -971,6 +1032,9 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
|
|||||||
SelectedSpacingPreset = SpacingPresets.FirstOrDefault(option =>
|
SelectedSpacingPreset = SpacingPresets.FirstOrDefault(option =>
|
||||||
string.Equals(option.Value, spacingPreset, StringComparison.OrdinalIgnoreCase))
|
string.Equals(option.Value, spacingPreset, StringComparison.OrdinalIgnoreCase))
|
||||||
?? SpacingPresets[1];
|
?? SpacingPresets[1];
|
||||||
|
|
||||||
|
var theme = _settingsFacade.Theme.Get();
|
||||||
|
GlobalCornerRadiusScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(theme.GlobalCornerRadiusScale);
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnShortSideCellsChanged(int value)
|
partial void OnShortSideCellsChanged(int value)
|
||||||
@@ -1003,6 +1067,32 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
|
|||||||
SaveGrid();
|
SaveGrid();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnGlobalCornerRadiusScaleChanged(double value)
|
||||||
|
{
|
||||||
|
if (_isInitializing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = GlobalAppearanceSettings.NormalizeCornerRadiusScale(value);
|
||||||
|
if (Math.Abs(normalized - value) > 0.0001d)
|
||||||
|
{
|
||||||
|
_isInitializing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
GlobalCornerRadiusScale = normalized;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isInitializing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SaveComponentCornerRadius();
|
||||||
|
}
|
||||||
|
|
||||||
private void SaveGrid()
|
private void SaveGrid()
|
||||||
{
|
{
|
||||||
_settingsFacade.Grid.Save(new GridSettingsState(
|
_settingsFacade.Grid.Save(new GridSettingsState(
|
||||||
@@ -1011,23 +1101,41 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
|
|||||||
Math.Clamp(EdgeInsetPercent, 0, 30)));
|
Math.Clamp(EdgeInsetPercent, 0, 30)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SaveComponentCornerRadius()
|
||||||
|
{
|
||||||
|
var theme = _settingsFacade.Theme.Get();
|
||||||
|
_settingsFacade.Theme.Save(new ThemeAppearanceSettingsState(
|
||||||
|
theme.IsNightMode,
|
||||||
|
theme.ThemeColor,
|
||||||
|
theme.UseSystemChrome,
|
||||||
|
GlobalAppearanceSettings.NormalizeCornerRadiusScale(GlobalCornerRadiusScale),
|
||||||
|
theme.ThemeColorMode,
|
||||||
|
theme.SystemMaterialMode,
|
||||||
|
theme.SelectedWallpaperSeed));
|
||||||
|
}
|
||||||
|
|
||||||
private IReadOnlyList<SelectionOption> CreateSpacingPresets()
|
private IReadOnlyList<SelectionOption> CreateSpacingPresets()
|
||||||
{
|
{
|
||||||
return
|
return
|
||||||
[
|
[
|
||||||
new SelectionOption("Compact", L("settings.grid.spacing_compact", "Compact")),
|
new SelectionOption("Compact", L("settings.components.spacing_compact", "Compact")),
|
||||||
new SelectionOption("Relaxed", L("settings.grid.spacing_relaxed", "Relaxed"))
|
new SelectionOption("Relaxed", L("settings.components.spacing_relaxed", "Relaxed"))
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RefreshLocalizedText()
|
private void RefreshLocalizedText()
|
||||||
{
|
{
|
||||||
PageTitle = L("settings.components.title", "Components");
|
PageTitle = L("settings.components.title", "Components");
|
||||||
PageDescription = L("settings.components.description", "Desktop grid and widget placement density.");
|
PageDescription = L("settings.components.description", "Adjust component layout and corner design.");
|
||||||
GridHeader = L("settings.components.grid_header", "Grid Layout");
|
ComponentsHeader = L("settings.components.header", "Grid Settings");
|
||||||
ShortSideCellsLabel = L("settings.grid.short_side_label", "Short Side Cells");
|
ShortSideCellsLabel = L("settings.components.short_side_label", "Short Side Cells");
|
||||||
EdgeInsetPercentLabel = L("settings.grid.edge_inset_label", "Screen Inset");
|
EdgeInsetPercentLabel = L("settings.components.edge_inset_label", "Screen Inset");
|
||||||
SpacingPresetLabel = L("settings.grid.spacing_label", "Grid Spacing");
|
SpacingPresetLabel = L("settings.components.spacing_label", "Component Spacing");
|
||||||
|
ComponentRadiusHeader = L("settings.components.corner_radius.header", "Corner Design");
|
||||||
|
GlobalCornerRadiusLabel = L("settings.components.corner_radius.label", "Component Corner Radius");
|
||||||
|
GlobalCornerRadiusDescription = L(
|
||||||
|
"settings.components.corner_radius.description",
|
||||||
|
"Adjust the shared corner radius from a square edge to a capsule-like shape, and expand the internal safe area with it.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private string L(string key, string fallback)
|
private string L(string key, string fallback)
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private SelectionOption _selectedClockFormat = new("HourMinuteSecond", "Hour:Minute:Second");
|
private SelectionOption _selectedClockFormat = new("HourMinuteSecond", "Hour:Minute:Second");
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _clockTransparentBackground;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private SelectionOption _selectedSpacingMode = new("Relaxed", "Relaxed");
|
private SelectionOption _selectedSpacingMode = new("Relaxed", "Relaxed");
|
||||||
|
|
||||||
@@ -66,6 +69,12 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _clockFormatLabel = string.Empty;
|
private string _clockFormatLabel = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _clockTransparentBackgroundLabel = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _clockTransparentBackgroundDescription = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _spacingHeader = string.Empty;
|
private string _spacingHeader = string.Empty;
|
||||||
|
|
||||||
@@ -88,6 +97,7 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
|||||||
SelectedClockFormat = ClockFormats.FirstOrDefault(option =>
|
SelectedClockFormat = ClockFormats.FirstOrDefault(option =>
|
||||||
string.Equals(option.Value, clockFormat, StringComparison.OrdinalIgnoreCase))
|
string.Equals(option.Value, clockFormat, StringComparison.OrdinalIgnoreCase))
|
||||||
?? ClockFormats[1];
|
?? ClockFormats[1];
|
||||||
|
ClockTransparentBackground = state.ClockTransparentBackground;
|
||||||
|
|
||||||
var spacingMode = NormalizeSpacingMode(state.SpacingMode);
|
var spacingMode = NormalizeSpacingMode(state.SpacingMode);
|
||||||
SelectedSpacingMode = SpacingModes.FirstOrDefault(option =>
|
SelectedSpacingMode = SpacingModes.FirstOrDefault(option =>
|
||||||
@@ -117,6 +127,16 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
|||||||
Save();
|
Save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnClockTransparentBackgroundChanged(bool value)
|
||||||
|
{
|
||||||
|
if (_isInitializing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnSelectedSpacingModeChanged(SelectionOption value)
|
partial void OnSelectedSpacingModeChanged(SelectionOption value)
|
||||||
{
|
{
|
||||||
IsCustomSpacingVisible = string.Equals(value?.Value, "Custom", StringComparison.OrdinalIgnoreCase);
|
IsCustomSpacingVisible = string.Equals(value?.Value, "Custom", StringComparison.OrdinalIgnoreCase);
|
||||||
@@ -163,6 +183,7 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
|||||||
state.EnableDynamicTaskbarActions,
|
state.EnableDynamicTaskbarActions,
|
||||||
state.TaskbarLayoutMode,
|
state.TaskbarLayoutMode,
|
||||||
SelectedClockFormat.Value,
|
SelectedClockFormat.Value,
|
||||||
|
ClockTransparentBackground,
|
||||||
NormalizeSpacingMode(SelectedSpacingMode.Value),
|
NormalizeSpacingMode(SelectedSpacingMode.Value),
|
||||||
Math.Clamp(CustomSpacingPercent, 0, 30)));
|
Math.Clamp(CustomSpacingPercent, 0, 30)));
|
||||||
}
|
}
|
||||||
@@ -194,6 +215,8 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
|||||||
ClockHeader = L("settings.status_bar.clock_header", "Clock Component");
|
ClockHeader = L("settings.status_bar.clock_header", "Clock Component");
|
||||||
ClockDescription = L("settings.status_bar.clock_description", "Display a clock on the top status bar.");
|
ClockDescription = L("settings.status_bar.clock_description", "Display a clock on the top status bar.");
|
||||||
ClockFormatLabel = L("settings.status_bar.clock_format_label", "Clock format");
|
ClockFormatLabel = L("settings.status_bar.clock_format_label", "Clock format");
|
||||||
|
ClockTransparentBackgroundLabel = L("settings.status_bar.clock_transparent_background_label", "Transparent background");
|
||||||
|
ClockTransparentBackgroundDescription = L("settings.status_bar.clock_transparent_background_desc", "Remove the capsule background and keep only the clock text.");
|
||||||
SpacingHeader = L("settings.status_bar.spacing_header", "Component Spacing");
|
SpacingHeader = L("settings.status_bar.spacing_header", "Component Spacing");
|
||||||
SpacingDescription = L("settings.status_bar.spacing_desc", "Adjust spacing between status bar components.");
|
SpacingDescription = L("settings.status_bar.spacing_desc", "Adjust spacing between status bar components.");
|
||||||
CustomSpacingLabel = L("settings.status_bar.spacing_custom_label", "Custom spacing (%)");
|
CustomSpacingLabel = L("settings.status_bar.spacing_custom_label", "Custom spacing (%)");
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
|
|
||||||
<!-- MD3 Button Styles -->
|
<!-- MD3 Button Styles -->
|
||||||
<Style Selector="Button.component-editor-footer-button">
|
<Style Selector="Button.component-editor-footer-button">
|
||||||
<Setter Property="CornerRadius" Value="20" />
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
|
||||||
<Setter Property="Background" Value="{DynamicResource EditorPrimaryBrush}" />
|
<Setter Property="Background" Value="{DynamicResource EditorPrimaryBrush}" />
|
||||||
<Setter Property="Foreground" Value="{DynamicResource EditorOnPrimaryBrush}" />
|
<Setter Property="Foreground" Value="{DynamicResource EditorOnPrimaryBrush}" />
|
||||||
<Setter Property="Height" Value="40" />
|
<Setter Property="Height" Value="40" />
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
Height="64"
|
Height="64"
|
||||||
Background="{DynamicResource EditorPrimaryBrush}"
|
Background="{DynamicResource EditorPrimaryBrush}"
|
||||||
Foreground="{DynamicResource EditorOnPrimaryBrush}"
|
Foreground="{DynamicResource EditorOnPrimaryBrush}"
|
||||||
CornerRadius="18"
|
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||||
Classes="accent"
|
Classes="accent"
|
||||||
Click="OnCloseClick">
|
Click="OnCloseClick">
|
||||||
<Button.Styles>
|
<Button.Styles>
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ public partial class ComponentEditorWindow : Window
|
|||||||
"DataLine" => MaterialIconKind.ChartLine,
|
"DataLine" => MaterialIconKind.ChartLine,
|
||||||
"Edit" => MaterialIconKind.Pencil,
|
"Edit" => MaterialIconKind.Pencil,
|
||||||
"Calculator" => MaterialIconKind.Calculator,
|
"Calculator" => MaterialIconKind.Calculator,
|
||||||
|
"Storage" => MaterialIconKind.UsbFlashDrive,
|
||||||
"Globe" => MaterialIconKind.Web,
|
"Globe" => MaterialIconKind.Web,
|
||||||
"Play" => MaterialIconKind.Play,
|
"Play" => MaterialIconKind.Play,
|
||||||
_ => MaterialIconKind.Settings
|
_ => MaterialIconKind.Settings
|
||||||
|
|||||||
@@ -22,14 +22,17 @@
|
|||||||
<StackPanel Spacing="12">
|
<StackPanel Spacing="12">
|
||||||
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
|
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
|
||||||
Classes="component-editor-section-title" />
|
Classes="component-editor-section-title" />
|
||||||
<StackPanel Spacing="8">
|
<ComboBox x:Name="ColorSchemeComboBox"
|
||||||
<RadioButton x:Name="FollowSystemRadioButton"
|
Classes="component-editor-select"
|
||||||
GroupName="ColorScheme"
|
HorizontalAlignment="Stretch"
|
||||||
IsCheckedChanged="OnColorSchemeChanged" />
|
SelectionChanged="OnColorSchemeSelectionChanged">
|
||||||
<RadioButton x:Name="UseNativeRadioButton"
|
<ComboBoxItem x:Name="FollowSystemColorSchemeItem"
|
||||||
GroupName="ColorScheme"
|
Classes="component-editor-select-item"
|
||||||
IsCheckedChanged="OnColorSchemeChanged" />
|
Tag="follow_system" />
|
||||||
</StackPanel>
|
<ComboBoxItem x:Name="UseNativeColorSchemeItem"
|
||||||
|
Classes="component-editor-select-item"
|
||||||
|
Tag="native" />
|
||||||
|
</ComboBox>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -68,40 +68,37 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
|
|||||||
var colorSchemeSource = snapshot.ColorSchemeSource;
|
var colorSchemeSource = snapshot.ColorSchemeSource;
|
||||||
|
|
||||||
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Class Schedule";
|
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Class Schedule";
|
||||||
DescriptionTextBlock.Text = L("schedule.settings.desc", "导入 ClassIsland 的 CSES 课表文件并选择启用项。");
|
DescriptionTextBlock.Text = L(
|
||||||
|
"schedule.settings.desc",
|
||||||
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "配色方案");
|
"Import a ClassIsland CSES schedule file and choose which one to use.");
|
||||||
FollowSystemRadioButton.Content = L("component.color_scheme.follow_system", "跟随系统配色");
|
|
||||||
UseNativeRadioButton.Content = L("component.color_scheme.native", "使用组件自定义配色");
|
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "Color Scheme");
|
||||||
|
FollowSystemColorSchemeItem.Content = L("component.color_scheme.follow_system", "Follow system color scheme");
|
||||||
AddScheduleButton.Content = L("schedule.settings.add", "添加课表");
|
UseNativeColorSchemeItem.Content = L("component.color_scheme.native", "Use component custom color scheme");
|
||||||
EmptyStateTextBlock.Text = L("schedule.settings.empty", "暂无导入课表");
|
|
||||||
|
AddScheduleButton.Content = L("schedule.settings.add", "Add Schedule");
|
||||||
|
EmptyStateTextBlock.Text = L("schedule.settings.empty", "No imported schedules yet.");
|
||||||
|
|
||||||
_suppressEvents = true;
|
_suppressEvents = true;
|
||||||
|
ColorSchemeComboBox.SelectedItem =
|
||||||
if (string.IsNullOrEmpty(colorSchemeSource) ||
|
string.IsNullOrEmpty(colorSchemeSource) ||
|
||||||
colorSchemeSource == ThemeAppearanceValues.ColorSchemeFollowSystem)
|
string.Equals(colorSchemeSource, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase)
|
||||||
{
|
? FollowSystemColorSchemeItem
|
||||||
FollowSystemRadioButton.IsChecked = true;
|
: UseNativeColorSchemeItem;
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
UseNativeRadioButton.IsChecked = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
_suppressEvents = false;
|
_suppressEvents = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnColorSchemeChanged(object? sender, RoutedEventArgs e)
|
private void OnColorSchemeSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
if (_suppressEvents)
|
if (_suppressEvents)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var useNative = UseNativeRadioButton.IsChecked == true;
|
var colorSchemeSource = ColorSchemeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
|
||||||
var colorSchemeSource = useNative
|
? tag
|
||||||
? ThemeAppearanceValues.ColorSchemeNative
|
|
||||||
: ThemeAppearanceValues.ColorSchemeFollowSystem;
|
: ThemeAppearanceValues.ColorSchemeFollowSystem;
|
||||||
|
|
||||||
var snapshot = LoadSnapshot();
|
var snapshot = LoadSnapshot();
|
||||||
@@ -121,11 +118,11 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
|
|||||||
|
|
||||||
var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||||
{
|
{
|
||||||
Title = L("schedule.settings.picker_title", "选择 ClassIsland 课表文件"),
|
Title = L("schedule.settings.picker_title", "Choose ClassIsland schedule file"),
|
||||||
AllowMultiple = false,
|
AllowMultiple = false,
|
||||||
FileTypeFilter =
|
FileTypeFilter =
|
||||||
[
|
[
|
||||||
new FilePickerFileType(L("schedule.settings.picker_file_type", "ClassIsland CSES 课表"))
|
new FilePickerFileType(L("schedule.settings.picker_file_type", "ClassIsland CSES Schedule"))
|
||||||
{
|
{
|
||||||
Patterns = ["*.cses", "*.yaml", "*.yml"]
|
Patterns = ["*.cses", "*.yaml", "*.yml"]
|
||||||
}
|
}
|
||||||
@@ -155,7 +152,7 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
|
|||||||
{
|
{
|
||||||
Id = Guid.NewGuid().ToString("N"),
|
Id = Guid.NewGuid().ToString("N"),
|
||||||
DisplayName = Path.GetFileNameWithoutExtension(importedPath)?.Trim()
|
DisplayName = Path.GetFileNameWithoutExtension(importedPath)?.Trim()
|
||||||
?? L("schedule.settings.unnamed", "未命名课表"),
|
?? L("schedule.settings.unnamed", "Untitled Schedule"),
|
||||||
FilePath = importedPath
|
FilePath = importedPath
|
||||||
});
|
});
|
||||||
_activeScheduleId = _importedSchedules[^1].Id;
|
_activeScheduleId = _importedSchedules[^1].Id;
|
||||||
@@ -219,7 +216,7 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
|
|||||||
var title = new TextBlock
|
var title = new TextBlock
|
||||||
{
|
{
|
||||||
Text = string.IsNullOrWhiteSpace(item.DisplayName)
|
Text = string.IsNullOrWhiteSpace(item.DisplayName)
|
||||||
? L("schedule.settings.unnamed", "未命名课表")
|
? L("schedule.settings.unnamed", "Untitled Schedule")
|
||||||
: item.DisplayName,
|
: item.DisplayName,
|
||||||
FontWeight = FontWeight.SemiBold
|
FontWeight = FontWeight.SemiBold
|
||||||
};
|
};
|
||||||
@@ -234,7 +231,7 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
|
|||||||
|
|
||||||
var deleteButton = new Button
|
var deleteButton = new Button
|
||||||
{
|
{
|
||||||
Content = L("schedule.settings.delete", "删除"),
|
Content = L("schedule.settings.delete", "Delete"),
|
||||||
Tag = item.Id,
|
Tag = item.Id,
|
||||||
Padding = new Thickness(12, 8),
|
Padding = new Thickness(12, 8),
|
||||||
HorizontalAlignment = HorizontalAlignment.Right
|
HorizontalAlignment = HorizontalAlignment.Right
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
x:Class="LanMountainDesktop.Views.ComponentEditors.OfficeRecentDocumentsComponentEditor">
|
||||||
|
<StackPanel Spacing="16">
|
||||||
|
<Border Classes="component-editor-hero-card"
|
||||||
|
Padding="24">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock x:Name="HeadlineTextBlock"
|
||||||
|
Classes="component-editor-headline"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="DescriptionTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="SourcesHeaderTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<TextBlock x:Name="SourcesDescriptionTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<CheckBox x:Name="RegistryCheckBox"
|
||||||
|
IsCheckedChanged="OnSourceSelectionChanged" />
|
||||||
|
<CheckBox x:Name="RecentFoldersCheckBox"
|
||||||
|
IsCheckedChanged="OnSourceSelectionChanged" />
|
||||||
|
<CheckBox x:Name="JumpListsCheckBox"
|
||||||
|
IsCheckedChanged="OnSourceSelectionChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock x:Name="HintTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
Margin="12,0"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||||
|
|
||||||
|
public partial class OfficeRecentDocumentsComponentEditor : ComponentEditorViewBase
|
||||||
|
{
|
||||||
|
private bool _suppressEvents;
|
||||||
|
|
||||||
|
public OfficeRecentDocumentsComponentEditor()
|
||||||
|
: this(null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public OfficeRecentDocumentsComponentEditor(DesktopComponentEditorContext? context)
|
||||||
|
: base(context)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
ApplyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyState()
|
||||||
|
{
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
var enabledSources = OfficeRecentDocumentSourceTypes.NormalizeValues(
|
||||||
|
snapshot.OfficeRecentDocumentsEnabledSources,
|
||||||
|
useDefaultWhenEmpty: snapshot.OfficeRecentDocumentsEnabledSources is null);
|
||||||
|
|
||||||
|
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? L(
|
||||||
|
"component.office_recent_documents",
|
||||||
|
"Recent Documents");
|
||||||
|
DescriptionTextBlock.Text = L(
|
||||||
|
"office_recent_documents.settings.desc",
|
||||||
|
"Choose which Windows and Office sources this widget should scan for recent documents.");
|
||||||
|
SourcesHeaderTextBlock.Text = L(
|
||||||
|
"office_recent_documents.settings.sources_title",
|
||||||
|
"Recent document sources");
|
||||||
|
SourcesDescriptionTextBlock.Text = L(
|
||||||
|
"office_recent_documents.settings.sources_desc",
|
||||||
|
"You can combine multiple sources. Registry selection also keeps the Office interop MRU fallback available.");
|
||||||
|
RegistryCheckBox.Content = L(
|
||||||
|
"office_recent_documents.settings.source.registry",
|
||||||
|
"Office registry MRU");
|
||||||
|
RecentFoldersCheckBox.Content = L(
|
||||||
|
"office_recent_documents.settings.source.recent_folders",
|
||||||
|
"Windows Recent folders");
|
||||||
|
JumpListsCheckBox.Content = L(
|
||||||
|
"office_recent_documents.settings.source.jump_lists",
|
||||||
|
"Windows Jump Lists");
|
||||||
|
HintTextBlock.Text = L(
|
||||||
|
"office_recent_documents.settings.hint",
|
||||||
|
"If you disable all sources, this widget will stay empty until at least one source is enabled again.");
|
||||||
|
|
||||||
|
_suppressEvents = true;
|
||||||
|
RegistryCheckBox.IsChecked = enabledSources.Contains(OfficeRecentDocumentSourceTypes.Registry, StringComparer.OrdinalIgnoreCase);
|
||||||
|
RecentFoldersCheckBox.IsChecked = enabledSources.Contains(OfficeRecentDocumentSourceTypes.RecentFolders, StringComparer.OrdinalIgnoreCase);
|
||||||
|
JumpListsCheckBox.IsChecked = enabledSources.Contains(OfficeRecentDocumentSourceTypes.JumpLists, StringComparer.OrdinalIgnoreCase);
|
||||||
|
_suppressEvents = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSourceSelectionChanged(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
|
if (_suppressEvents)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedSources = new[]
|
||||||
|
{
|
||||||
|
RegistryCheckBox.IsChecked == true ? OfficeRecentDocumentSourceTypes.Registry : null,
|
||||||
|
RecentFoldersCheckBox.IsChecked == true ? OfficeRecentDocumentSourceTypes.RecentFolders : null,
|
||||||
|
JumpListsCheckBox.IsChecked == true ? OfficeRecentDocumentSourceTypes.JumpLists : null
|
||||||
|
}
|
||||||
|
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Cast<string>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
snapshot.OfficeRecentDocumentsEnabledSources = selectedSources;
|
||||||
|
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.OfficeRecentDocumentsEnabledSources));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
x:Class="LanMountainDesktop.Views.ComponentEditors.RemovableStorageComponentEditor">
|
||||||
|
<StackPanel Spacing="16">
|
||||||
|
<Border Classes="component-editor-hero-card"
|
||||||
|
Padding="24">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock x:Name="HeadlineTextBlock"
|
||||||
|
Classes="component-editor-headline"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="DescriptionTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<ComboBox x:Name="ColorSchemeComboBox"
|
||||||
|
Classes="component-editor-select"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
SelectionChanged="OnColorSchemeSelectionChanged">
|
||||||
|
<ComboBoxItem x:Name="FollowSystemColorSchemeItem"
|
||||||
|
Classes="component-editor-select-item"
|
||||||
|
Tag="follow_system" />
|
||||||
|
<ComboBoxItem x:Name="UseNativeColorSchemeItem"
|
||||||
|
Classes="component-editor-select-item"
|
||||||
|
Tag="native" />
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<TextBlock x:Name="BehaviorHeaderTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<TextBlock x:Name="BehaviorTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||||
|
|
||||||
|
public partial class RemovableStorageComponentEditor : ComponentEditorViewBase
|
||||||
|
{
|
||||||
|
private bool _suppressEvents;
|
||||||
|
|
||||||
|
public RemovableStorageComponentEditor()
|
||||||
|
: this(null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public RemovableStorageComponentEditor(DesktopComponentEditorContext? context)
|
||||||
|
: base(context)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
ApplyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyState()
|
||||||
|
{
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
var colorSchemeSource = snapshot.ColorSchemeSource;
|
||||||
|
|
||||||
|
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Removable Storage";
|
||||||
|
DescriptionTextBlock.Text = L(
|
||||||
|
"removable_storage.settings.desc",
|
||||||
|
"Show a connected USB drive with quick open and eject actions.");
|
||||||
|
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "Color Scheme");
|
||||||
|
FollowSystemColorSchemeItem.Content = L("component.color_scheme.follow_system", "Follow system color scheme");
|
||||||
|
UseNativeColorSchemeItem.Content = L("component.color_scheme.native", "Use component custom color scheme");
|
||||||
|
BehaviorHeaderTextBlock.Text = L("removable_storage.settings.behavior_title", "Behavior");
|
||||||
|
BehaviorTextBlock.Text = L(
|
||||||
|
"removable_storage.settings.behavior_desc",
|
||||||
|
"The widget automatically watches for removable drives and switches to the newest inserted USB drive.");
|
||||||
|
|
||||||
|
_suppressEvents = true;
|
||||||
|
ColorSchemeComboBox.SelectedItem =
|
||||||
|
string.IsNullOrWhiteSpace(colorSchemeSource) ||
|
||||||
|
string.Equals(colorSchemeSource, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? FollowSystemColorSchemeItem
|
||||||
|
: UseNativeColorSchemeItem;
|
||||||
|
_suppressEvents = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnColorSchemeSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
|
if (_suppressEvents)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
snapshot.ColorSchemeSource = ColorSchemeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
|
||||||
|
? tag
|
||||||
|
: ThemeAppearanceValues.ColorSchemeFollowSystem;
|
||||||
|
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ColorSchemeSource));
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user