mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
685323e057 | ||
|
|
def21c79b1 | ||
|
|
c3db5af923 | ||
|
|
1a7dde34d0 | ||
|
|
73cdefe296 | ||
|
|
46a8df5900 | ||
|
|
2a1c09ae39 | ||
|
|
33baaa579d | ||
|
|
20cd6041a7 | ||
|
|
65a3cf832a | ||
|
|
5d48a03f57 | ||
|
|
ea8ce1f5ff | ||
|
|
aeae4be060 | ||
|
|
915739ff7b | ||
|
|
cb86ca10e7 | ||
|
|
b3a74aa072 | ||
|
|
b436bfa884 | ||
|
|
081abeb688 | ||
|
|
594a62132f |
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
|
||||
28
.github/workflows/build.yml
vendored
28
.github/workflows/build.yml
vendored
@@ -113,3 +113,31 @@ jobs:
|
||||
path: |
|
||||
LanMountainDesktop/bin/Release/
|
||||
retention-days: 7
|
||||
|
||||
pack-plugin-packages:
|
||||
runs-on: ubuntu-latest
|
||||
name: Pack_Plugin_Packages
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
|
||||
- name: Pack SDK and template packages
|
||||
shell: pwsh
|
||||
run: .\scripts\Pack-PluginPackages.ps1 -Configuration Release -OutputPath .\artifacts\nuget
|
||||
|
||||
- name: Upload plugin package artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: plugin-packages
|
||||
path: artifacts/nuget/*.nupkg
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -1,578 +0,0 @@
|
||||
# 移除视频壁纸功能 - 技术设计文档
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 设计目标
|
||||
|
||||
本设计文档描述如何从 LanMountainDesktop 项目中完全移除视频壁纸功能,包括:
|
||||
- 移除 LibVLC 相关依赖
|
||||
- 清理主窗口中的视频壁纸代码
|
||||
- 简化壁纸设置页面
|
||||
- 清理本地化资源
|
||||
|
||||
### 1.2 技术约束
|
||||
|
||||
- 保持现有图片壁纸和纯色壁纸功能完整
|
||||
- 确保应用构建和运行正常
|
||||
- 不引入新的外部依赖
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构变更
|
||||
|
||||
### 2.1 变更概览图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 变更前架构 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ MainWindow │
|
||||
│ ├── DesktopWallpaperLayer (背景层) │
|
||||
│ │ ├── DesktopWallpaperImageLayer (图片层) │
|
||||
│ │ ├── DesktopVideoWallpaperImage (视频海报层) │
|
||||
│ │ └── DesktopVideoWallpaperView (VLC视频播放层) │
|
||||
│ ├── _libVlc, _videoWallpaperPlayer, _videoWallpaperMedia │
|
||||
│ └── StartVideoWallpaper(), StopVideoWallpaper() │
|
||||
│ │
|
||||
│ WallpaperSettingsPage │
|
||||
│ ├── 类型选择: Image | Video | SolidColor │
|
||||
│ └── 视频预览区域 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 变更后架构 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ MainWindow │
|
||||
│ ├── DesktopWallpaperLayer (背景层) │
|
||||
│ │ └── DesktopWallpaperImageLayer (图片层) │
|
||||
│ └── (移除所有视频相关字段和方法) │
|
||||
│ │
|
||||
│ WallpaperSettingsPage │
|
||||
│ ├── 类型选择: Image | SolidColor │
|
||||
│ └── (移除视频预览区域) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 组件变更清单
|
||||
|
||||
| 组件 | 变更类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| LanMountainDesktop.csproj | 修改 | 移除 LibVLC 包引用 |
|
||||
| MainWindow.axaml | 修改 | 移除视频控件和命名空间 |
|
||||
| MainWindow.axaml.cs | 修改 | 移除视频相关字段和清理代码 |
|
||||
| MainWindow.SettingsHardCut.Stubs.cs | 修改 | 移除视频壁纸方法 |
|
||||
| AppearanceThemeService.cs | 修改 | 移除视频种子提取器 |
|
||||
| WallpaperSettingsPage.axaml | 修改 | 移除视频类型UI |
|
||||
| WallpaperSettingsPageViewModel.cs | 修改 | 移除视频相关属性 |
|
||||
| SettingsContracts.cs | 修改 | 移除 Video 枚举值 |
|
||||
| SettingsDomainServices.cs | 修改 | 移除视频扩展名检测 |
|
||||
| zh-CN.json | 修改 | 移除视频相关本地化文本 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 详细设计
|
||||
|
||||
### 3.1 项目依赖变更 (LanMountainDesktop.csproj)
|
||||
|
||||
#### 3.1.1 移除的包引用
|
||||
|
||||
```xml
|
||||
<!-- 移除以下包引用 -->
|
||||
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
|
||||
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23" Condition="..." />
|
||||
<PackageReference Include="VideoLAN.LibVLC.Mac" Version="3.1.3.1" Condition="..." />
|
||||
```
|
||||
|
||||
#### 3.1.2 变更影响
|
||||
|
||||
- 减少约 100MB+ 的依赖包大小
|
||||
- 简化构建和发布流程
|
||||
- 移除平台特定的原生库依赖
|
||||
|
||||
---
|
||||
|
||||
### 3.2 主窗口 XAML 变更 (MainWindow.axaml)
|
||||
|
||||
#### 3.2.1 移除命名空间声明
|
||||
|
||||
```xml
|
||||
<!-- 移除此行 -->
|
||||
xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
|
||||
```
|
||||
|
||||
#### 3.2.2 移除视频壁纸控件
|
||||
|
||||
移除以下控件(约第126-137行):
|
||||
|
||||
```xml
|
||||
<!-- 移除 DesktopVideoWallpaperImage -->
|
||||
<Image x:Name="DesktopVideoWallpaperImage"
|
||||
IsVisible="False"
|
||||
IsHitTestVisible="False"
|
||||
Stretch="UniformToFill"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch" />
|
||||
|
||||
<!-- 移除 DesktopVideoWallpaperView -->
|
||||
<vlc:VideoView x:Name="DesktopVideoWallpaperView"
|
||||
IsVisible="False"
|
||||
IsHitTestVisible="False"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 主窗口代码变更 (MainWindow.axaml.cs)
|
||||
|
||||
#### 3.3.1 移除 using 声明
|
||||
|
||||
```csharp
|
||||
// 移除以下 using(如果存在)
|
||||
using LibVLCSharp.Shared;
|
||||
using LibVLCSharp.Avalonia;
|
||||
```
|
||||
|
||||
#### 3.3.2 移除静态字段
|
||||
|
||||
```csharp
|
||||
// 移除以下字段(约第68-71行)
|
||||
private static readonly HashSet<string> SupportedVideoExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".mp4", ".mkv", ".webm", ".avi", ".mov", ".m4v"
|
||||
};
|
||||
```
|
||||
|
||||
#### 3.3.3 移除实例字段
|
||||
|
||||
```csharp
|
||||
// 移除以下字段(约第123-146行)
|
||||
private Bitmap? _videoWallpaperPosterBitmap;
|
||||
private string? _videoWallpaperPosterPath;
|
||||
private string? _wallpaperVideoPath;
|
||||
private LibVLC? _libVlc;
|
||||
private MediaPlayer? _videoWallpaperPlayer;
|
||||
private Media? _videoWallpaperMedia;
|
||||
private readonly object _desktopVideoFrameSync = new();
|
||||
private MediaPlayer.LibVLCVideoLockCb? _desktopVideoLockCallback;
|
||||
private MediaPlayer.LibVLCVideoUnlockCb? _desktopVideoUnlockCallback;
|
||||
private MediaPlayer.LibVLCVideoDisplayCb? _desktopVideoDisplayCallback;
|
||||
private DispatcherTimer? _desktopVideoFrameRefreshTimer;
|
||||
private IntPtr _desktopVideoFrameBufferPtr;
|
||||
private byte[]? _desktopVideoStagingBuffer;
|
||||
private WriteableBitmap? _desktopVideoBitmap;
|
||||
private int _desktopVideoFrameWidth;
|
||||
private int _desktopVideoFrameHeight;
|
||||
private int _desktopVideoFramePitch;
|
||||
private int _desktopVideoFrameBufferSize;
|
||||
private int _desktopVideoFrameDirtyFlag;
|
||||
```
|
||||
|
||||
#### 3.3.4 修改 OnClosed 方法
|
||||
|
||||
移除视频相关清理代码(约第336-350行):
|
||||
|
||||
```csharp
|
||||
// 移除以下代码行
|
||||
StopVideoWallpaper();
|
||||
_videoWallpaperMedia?.Dispose();
|
||||
_videoWallpaperMedia = null;
|
||||
_videoWallpaperPlayer?.Dispose();
|
||||
_videoWallpaperPlayer = null;
|
||||
_desktopVideoFrameRefreshTimer?.Stop();
|
||||
_desktopVideoFrameRefreshTimer = null;
|
||||
_videoWallpaperPosterBitmap?.Dispose();
|
||||
_videoWallpaperPosterBitmap = null;
|
||||
_videoWallpaperPosterPath = null;
|
||||
_libVlc?.Dispose();
|
||||
_libVlc = null;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 主窗口 Stub 方法变更 (MainWindow.SettingsHardCut.Stubs.cs)
|
||||
|
||||
#### 3.4.1 移除 using 声明
|
||||
|
||||
```csharp
|
||||
// 移除以下 using(第19-20行)
|
||||
using LibVLCSharp.Shared;
|
||||
using LibVLCSharp.Avalonia;
|
||||
```
|
||||
|
||||
#### 3.4.2 移除方法
|
||||
|
||||
移除以下完整方法:
|
||||
|
||||
| 方法名 | 行号范围 | 说明 |
|
||||
|--------|----------|------|
|
||||
| `StartVideoWallpaper` | 337-383 | 启动视频壁纸播放 |
|
||||
| `StopVideoWallpaper` | 385-395 | 停止视频壁纸播放 |
|
||||
| `TryCaptureVideoWallpaperPosterFrame` | 666-751 | 捕获视频海报帧 |
|
||||
| `ApplyVideoWallpaperPosterVisibility` | 647-664 | 控制视频海报可见性 |
|
||||
|
||||
#### 3.4.3 修改 UpdateWallpaperDisplay 方法
|
||||
|
||||
简化为仅处理图片壁纸:
|
||||
|
||||
```csharp
|
||||
private void UpdateWallpaperDisplay()
|
||||
{
|
||||
// 移除视频分支,仅保留图片处理
|
||||
StopVideoWallpaper(); // 移除此调用
|
||||
ApplyWallpaperBrush();
|
||||
}
|
||||
```
|
||||
|
||||
修改后:
|
||||
|
||||
```csharp
|
||||
private void UpdateWallpaperDisplay()
|
||||
{
|
||||
ApplyWallpaperBrush();
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.4.4 修改 ApplyWallpaperBrush 方法
|
||||
|
||||
移除所有 `ApplyVideoWallpaperPosterVisibility` 调用:
|
||||
|
||||
```csharp
|
||||
// 移除以下调用
|
||||
ApplyVideoWallpaperPosterVisibility(showPoster: false);
|
||||
ApplyVideoWallpaperPosterVisibility(showPoster: _videoWallpaperPosterBitmap is not null);
|
||||
```
|
||||
|
||||
#### 3.4.5 修改 SetWallpaperState 方法
|
||||
|
||||
移除视频类型处理分支(约第238-247行):
|
||||
|
||||
```csharp
|
||||
// 移除以下代码块
|
||||
var requestedTypeIsVideo = string.Equals(_wallpaperType, "Video", StringComparison.OrdinalIgnoreCase);
|
||||
if (SupportedVideoExtensions.Contains(extension) || requestedTypeIsVideo)
|
||||
{
|
||||
_wallpaperMediaType = WallpaperMediaType.Video;
|
||||
_wallpaperVideoPath = _wallpaperPath;
|
||||
_wallpaperDisplayState = File.Exists(_wallpaperPath)
|
||||
? WallpaperDisplayState.CurrentValidWallpaper
|
||||
: WallpaperDisplayState.TemporarilyUnavailable;
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.5 外观主题服务变更 (AppearanceThemeService.cs)
|
||||
|
||||
#### 3.5.1 移除接口和类
|
||||
|
||||
移除以下代码(约第92-184行):
|
||||
|
||||
```csharp
|
||||
// 移除接口
|
||||
internal interface IVideoWallpaperSeedExtractor
|
||||
{
|
||||
IReadOnlyList<Color> ExtractSeedCandidates(string videoPath, MonetColorService monetColorService);
|
||||
}
|
||||
|
||||
// 移除实现类
|
||||
internal sealed class LibVlcVideoWallpaperSeedExtractor : IVideoWallpaperSeedExtractor
|
||||
{
|
||||
// ... 整个类实现
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.6 壁纸设置页面 XAML 变更 (WallpaperSettingsPage.axaml)
|
||||
|
||||
#### 3.6.1 移除视频预览区域
|
||||
|
||||
移除以下代码(约第29-44行):
|
||||
|
||||
```xml
|
||||
<Border Background="#FFF6F7F9"
|
||||
IsVisible="{Binding IsVideo}">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="12">
|
||||
<fi:FluentIcon Icon="Video"
|
||||
Width="72"
|
||||
Height="72"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
<TextBlock Text="{Binding VideoModeHintText}"
|
||||
Width="300"
|
||||
TextAlignment="Center"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
```
|
||||
|
||||
#### 3.6.2 移除视频模式提示文本
|
||||
|
||||
移除以下代码(约第150-154行):
|
||||
|
||||
```xml
|
||||
<TextBlock Margin="0,8,0,0"
|
||||
IsVisible="{Binding IsVideo}"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="{Binding VideoModeHintText}"
|
||||
TextWrapping="Wrap" />
|
||||
```
|
||||
|
||||
#### 3.6.3 修改填充方式设置可见性绑定
|
||||
|
||||
```xml
|
||||
<!-- 修改前 -->
|
||||
IsVisible="{Binding IsImageOrVideo}"
|
||||
|
||||
<!-- 修改后 -->
|
||||
IsVisible="{Binding IsImage}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.7 壁纸设置 ViewModel 变更 (WallpaperSettingsPageViewModel.cs)
|
||||
|
||||
#### 3.7.1 移除属性
|
||||
|
||||
```csharp
|
||||
// 移除以下属性
|
||||
[ObservableProperty]
|
||||
private bool _isImageOrVideo;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isVideo;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _videoModeHintText = string.Empty;
|
||||
```
|
||||
|
||||
#### 3.7.2 修改 CreateWallpaperTypes 方法
|
||||
|
||||
```csharp
|
||||
// 修改前
|
||||
private IReadOnlyList<SelectionOption> CreateWallpaperTypes()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption("Image", L("settings.wallpaper.type.image", "Image")),
|
||||
new SelectionOption("Video", L("settings.wallpaper.type.video", "Video")),
|
||||
new SelectionOption("SolidColor", L("settings.wallpaper.type.solid_color", "Solid Color"))
|
||||
];
|
||||
}
|
||||
|
||||
// 修改后
|
||||
private IReadOnlyList<SelectionOption> CreateWallpaperTypes()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption("Image", L("settings.wallpaper.type.image", "Image")),
|
||||
new SelectionOption("SolidColor", L("settings.wallpaper.type.solid_color", "Solid Color"))
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.7.3 修改 UpdateVisibility 方法
|
||||
|
||||
移除 IsVideo 和 IsImageOrVideo 的赋值:
|
||||
|
||||
```csharp
|
||||
// 移除以下行
|
||||
IsVideo = SelectedWallpaperType?.Value == "Video";
|
||||
IsImageOrVideo = SelectedWallpaperType?.Value is "Image" or "Video";
|
||||
```
|
||||
|
||||
#### 3.7.4 修改 RefreshLocalizedText 方法
|
||||
|
||||
```csharp
|
||||
// 移除以下行
|
||||
VideoModeHintText = L("settings.wallpaper.video_mode", "Video wallpaper uses automatic fill mode.");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.8 设置契约变更 (SettingsContracts.cs)
|
||||
|
||||
#### 3.8.1 修改 WallpaperMediaType 枚举
|
||||
|
||||
```csharp
|
||||
// 修改前
|
||||
public enum WallpaperMediaType
|
||||
{
|
||||
None,
|
||||
Image,
|
||||
Video
|
||||
}
|
||||
|
||||
// 修改后
|
||||
public enum WallpaperMediaType
|
||||
{
|
||||
None,
|
||||
Image
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.9 设置域服务变更 (SettingsDomainServices.cs)
|
||||
|
||||
#### 3.9.1 移除视频扩展名集合
|
||||
|
||||
```csharp
|
||||
// 移除以下字段(约第150-153行)
|
||||
private static readonly HashSet<string> VideoExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".mp4", ".mkv", ".webm", ".avi", ".mov", ".m4v"
|
||||
};
|
||||
```
|
||||
|
||||
#### 3.9.2 修改 DetectMediaType 方法
|
||||
|
||||
```csharp
|
||||
// 修改前
|
||||
public WallpaperMediaType DetectMediaType(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return WallpaperMediaType.None;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(path.Trim());
|
||||
if (string.IsNullOrWhiteSpace(extension))
|
||||
{
|
||||
return WallpaperMediaType.None;
|
||||
}
|
||||
|
||||
if (ImageExtensions.Contains(extension))
|
||||
{
|
||||
return WallpaperMediaType.Image;
|
||||
}
|
||||
|
||||
if (VideoExtensions.Contains(extension))
|
||||
{
|
||||
return WallpaperMediaType.Video;
|
||||
}
|
||||
|
||||
return WallpaperMediaType.None;
|
||||
}
|
||||
|
||||
// 修改后
|
||||
public WallpaperMediaType DetectMediaType(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return WallpaperMediaType.None;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(path.Trim());
|
||||
if (string.IsNullOrWhiteSpace(extension))
|
||||
{
|
||||
return WallpaperMediaType.None;
|
||||
}
|
||||
|
||||
if (ImageExtensions.Contains(extension))
|
||||
{
|
||||
return WallpaperMediaType.Image;
|
||||
}
|
||||
|
||||
return WallpaperMediaType.None;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.10 本地化文件变更 (zh-CN.json)
|
||||
|
||||
#### 3.10.1 移除的本地化键
|
||||
|
||||
```json
|
||||
// 移除以下键值对
|
||||
"settings.wallpaper.type.video": "视频",
|
||||
"settings.wallpaper.video_applied": "视频壁纸已应用。",
|
||||
"settings.wallpaper.video_mode": "视频壁纸使用自动填充模式。",
|
||||
"settings.wallpaper.video_restored": "已恢复保存的视频壁纸。",
|
||||
"settings.wallpaper.video_not_found": "未找到视频壁纸文件。",
|
||||
"settings.wallpaper.video_player_unavailable": "视频播放器不可用。",
|
||||
"settings.wallpaper.video_play_failed_format": "播放视频壁纸失败:{0}"
|
||||
```
|
||||
|
||||
#### 3.10.2 修改描述文本
|
||||
|
||||
```json
|
||||
// 修改前
|
||||
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
|
||||
|
||||
// 修改后
|
||||
"settings.wallpaper.description": "选择图片后可立即设为应用窗口壁纸。",
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据模型变更
|
||||
|
||||
### 4.1 WallpaperMediaType 枚举简化
|
||||
|
||||
```
|
||||
变更前: None | Image | Video
|
||||
变更后: None | Image
|
||||
```
|
||||
|
||||
### 4.2 设置存储兼容性
|
||||
|
||||
现有用户设置中如果包含 `Type: "Video"` 的壁纸配置:
|
||||
- 应用将无法识别该类型
|
||||
- 将回退到纯色背景
|
||||
- 用户需要重新选择图片壁纸
|
||||
|
||||
---
|
||||
|
||||
## 5. 风险评估
|
||||
|
||||
### 5.1 潜在风险
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| 现有视频壁纸用户设置失效 | 中 | 应用会自动回退到纯色背景 |
|
||||
| 遗漏的视频相关代码引用 | 低 | 编译器会报告未定义类型错误 |
|
||||
| 本地化键遗漏 | 低 | 运行时会显示键名而非翻译文本 |
|
||||
|
||||
### 5.2 回滚策略
|
||||
|
||||
如需回滚,可通过 Git 恢复以下文件:
|
||||
- LanMountainDesktop.csproj
|
||||
- MainWindow.axaml / .axaml.cs
|
||||
- MainWindow.SettingsHardCut.Stubs.cs
|
||||
- AppearanceThemeService.cs
|
||||
- WallpaperSettingsPage.axaml
|
||||
- WallpaperSettingsPageViewModel.cs
|
||||
- SettingsContracts.cs
|
||||
- SettingsDomainServices.cs
|
||||
- zh-CN.json
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证清单
|
||||
|
||||
### 6.1 编译验证
|
||||
|
||||
- [ ] 项目编译无错误
|
||||
- [ ] 无 LibVLC 相关类型引用警告
|
||||
- [ ] 无未使用变量警告
|
||||
|
||||
### 6.2 功能验证
|
||||
|
||||
- [ ] 应用正常启动
|
||||
- [ ] 图片壁纸正常显示
|
||||
- [ ] 纯色壁纸正常显示
|
||||
- [ ] 壁纸设置页面正常打开
|
||||
- [ ] 类型选择器仅显示"图片"和"纯色"
|
||||
- [ ] 壁纸导入功能正常工作
|
||||
|
||||
### 6.3 清理验证
|
||||
|
||||
- [ ] 无 LibVLC 相关 DLL 在输出目录
|
||||
- [ ] 无视频相关本地化文本残留
|
||||
- [ ] 无视频相关 UI 控件残留
|
||||
@@ -1,206 +0,0 @@
|
||||
# 移除视频壁纸功能规格说明书
|
||||
|
||||
## Why
|
||||
|
||||
当前 LanMountainDesktop 项目包含视频壁纸功能,该功能引入了以下复杂性和依赖:
|
||||
1. 引入了 LibVLCSharp.Avalonia、VideoLAN.LibVLC.Windows、VideoLAN.LibVLC.Mac 等重型依赖
|
||||
2. 在主窗口中残留大量视频壁纸相关代码和字段
|
||||
3. 在设置页面中保留了视频类型选择器和相关 UI 元素
|
||||
4. 在本地化文件中保留了视频壁纸相关文本
|
||||
5. 增加了应用复杂度和维护成本
|
||||
|
||||
用户决定移除该功能以简化代码库。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 移除 LibVLCSharp.Avalonia 及 VideoLAN.LibVLC.* NuGet 依赖
|
||||
- 移除 AppearanceThemeService.cs 中的 LibVlcVideoWallpaperSeedExtractor 类和 IVideoWallpaperSeedExtractor 接口
|
||||
- 移除 MainWindow.axaml.cs 中的视频壁纸相关字段和清理代码
|
||||
- 移除 MainWindow.SettingsHardCut.Stubs.cs 中的视频壁纸相关方法
|
||||
- 移除 MainWindow.axaml 中的 DesktopVideoWallpaperImage 和 DesktopVideoWallpaperView 控件
|
||||
- 移除 WallpaperSettingsPage.axaml 中的视频类型选择器和视频模式提示
|
||||
- 移除 WallpaperSettingsPageViewModel.cs 中的 IsVideo、VideoModeHintText 等属性
|
||||
- 移除 SettingsContracts.cs 中 WallpaperMediaType 枚举的 Video 值
|
||||
- 移除 SettingsDomainServices.cs 中 WallpaperMediaService 类的视频扩展名检测逻辑
|
||||
- 移除本地化文件中的视频壁纸相关文本
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected specs
|
||||
- 壁纸设置功能规格
|
||||
- 主窗口桌面层规格
|
||||
|
||||
### Affected code
|
||||
- `LanMountainDesktop.csproj` - NuGet 依赖配置
|
||||
- `Services/AppearanceThemeService.cs` - 视频壁纸种子提取器
|
||||
- `Views/MainWindow.axaml.cs` - 主窗口字段和清理逻辑
|
||||
- `Views/MainWindow.SettingsHardCut.Stubs.cs` - 视频壁纸控制方法
|
||||
- `Views/MainWindow.axaml` - 视频壁纸 UI 控件
|
||||
- `Views/SettingsPages/WallpaperSettingsPage.axaml` - 壁纸设置页面 UI
|
||||
- `ViewModels/WallpaperSettingsPageViewModel.cs` - 壁纸设置 ViewModel
|
||||
- `Services/Settings/SettingsContracts.cs` - 壁纸媒体类型枚举
|
||||
- `Services/Settings/SettingsDomainServices.cs` - 壁纸媒体服务
|
||||
- `Localization/zh-CN.json` - 本地化文本
|
||||
|
||||
---
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: 视频壁纸播放功能
|
||||
|
||||
**Reason**: 用户决定移除视频壁纸功能以简化代码库,减少重型依赖
|
||||
|
||||
**Migration**:
|
||||
- 用户如需动态壁纸,可使用静态图片壁纸替代
|
||||
- 现有视频壁纸设置将被重置为纯色背景
|
||||
|
||||
#### Scenario: 视频壁纸播放
|
||||
- **GIVEN** 用户选择了视频文件作为壁纸
|
||||
- **WHEN** 系统检测到视频格式
|
||||
- **THEN** 系统不再支持视频壁纸播放
|
||||
- **AND THEN** 系统提示用户该文件类型不受支持
|
||||
|
||||
### Requirement: LibVLC 依赖
|
||||
|
||||
**Reason**: 移除视频壁纸功能后不再需要 LibVLC 库
|
||||
|
||||
**Migration**: 从项目依赖中移除以下包:
|
||||
- LibVLCSharp.Avalonia
|
||||
- VideoLAN.LibVLC.Windows
|
||||
- VideoLAN.LibVLC.Mac
|
||||
|
||||
### Requirement: 视频壁纸种子提取
|
||||
|
||||
**Reason**: 移除视频壁纸功能后不再需要从视频中提取颜色种子
|
||||
|
||||
**Migration**: 移除 `LibVlcVideoWallpaperSeedExtractor` 类和 `IVideoWallpaperSeedExtractor` 接口
|
||||
|
||||
### Requirement: 视频壁纸 UI 控件
|
||||
|
||||
**Reason**: 移除视频壁纸功能后不再需要视频显示控件
|
||||
|
||||
**Migration**: 移除 `DesktopVideoWallpaperImage` 和 `DesktopVideoWallpaperView` 控件
|
||||
|
||||
### Requirement: 视频类型选择器
|
||||
|
||||
**Reason**: 移除视频壁纸功能后不再需要视频类型选项
|
||||
|
||||
**Migration**: 从壁纸类型选择器中移除"视频"选项
|
||||
|
||||
---
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 壁纸媒体类型检测
|
||||
|
||||
**当前**: 支持检测 None、Image、Video 三种类型
|
||||
|
||||
**修改后**: 仅支持检测 None、Image 两种类型
|
||||
|
||||
#### Scenario: 检测媒体类型
|
||||
- **WHEN** 用户选择壁纸文件
|
||||
- **THEN** 系统仅检测图片格式(.png, .jpg, .jpeg, .bmp, .gif, .webp)
|
||||
- **AND THEN** 视频格式文件将被识别为不受支持的类型
|
||||
|
||||
### Requirement: 壁纸类型选项
|
||||
|
||||
**当前**: 提供图片、视频、纯色三种类型选项
|
||||
|
||||
**修改后**: 仅提供图片、纯色两种类型选项
|
||||
|
||||
#### Scenario: 壁纸类型选择
|
||||
- **WHEN** 用户打开壁纸设置页面
|
||||
- **THEN** 类型选择器仅显示"图片"和"纯色"选项
|
||||
- **AND THEN** "视频"选项不再显示
|
||||
|
||||
### Requirement: 壁纸设置页面预览
|
||||
|
||||
**当前**: 根据类型显示图片预览、视频预览或纯色预览
|
||||
|
||||
**修改后**: 根据类型显示图片预览或纯色预览
|
||||
|
||||
#### Scenario: 预览显示
|
||||
- **WHEN** 用户选择壁纸类型
|
||||
- **THEN** 系统仅显示图片预览或纯色预览
|
||||
- **AND THEN** 视频预览区域不再显示
|
||||
|
||||
### Requirement: 主窗口壁纸显示
|
||||
|
||||
**当前**: 支持显示静态图片壁纸和视频壁纸
|
||||
|
||||
**修改后**: 仅支持显示静态图片壁纸
|
||||
|
||||
#### Scenario: 壁纸显示更新
|
||||
- **WHEN** 用户应用新壁纸
|
||||
- **THEN** 系统仅处理静态图片壁纸显示
|
||||
- **AND THEN** 视频壁纸播放逻辑不再执行
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 清理残留代码
|
||||
|
||||
系统 SHALL 完全移除视频壁纸功能相关的所有代码和资源。
|
||||
|
||||
#### Scenario: 主窗口字段清理
|
||||
- **WHEN** 执行代码清理
|
||||
- **THEN** 移除以下字段:
|
||||
- `_videoWallpaperPosterBitmap`
|
||||
- `_videoWallpaperPosterPath`
|
||||
- `_libVlc`
|
||||
- `_videoWallpaperPlayer`
|
||||
- `_videoWallpaperMedia`
|
||||
- `_wallpaperVideoPath`
|
||||
|
||||
#### Scenario: 主窗口方法清理
|
||||
- **WHEN** 执行代码清理
|
||||
- **THEN** 移除以下方法:
|
||||
- `StartVideoWallpaper`
|
||||
- `StopVideoWallpaper`
|
||||
- `TryCaptureVideoWallpaperPosterFrame`
|
||||
- `ApplyVideoWallpaperPosterVisibility`
|
||||
- `UpdateWallpaperDisplay` 中的视频处理分支
|
||||
|
||||
#### Scenario: ViewModel 属性清理
|
||||
- **WHEN** 执行代码清理
|
||||
- **THEN** 移除以下属性:
|
||||
- `IsVideo`
|
||||
- `VideoModeHintText`
|
||||
- `IsImageOrVideo`(改为 `IsImage`)
|
||||
|
||||
#### Scenario: 本地化文本清理
|
||||
- **WHEN** 执行代码清理
|
||||
- **THEN** 移除以下本地化键:
|
||||
- `settings.wallpaper.type.video`
|
||||
- `settings.wallpaper.video_applied`
|
||||
- `settings.wallpaper.video_mode`
|
||||
- `settings.wallpaper.video_restored`
|
||||
- `settings.wallpaper.video_not_found`
|
||||
- `settings.wallpaper.video_player_unavailable`
|
||||
- `settings.wallpaper.video_play_failed_format`
|
||||
|
||||
### Requirement: 依赖项清理
|
||||
|
||||
系统 SHALL 从项目文件中移除 LibVLC 相关 NuGet 包引用。
|
||||
|
||||
#### Scenario: NuGet 包移除
|
||||
- **WHEN** 执行依赖清理
|
||||
- **THEN** 移除以下包引用:
|
||||
- `LibVLCSharp.Avalonia`
|
||||
- `VideoLAN.LibVLC.Windows`
|
||||
- `VideoLAN.LibVLC.Mac`
|
||||
|
||||
### Requirement: 构建验证
|
||||
|
||||
系统 SHALL 在移除视频壁纸功能后保持正常构建和运行。
|
||||
|
||||
#### Scenario: 构建成功
|
||||
- **WHEN** 执行项目构建
|
||||
- **THEN** 构建成功无错误
|
||||
- **AND THEN** 所有现有测试通过
|
||||
|
||||
#### Scenario: 应用启动
|
||||
- **WHEN** 启动应用程序
|
||||
- **THEN** 应用正常启动
|
||||
- **AND THEN** 壁纸设置功能正常工作(仅支持图片和纯色)
|
||||
@@ -1,600 +0,0 @@
|
||||
# 移除视频壁纸功能 - 编码任务清单
|
||||
|
||||
## 任务概览
|
||||
|
||||
本文档将技术设计分解为可执行的编码任务,按依赖关系排序执行。
|
||||
|
||||
---
|
||||
|
||||
## 任务 1: 移除项目依赖
|
||||
|
||||
**优先级**: P0 (最高)
|
||||
**依赖**: 无
|
||||
**预估工作量**: 5 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
从项目文件中移除 LibVLC 相关的 NuGet 包引用。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/LanMountainDesktop.csproj`
|
||||
|
||||
### 输出
|
||||
|
||||
- 修改后的 `LanMountainDesktop.csproj`,移除以下包引用:
|
||||
- `LibVLCSharp.Avalonia`
|
||||
- `VideoLAN.LibVLC.Windows`
|
||||
- `VideoLAN.LibVLC.Mac`
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] 项目文件中不再包含 LibVLC 相关包引用
|
||||
- [ ] 执行 `dotnet restore` 成功
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 LanMountainDesktop.csproj,移除以下 PackageReference 节点:
|
||||
1. <PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
|
||||
2. <PackageReference Include="VideoLAN.LibVLC.Windows" ... />
|
||||
3. <PackageReference Include="VideoLAN.LibVLC.Mac" ... />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 2: 移除主窗口 XAML 视频控件
|
||||
|
||||
**优先级**: P0
|
||||
**依赖**: 任务 1
|
||||
**预估工作量**: 10 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
从 MainWindow.axaml 中移除视频壁纸相关的 XAML 控件和命名空间声明。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/Views/MainWindow.axaml`
|
||||
|
||||
### 输出
|
||||
|
||||
- 移除 LibVLC 命名空间声明
|
||||
- 移除 `DesktopVideoWallpaperImage` 控件
|
||||
- 移除 `DesktopVideoWallpaperView` 控件
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] XAML 中无 `xmlns:vlc` 命名空间
|
||||
- [ ] XAML 中无 `DesktopVideoWallpaperImage` 元素
|
||||
- [ ] XAML 中无 `DesktopVideoWallpaperView` 元素
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 MainWindow.axaml:
|
||||
1. 移除第 9 行: xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
|
||||
2. 移除第 126-131 行: <Image x:Name="DesktopVideoWallpaperImage" ... />
|
||||
3. 移除第 133-137 行: <vlc:VideoView x:Name="DesktopVideoWallpaperView" ... />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 3: 移除主窗口代码视频字段
|
||||
|
||||
**优先级**: P0
|
||||
**依赖**: 任务 1
|
||||
**预估工作量**: 15 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
从 MainWindow.axaml.cs 中移除视频壁纸相关的字段声明。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/Views/MainWindow.axaml.cs`
|
||||
|
||||
### 输出
|
||||
|
||||
- 移除 `SupportedVideoExtensions` 静态字段
|
||||
- 移除所有视频相关实例字段
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] 无 `SupportedVideoExtensions` 字段
|
||||
- [ ] 无 `_videoWallpaperPosterBitmap` 字段
|
||||
- [ ] 无 `_videoWallpaperPosterPath` 字段
|
||||
- [ ] 无 `_wallpaperVideoPath` 字段
|
||||
- [ ] 无 `_libVlc` 字段
|
||||
- [ ] 无 `_videoWallpaperPlayer` 字段
|
||||
- [ ] 无 `_videoWallpaperMedia` 字段
|
||||
- [ ] 无 `_desktopVideoFrameSync` 及相关视频帧处理字段
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 MainWindow.axaml.cs:
|
||||
1. 移除第 68-71 行的 SupportedVideoExtensions 定义
|
||||
2. 移除第 123-146 行的所有视频相关字段
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 4: 移除主窗口 OnClosed 清理代码
|
||||
|
||||
**优先级**: P0
|
||||
**依赖**: 任务 3
|
||||
**预估工作量**: 5 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
从 MainWindow.axaml.cs 的 OnClosed 方法中移除视频相关清理代码。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/Views/MainWindow.axaml.cs` (OnClosed 方法)
|
||||
|
||||
### 输出
|
||||
|
||||
- 简化的 OnClosed 方法,无视频清理逻辑
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] OnClosed 方法中无 `StopVideoWallpaper()` 调用
|
||||
- [ ] OnClosed 方法中无 `_videoWallpaperMedia` 相关清理
|
||||
- [ ] OnClosed 方法中无 `_videoWallpaperPlayer` 相关清理
|
||||
- [ ] OnClosed 方法中无 `_libVlc` 相关清理
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 MainWindow.axaml.cs 的 OnClosed 方法,移除以下代码行:
|
||||
- StopVideoWallpaper();
|
||||
- _videoWallpaperMedia?.Dispose(); _videoWallpaperMedia = null;
|
||||
- _videoWallpaperPlayer?.Dispose(); _videoWallpaperPlayer = null;
|
||||
- _desktopVideoFrameRefreshTimer?.Stop(); _desktopVideoFrameRefreshTimer = null;
|
||||
- _videoWallpaperPosterBitmap?.Dispose(); _videoWallpaperPosterBitmap = null;
|
||||
- _videoWallpaperPosterPath = null;
|
||||
- _libVlc?.Dispose(); _libVlc = null;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 5: 移除主窗口 Stub 方法
|
||||
|
||||
**优先级**: P0
|
||||
**依赖**: 任务 1
|
||||
**预估工作量**: 20 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
从 MainWindow.SettingsHardCut.Stubs.cs 中移除视频壁纸相关方法和 using 声明。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs`
|
||||
|
||||
### 输出
|
||||
|
||||
- 移除 LibVLC using 声明
|
||||
- 移除 `StartVideoWallpaper` 方法
|
||||
- 移除 `StopVideoWallpaper` 方法
|
||||
- 移除 `TryCaptureVideoWallpaperPosterFrame` 方法
|
||||
- 移除 `ApplyVideoWallpaperPosterVisibility` 方法
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] 无 `using LibVLCSharp.Shared;`
|
||||
- [ ] 无 `using LibVLCSharp.Avalonia;`
|
||||
- [ ] 无 `StartVideoWallpaper` 方法定义
|
||||
- [ ] 无 `StopVideoWallpaper` 方法定义
|
||||
- [ ] 无 `TryCaptureVideoWallpaperPosterFrame` 方法定义
|
||||
- [ ] 无 `ApplyVideoWallpaperPosterVisibility` 方法定义
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 MainWindow.SettingsHardCut.Stubs.cs:
|
||||
1. 移除第 19-20 行的 using 声明
|
||||
2. 移除 StartVideoWallpaper 方法(第 337-383 行)
|
||||
3. 移除 StopVideoWallpaper 方法(第 385-395 行)
|
||||
4. 移除 ApplyVideoWallpaperPosterVisibility 方法(第 647-664 行)
|
||||
5. 移除 TryCaptureVideoWallpaperPosterFrame 方法(第 666-751 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 6: 简化壁纸状态处理逻辑
|
||||
|
||||
**优先级**: P0
|
||||
**依赖**: 任务 5
|
||||
**预估工作量**: 15 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
修改 MainWindow.SettingsHardCut.Stubs.cs 中的壁纸状态处理方法,移除视频类型分支。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs`
|
||||
|
||||
### 输出
|
||||
|
||||
- 简化的 `SetWallpaperState` 方法
|
||||
- 简化的 `UpdateWallpaperDisplay` 方法
|
||||
- 简化的 `ApplyWallpaperBrush` 方法
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] `SetWallpaperState` 中无视频类型检测分支
|
||||
- [ ] `SetWallpaperState` 中无 `_wallpaperVideoPath` 赋值
|
||||
- [ ] `UpdateWallpaperDisplay` 中无 `StopVideoWallpaper()` 调用
|
||||
- [ ] `ApplyWallpaperBrush` 中无 `ApplyVideoWallpaperPosterVisibility` 调用
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 MainWindow.SettingsHardCut.Stubs.cs:
|
||||
|
||||
1. SetWallpaperState 方法:
|
||||
- 移除 requestedTypeIsVideo 变量定义
|
||||
- 移除视频类型检测 if 块(SupportedVideoExtensions.Contains 检查)
|
||||
|
||||
2. UpdateWallpaperDisplay 方法:
|
||||
- 移除视频类型分支,仅保留 ApplyWallpaperBrush() 调用
|
||||
|
||||
3. ApplyWallpaperBrush 方法:
|
||||
- 移除所有 ApplyVideoWallpaperPosterVisibility 调用
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 7: 移除外观主题服务视频提取器
|
||||
|
||||
**优先级**: P1
|
||||
**依赖**: 任务 1
|
||||
**预估工作量**: 10 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
从 AppearanceThemeService.cs 中移除视频壁纸种子提取器接口和实现类。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/Services/AppearanceThemeService.cs`
|
||||
|
||||
### 输出
|
||||
|
||||
- 移除 `IVideoWallpaperSeedExtractor` 接口
|
||||
- 移除 `LibVlcVideoWallpaperSeedExtractor` 类
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] 无 `IVideoWallpaperSeedExtractor` 接口定义
|
||||
- [ ] 无 `LibVlcVideoWallpaperSeedExtractor` 类定义
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 AppearanceThemeService.cs:
|
||||
移除第 92-184 行的接口和类定义:
|
||||
- IVideoWallpaperSeedExtractor 接口
|
||||
- LibVlcVideoWallpaperSeedExtractor 类
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 8: 简化壁纸设置页面 XAML
|
||||
|
||||
**优先级**: P1
|
||||
**依赖**: 无
|
||||
**预估工作量**: 10 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
从 WallpaperSettingsPage.axaml 中移除视频预览区域和相关 UI 元素。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/Views/SettingsPages/WallpaperSettingsPage.axaml`
|
||||
|
||||
### 输出
|
||||
|
||||
- 移除视频预览 Border 区域
|
||||
- 移除视频模式提示 TextBlock
|
||||
- 修改填充方式可见性绑定
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] 无视频预览 Border(IsVisible="{Binding IsVideo}")
|
||||
- [ ] 无 VideoModeHintText 绑定的 TextBlock
|
||||
- [ ] 填充方式设置绑定改为 `IsVisible="{Binding IsImage}"`
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 WallpaperSettingsPage.axaml:
|
||||
1. 移除第 29-44 行的视频预览 Border
|
||||
2. 移除第 150-154 行的视频模式提示 TextBlock
|
||||
3. 修改第 132 行: IsVisible="{Binding IsImageOrVideo}" 改为 IsVisible="{Binding IsImage}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 9: 简化壁纸设置 ViewModel
|
||||
|
||||
**优先级**: P1
|
||||
**依赖**: 任务 8
|
||||
**预估工作量**: 15 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
从 WallpaperSettingsPageViewModel.cs 中移除视频相关属性和方法逻辑。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/ViewModels/WallpaperSettingsPageViewModel.cs`
|
||||
|
||||
### 输出
|
||||
|
||||
- 移除 `_isImageOrVideo`、`_isVideo`、`_videoModeHintText` 属性
|
||||
- 修改 `CreateWallpaperTypes` 方法
|
||||
- 修改 `UpdateVisibility` 方法
|
||||
- 修改 `RefreshLocalizedText` 方法
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] 无 `IsImageOrVideo` 属性
|
||||
- [ ] 无 `IsVideo` 属性
|
||||
- [ ] 无 `VideoModeHintText` 属性
|
||||
- [ ] `CreateWallpaperTypes` 仅返回 Image 和 SolidColor 选项
|
||||
- [ ] `UpdateVisibility` 中无 IsVideo、IsImageOrVideo 赋值
|
||||
- [ ] `RefreshLocalizedText` 中无 VideoModeHintText 赋值
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 WallpaperSettingsPageViewModel.cs:
|
||||
1. 移除第 76-77 行的 _isImageOrVideo 字段和属性
|
||||
2. 移除第 85-86 行的 _isVideo 字段和属性
|
||||
3. 移除第 94-95 行的 _videoModeHintText 字段和属性
|
||||
4. 修改 CreateWallpaperTypes 方法,移除 Video 选项
|
||||
5. 修改 UpdateVisibility 方法,移除 IsVideo 和 IsImageOrVideo 赋值
|
||||
6. 修改 RefreshLocalizedText 方法,移除 VideoModeHintText 赋值
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 10: 简化壁纸媒体类型枚举
|
||||
|
||||
**优先级**: P1
|
||||
**依赖**: 无
|
||||
**预估工作量**: 5 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
从 SettingsContracts.cs 中移除 WallpaperMediaType 枚举的 Video 值。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/Services/Settings/SettingsContracts.cs`
|
||||
|
||||
### 输出
|
||||
|
||||
- 简化的 `WallpaperMediaType` 枚举
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] `WallpaperMediaType` 枚举仅包含 `None` 和 `Image`
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 SettingsContracts.cs:
|
||||
修改第 11-16 行的枚举定义:
|
||||
public enum WallpaperMediaType
|
||||
{
|
||||
None,
|
||||
Image
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 11: 简化壁纸媒体服务
|
||||
|
||||
**优先级**: P1
|
||||
**依赖**: 任务 10
|
||||
**预估工作量**: 10 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
从 SettingsDomainServices.cs 中移除视频扩展名检测逻辑。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/Services/Settings/SettingsDomainServices.cs`
|
||||
|
||||
### 输出
|
||||
|
||||
- 移除 `VideoExtensions` 字段
|
||||
- 简化 `DetectMediaType` 方法
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] 无 `VideoExtensions` 字段定义
|
||||
- [ ] `DetectMediaType` 方法中无视频扩展名检测逻辑
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 SettingsDomainServices.cs:
|
||||
1. 移除第 150-153 行的 VideoExtensions 字段定义
|
||||
2. 修改 DetectMediaType 方法,移除视频检测分支
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 12: 清理本地化文件
|
||||
|
||||
**优先级**: P2
|
||||
**依赖**: 无
|
||||
**预估工作量**: 5 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
从 zh-CN.json 中移除视频壁纸相关的本地化文本。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/Localization/zh-CN.json`
|
||||
|
||||
### 输出
|
||||
|
||||
- 移除视频相关本地化键
|
||||
- 修改壁纸描述文本
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] 无 `settings.wallpaper.type.video` 键
|
||||
- [ ] 无 `settings.wallpaper.video_applied` 键
|
||||
- [ ] 无 `settings.wallpaper.video_mode` 键
|
||||
- [ ] 无 `settings.wallpaper.video_restored` 键
|
||||
- [ ] 无 `settings.wallpaper.video_not_found` 键
|
||||
- [ ] 无 `settings.wallpaper.video_player_unavailable` 键
|
||||
- [ ] 无 `settings.wallpaper.video_play_failed_format` 键
|
||||
- [ ] `settings.wallpaper.description` 文本已更新
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 zh-CN.json:
|
||||
1. 移除以下键值对:
|
||||
- "settings.wallpaper.type.video"
|
||||
- "settings.wallpaper.video_applied"
|
||||
- "settings.wallpaper.video_mode"
|
||||
- "settings.wallpaper.video_restored"
|
||||
- "settings.wallpaper.video_not_found"
|
||||
- "settings.wallpaper.video_player_unavailable"
|
||||
- "settings.wallpaper.video_play_failed_format"
|
||||
|
||||
2. 修改描述文本:
|
||||
"settings.wallpaper.description": "选择图片后可立即设为应用窗口壁纸。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 13: 构建验证
|
||||
|
||||
**优先级**: P0
|
||||
**依赖**: 任务 1-12 全部完成
|
||||
**预估工作量**: 10 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
验证项目在移除视频壁纸功能后能够正常构建。
|
||||
|
||||
### 输入
|
||||
|
||||
- 整个项目
|
||||
|
||||
### 输出
|
||||
|
||||
- 构建成功确认
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] `dotnet build` 执行成功,无编译错误
|
||||
- [ ] 无 LibVLC 相关类型未定义错误
|
||||
- [ ] 无未使用变量警告(或已处理)
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
在项目根目录执行:
|
||||
dotnet build LanMountainDesktop/LanMountainDesktop.csproj
|
||||
|
||||
检查输出:
|
||||
- 确认无编译错误
|
||||
- 确认无 LibVLC 相关类型引用错误
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 14: 功能验证
|
||||
|
||||
**优先级**: P0
|
||||
**依赖**: 任务 13
|
||||
**预估工作量**: 15 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
验证应用在移除视频壁纸功能后核心功能正常工作。
|
||||
|
||||
### 输入
|
||||
|
||||
- 构建后的应用
|
||||
|
||||
### 输出
|
||||
|
||||
- 功能验证报告
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] 应用正常启动
|
||||
- [ ] 图片壁纸正常显示
|
||||
- [ ] 纯色壁纸正常显示
|
||||
- [ ] 壁纸设置页面正常打开
|
||||
- [ ] 类型选择器仅显示"图片"和"纯色"选项
|
||||
- [ ] 壁纸导入功能正常工作
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
运行应用:
|
||||
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
|
||||
|
||||
手动验证:
|
||||
1. 应用启动无崩溃
|
||||
2. 打开设置 -> 壁纸页面
|
||||
3. 确认类型选择器仅有"图片"和"纯色"
|
||||
4. 测试选择图片壁纸
|
||||
5. 测试选择纯色壁纸
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务依赖关系图
|
||||
|
||||
```
|
||||
任务 1 (移除依赖)
|
||||
├── 任务 2 (XAML控件)
|
||||
├── 任务 3 (代码字段)
|
||||
│ └── 任务 4 (OnClosed清理)
|
||||
├── 任务 5 (Stub方法)
|
||||
│ └── 任务 6 (状态处理逻辑)
|
||||
└── 任务 7 (主题服务)
|
||||
|
||||
任务 8 (设置页面XAML)
|
||||
└── 任务 9 (设置ViewModel)
|
||||
|
||||
任务 10 (枚举简化)
|
||||
└── 任务 11 (媒体服务)
|
||||
|
||||
任务 12 (本地化) - 独立
|
||||
|
||||
任务 13 (构建验证) - 依赖所有任务
|
||||
└── 任务 14 (功能验证)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序建议
|
||||
|
||||
按以下顺序执行可确保依赖关系正确:
|
||||
|
||||
1. **第一批** (可并行): 任务 1, 任务 8, 任务 10, 任务 12
|
||||
2. **第二批** (可并行): 任务 2, 任务 3, 任务 5, 任务 7, 任务 9, 任务 11
|
||||
3. **第三批** (可并行): 任务 4, 任务 6
|
||||
4. **第四批**: 任务 13
|
||||
5. **第五批**: 任务 14
|
||||
@@ -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>
|
||||
31
LanMountainDesktop.DesktopHost/DesktopBootstrap.cs
Normal file
31
LanMountainDesktop.DesktopHost/DesktopBootstrap.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
|
||||
namespace LanMountainDesktop.DesktopHost;
|
||||
|
||||
public static class DesktopBootstrap
|
||||
{
|
||||
public static void InitializeStartupServices(
|
||||
Action initializeTelemetryIdentity,
|
||||
Action initializeCrashTelemetry,
|
||||
Action initializeUsageTelemetry,
|
||||
Action scheduleStartupCleanup)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(initializeTelemetryIdentity);
|
||||
ArgumentNullException.ThrowIfNull(initializeCrashTelemetry);
|
||||
ArgumentNullException.ThrowIfNull(initializeUsageTelemetry);
|
||||
ArgumentNullException.ThrowIfNull(scheduleStartupCleanup);
|
||||
|
||||
initializeTelemetryIdentity();
|
||||
initializeCrashTelemetry();
|
||||
initializeUsageTelemetry();
|
||||
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,11 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
</ItemGroup>
|
||||
</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; }
|
||||
|
||||
IPluginAppearanceContext Appearance { get; }
|
||||
|
||||
T? GetService<T>();
|
||||
|
||||
bool TryGetProperty<T>(string key, out T? value);
|
||||
|
||||
@@ -4,7 +4,15 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>3.0.0</Version>
|
||||
<Version>4.0.0</Version>
|
||||
<PackageId>LanMountainDesktop.PluginSdk</PackageId>
|
||||
<IsPackable>true</IsPackable>
|
||||
<Authors>LanMountainDesktop</Authors>
|
||||
<Description>Official plugin SDK for LanMountainDesktop, including plugin manifest contracts, runtime interfaces, and registration extensions.</Description>
|
||||
<PackageTags>LanMountainDesktop;Plugin;SDK;Avalonia</PackageTags>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -12,6 +20,13 @@
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.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>
|
||||
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||
<None Include="buildTransitive\LanMountainDesktop.PluginSdk.props" Pack="true" PackagePath="buildTransitive\" />
|
||||
<None Include="buildTransitive\LanMountainDesktop.PluginSdk.targets" Pack="true" PackagePath="buildTransitive\" />
|
||||
</ItemGroup>
|
||||
|
||||
</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? placementId,
|
||||
double cellSize,
|
||||
IPluginAppearanceContext appearance,
|
||||
IPluginSettingsService? pluginSettings = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
@@ -19,6 +20,7 @@ public sealed class PluginDesktopComponentContext
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(properties);
|
||||
ArgumentNullException.ThrowIfNull(appearance);
|
||||
|
||||
Manifest = manifest;
|
||||
PluginDirectory = pluginDirectory;
|
||||
@@ -28,6 +30,7 @@ public sealed class PluginDesktopComponentContext
|
||||
ComponentId = componentId.Trim();
|
||||
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
|
||||
CellSize = Math.Max(1, cellSize);
|
||||
Appearance = appearance;
|
||||
PluginSettings = pluginSettings;
|
||||
}
|
||||
|
||||
@@ -47,8 +50,24 @@ public sealed class PluginDesktopComponentContext
|
||||
|
||||
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 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>()
|
||||
{
|
||||
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 PluginDesktopComponentRegistration(
|
||||
string componentId,
|
||||
string displayName,
|
||||
Func<IServiceProvider, PluginDesktopComponentContext, Control> controlFactory,
|
||||
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)
|
||||
PluginDesktopComponentOptions options)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(displayName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(iconKey);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(category);
|
||||
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();
|
||||
DisplayName = displayName.Trim();
|
||||
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(displayNameLocalizationKey)
|
||||
ComponentId = options.ComponentId.Trim();
|
||||
DisplayName = options.DisplayName.Trim();
|
||||
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(options.DisplayNameLocalizationKey)
|
||||
? null
|
||||
: displayNameLocalizationKey.Trim();
|
||||
: options.DisplayNameLocalizationKey.Trim();
|
||||
ControlFactory = controlFactory;
|
||||
IconKey = iconKey.Trim();
|
||||
Category = category.Trim();
|
||||
MinWidthCells = Math.Max(1, minWidthCells);
|
||||
MinHeightCells = Math.Max(1, minHeightCells);
|
||||
AllowDesktopPlacement = allowDesktopPlacement;
|
||||
AllowStatusBarPlacement = allowStatusBarPlacement;
|
||||
ResizeMode = resizeMode;
|
||||
CornerRadiusResolver = cornerRadiusResolver;
|
||||
IconKey = options.IconKey.Trim();
|
||||
Category = options.Category.Trim();
|
||||
MinWidthCells = Math.Max(1, options.MinWidthCells);
|
||||
MinHeightCells = Math.Max(1, options.MinHeightCells);
|
||||
AllowDesktopPlacement = options.AllowDesktopPlacement;
|
||||
AllowStatusBarPlacement = options.AllowStatusBarPlacement;
|
||||
ResizeMode = options.ResizeMode;
|
||||
CornerRadiusPreset = options.CornerRadiusPreset;
|
||||
CornerRadiusResolver = options.CornerRadiusResolver;
|
||||
}
|
||||
|
||||
public PluginDesktopComponentRegistration(
|
||||
string componentId,
|
||||
string displayName,
|
||||
Func<PluginDesktopComponentContext, Control> controlFactory,
|
||||
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)
|
||||
: this(
|
||||
componentId,
|
||||
displayName,
|
||||
(_, context) => controlFactory(context),
|
||||
iconKey,
|
||||
category,
|
||||
minWidthCells,
|
||||
minHeightCells,
|
||||
allowDesktopPlacement,
|
||||
allowStatusBarPlacement,
|
||||
resizeMode,
|
||||
displayNameLocalizationKey,
|
||||
cornerRadiusResolver)
|
||||
PluginDesktopComponentOptions options)
|
||||
: this((_, context) => controlFactory(context), options)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -91,5 +61,25 @@ public sealed class PluginDesktopComponentRegistration
|
||||
|
||||
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(
|
||||
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}' (major {requestedVersion.Major}), " +
|
||||
$"but the host provides '{PluginSdkInfo.ApiVersion}' (major {currentVersion.Major}). " +
|
||||
$"This host only supports v{currentVersion.Major}.x plugins. " +
|
||||
$"Migrate the plugin to API {PluginSdkInfo.ApiVersion} and rebuild the package.");
|
||||
$"This host only supports v{currentVersion.Major}.x plugins and rejects v{requestedVersion.Major}.x packages by default. " +
|
||||
$"Migrate the plugin manifest and code to API {PluginSdkInfo.ApiVersion}, then rebuild and republish the package.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
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 PackageFileExtension = ".laapp";
|
||||
public const string DataDirectoryName = "Data";
|
||||
|
||||
@@ -30,34 +30,15 @@ public static class PluginServiceCollectionExtensions
|
||||
|
||||
public static IServiceCollection AddPluginDesktopComponent<TControl>(
|
||||
this IServiceCollection services,
|
||||
string componentId,
|
||||
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)
|
||||
PluginDesktopComponentOptions options)
|
||||
where TControl : Control
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
services.AddSingleton(new PluginDesktopComponentRegistration(
|
||||
componentId,
|
||||
displayName,
|
||||
(provider, context) => ActivatorUtilities.CreateInstance<TControl>(provider, context),
|
||||
iconKey,
|
||||
category,
|
||||
minWidthCells,
|
||||
minHeightCells,
|
||||
allowDesktopPlacement,
|
||||
allowStatusBarPlacement,
|
||||
resizeMode,
|
||||
displayNameLocalizationKey,
|
||||
cornerRadiusResolver));
|
||||
options));
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
21
LanMountainDesktop.PluginSdk/README.md
Normal file
21
LanMountainDesktop.PluginSdk/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# LanMountainDesktop.PluginSdk
|
||||
|
||||
Official SDK package for LanMountainDesktop plugins.
|
||||
|
||||
## Includes
|
||||
|
||||
- `IPlugin`/`PluginBase` entry abstractions
|
||||
- `PluginManifest` and shared contract declarations
|
||||
- desktop component registration extensions
|
||||
- plugin runtime context and host service abstractions
|
||||
- build-transitive packaging targets for `.laapp` output
|
||||
|
||||
## Quick Start
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.0" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
Create `plugin.json` in your plugin project root, then run `dotnet build` to produce both build output and a `.laapp` package.
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<LanMountainPluginManifestFileName Condition="'$(LanMountainPluginManifestFileName)' == ''">plugin.json</LanMountainPluginManifestFileName>
|
||||
<LanMountainPluginPackageExtension Condition="'$(LanMountainPluginPackageExtension)' == ''">.laapp</LanMountainPluginPackageExtension>
|
||||
<LanMountainPluginPackageOutputDirectory Condition="'$(LanMountainPluginPackageOutputDirectory)' == ''">$(MSBuildProjectDirectory)\</LanMountainPluginPackageOutputDirectory>
|
||||
|
||||
<LanMountainPluginEnablePackaging Condition="'$(LanMountainPluginEnablePackaging)' == '' and Exists('$(MSBuildProjectDirectory)\$(LanMountainPluginManifestFileName)')">true</LanMountainPluginEnablePackaging>
|
||||
<LanMountainPluginEnablePackaging Condition="'$(LanMountainPluginEnablePackaging)' == ''">false</LanMountainPluginEnablePackaging>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="Exists('$(MSBuildProjectDirectory)\$(LanMountainPluginManifestFileName)')">
|
||||
<None Update="$(LanMountainPluginManifestFileName)" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,51 @@
|
||||
<Project>
|
||||
<Target Name="ValidateLanMountainPluginManifest"
|
||||
BeforeTargets="Build"
|
||||
Condition="'$(LanMountainPluginEnablePackaging)' == 'true'">
|
||||
<Error Condition="!Exists('$(MSBuildProjectDirectory)\$(LanMountainPluginManifestFileName)')"
|
||||
Text="LanMountain plugin packaging is enabled, but '$(LanMountainPluginManifestFileName)' was not found in '$(MSBuildProjectDirectory)'." />
|
||||
</Target>
|
||||
|
||||
<Target Name="CreateLanMountainPluginPackage"
|
||||
AfterTargets="Build"
|
||||
Condition="'$(LanMountainPluginEnablePackaging)' == 'true'">
|
||||
<PropertyGroup>
|
||||
<_LanMountainPluginBuildOutputDirectory>$(LanMountainPluginBuildOutputDirectory)</_LanMountainPluginBuildOutputDirectory>
|
||||
<_LanMountainPluginBuildOutputDirectory Condition="'$(_LanMountainPluginBuildOutputDirectory)' == ''">$(TargetDir)</_LanMountainPluginBuildOutputDirectory>
|
||||
<_LanMountainPluginBuildOutputDirectory Condition="'$(_LanMountainPluginBuildOutputDirectory)' == ''">$(MSBuildProjectDirectory)\$(OutputPath)</_LanMountainPluginBuildOutputDirectory>
|
||||
<_LanMountainPluginAssemblyName>$(LanMountainPluginAssemblyName)</_LanMountainPluginAssemblyName>
|
||||
<_LanMountainPluginAssemblyName Condition="'$(_LanMountainPluginAssemblyName)' == '' and '$(AssemblyName)' != ''">$(AssemblyName)</_LanMountainPluginAssemblyName>
|
||||
<_LanMountainPluginAssemblyName Condition="'$(_LanMountainPluginAssemblyName)' == ''">$(MSBuildProjectName)</_LanMountainPluginAssemblyName>
|
||||
<_LanMountainPluginPackageVersion>$(LanMountainPluginPackageVersion)</_LanMountainPluginPackageVersion>
|
||||
<_LanMountainPluginPackageVersion Condition="'$(_LanMountainPluginPackageVersion)' == '' and '$(Version)' != ''">$(Version)</_LanMountainPluginPackageVersion>
|
||||
<_LanMountainPluginPackageVersion Condition="'$(_LanMountainPluginPackageVersion)' == ''">1.0.0</_LanMountainPluginPackageVersion>
|
||||
<_LanMountainPluginPackageOutputDirectory>$(LanMountainPluginPackageOutputDirectory)</_LanMountainPluginPackageOutputDirectory>
|
||||
<_LanMountainPluginPackageOutputDirectory Condition="'$(_LanMountainPluginPackageOutputDirectory)' == ''">$(MSBuildProjectDirectory)\</_LanMountainPluginPackageOutputDirectory>
|
||||
<_LanMountainPluginPackageFileName>$(LanMountainPluginPackageFileName)</_LanMountainPluginPackageFileName>
|
||||
<_LanMountainPluginPackageFileName Condition="'$(_LanMountainPluginPackageFileName)' == ''">$(_LanMountainPluginAssemblyName).$(_LanMountainPluginPackageVersion)$(LanMountainPluginPackageExtension)</_LanMountainPluginPackageFileName>
|
||||
<_LanMountainPluginPackagePath>$(LanMountainPluginPackagePath)</_LanMountainPluginPackagePath>
|
||||
<_LanMountainPluginPackagePath Condition="'$(_LanMountainPluginPackagePath)' == ''">$(_LanMountainPluginPackageOutputDirectory)$(_LanMountainPluginPackageFileName)</_LanMountainPluginPackagePath>
|
||||
<_LanMountainPluginManifestOutputPath>$(_LanMountainPluginBuildOutputDirectory)$(LanMountainPluginManifestFileName)</_LanMountainPluginManifestOutputPath>
|
||||
<_LanMountainPluginDepsPath>$(ProjectDepsFilePath)</_LanMountainPluginDepsPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<Copy SourceFiles="$(MSBuildProjectDirectory)\$(LanMountainPluginManifestFileName)"
|
||||
DestinationFiles="$(_LanMountainPluginManifestOutputPath)"
|
||||
SkipUnchangedFiles="true"
|
||||
Condition="Exists('$(MSBuildProjectDirectory)\$(LanMountainPluginManifestFileName)')" />
|
||||
|
||||
<Error Condition="!Exists('$(_LanMountainPluginManifestOutputPath)')"
|
||||
Text="Plugin manifest '$(_LanMountainPluginManifestOutputPath)' was not found in build output. Ensure '$(LanMountainPluginManifestFileName)' is copied to output." />
|
||||
<Error Condition="!Exists('$(TargetPath)')"
|
||||
Text="Plugin assembly '$(TargetPath)' was not found. Build output is incomplete." />
|
||||
<Error Condition="'$(_LanMountainPluginDepsPath)' != '' and !Exists('$(_LanMountainPluginDepsPath)')"
|
||||
Text="Plugin deps file '$(_LanMountainPluginDepsPath)' was not found. Plugin packages must include a .deps.json file." />
|
||||
|
||||
<MakeDir Directories="$(_LanMountainPluginPackageOutputDirectory)" />
|
||||
<Delete Files="$(_LanMountainPluginPackagePath)" TreatErrorsAsWarnings="true" />
|
||||
<ZipDirectory SourceDirectory="$(_LanMountainPluginBuildOutputDirectory)"
|
||||
DestinationFile="$(_LanMountainPluginPackagePath)" />
|
||||
<Message Importance="High"
|
||||
Text="LanMountain plugin package generated: $(_LanMountainPluginPackagePath)" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<NoWarn>$(NoWarn);NU5128</NoWarn>
|
||||
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
|
||||
<PackageId>LanMountainDesktop.PluginTemplate</PackageId>
|
||||
<Version>1.0.0</Version>
|
||||
<Authors>LanMountainDesktop</Authors>
|
||||
<Description>Official dotnet new template package for LanMountainDesktop plugins.</Description>
|
||||
<PackageTags>LanMountainDesktop;Plugin;Template;dotnet-new</PackageTags>
|
||||
<PackageType>Template</PackageType>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||
<IsPackable>true</IsPackable>
|
||||
<NoDefaultExcludes>true</NoDefaultExcludes>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="content\**\*.cs" />
|
||||
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||
<None Include="content\**\*" Pack="true" PackagePath="content\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
17
LanMountainDesktop.PluginTemplate/README.md
Normal file
17
LanMountainDesktop.PluginTemplate/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# LanMountainDesktop.PluginTemplate
|
||||
|
||||
Official `dotnet new` template package for LanMountainDesktop plugins.
|
||||
|
||||
## Install
|
||||
|
||||
```powershell
|
||||
dotnet new install LanMountainDesktop.PluginTemplate
|
||||
```
|
||||
|
||||
## Create a plugin
|
||||
|
||||
```powershell
|
||||
dotnet new lmd-plugin -n YourPluginName
|
||||
```
|
||||
|
||||
The generated project references `LanMountainDesktop.PluginSdk` and produces a `.laapp` package automatically when built.
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/template",
|
||||
"author": "LanMountainDesktop",
|
||||
"classifications": [
|
||||
"LanMountainDesktop",
|
||||
"Plugin",
|
||||
"Desktop"
|
||||
],
|
||||
"name": "LanMountainDesktop Plugin",
|
||||
"identity": "LanMountainDesktop.PluginTemplate.CSharp",
|
||||
"shortName": "lmd-plugin",
|
||||
"sourceName": "LanMountainDesktop.PluginTemplate",
|
||||
"preferNameDirectory": true,
|
||||
"tags": {
|
||||
"type": "project",
|
||||
"language": "C#"
|
||||
},
|
||||
"symbols": {
|
||||
"pluginId": {
|
||||
"type": "parameter",
|
||||
"datatype": "text",
|
||||
"defaultValue": "LanMountainDesktop.PluginTemplate",
|
||||
"description": "Plugin manifest id.",
|
||||
"replaces": "__PLUGIN_ID__"
|
||||
},
|
||||
"pluginAuthor": {
|
||||
"type": "parameter",
|
||||
"datatype": "text",
|
||||
"defaultValue": "Your Name",
|
||||
"description": "Plugin author.",
|
||||
"replaces": "__PLUGIN_AUTHOR__"
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "parameter",
|
||||
"datatype": "text",
|
||||
"defaultValue": "LanMountain Plugin Template",
|
||||
"description": "Display name shown in plugin manifest.",
|
||||
"replaces": "__PLUGIN_NAME__"
|
||||
},
|
||||
"pluginDescription": {
|
||||
"type": "parameter",
|
||||
"datatype": "text",
|
||||
"defaultValue": "Plugin generated from the official LanMountainDesktop template.",
|
||||
"description": "Plugin description shown in plugin manifest.",
|
||||
"replaces": "__PLUGIN_DESCRIPTION__"
|
||||
},
|
||||
"pluginSdkVersion": {
|
||||
"type": "parameter",
|
||||
"datatype": "text",
|
||||
"defaultValue": "4.0.0",
|
||||
"description": "LanMountainDesktop.PluginSdk package version.",
|
||||
"replaces": "__PLUGIN_SDK_VERSION__"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
<LanMountainPluginPackageVersion>$(Version)</LanMountainPluginPackageVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="__PLUGIN_SDK_VERSION__" ExcludeAssets="runtime" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="plugin.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
15
LanMountainDesktop.PluginTemplate/content/Plugin.cs
Normal file
15
LanMountainDesktop.PluginTemplate/content/Plugin.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace LanMountainDesktop.PluginTemplate;
|
||||
|
||||
[PluginEntrance]
|
||||
public sealed class Plugin : PluginBase
|
||||
{
|
||||
public override void Initialize(HostBuilderContext context, IServiceCollection services)
|
||||
{
|
||||
_ = context;
|
||||
_ = services;
|
||||
}
|
||||
}
|
||||
24
LanMountainDesktop.PluginTemplate/content/README.md
Normal file
24
LanMountainDesktop.PluginTemplate/content/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# __PLUGIN_NAME__
|
||||
|
||||
Official-style plugin scaffold generated for LanMountainDesktop.
|
||||
|
||||
## Build
|
||||
|
||||
```powershell
|
||||
dotnet build -c Release
|
||||
```
|
||||
|
||||
`LanMountainDesktop.PluginSdk` build targets will generate:
|
||||
|
||||
- plugin output files under `bin/<Configuration>/<TFM>/`
|
||||
- a `.laapp` package in the project root
|
||||
|
||||
## Manifest
|
||||
|
||||
Update `plugin.json` fields as needed before release:
|
||||
|
||||
- `id`
|
||||
- `name`
|
||||
- `description`
|
||||
- `author`
|
||||
- `version`
|
||||
10
LanMountainDesktop.PluginTemplate/content/plugin.json
Normal file
10
LanMountainDesktop.PluginTemplate/content/plugin.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "__PLUGIN_ID__",
|
||||
"name": "__PLUGIN_NAME__",
|
||||
"description": "__PLUGIN_DESCRIPTION__",
|
||||
"author": "__PLUGIN_AUTHOR__",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": "4.0.0",
|
||||
"entranceAssembly": "LanMountainDesktop.PluginTemplate.dll",
|
||||
"sharedContracts": []
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
<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);
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<PackageId>LanMountainDesktop.Shared.Contracts</PackageId>
|
||||
<IsPackable>true</IsPackable>
|
||||
<Authors>LanMountainDesktop</Authors>
|
||||
<Description>Shared contracts used by LanMountainDesktop host and plugins for cross-boundary communication.</Description>
|
||||
<PackageTags>LanMountainDesktop;Plugin;SharedContracts;Avalonia</PackageTags>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
16
LanMountainDesktop.Shared.Contracts/README.md
Normal file
16
LanMountainDesktop.Shared.Contracts/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# LanMountainDesktop.Shared.Contracts
|
||||
|
||||
Shared contracts package for LanMountainDesktop host and plugin ecosystems.
|
||||
|
||||
## Includes
|
||||
|
||||
- cross-boundary records used by host/runtime and plugins
|
||||
- contract types intended for stable shared communication
|
||||
|
||||
## Usage
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LanMountainDesktop.Shared.Contracts" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
```
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.DesktopEditing;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class ComponentLibraryCollapseStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateExpanded_InitializesExpandedStateAndHidesChip()
|
||||
{
|
||||
var margin = new Thickness(24, 24, 24, 100);
|
||||
var state = ComponentLibraryCollapseState.CreateExpanded(margin, 0.75);
|
||||
|
||||
Assert.Equal(ComponentLibraryCollapseVisualState.Expanded, state.VisualState);
|
||||
Assert.Equal(margin, state.ExpandedMargin);
|
||||
Assert.Equal(0.75, state.ExpandedOpacity, 3);
|
||||
Assert.False(state.IsChipVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithVisualState_PreservesStableExpandedSnapshotAcrossTransitions()
|
||||
{
|
||||
var margin = new Thickness(20, 18, 20, 96);
|
||||
var expanded = ComponentLibraryCollapseState.CreateExpanded(margin, 1);
|
||||
|
||||
var collapsing = expanded.WithVisualState(ComponentLibraryCollapseVisualState.Collapsing, isChipVisible: true);
|
||||
var collapsed = collapsing.WithVisualState(ComponentLibraryCollapseVisualState.Collapsed, isChipVisible: true);
|
||||
var restoring = collapsed.WithVisualState(ComponentLibraryCollapseVisualState.Restoring, isChipVisible: false);
|
||||
|
||||
Assert.Equal(ComponentLibraryCollapseVisualState.Collapsing, collapsing.VisualState);
|
||||
Assert.Equal(ComponentLibraryCollapseVisualState.Collapsed, collapsed.VisualState);
|
||||
Assert.Equal(ComponentLibraryCollapseVisualState.Restoring, restoring.VisualState);
|
||||
|
||||
Assert.Equal(margin, collapsing.ExpandedMargin);
|
||||
Assert.Equal(margin, collapsed.ExpandedMargin);
|
||||
Assert.Equal(margin, restoring.ExpandedMargin);
|
||||
|
||||
Assert.Equal(1, collapsing.ExpandedOpacity, 3);
|
||||
Assert.Equal(1, collapsed.ExpandedOpacity, 3);
|
||||
Assert.Equal(1, restoring.ExpandedOpacity, 3);
|
||||
|
||||
Assert.True(collapsing.IsChipVisible);
|
||||
Assert.True(collapsed.IsChipVisible);
|
||||
Assert.False(restoring.IsChipVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateExpanded_ProducesRestorableSnapshotEvenWhenOriginalOpacityIsLow()
|
||||
{
|
||||
var margin = new Thickness(18, 22, 18, 88);
|
||||
var expanded = ComponentLibraryCollapseState.CreateExpanded(margin, 0.15);
|
||||
var restored = expanded.WithVisualState(ComponentLibraryCollapseVisualState.Expanded, isChipVisible: false);
|
||||
|
||||
Assert.Equal(margin, restored.ExpandedMargin);
|
||||
Assert.Equal(0.15, restored.ExpandedOpacity, 3);
|
||||
Assert.Equal(ComponentLibraryCollapseVisualState.Expanded, restored.VisualState);
|
||||
Assert.False(restored.IsChipVisible);
|
||||
}
|
||||
}
|
||||
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)));
|
||||
}
|
||||
}
|
||||
15
LanMountainDesktop.Tests/DesktopEditCommitMathTests.cs
Normal file
15
LanMountainDesktop.Tests/DesktopEditCommitMathTests.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using LanMountainDesktop.DesktopEditing;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class DesktopEditCommitMathTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsPendingCommitValid_ReturnsTrueOnlyForMatchingActiveVersion()
|
||||
{
|
||||
Assert.True(DesktopEditCommitMath.IsPendingCommitValid(isPending: true, scheduledVersion: 4, currentVersion: 4));
|
||||
Assert.False(DesktopEditCommitMath.IsPendingCommitValid(isPending: false, scheduledVersion: 4, currentVersion: 4));
|
||||
Assert.False(DesktopEditCommitMath.IsPendingCommitValid(isPending: true, scheduledVersion: 4, currentVersion: 5));
|
||||
}
|
||||
}
|
||||
173
LanMountainDesktop.Tests/DesktopPlacementMathTests.cs
Normal file
173
LanMountainDesktop.Tests/DesktopPlacementMathTests.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.DesktopEditing;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class DesktopPlacementMathTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeDragStartThreshold_UsesFloorAndCellScale()
|
||||
{
|
||||
Assert.Equal(10d, DesktopPlacementMath.ComputeDragStartThreshold(24));
|
||||
Assert.Equal(14.4d, DesktopPlacementMath.ComputeDragStartThreshold(80), 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasExceededThreshold_OnlyReturnsTrueAfterEnoughMovement()
|
||||
{
|
||||
var start = new Point(20, 20);
|
||||
|
||||
Assert.False(DesktopPlacementMath.HasExceededThreshold(start, new Point(27, 25), 10));
|
||||
Assert.True(DesktopPlacementMath.HasExceededThreshold(start, new Point(31, 20), 10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OcclusionHelpers_DetectPointAndRectOverlap()
|
||||
{
|
||||
var libraryBounds = new Rect(100, 100, 200, 160);
|
||||
|
||||
Assert.True(DesktopPlacementMath.IsOccludedByComponentLibrary(new Point(120, 150), libraryBounds));
|
||||
Assert.False(DesktopPlacementMath.IsOccludedByComponentLibrary(new Point(80, 90), libraryBounds));
|
||||
Assert.True(DesktopPlacementMath.IsOccludedByComponentLibrary(new Rect(250, 120, 120, 80), libraryBounds));
|
||||
Assert.False(DesktopPlacementMath.IsOccludedByComponentLibrary(new Rect(10, 10, 40, 40), libraryBounds));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetSnappedCell_ClampsInsideGridBounds()
|
||||
{
|
||||
var grid = new DesktopGridGeometry(
|
||||
Origin: default,
|
||||
CellSize: 80,
|
||||
CellGap: 8,
|
||||
ColumnCount: 4,
|
||||
RowCount: 5);
|
||||
|
||||
var result = DesktopPlacementMath.TryGetSnappedCell(
|
||||
grid,
|
||||
pointerInViewport: new Point(490, 520),
|
||||
pointerOffset: new Point(10, 10),
|
||||
widthCells: 2,
|
||||
heightCells: 3,
|
||||
out var column,
|
||||
out var row);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(2, column);
|
||||
Assert.Equal(2, row);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCellRect_MapsCellsToPixelRect()
|
||||
{
|
||||
var grid = new DesktopGridGeometry(
|
||||
Origin: new Point(12, 24),
|
||||
CellSize: 80,
|
||||
CellGap: 8,
|
||||
ColumnCount: 6,
|
||||
RowCount: 8);
|
||||
|
||||
var rect = DesktopPlacementMath.GetCellRect(grid, column: 2, row: 3, widthCells: 2, heightCells: 3);
|
||||
|
||||
Assert.Equal(188, rect.X, 3);
|
||||
Assert.Equal(288, rect.Y, 3);
|
||||
Assert.Equal(168, rect.Width, 3);
|
||||
Assert.Equal(256, rect.Height, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Session_DoesNotCommitWhilePointerIsStillInsideLibrary()
|
||||
{
|
||||
var session = DesktopEditSession.CreatePendingNew(
|
||||
componentId: "demo",
|
||||
pageIndex: 0,
|
||||
widthCells: 2,
|
||||
heightCells: 2,
|
||||
startPointerInViewport: new Point(80, 80),
|
||||
pointerOffsetInViewport: new Point(60, 60),
|
||||
componentLibraryBounds: new Rect(0, 0, 220, 300));
|
||||
|
||||
session = session.WithCurrentPointer(new Point(130, 150));
|
||||
|
||||
Assert.True(session.HasExceededThreshold(DesktopPlacementMath.ComputeDragStartThreshold(80)));
|
||||
Assert.True(session.IsPointerInsideComponentLibrary());
|
||||
Assert.False(session.CanCommit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Session_ResizePreviewStillBlocksWhenPointerRemainsInsideLibrary()
|
||||
{
|
||||
var session = DesktopEditSession.CreateResizingExisting(
|
||||
componentId: "demo",
|
||||
placementId: "placement-1",
|
||||
pageIndex: 0,
|
||||
widthCells: 2,
|
||||
heightCells: 2,
|
||||
startPointerInViewport: new Point(80, 80),
|
||||
componentLibraryBounds: new Rect(0, 0, 220, 300))
|
||||
.WithCurrentPointer(new Point(130, 150));
|
||||
|
||||
Assert.True(session.IsPointerInsideComponentLibrary());
|
||||
Assert.False(session.CanCommit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasCellPositionChanged_DetectsNoOpAndRealMoves()
|
||||
{
|
||||
Assert.False(DesktopPlacementMath.HasCellPositionChanged(2, 3, 2, 3));
|
||||
Assert.True(DesktopPlacementMath.HasCellPositionChanged(2, 3, 2, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasCellSpanChanged_DetectsNoOpAndRealResizes()
|
||||
{
|
||||
Assert.False(DesktopPlacementMath.HasCellSpanChanged(2, 3, 2, 3));
|
||||
Assert.True(DesktopPlacementMath.HasCellSpanChanged(2, 3, 3, 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanCommitPlacement_BlocksWhenPlacementIsOccludedByLibrary()
|
||||
{
|
||||
var placementRect = new Rect(160, 110, 180, 140);
|
||||
var occludingLibraryBounds = new Rect(120, 80, 240, 220);
|
||||
var distantLibraryBounds = new Rect(420, 420, 80, 80);
|
||||
|
||||
Assert.False(DesktopPlacementMath.CanCommitPlacement(placementRect, occludingLibraryBounds));
|
||||
Assert.True(DesktopPlacementMath.CanCommitPlacement(placementRect, distantLibraryBounds));
|
||||
Assert.True(DesktopPlacementMath.CanCommitPlacement(placementRect, componentLibraryBounds: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Session_AllowsCommitWhenComponentLibraryBoundsAreCleared()
|
||||
{
|
||||
var pendingSession = DesktopEditSession.CreatePendingNew(
|
||||
componentId: "demo",
|
||||
pageIndex: 0,
|
||||
widthCells: 2,
|
||||
heightCells: 2,
|
||||
startPointerInViewport: new Point(80, 80),
|
||||
pointerOffsetInViewport: new Point(60, 60),
|
||||
componentLibraryBounds: null)
|
||||
.WithCurrentPointer(new Point(200, 180));
|
||||
|
||||
Assert.True(pendingSession.HasExceededThreshold(DesktopPlacementMath.ComputeDragStartThreshold(80)));
|
||||
Assert.False(pendingSession.IsPointerInsideComponentLibrary());
|
||||
Assert.False(pendingSession.IsPreviewOccludedByComponentLibrary(new Rect(100, 100, 40, 40)));
|
||||
Assert.False(pendingSession.CanCommit);
|
||||
|
||||
var resizeSession = DesktopEditSession.CreateResizingExisting(
|
||||
componentId: "demo",
|
||||
placementId: "placement-1",
|
||||
pageIndex: 0,
|
||||
widthCells: 2,
|
||||
heightCells: 2,
|
||||
startPointerInViewport: new Point(80, 80),
|
||||
componentLibraryBounds: null)
|
||||
.WithCurrentPointer(new Point(200, 180))
|
||||
.WithTargetCell(row: 2, column: 3);
|
||||
|
||||
Assert.False(resizeSession.IsPointerInsideComponentLibrary());
|
||||
Assert.False(resizeSession.IsPreviewOccludedByComponentLibrary(new Rect(100, 100, 40, 40)));
|
||||
Assert.True(resizeSession.CanCommit);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<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,6 +1,12 @@
|
||||
<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.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj" />
|
||||
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
|
||||
<Project Path="LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj" />
|
||||
|
||||
@@ -71,4 +71,5 @@
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
</Application.Styles>
|
||||
|
||||
</Application>
|
||||
|
||||
@@ -15,6 +15,7 @@ using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using AvaloniaWebView;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.DesktopHost;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
@@ -56,14 +57,19 @@ public partial class App : Application
|
||||
private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop;
|
||||
private ShutdownIntent _shutdownIntent;
|
||||
|
||||
private TrayIcons? _trayIcons;
|
||||
private TrayIcon? _trayIcon;
|
||||
private NativeMenuItem? _trayShowDesktopMenuItem;
|
||||
private NativeMenuItem? _traySettingsMenuItem;
|
||||
private NativeMenuItem? _trayComponentLibraryMenuItem;
|
||||
private NativeMenuItem? _trayRestartMenuItem;
|
||||
private NativeMenuItem? _trayExitMenuItem;
|
||||
private PluginRuntimeService? _pluginRuntimeService;
|
||||
private MainWindow? _mainWindow;
|
||||
private bool _mainWindowClosed;
|
||||
private bool _uiUnhandledExceptionHooked;
|
||||
private DesktopShellHost? _desktopShellHost;
|
||||
|
||||
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
||||
internal static (UserBehaviorAnalyticsService?, CrashReportService?) AnalyticsServices { get; set; }
|
||||
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
|
||||
(Current as App)?._hostApplicationLifecycle;
|
||||
|
||||
@@ -95,6 +101,11 @@ public partial class App : Application
|
||||
|
||||
public App()
|
||||
{
|
||||
if (Design.IsDesignMode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_settingsFacade.Settings.Changed += OnSettingsChanged;
|
||||
_appearanceThemeService.Changed += OnAppearanceThemeChanged;
|
||||
}
|
||||
@@ -102,9 +113,16 @@ public partial class App : Application
|
||||
public override void Initialize()
|
||||
{
|
||||
AppLogger.Info("App", "Initializing application resources.");
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
if (Design.IsDesignMode)
|
||||
{
|
||||
ApplyDesignTimeTheme();
|
||||
return;
|
||||
}
|
||||
|
||||
ConfigureWebViewUserDataFolder();
|
||||
AvaloniaWebViewBuilder.Initialize(default);
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
ApplyThemeFromSettings();
|
||||
ApplyCurrentCultureFromSettings();
|
||||
EnsureSettingsWindowService();
|
||||
@@ -113,31 +131,55 @@ public partial class App : Application
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (Design.IsDesignMode)
|
||||
{
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Info("App", "Framework initialization completed.");
|
||||
RegisterUiUnhandledExceptionGuard();
|
||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||
InitializePluginRuntime();
|
||||
InitializeTrayIcon();
|
||||
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private void ApplyDesignTimeTheme()
|
||||
{
|
||||
RequestedThemeVariant = ThemeVariant.Light;
|
||||
|
||||
try
|
||||
{
|
||||
// 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;
|
||||
desktop.Exit += (_, _) =>
|
||||
ApplyAdaptiveThemeResources();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Previewer", "Failed to apply adaptive theme resources in design mode.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeDesktopShell()
|
||||
{
|
||||
_desktopShellHost ??= new DesktopShellHost(
|
||||
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.");
|
||||
PerformExitCleanup();
|
||||
};
|
||||
|
||||
CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
|
||||
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
|
||||
}
|
||||
|
||||
StartWeatherLocationRefreshIfNeeded();
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
},
|
||||
() => CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow),
|
||||
StartWeatherLocationRefreshIfNeeded);
|
||||
_desktopShellHost.Initialize(this);
|
||||
}
|
||||
|
||||
private void OnTrayExitClick(object? sender, EventArgs e)
|
||||
@@ -238,18 +280,43 @@ public partial class App : Application
|
||||
{
|
||||
try
|
||||
{
|
||||
DisposeTrayIcon();
|
||||
|
||||
var trayIcon = new TrayIcon
|
||||
if (_trayIcon is null)
|
||||
{
|
||||
Icon = _appLogoService.CreateTrayIcon(),
|
||||
ToolTipText = L("tray.tooltip", "LanMountainDesktop"),
|
||||
Menu = BuildTrayMenu(),
|
||||
IsVisible = true
|
||||
};
|
||||
_trayShowDesktopMenuItem = new NativeMenuItem();
|
||||
_trayShowDesktopMenuItem.Click += OnTrayShowDesktopClick;
|
||||
|
||||
_trayIcons = [trayIcon];
|
||||
TrayIcon.SetIcons(this, _trayIcons);
|
||||
_traySettingsMenuItem = new NativeMenuItem();
|
||||
_traySettingsMenuItem.Click += OnTraySettingsClick;
|
||||
|
||||
_trayComponentLibraryMenuItem = new NativeMenuItem();
|
||||
_trayComponentLibraryMenuItem.Click += OnTrayComponentLibraryClick;
|
||||
|
||||
_trayRestartMenuItem = new NativeMenuItem();
|
||||
_trayRestartMenuItem.Click += OnTrayRestartClick;
|
||||
|
||||
_trayExitMenuItem = new NativeMenuItem();
|
||||
_trayExitMenuItem.Click += OnTrayExitClick;
|
||||
|
||||
var trayMenu = new NativeMenu();
|
||||
trayMenu.Items.Add(_trayShowDesktopMenuItem);
|
||||
trayMenu.Items.Add(_traySettingsMenuItem);
|
||||
trayMenu.Items.Add(_trayComponentLibraryMenuItem);
|
||||
trayMenu.Items.Add(new NativeMenuItemSeparator());
|
||||
trayMenu.Items.Add(_trayRestartMenuItem);
|
||||
trayMenu.Items.Add(new NativeMenuItemSeparator());
|
||||
trayMenu.Items.Add(_trayExitMenuItem);
|
||||
|
||||
_trayIcon = new TrayIcon
|
||||
{
|
||||
Icon = _appLogoService.CreateTrayIcon(),
|
||||
Menu = trayMenu,
|
||||
IsVisible = true
|
||||
};
|
||||
|
||||
TrayIcon.SetIcons(this, [_trayIcon]);
|
||||
}
|
||||
|
||||
RefreshTrayIconContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -257,51 +324,58 @@ public partial class App : Application
|
||||
}
|
||||
}
|
||||
|
||||
private NativeMenu BuildTrayMenu()
|
||||
private void RefreshTrayIconContent()
|
||||
{
|
||||
var menu = new NativeMenu();
|
||||
if (_trayIcon is not null)
|
||||
{
|
||||
_trayIcon.IsVisible = true;
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
_trayIcon.ToolTipText = L("tray.tooltip", "LanMountainDesktop");
|
||||
}
|
||||
}
|
||||
|
||||
var showDesktopItem = new NativeMenuItem(L("tray.menu.show_desktop", "Open Desktop"));
|
||||
showDesktopItem.Click += OnTrayShowDesktopClick;
|
||||
menu.Items.Add(showDesktopItem);
|
||||
if (_trayShowDesktopMenuItem is not null)
|
||||
{
|
||||
_trayShowDesktopMenuItem.Header = L("tray.menu.show_desktop", "Open Desktop");
|
||||
}
|
||||
|
||||
var settingsItem = new NativeMenuItem(L("tray.menu.settings", "Settings"));
|
||||
settingsItem.Click += OnTraySettingsClick;
|
||||
menu.Items.Add(settingsItem);
|
||||
if (_traySettingsMenuItem is not null)
|
||||
{
|
||||
_traySettingsMenuItem.Header = L("tray.menu.settings", "Settings");
|
||||
}
|
||||
|
||||
var componentLibraryItem = new NativeMenuItem(L("tray.menu.component_library", "Component Library"));
|
||||
componentLibraryItem.Click += OnTrayComponentLibraryClick;
|
||||
menu.Items.Add(componentLibraryItem);
|
||||
if (_trayComponentLibraryMenuItem is not null)
|
||||
{
|
||||
_trayComponentLibraryMenuItem.Header = L("tray.menu.component_library", "Component Library");
|
||||
}
|
||||
|
||||
menu.Items.Add(new NativeMenuItemSeparator());
|
||||
if (_trayRestartMenuItem is not null)
|
||||
{
|
||||
_trayRestartMenuItem.Header = L("tray.menu.restart", "Restart App");
|
||||
}
|
||||
|
||||
var restartItem = new NativeMenuItem(L("tray.menu.restart", "Restart App"));
|
||||
restartItem.Click += OnTrayRestartClick;
|
||||
menu.Items.Add(restartItem);
|
||||
|
||||
menu.Items.Add(new NativeMenuItemSeparator());
|
||||
|
||||
var exitItem = new NativeMenuItem(L("tray.menu.exit", "Exit App"));
|
||||
exitItem.Click += OnTrayExitClick;
|
||||
menu.Items.Add(exitItem);
|
||||
|
||||
return menu;
|
||||
if (_trayExitMenuItem is not null)
|
||||
{
|
||||
_trayExitMenuItem.Header = L("tray.menu.exit", "Exit App");
|
||||
}
|
||||
}
|
||||
|
||||
private void DisposeTrayIcon()
|
||||
{
|
||||
if (_trayIcons is null)
|
||||
if (_trayIcon is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TrayIcon.SetIcons(this, null);
|
||||
foreach (var trayIcon in _trayIcons)
|
||||
try
|
||||
{
|
||||
trayIcon.Dispose();
|
||||
_trayIcon.IsVisible = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("TrayIcon", "Failed to hide tray icon during cleanup.", ex);
|
||||
}
|
||||
|
||||
_trayIcons = null;
|
||||
}
|
||||
|
||||
private void EnsureSettingsWindowService()
|
||||
@@ -493,6 +567,7 @@ public partial class App : Application
|
||||
refreshAll ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.GlobalCornerRadiusScale), StringComparer.OrdinalIgnoreCase) ||
|
||||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) &&
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) ||
|
||||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&
|
||||
@@ -513,10 +588,7 @@ public partial class App : Application
|
||||
// 清除本地化缓存,强制重新加载语言文件
|
||||
_localizationService.ClearCache();
|
||||
ApplyCurrentCultureFromSettings();
|
||||
if (_trayIcons is not null)
|
||||
{
|
||||
InitializeTrayIcon();
|
||||
}
|
||||
RefreshTrayIconContent();
|
||||
}
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
@@ -584,13 +656,13 @@ public partial class App : Application
|
||||
|
||||
try
|
||||
{
|
||||
var (analytics, crashReport) = App.AnalyticsServices;
|
||||
analytics?.SendShutdownEvent();
|
||||
crashReport?.SendShutdownEvent();
|
||||
TelemetryServices.Usage?.Shutdown(
|
||||
_shutdownIntent == ShutdownIntent.RestartRequested,
|
||||
"App.PerformExitCleanup");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Analytics", "Failed to send shutdown events during exit cleanup.", ex);
|
||||
AppLogger.Warn("Analytics", "Failed to shut down usage telemetry during exit cleanup.", ex);
|
||||
}
|
||||
|
||||
try
|
||||
@@ -624,6 +696,27 @@ public partial class App : Application
|
||||
AudioRecorderServiceFactory.DisposeSharedServices();
|
||||
StudyAnalyticsServiceFactory.DisposeSharedService();
|
||||
DisposeTrayIcon();
|
||||
|
||||
try
|
||||
{
|
||||
TelemetryServices.Crash?.CaptureShutdown(
|
||||
_shutdownIntent == ShutdownIntent.RestartRequested,
|
||||
"App.PerformExitCleanup");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Analytics", "Failed to capture crash shutdown telemetry during exit cleanup.", ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
TelemetryServices.Crash?.Dispose();
|
||||
TelemetryServices.Usage?.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Analytics", "Failed to dispose telemetry services during exit cleanup.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private MainWindow CreateAndAssignMainWindow(
|
||||
|
||||
@@ -1,326 +1,229 @@
|
||||
# LanMountainDesktop 隐私政策
|
||||
# LanMountainDesktop 遥测隐私政策
|
||||
|
||||
**最后更新日期:2026年3月17日**
|
||||
**生效日期**:2026年3月22日\
|
||||
**最后更新**:2026年3月22日
|
||||
|
||||
---
|
||||
***
|
||||
|
||||
## 引言
|
||||
|
||||
欢迎使用 LanMountainDesktop!我们非常重视您的隐私保护。本隐私政策旨在向您说明我们如何收集、使用、存储和保护您的数据。
|
||||
LanMountainDesktop(以下简称"本应用")由 灵方软件Lincube(以下简称"我们")开发和维护。我们深知用户隐私的重要性,并致力于保护您的个人信息安全。本隐私政策旨在向您说明我们如何收集、使用、存储和保护您的数据。
|
||||
|
||||
**请在使用本应用前仔细阅读本隐私政策。使用本应用即表示您同意本政策的条款。**
|
||||
使用本应用即表示您同意本隐私政策的条款。如果您不同意本政策的任何部分,请停止使用本应用。
|
||||
|
||||
---
|
||||
***
|
||||
|
||||
## 1. 数据收集范围
|
||||
|
||||
### 1.1 我们收集的数据
|
||||
|
||||
当您启用匿名数据收集功能时,我们会收集以下数据:
|
||||
本应用提供两类可选的数据收集功能:
|
||||
|
||||
#### 匿名崩溃数据
|
||||
- **崩溃报告**:应用崩溃时的错误日志和堆栈跟踪
|
||||
- **设备信息**:操作系统版本、设备型号、架构(x64/x86)
|
||||
- **应用版本**:当前使用的应用版本号
|
||||
- **设备标识符**:匿名生成的唯一设备ID(不包含个人信息)
|
||||
| 数据类型 | 收集方式 | 默认状态 | 用途 |
|
||||
| ------ | ---- | ---- | -------- |
|
||||
| 启动基线事件 | 自动收集 | 开启 | 统计用户量 |
|
||||
| 崩溃数据 | 用户授权 | 关闭 | 分析稳定性问题 |
|
||||
| 行为数据 | 用户授权 | 关闭 | 分析功能使用情况 |
|
||||
|
||||
#### 匿名使用数据
|
||||
- **应用启动和关闭事件**:记录应用何时启动和关闭
|
||||
- **功能使用统计**:哪些功能被使用、使用频率
|
||||
- **设置变更**:用户更改了哪些设置(不包含具体设置值)
|
||||
- **界面交互**:点击了哪些按钮、访问了哪些页面
|
||||
- **设备信息**:操作系统、应用版本、设备类型
|
||||
### 1.2 启动基线事件
|
||||
|
||||
### 1.2 始终收集的基础数据
|
||||
无论您是否开启其他遥测选项,本应用会在首次启动时发送一次最小化的启动基线事件(`app_first_launch`),用于统计活跃用户量。该事件仅包含:
|
||||
|
||||
**重要说明:** 为了统计应用的用户数量和日活跃用户,即使您关闭了匿名数据收集开关,我们仍会收集以下基础数据:
|
||||
- 匿名安装标识符(Install ID)
|
||||
- 应用版本号
|
||||
- 启动时间戳
|
||||
|
||||
- ✅ **应用启动事件**:用于统计日活跃用户
|
||||
- ✅ **设备标识符**:用于区分不同用户(不包含个人信息)
|
||||
- ✅ **应用版本**:用于统计版本分布
|
||||
### 1.3 崩溃数据
|
||||
|
||||
**这些基础数据不包含任何个人身份信息,仅用于统计用户数量和应用使用情况。**
|
||||
当您开启"崩溃数据上传"功能时,我们可能收集以下信息:
|
||||
|
||||
### 1.3 我们不收集的数据
|
||||
- **异常信息**:异常类型、错误消息、堆栈跟踪
|
||||
- **应用信息**:应用版本、构建号、运行时环境
|
||||
- **系统信息**:操作系统版本、系统架构、可用内存
|
||||
- **设备信息**:设备型号、屏幕分辨率
|
||||
- **日志信息**:应用崩溃前的最近日志记录(可能包含您在使用过程中产生的操作记录)
|
||||
|
||||
我们**明确承诺不收集**以下数据:
|
||||
### 1.4 行为数据
|
||||
|
||||
- ❌ 个人身份信息(姓名、邮箱、电话等)
|
||||
- ❌ 真实姓名或用户名
|
||||
- ❌ 地理位置信息(精确位置)
|
||||
- ❌ 文件内容或文档数据
|
||||
- ❌ 密码或凭据信息
|
||||
- ❌ 网络浏览历史
|
||||
- ❌ 联系人信息
|
||||
- ❌ 照片、视频或音频文件
|
||||
当您开启"行为数据分析"功能时,我们可能收集以下信息:
|
||||
|
||||
---
|
||||
- **会话信息**:应用启动/退出时间、会话持续时间
|
||||
- **功能使用**:设置页面访问、抽屉操作、组件库操作
|
||||
- **组件操作**:桌面组件的放置、移动、调整大小、删除操作
|
||||
- **界面交互**:页面切换、编辑模式进入/退出
|
||||
|
||||
## 2. 数据收集目的
|
||||
***
|
||||
|
||||
我们收集数据的目的如下:
|
||||
## 2. 数据使用目的
|
||||
|
||||
### 2.1 基础数据用途(始终收集)
|
||||
- **统计用户数量**:了解应用的用户规模
|
||||
- **统计日活跃用户**:了解应用的活跃程度
|
||||
- **版本分布统计**:了解用户使用的版本情况
|
||||
我们收集的数据将用于以下目的:
|
||||
|
||||
### 2.2 崩溃数据用途
|
||||
- **提高应用稳定性**:识别和修复崩溃问题
|
||||
- **优化性能**:分析性能瓶颈
|
||||
- **改进用户体验**:了解应用在不同设备上的表现
|
||||
### 2.1 启动基线事件
|
||||
|
||||
### 2.3 使用数据用途
|
||||
- **功能优化**:了解哪些功能最受欢迎,优先改进
|
||||
- **用户体验改进**:优化界面设计和交互流程
|
||||
- **统计分析**:了解用户规模和使用趋势
|
||||
- **产品决策**:基于数据做出产品发展方向决策
|
||||
- 统计应用的用户数量和活跃度
|
||||
- 了解应用的安装分布情况
|
||||
|
||||
---
|
||||
### 2.2 崩溃数据
|
||||
|
||||
## 3. 数据存储和处理
|
||||
- 诊断和修复应用崩溃问题
|
||||
- 提高应用的稳定性和可靠性
|
||||
- 识别和解决性能瓶颈
|
||||
|
||||
### 3.1 数据存储位置
|
||||
### 2.3 行为数据
|
||||
|
||||
我们使用以下第三方服务存储和处理数据:
|
||||
- 了解用户如何使用本应用的功能
|
||||
- 改进用户体验和界面设计
|
||||
- 指导功能开发和优先级决策
|
||||
- 分析用户行为模式和趋势
|
||||
|
||||
#### Sentry(崩溃报告)
|
||||
- **用途**:崩溃数据收集和分析
|
||||
- **位置**:美国
|
||||
- **官网**:https://sentry.io
|
||||
- **隐私政策**:https://sentry.io/privacy/
|
||||
***
|
||||
|
||||
#### PostHog(使用分析)
|
||||
- **用途**:用户行为分析和统计
|
||||
- **位置**:美国
|
||||
- **官网**:https://posthog.com
|
||||
- **隐私政策**:https://posthog.com/privacy
|
||||
## 3. 数据存储与传输
|
||||
|
||||
### 3.2 数据保留期限
|
||||
### 3.1 数据传输
|
||||
|
||||
- **崩溃数据**:保留90天后自动删除
|
||||
- **使用数据**:保留12个月后自动删除
|
||||
- **设备标识符**:永久保留(用于统计日活用户)
|
||||
您的数据将通过加密连接传输至以下第三方服务:
|
||||
|
||||
### 3.3 数据安全措施
|
||||
| 服务提供商 | 服务类型 | 数据内容 | 隐私政策 |
|
||||
| ------- | ---- | --------- | ----------------------------- |
|
||||
| PostHog | 产品分析 | 启动事件、行为数据 | <https://posthog.com/privacy> |
|
||||
| Sentry | 错误监控 | 崩溃数据、异常信息 | <https://sentry.io/privacy/> |
|
||||
|
||||
### 3.2 数据存储位置
|
||||
|
||||
数据存储于上述第三方服务的服务器,这些服务器可能位于中国境外。我们已与这些服务提供商签订数据处理协议,确保您的数据得到适当保护。
|
||||
|
||||
### 3.3 数据保留期限
|
||||
|
||||
- **启动基线事件**:保留期限由 PostHog 服务配置决定,通常为 13 个月
|
||||
- **崩溃数据**:保留期限由 Sentry 服务配置决定,通常为 90 天
|
||||
- **行为数据**:保留期限由 PostHog 服务配置决定,通常为 13 个月
|
||||
|
||||
***
|
||||
|
||||
## 4. 用户权利与控制
|
||||
|
||||
### 4.1 您的权利
|
||||
|
||||
根据适用的数据保护法律,您享有以下权利:
|
||||
|
||||
- **知情权**:了解我们收集哪些数据及其用途
|
||||
- **访问权**:请求获取我们持有的您的个人数据副本
|
||||
- **更正权**:请求更正不准确或不完整的个人数据
|
||||
- **删除权**:请求删除您的个人数据
|
||||
- **撤回同意权**:随时撤回您对数据收集的同意
|
||||
- **数据可携带权**:以结构化格式接收您的个人数据
|
||||
|
||||
### 4.2 如何行使您的权利
|
||||
|
||||
您可以通过以下方式行使上述权利:
|
||||
|
||||
1. **关闭遥测功能**:在应用设置 > 隐私设置中关闭相应开关
|
||||
2. **刷新遥测标识**:在应用设置 > 隐私设置中点击"刷新遥测 ID"
|
||||
3. **联系我们**:通过 GitHub Issues 提交数据相关请求
|
||||
|
||||
### 4.3 功能控制
|
||||
|
||||
| 功能 | 控制方式 | 效果 |
|
||||
| ------- | ---- | ----------- |
|
||||
| 崩溃数据上传 | 设置开关 | 关闭后停止发送崩溃数据 |
|
||||
| 行为数据分析 | 设置开关 | 关闭后停止发送行为数据 |
|
||||
| 刷新遥测 ID | 手动触发 | 生成新的匿名标识符 |
|
||||
|
||||
***
|
||||
|
||||
## 5. 身份标识
|
||||
|
||||
### 5.1 匿名标识符
|
||||
|
||||
我们使用以下匿名标识符来区分用户和会话:
|
||||
|
||||
- **Install ID**:在应用首次安装时随机生成的唯一标识符,用于区分不同的安装实例
|
||||
- **Telemetry ID**:匿名标识符,用于关联遥测数据
|
||||
|
||||
### 5.2 标识符特性
|
||||
|
||||
- 这些标识符不包含您的真实身份信息
|
||||
- 标识符与您的个人身份(如姓名、邮箱、电话)无关联
|
||||
|
||||
***
|
||||
|
||||
## 6. 数据安全
|
||||
|
||||
### 6.1 安全措施
|
||||
|
||||
我们采取以下安全措施保护您的数据:
|
||||
|
||||
- ✅ 数据传输使用HTTPS加密
|
||||
- ✅ 数据存储使用加密技术
|
||||
- ✅ 访问权限严格控制
|
||||
- ✅ 定期安全审计
|
||||
- **传输加密**:所有数据传输均使用 TLS/HTTPS 加密
|
||||
- **访问控制**:限制对数据的访问权限,仅授权人员可访问
|
||||
- **匿名化处理**:使用匿名标识符而非个人身份信息
|
||||
|
||||
---
|
||||
### 6.2 数据泄露响应
|
||||
|
||||
## 4. 数据共享
|
||||
如发生数据泄露事件,我们将:
|
||||
|
||||
### 4.1 我们不会出售您的数据
|
||||
1. 及时评估泄露的影响范围和严重程度
|
||||
2. 采取必要措施阻止进一步泄露
|
||||
3. 根据法律要求通知相关监管机构和受影响用户
|
||||
|
||||
我们**明确承诺**:
|
||||
- ❌ 不会出售您的个人数据
|
||||
- ❌ 不会将您的数据用于广告目的
|
||||
- ❌ 不会与第三方共享可识别个人的数据
|
||||
***
|
||||
|
||||
### 4.2 必要的共享
|
||||
## 7. 第三方服务
|
||||
|
||||
我们仅在以下情况下共享数据:
|
||||
### 7.1 PostHog
|
||||
|
||||
- **服务提供商**:与Sentry和PostHog共享数据以提供服务
|
||||
- **法律要求**:在法律要求或政府机构合法要求时
|
||||
PostHog 是我们使用的产品分析平台,用于收集和分析用户行为数据。PostHog 的隐私政策请参阅:<https://posthog.com/privacy>
|
||||
|
||||
---
|
||||
### 7.2 Sentry
|
||||
|
||||
## 5. 您的权利
|
||||
Sentry 是我们使用的错误监控平台,用于收集和分析崩溃数据。Sentry 的隐私政策请参阅:<https://sentry.io/privacy/>
|
||||
|
||||
### 5.1 选择权
|
||||
### 7.3 第三方责任
|
||||
|
||||
您完全控制详细数据收集:
|
||||
我们仅将上述第三方服务用于本政策所述目的。我们不对这些第三方的隐私实践负责,建议您阅读其隐私政策。
|
||||
|
||||
- **匿名崩溃数据**:可在设置中开启或关闭
|
||||
- **匿名使用数据**:可在设置中开启或关闭
|
||||
- **基础数据**:始终收集(用于统计用户数量)
|
||||
***
|
||||
|
||||
**注意:** 即使关闭所有开关,我们仍会收集基础数据(应用启动事件和设备标识符)以统计用户数量。
|
||||
## 8. 儿童隐私
|
||||
|
||||
### 5.2 数据访问权
|
||||
本应用不面向 14 周岁以下的儿童。我们不会故意收集儿童的个人信息。如果您是 14 周岁以下儿童的监护人,且发现您的孩子向我们提供了个人信息,请联系我们,我们将采取措施删除相关信息。
|
||||
|
||||
您可以:
|
||||
- 查看我们收集的数据类型
|
||||
- 了解数据的使用目的
|
||||
- 了解数据的存储位置
|
||||
|
||||
### 5.3 数据删除权
|
||||
|
||||
您可以:
|
||||
- 随时关闭详细数据收集功能
|
||||
- 删除本地存储的设备标识符
|
||||
- 联系我们删除已收集的数据
|
||||
|
||||
---
|
||||
|
||||
## 6. 设备标识符
|
||||
|
||||
### 6.1 什么是设备标识符?
|
||||
|
||||
设备标识符是一个随机生成的唯一字符串,用于:
|
||||
- 统计日活用户数量
|
||||
- 区分不同设备
|
||||
- 分析用户使用趋势
|
||||
|
||||
### 6.2 设备标识符的特点
|
||||
|
||||
- **匿名性**:不包含任何个人信息
|
||||
- **随机性**:通过SHA256算法生成
|
||||
- **唯一性**:每个设备有唯一标识符
|
||||
- **持久性**:即使刷新设备ID,仍能关联到同一用户
|
||||
- **可重置**:您可以在设置中刷新设备标识符
|
||||
|
||||
### 6.3 设备标识符刷新
|
||||
|
||||
当您刷新设备标识符时:
|
||||
- 生成新的设备ID
|
||||
- **持久用户ID保持不变**,确保仍能关联到同一用户
|
||||
- 统计数据不会丢失
|
||||
|
||||
### 6.4 设备标识符示例
|
||||
|
||||
```
|
||||
a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 儿童隐私保护
|
||||
|
||||
本应用不面向13岁以下儿童。我们不会故意收集儿童的个人信息。如果您发现我们无意中收集了儿童的数据,请联系我们,我们将立即删除相关数据。
|
||||
|
||||
---
|
||||
|
||||
## 8. 国际数据传输
|
||||
|
||||
由于我们的服务提供商位于美国,您的数据可能会被传输到美国。我们确保:
|
||||
|
||||
- 数据传输符合相关法律法规
|
||||
- 服务提供商遵守GDPR等隐私法规
|
||||
- 采取适当的安全措施保护数据
|
||||
|
||||
---
|
||||
***
|
||||
|
||||
## 9. 隐私政策更新
|
||||
|
||||
我们可能会不时更新本隐私政策。更新时,我们将:
|
||||
我们可能会不时更新本隐私政策。更新后的政策将在本应用内发布,并在政策顶部注明"最后更新"日期。重大变更时,我们将在应用内通过显著方式通知您。
|
||||
|
||||
- 在本页面更新"最后更新日期"
|
||||
- 在应用内通知您重大变更
|
||||
- 继续使用应用即表示您同意更新后的政策
|
||||
建议您定期查阅本政策,以了解我们如何保护您的信息。继续使用本应用即表示您接受更新后的隐私政策。
|
||||
|
||||
---
|
||||
***
|
||||
|
||||
## 10. 联系我们
|
||||
## 10. 适用法律
|
||||
|
||||
如果您对本隐私政策有任何疑问或建议,请通过以下方式联系我们:
|
||||
本隐私政策的解释和执行适用中华人民共和国法律法规,包括但不限于:
|
||||
|
||||
- **GitHub Issues**:https://github.com/wwiinnddyy/LanMountainDesktop/issues
|
||||
- **电子邮件**:[您的邮箱地址]
|
||||
- 《中华人民共和国个人信息保护法》
|
||||
- 《中华人民共和国数据安全法》
|
||||
- 《中华人民共和国网络安全法》
|
||||
- 《信息安全技术 个人信息安全规范》(GB/T 35273)
|
||||
|
||||
---
|
||||
***
|
||||
|
||||
## 11. 法律依据
|
||||
## 11. 联系我们
|
||||
|
||||
### 11.1 GDPR合规
|
||||
如果您对本隐私政策有任何疑问、意见或建议,请通过以下方式联系我们:
|
||||
|
||||
如果您位于欧洲经济区(EEA),我们的数据处理基于:
|
||||
- **GitHub 仓库**:<https://github.com/wwiinnddyy/LanMountainDesktop>
|
||||
- **问题反馈**:<https://github.com/wwiinnddyy/LanMountainDesktop/issues>
|
||||
|
||||
- **同意**:您明确同意数据收集
|
||||
- **合法利益**:改进应用性能和用户体验
|
||||
我们将在收到您的请求后 30 日内予以答复。
|
||||
|
||||
### 11.2 CCPA合规
|
||||
***
|
||||
|
||||
如果您是加州居民,您有权:
|
||||
## 12. 条款可分割性
|
||||
|
||||
- 知道我们收集了哪些数据
|
||||
- 要求删除您的数据
|
||||
- 选择退出数据销售(我们不销售数据)
|
||||
如果本隐私政策的任何条款被有管辖权的法院或监管机构认定为无效或不可执行,该条款应在最小必要范围内进行修改以使其有效和可执行,或如果无法修改,则予以删除。本政策的其余条款将继续有效。
|
||||
|
||||
---
|
||||
***
|
||||
|
||||
## 12. 第三方链接
|
||||
|
||||
本应用可能包含第三方网站链接。我们不对这些网站的隐私政策负责。请阅读这些网站的隐私政策。
|
||||
|
||||
---
|
||||
|
||||
## 13. 数据收集示例
|
||||
|
||||
### 13.1 崩溃报告示例
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "abc123",
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"device_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
|
||||
"app_version": "1.0.0",
|
||||
"os_name": "Windows",
|
||||
"os_version": "10.0.19041",
|
||||
"error_message": "NullReferenceException",
|
||||
"stack_trace": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### 13.2 使用数据示例
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "app_online",
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"distinct_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
|
||||
"properties": {
|
||||
"app_version": "1.0.0",
|
||||
"os_name": "Windows",
|
||||
"event_type": "app_start",
|
||||
"analytics_enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 13.3 基础数据示例(始终收集)
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "$pageview",
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"distinct_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
|
||||
"properties": {
|
||||
"$current_url": "app://main",
|
||||
"$title": "LanMountainDesktop"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. 您的同意
|
||||
|
||||
使用本应用即表示您:
|
||||
|
||||
- ✅ 已阅读并理解本隐私政策
|
||||
- ✅ 同意我们按照本政策收集和使用数据
|
||||
- ✅ 了解您可以随时撤回同意(详细数据收集)
|
||||
- ✅ 了解基础数据将始终收集以统计用户数量
|
||||
|
||||
---
|
||||
|
||||
## 15. 免责声明
|
||||
|
||||
本隐私政策仅适用于 LanMountainDesktop 应用。我们不对以下情况负责:
|
||||
|
||||
- 第三方服务的隐私政策
|
||||
- 您自行分享的数据
|
||||
- 不可抗力导致的数据泄露
|
||||
|
||||
---
|
||||
|
||||
**感谢您信任阑山桌面LanMountainDesktop!**
|
||||
|
||||
我们承诺保护您的隐私,并持续改进我们的隐私保护措施。
|
||||
**本隐私政策最终解释权归灵方软件Lincube所有。**
|
||||
|
||||
@@ -41,4 +41,5 @@ public static class BuiltInComponentIds
|
||||
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
||||
public const string DesktopBrowser = "DesktopBrowser";
|
||||
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
|
||||
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
|
||||
}
|
||||
|
||||
@@ -336,6 +336,15 @@ public sealed class ComponentRegistry
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopRemovableStorage,
|
||||
"Removable Storage",
|
||||
"Storage",
|
||||
"File",
|
||||
MinWidthCells: 2,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.Date,
|
||||
"Calendar",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using LanMountainDesktop.Host.Abstractions;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
@@ -10,5 +11,6 @@ public sealed record DesktopComponentRuntimeContext(
|
||||
ISettingsFacadeService SettingsFacade,
|
||||
ISettingsService SettingsService,
|
||||
IAppearanceThemeService AppearanceTheme,
|
||||
ComponentChromeContext Chrome,
|
||||
IComponentSettingsAccessor ComponentSettingsAccessor,
|
||||
IComponentInstanceSettingsStore ComponentSettingsStore);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using LanMountainDesktop.Host.Abstractions;
|
||||
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public interface IComponentChromeContextAware
|
||||
{
|
||||
void SetComponentChromeContext(ComponentChromeContext context);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Animation;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace LanMountainDesktop.DesktopEditing;
|
||||
|
||||
internal sealed class ComponentLibraryCollapsePresenter
|
||||
{
|
||||
private static readonly TimeSpan TransitionDuration = TimeSpan.FromMilliseconds(150);
|
||||
private static readonly Easing TransitionEasing = new CubicEaseOut();
|
||||
private const double StableOpacityThreshold = 0.01;
|
||||
|
||||
private readonly Border _componentLibraryWindow;
|
||||
private readonly Border _collapsedChipHost;
|
||||
private readonly TextBlock _collapsedChipTextBlock;
|
||||
private readonly Control? _collapsedChipIcon;
|
||||
private readonly TranslateTransform _windowTranslate = new();
|
||||
private readonly TranslateTransform _chipTranslate = new();
|
||||
private readonly ScaleTransform _chipScale = new(1, 1);
|
||||
|
||||
private ComponentLibraryCollapseState _state;
|
||||
private int _transitionVersion;
|
||||
|
||||
public ComponentLibraryCollapsePresenter(
|
||||
Border componentLibraryWindow,
|
||||
Border collapsedChipHost,
|
||||
TextBlock collapsedChipTextBlock,
|
||||
Control? collapsedChipIcon = null)
|
||||
{
|
||||
_componentLibraryWindow = componentLibraryWindow ?? throw new ArgumentNullException(nameof(componentLibraryWindow));
|
||||
_collapsedChipHost = collapsedChipHost ?? throw new ArgumentNullException(nameof(collapsedChipHost));
|
||||
_collapsedChipTextBlock = collapsedChipTextBlock ?? throw new ArgumentNullException(nameof(collapsedChipTextBlock));
|
||||
_collapsedChipIcon = collapsedChipIcon;
|
||||
|
||||
EnsureTransforms();
|
||||
_state = ComponentLibraryCollapseState.CreateExpanded(
|
||||
_componentLibraryWindow.Margin,
|
||||
_componentLibraryWindow.Opacity <= 0 ? 1 : _componentLibraryWindow.Opacity);
|
||||
ApplyExpandedSnapshot();
|
||||
_collapsedChipHost.IsVisible = false;
|
||||
_collapsedChipHost.IsHitTestVisible = false;
|
||||
_collapsedChipHost.Opacity = 0;
|
||||
}
|
||||
|
||||
public bool IsCollapsed => _state.VisualState is ComponentLibraryCollapseVisualState.Collapsing or ComponentLibraryCollapseVisualState.Collapsed;
|
||||
|
||||
public ComponentLibraryCollapseVisualState VisualState => _state.VisualState;
|
||||
|
||||
public void SyncExpandedState(Thickness margin, double opacity)
|
||||
{
|
||||
var hasStableOpacity = IsStableExpandedOpacity(opacity);
|
||||
var nextExpandedOpacity = hasStableOpacity ? Math.Clamp(opacity, 0, 1) : _state.ExpandedOpacity;
|
||||
_state = _state with
|
||||
{
|
||||
ExpandedMargin = margin,
|
||||
ExpandedOpacity = nextExpandedOpacity
|
||||
};
|
||||
|
||||
if (_state.VisualState is ComponentLibraryCollapseVisualState.Expanded or ComponentLibraryCollapseVisualState.Restoring)
|
||||
{
|
||||
ApplyExpandedSnapshot(applyOpacity: hasStableOpacity);
|
||||
}
|
||||
}
|
||||
|
||||
public void Collapse(string title)
|
||||
{
|
||||
_collapsedChipTextBlock.Text = string.IsNullOrWhiteSpace(title) ? "Widgets" : title;
|
||||
|
||||
if (_state.VisualState is ComponentLibraryCollapseVisualState.Collapsing or ComponentLibraryCollapseVisualState.Collapsed)
|
||||
{
|
||||
ShowCollapsedChip(_transitionVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
var version = ++_transitionVersion;
|
||||
_state = _state.WithVisualState(ComponentLibraryCollapseVisualState.Collapsing, isChipVisible: true);
|
||||
|
||||
ApplyExpandedSnapshot();
|
||||
ShowCollapsedChip(version);
|
||||
SetCollapsedWindowTargets();
|
||||
|
||||
DispatcherTimer.RunOnce(
|
||||
() =>
|
||||
{
|
||||
if (version != _transitionVersion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_state = _state.WithVisualState(ComponentLibraryCollapseVisualState.Collapsed, isChipVisible: true);
|
||||
_componentLibraryWindow.IsVisible = false;
|
||||
_componentLibraryWindow.IsHitTestVisible = false;
|
||||
},
|
||||
TransitionDuration);
|
||||
}
|
||||
|
||||
public void Restore()
|
||||
{
|
||||
if (_state.VisualState is ComponentLibraryCollapseVisualState.Expanded)
|
||||
{
|
||||
ApplyExpandedSnapshot();
|
||||
_collapsedChipHost.IsVisible = false;
|
||||
_collapsedChipHost.IsHitTestVisible = false;
|
||||
_collapsedChipHost.Opacity = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
var version = ++_transitionVersion;
|
||||
_state = _state.WithVisualState(ComponentLibraryCollapseVisualState.Restoring, isChipVisible: false);
|
||||
|
||||
PrepareRestoringWindow();
|
||||
HideCollapsedChip(version);
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
if (version != _transitionVersion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_componentLibraryWindow.Opacity = _state.ExpandedOpacity;
|
||||
_windowTranslate.Y = 0;
|
||||
},
|
||||
DispatcherPriority.Background);
|
||||
|
||||
DispatcherTimer.RunOnce(
|
||||
() =>
|
||||
{
|
||||
if (version != _transitionVersion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_state = _state.WithVisualState(ComponentLibraryCollapseVisualState.Expanded, isChipVisible: false);
|
||||
_componentLibraryWindow.IsVisible = true;
|
||||
_componentLibraryWindow.IsHitTestVisible = true;
|
||||
},
|
||||
TransitionDuration);
|
||||
}
|
||||
|
||||
private void EnsureTransforms()
|
||||
{
|
||||
_componentLibraryWindow.RenderTransform = _windowTranslate;
|
||||
_windowTranslate.Transitions = new Transitions
|
||||
{
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = TranslateTransform.YProperty,
|
||||
Duration = TransitionDuration,
|
||||
Easing = TransitionEasing
|
||||
}
|
||||
};
|
||||
|
||||
_collapsedChipHost.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative);
|
||||
_collapsedChipHost.RenderTransform = new TransformGroup
|
||||
{
|
||||
Children =
|
||||
{
|
||||
_chipTranslate,
|
||||
_chipScale
|
||||
}
|
||||
};
|
||||
_chipTranslate.Transitions = new Transitions
|
||||
{
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = TranslateTransform.YProperty,
|
||||
Duration = TransitionDuration,
|
||||
Easing = TransitionEasing
|
||||
}
|
||||
};
|
||||
_chipScale.Transitions = new Transitions
|
||||
{
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = ScaleTransform.ScaleXProperty,
|
||||
Duration = TransitionDuration,
|
||||
Easing = TransitionEasing
|
||||
},
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = ScaleTransform.ScaleYProperty,
|
||||
Duration = TransitionDuration,
|
||||
Easing = TransitionEasing
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void ApplyExpandedSnapshot(bool applyOpacity = true)
|
||||
{
|
||||
_componentLibraryWindow.Margin = _state.ExpandedMargin;
|
||||
if (applyOpacity)
|
||||
{
|
||||
_componentLibraryWindow.Opacity = _state.ExpandedOpacity;
|
||||
}
|
||||
|
||||
_componentLibraryWindow.IsVisible = true;
|
||||
_componentLibraryWindow.IsHitTestVisible = true;
|
||||
_windowTranslate.Y = 0;
|
||||
}
|
||||
|
||||
private void SetCollapsedWindowTargets()
|
||||
{
|
||||
_componentLibraryWindow.Opacity = 0;
|
||||
_windowTranslate.Y = 28;
|
||||
}
|
||||
|
||||
private void ShowCollapsedChip(int version)
|
||||
{
|
||||
_collapsedChipHost.IsVisible = true;
|
||||
_collapsedChipHost.IsHitTestVisible = false;
|
||||
_collapsedChipTextBlock.IsVisible = true;
|
||||
if (_collapsedChipIcon is not null)
|
||||
{
|
||||
_collapsedChipIcon.IsVisible = true;
|
||||
}
|
||||
|
||||
_collapsedChipHost.Opacity = 0;
|
||||
_chipTranslate.Y = 8;
|
||||
_chipScale.ScaleX = 0.96;
|
||||
_chipScale.ScaleY = 0.96;
|
||||
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
if (version != _transitionVersion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_collapsedChipHost.Opacity = 1;
|
||||
_chipTranslate.Y = 0;
|
||||
_chipScale.ScaleX = 1;
|
||||
_chipScale.ScaleY = 1;
|
||||
},
|
||||
DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private void HideCollapsedChip(int version)
|
||||
{
|
||||
_collapsedChipHost.IsVisible = true;
|
||||
_collapsedChipHost.IsHitTestVisible = false;
|
||||
_collapsedChipHost.Opacity = 0;
|
||||
_chipTranslate.Y = 8;
|
||||
_chipScale.ScaleX = 0.96;
|
||||
_chipScale.ScaleY = 0.96;
|
||||
|
||||
DispatcherTimer.RunOnce(
|
||||
() =>
|
||||
{
|
||||
if (version != _transitionVersion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_collapsedChipHost.IsVisible = false;
|
||||
},
|
||||
TransitionDuration);
|
||||
}
|
||||
|
||||
private void PrepareRestoringWindow()
|
||||
{
|
||||
_componentLibraryWindow.IsVisible = true;
|
||||
_componentLibraryWindow.IsHitTestVisible = true;
|
||||
_componentLibraryWindow.Margin = _state.ExpandedMargin;
|
||||
_componentLibraryWindow.Opacity = 0;
|
||||
_windowTranslate.Y = 28;
|
||||
}
|
||||
|
||||
private static bool IsStableExpandedOpacity(double opacity)
|
||||
{
|
||||
return !double.IsNaN(opacity) &&
|
||||
!double.IsInfinity(opacity) &&
|
||||
opacity > StableOpacityThreshold;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Avalonia;
|
||||
|
||||
namespace LanMountainDesktop.DesktopEditing;
|
||||
|
||||
internal enum ComponentLibraryCollapseVisualState
|
||||
{
|
||||
Expanded,
|
||||
Collapsing,
|
||||
Collapsed,
|
||||
Restoring
|
||||
}
|
||||
|
||||
internal readonly record struct ComponentLibraryCollapseState(
|
||||
ComponentLibraryCollapseVisualState VisualState,
|
||||
Thickness ExpandedMargin,
|
||||
double ExpandedOpacity,
|
||||
bool IsChipVisible)
|
||||
{
|
||||
public static ComponentLibraryCollapseState CreateExpanded(Thickness expandedMargin, double expandedOpacity)
|
||||
{
|
||||
return new(
|
||||
ComponentLibraryCollapseVisualState.Expanded,
|
||||
expandedMargin,
|
||||
expandedOpacity,
|
||||
IsChipVisible: false);
|
||||
}
|
||||
|
||||
public ComponentLibraryCollapseState WithVisualState(ComponentLibraryCollapseVisualState visualState, bool isChipVisible)
|
||||
{
|
||||
return this with
|
||||
{
|
||||
VisualState = visualState,
|
||||
IsChipVisible = isChipVisible
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace LanMountainDesktop.DesktopEditing;
|
||||
|
||||
internal static class DesktopEditCommitMath
|
||||
{
|
||||
public static bool IsPendingCommitValid(bool isPending, int scheduledVersion, int currentVersion)
|
||||
{
|
||||
return isPending && scheduledVersion == currentVersion;
|
||||
}
|
||||
}
|
||||
241
LanMountainDesktop/DesktopEditing/DesktopEditGhostView.cs
Normal file
241
LanMountainDesktop/DesktopEditing/DesktopEditGhostView.cs
Normal file
@@ -0,0 +1,241 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Animation;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace LanMountainDesktop.DesktopEditing;
|
||||
|
||||
internal sealed class DesktopEditGhostView : Border
|
||||
{
|
||||
private static readonly TimeSpan FastDuration = TimeSpan.FromMilliseconds(120);
|
||||
private static readonly Easing StandardEasing = new CubicEaseOut();
|
||||
|
||||
private readonly Border _accentDot;
|
||||
private readonly TextBlock _titleTextBlock;
|
||||
private readonly TextBlock _detailTextBlock;
|
||||
private readonly Border _badgeBorder;
|
||||
private readonly TextBlock _badgeTextBlock;
|
||||
private readonly ScaleTransform _scaleTransform = new(1, 1);
|
||||
|
||||
private readonly SolidColorBrush _normalBackgroundBrush = new(Color.Parse("#F11B2430"));
|
||||
private readonly SolidColorBrush _normalBorderBrush = new(Color.Parse("#4D8AA3C1"));
|
||||
private readonly SolidColorBrush _normalAccentBrush = new(Color.Parse("#FF4F8EF7"));
|
||||
private readonly SolidColorBrush _normalTextBrush = new(Color.Parse("#FFF5F7FA"));
|
||||
private readonly SolidColorBrush _normalMutedTextBrush = new(Color.Parse("#BDE2E8F0"));
|
||||
private readonly SolidColorBrush _normalBadgeBackgroundBrush = new(Color.Parse("#245E86D6"));
|
||||
private readonly SolidColorBrush _normalBadgeBorderBrush = new(Color.Parse("#557EA7E6"));
|
||||
private readonly SolidColorBrush _invalidBackgroundBrush = new(Color.Parse("#F01B1022"));
|
||||
private readonly SolidColorBrush _invalidBorderBrush = new(Color.Parse("#FFE25555"));
|
||||
private readonly SolidColorBrush _invalidAccentBrush = new(Color.Parse("#FFFF6B6B"));
|
||||
private readonly SolidColorBrush _invalidBadgeBackgroundBrush = new(Color.Parse("#33FF4D4D"));
|
||||
private readonly SolidColorBrush _invalidBadgeBorderBrush = new(Color.Parse("#88FF7676"));
|
||||
|
||||
public DesktopEditGhostView()
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch;
|
||||
VerticalAlignment = VerticalAlignment.Stretch;
|
||||
Padding = new Thickness(14);
|
||||
Background = _normalBackgroundBrush;
|
||||
BorderBrush = _normalBorderBrush;
|
||||
BorderThickness = new Thickness(1);
|
||||
CornerRadius = new CornerRadius(22);
|
||||
ClipToBounds = true;
|
||||
RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative);
|
||||
RenderTransform = _scaleTransform;
|
||||
Transitions = new Transitions
|
||||
{
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = Visual.OpacityProperty,
|
||||
Duration = FastDuration,
|
||||
Easing = StandardEasing
|
||||
}
|
||||
};
|
||||
_scaleTransform.Transitions = new Transitions
|
||||
{
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = ScaleTransform.ScaleXProperty,
|
||||
Duration = FastDuration,
|
||||
Easing = StandardEasing
|
||||
},
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = ScaleTransform.ScaleYProperty,
|
||||
Duration = FastDuration,
|
||||
Easing = StandardEasing
|
||||
}
|
||||
};
|
||||
|
||||
_accentDot = new Border
|
||||
{
|
||||
Width = 10,
|
||||
Height = 10,
|
||||
CornerRadius = new CornerRadius(999),
|
||||
Background = _normalAccentBrush,
|
||||
BorderThickness = new Thickness(0),
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
|
||||
_titleTextBlock = new TextBlock
|
||||
{
|
||||
Foreground = _normalTextBrush,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
TextWrapping = TextWrapping.NoWrap,
|
||||
MaxLines = 1
|
||||
};
|
||||
|
||||
_detailTextBlock = new TextBlock
|
||||
{
|
||||
Foreground = _normalMutedTextBrush,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
TextWrapping = TextWrapping.NoWrap,
|
||||
MaxLines = 1
|
||||
};
|
||||
|
||||
_badgeTextBlock = new TextBlock
|
||||
{
|
||||
Foreground = _normalTextBrush,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
TextWrapping = TextWrapping.NoWrap,
|
||||
MaxLines = 1
|
||||
};
|
||||
|
||||
_badgeBorder = new Border
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Padding = new Thickness(9, 4),
|
||||
CornerRadius = new CornerRadius(999),
|
||||
Background = _normalBadgeBackgroundBrush,
|
||||
BorderBrush = _normalBadgeBorderBrush,
|
||||
BorderThickness = new Thickness(1),
|
||||
Child = _badgeTextBlock
|
||||
};
|
||||
|
||||
var headerPanel = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 8,
|
||||
Children =
|
||||
{
|
||||
_accentDot,
|
||||
_titleTextBlock
|
||||
}
|
||||
};
|
||||
|
||||
var contentPanel = new StackPanel
|
||||
{
|
||||
Spacing = 6,
|
||||
Children =
|
||||
{
|
||||
headerPanel,
|
||||
_detailTextBlock
|
||||
}
|
||||
};
|
||||
|
||||
var rootGrid = new Grid
|
||||
{
|
||||
RowDefinitions = new RowDefinitions
|
||||
{
|
||||
new RowDefinition(GridLength.Auto),
|
||||
new RowDefinition(GridLength.Auto)
|
||||
},
|
||||
RowSpacing = 8
|
||||
};
|
||||
rootGrid.Children.Add(contentPanel);
|
||||
rootGrid.Children.Add(_badgeBorder);
|
||||
Grid.SetRow(contentPanel, 0);
|
||||
Grid.SetRow(_badgeBorder, 1);
|
||||
_badgeBorder.Margin = new Thickness(0, 2, 0, 0);
|
||||
|
||||
Child = rootGrid;
|
||||
|
||||
UpdatePreviewMetrics(180, 120);
|
||||
UpdateContent(null, null, null);
|
||||
}
|
||||
|
||||
public void UpdateContent(string? title, string? detail, string? badgeText)
|
||||
{
|
||||
_titleTextBlock.Text = string.IsNullOrWhiteSpace(title) ? "Component" : title;
|
||||
_detailTextBlock.Text = string.IsNullOrWhiteSpace(detail) ? string.Empty : detail;
|
||||
_detailTextBlock.IsVisible = !string.IsNullOrWhiteSpace(detail);
|
||||
_badgeTextBlock.Text = string.IsNullOrWhiteSpace(badgeText) ? string.Empty : badgeText;
|
||||
_badgeBorder.IsVisible = !string.IsNullOrWhiteSpace(badgeText);
|
||||
}
|
||||
|
||||
public void UpdatePreviewMetrics(double width, double height)
|
||||
{
|
||||
var normalizedWidth = Math.Max(1, width);
|
||||
var normalizedHeight = Math.Max(1, height);
|
||||
var minSide = Math.Max(1, Math.Min(normalizedWidth, normalizedHeight));
|
||||
|
||||
CornerRadius = new CornerRadius(Math.Clamp(minSide * 0.16, 16, 28));
|
||||
Padding = new Thickness(
|
||||
Math.Clamp(minSide * 0.10, 10, 18),
|
||||
Math.Clamp(minSide * 0.10, 10, 18),
|
||||
Math.Clamp(minSide * 0.10, 10, 18),
|
||||
Math.Clamp(minSide * 0.09, 10, 16));
|
||||
|
||||
var titleFontSize = Math.Clamp(minSide * 0.12, 12, 18);
|
||||
var detailFontSize = Math.Clamp(minSide * 0.085, 10, 13);
|
||||
var badgeFontSize = Math.Clamp(minSide * 0.08, 9, 12);
|
||||
var dotSize = Math.Clamp(minSide * 0.07, 8, 12);
|
||||
var badgeHorizontalPadding = Math.Clamp(minSide * 0.07, 8, 14);
|
||||
var badgeVerticalPadding = Math.Clamp(minSide * 0.035, 3, 6);
|
||||
|
||||
_accentDot.Width = dotSize;
|
||||
_accentDot.Height = dotSize;
|
||||
_titleTextBlock.FontSize = titleFontSize;
|
||||
_detailTextBlock.FontSize = detailFontSize;
|
||||
_badgeTextBlock.FontSize = badgeFontSize;
|
||||
_badgeBorder.Padding = new Thickness(badgeHorizontalPadding, badgeVerticalPadding);
|
||||
}
|
||||
|
||||
public void SetInvalid(bool isInvalid)
|
||||
{
|
||||
if (isInvalid)
|
||||
{
|
||||
Background = _invalidBackgroundBrush;
|
||||
BorderBrush = _invalidBorderBrush;
|
||||
_accentDot.Background = _invalidAccentBrush;
|
||||
_badgeBorder.Background = _invalidBadgeBackgroundBrush;
|
||||
_badgeBorder.BorderBrush = _invalidBadgeBorderBrush;
|
||||
_titleTextBlock.Foreground = _invalidBorderBrush;
|
||||
_detailTextBlock.Foreground = _invalidBorderBrush;
|
||||
_badgeTextBlock.Foreground = _invalidBorderBrush;
|
||||
Opacity = 0.9;
|
||||
return;
|
||||
}
|
||||
|
||||
Background = _normalBackgroundBrush;
|
||||
BorderBrush = _normalBorderBrush;
|
||||
_accentDot.Background = _normalAccentBrush;
|
||||
_badgeBorder.Background = _normalBadgeBackgroundBrush;
|
||||
_badgeBorder.BorderBrush = _normalBadgeBorderBrush;
|
||||
_titleTextBlock.Foreground = _normalTextBrush;
|
||||
_detailTextBlock.Foreground = _normalMutedTextBrush;
|
||||
_badgeTextBlock.Foreground = _normalTextBrush;
|
||||
Opacity = 1.0;
|
||||
}
|
||||
|
||||
public void SetRestingScale(double scale)
|
||||
{
|
||||
var clampedScale = Math.Clamp(scale, 0.85, 1.12);
|
||||
_scaleTransform.ScaleX = clampedScale;
|
||||
_scaleTransform.ScaleY = clampedScale;
|
||||
}
|
||||
|
||||
public void AnimateToScale(double scale)
|
||||
{
|
||||
var clampedScale = Math.Clamp(scale, 0.85, 1.12);
|
||||
_scaleTransform.ScaleX = clampedScale;
|
||||
_scaleTransform.ScaleY = clampedScale;
|
||||
}
|
||||
}
|
||||
287
LanMountainDesktop/DesktopEditing/DesktopEditOverlayPresenter.cs
Normal file
287
LanMountainDesktop/DesktopEditing/DesktopEditOverlayPresenter.cs
Normal file
@@ -0,0 +1,287 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Animation;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.DesktopEditing;
|
||||
|
||||
internal enum DesktopEditGhostVisualStyle
|
||||
{
|
||||
StandardLift = 0,
|
||||
ElevatedFromLibrary
|
||||
}
|
||||
|
||||
internal sealed class DesktopEditOverlayPresenter
|
||||
{
|
||||
private static readonly TimeSpan FastDuration = FluttermotionToken.Fast;
|
||||
private static readonly Easing StandardEasing = new CubicEaseOut();
|
||||
|
||||
private readonly Canvas _root;
|
||||
private readonly DesktopEditGhostView _ghostView;
|
||||
private readonly Border _candidateOutline;
|
||||
private readonly ScaleTransform _candidateScale = new(1, 1);
|
||||
|
||||
private Rect? _previewRect;
|
||||
private Rect? _candidateRect;
|
||||
private bool _isInvalid;
|
||||
private bool _isVisible;
|
||||
private int _dismissVersion;
|
||||
|
||||
private readonly SolidColorBrush _candidateBrush = new(Color.Parse("#FF4F8EF7"));
|
||||
private readonly SolidColorBrush _candidateInvalidBrush = new(Color.Parse("#FFFF6B6B"));
|
||||
private readonly SolidColorBrush _candidateFillBrush = new(Color.Parse("#224F8EF7"));
|
||||
private readonly SolidColorBrush _candidateInvalidFillBrush = new(Color.Parse("#22FF6B6B"));
|
||||
|
||||
public DesktopEditOverlayPresenter()
|
||||
{
|
||||
_ghostView = new DesktopEditGhostView
|
||||
{
|
||||
IsHitTestVisible = false,
|
||||
Opacity = 1
|
||||
};
|
||||
|
||||
_candidateOutline = new Border
|
||||
{
|
||||
IsHitTestVisible = false,
|
||||
Background = _candidateFillBrush,
|
||||
BorderBrush = _candidateBrush,
|
||||
BorderThickness = new Thickness(2),
|
||||
CornerRadius = new CornerRadius(22),
|
||||
Opacity = 0,
|
||||
RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative),
|
||||
RenderTransform = _candidateScale,
|
||||
Transitions = new Transitions
|
||||
{
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = Visual.OpacityProperty,
|
||||
Duration = FastDuration,
|
||||
Easing = StandardEasing
|
||||
}
|
||||
}
|
||||
};
|
||||
_candidateScale.Transitions = new Transitions
|
||||
{
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = ScaleTransform.ScaleXProperty,
|
||||
Duration = FastDuration,
|
||||
Easing = StandardEasing
|
||||
},
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = ScaleTransform.ScaleYProperty,
|
||||
Duration = FastDuration,
|
||||
Easing = StandardEasing
|
||||
}
|
||||
};
|
||||
|
||||
_candidateOutline.SetValue(Panel.ZIndexProperty, 0);
|
||||
_ghostView.SetValue(Panel.ZIndexProperty, 1);
|
||||
|
||||
_root = new Canvas
|
||||
{
|
||||
IsHitTestVisible = false,
|
||||
ClipToBounds = false,
|
||||
Opacity = 0,
|
||||
IsVisible = false,
|
||||
Children =
|
||||
{
|
||||
_candidateOutline,
|
||||
_ghostView
|
||||
}
|
||||
};
|
||||
|
||||
_root.Transitions = new Transitions
|
||||
{
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = Visual.OpacityProperty,
|
||||
Duration = FastDuration,
|
||||
Easing = StandardEasing
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public Control Root => _root;
|
||||
|
||||
public void SetViewportSize(Size size)
|
||||
{
|
||||
_root.Width = Math.Max(1, size.Width);
|
||||
_root.Height = Math.Max(1, size.Height);
|
||||
}
|
||||
|
||||
public void SetPreviewRect(Rect rect)
|
||||
{
|
||||
_previewRect = Normalize(rect);
|
||||
ApplyPreviewRect();
|
||||
}
|
||||
|
||||
public void SetCandidateRect(Rect? rect)
|
||||
{
|
||||
_candidateRect = rect is null ? null : Normalize(rect.Value);
|
||||
ApplyCandidateRect();
|
||||
}
|
||||
|
||||
public void UpdateGhostContent(string? title, string? detail = null, string? badge = null)
|
||||
{
|
||||
_ghostView.UpdateContent(title, detail, badge);
|
||||
}
|
||||
|
||||
public void SetInvalid(bool isInvalid)
|
||||
{
|
||||
_isInvalid = isInvalid;
|
||||
_ghostView.SetInvalid(isInvalid);
|
||||
UpdateCandidateAppearance();
|
||||
}
|
||||
|
||||
public void Show(DesktopEditGhostVisualStyle visualStyle = DesktopEditGhostVisualStyle.StandardLift)
|
||||
{
|
||||
_dismissVersion++;
|
||||
_isVisible = true;
|
||||
_root.IsVisible = true;
|
||||
_root.Opacity = 0;
|
||||
_ghostView.Opacity = 0;
|
||||
var initialGhostScale = visualStyle == DesktopEditGhostVisualStyle.ElevatedFromLibrary ? 1.02 : 0.985;
|
||||
var targetGhostScale = visualStyle == DesktopEditGhostVisualStyle.ElevatedFromLibrary ? 1.06 : 1;
|
||||
_ghostView.SetRestingScale(initialGhostScale);
|
||||
_candidateOutline.Opacity = 0;
|
||||
_candidateScale.ScaleX = 0.96;
|
||||
_candidateScale.ScaleY = 0.96;
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (!_isVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_root.Opacity = 1;
|
||||
_ghostView.Opacity = 1;
|
||||
_ghostView.SetRestingScale(targetGhostScale);
|
||||
if (_candidateRect.HasValue)
|
||||
{
|
||||
_candidateOutline.Opacity = 1;
|
||||
_candidateScale.ScaleX = 1;
|
||||
_candidateScale.ScaleY = 1;
|
||||
}
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
_dismissVersion++;
|
||||
_isVisible = false;
|
||||
_root.Opacity = 0;
|
||||
_ghostView.Opacity = 0;
|
||||
_candidateOutline.Opacity = 0;
|
||||
_candidateScale.ScaleX = 0.96;
|
||||
_candidateScale.ScaleY = 0.96;
|
||||
_ghostView.SetRestingScale(0.96);
|
||||
_root.IsVisible = false;
|
||||
}
|
||||
|
||||
public void Commit()
|
||||
{
|
||||
BeginDismiss(isCancel: false);
|
||||
}
|
||||
|
||||
public void Cancel()
|
||||
{
|
||||
BeginDismiss(isCancel: true);
|
||||
}
|
||||
|
||||
private void BeginDismiss(bool isCancel)
|
||||
{
|
||||
if (!_isVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var version = ++_dismissVersion;
|
||||
_isVisible = false;
|
||||
_candidateOutline.Opacity = 0;
|
||||
_ghostView.Opacity = 0;
|
||||
_root.Opacity = 0;
|
||||
|
||||
var targetScale = isCancel ? 0.96 : 1.04;
|
||||
_ghostView.AnimateToScale(targetScale);
|
||||
_candidateScale.ScaleX = targetScale;
|
||||
_candidateScale.ScaleY = targetScale;
|
||||
|
||||
DispatcherTimer.RunOnce(
|
||||
() =>
|
||||
{
|
||||
if (version != _dismissVersion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_root.IsVisible = false;
|
||||
},
|
||||
FastDuration);
|
||||
}
|
||||
|
||||
private void ApplyPreviewRect()
|
||||
{
|
||||
if (!_previewRect.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var rect = _previewRect.Value;
|
||||
_ghostView.Width = Math.Max(1, rect.Width);
|
||||
_ghostView.Height = Math.Max(1, rect.Height);
|
||||
Canvas.SetLeft(_ghostView, rect.X);
|
||||
Canvas.SetTop(_ghostView, rect.Y);
|
||||
_ghostView.UpdatePreviewMetrics(rect.Width, rect.Height);
|
||||
}
|
||||
|
||||
private void ApplyCandidateRect()
|
||||
{
|
||||
if (!_candidateRect.HasValue)
|
||||
{
|
||||
_candidateOutline.IsVisible = false;
|
||||
_candidateOutline.Opacity = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
var rect = _candidateRect.Value;
|
||||
_candidateOutline.IsVisible = true;
|
||||
_candidateOutline.Width = Math.Max(1, rect.Width);
|
||||
_candidateOutline.Height = Math.Max(1, rect.Height);
|
||||
Canvas.SetLeft(_candidateOutline, rect.X);
|
||||
Canvas.SetTop(_candidateOutline, rect.Y);
|
||||
|
||||
var cornerRadius = Math.Clamp(Math.Min(rect.Width, rect.Height) * 0.12, 14, 28);
|
||||
_candidateOutline.CornerRadius = new CornerRadius(cornerRadius);
|
||||
_candidateOutline.BorderBrush = _isInvalid ? _candidateInvalidBrush : _candidateBrush;
|
||||
_candidateOutline.Background = _isInvalid ? _candidateInvalidFillBrush : _candidateFillBrush;
|
||||
_candidateOutline.Opacity = _isVisible ? 1 : 0;
|
||||
_candidateScale.ScaleX = _isVisible ? 1 : 0.96;
|
||||
_candidateScale.ScaleY = _isVisible ? 1 : 0.96;
|
||||
UpdateCandidateAppearance();
|
||||
}
|
||||
|
||||
private void UpdateCandidateAppearance()
|
||||
{
|
||||
if (!_candidateRect.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_candidateOutline.BorderBrush = _isInvalid ? _candidateInvalidBrush : _candidateBrush;
|
||||
_candidateOutline.Background = _isInvalid ? _candidateInvalidFillBrush : _candidateFillBrush;
|
||||
}
|
||||
|
||||
private static Rect Normalize(Rect rect)
|
||||
{
|
||||
var width = Math.Max(1, rect.Width);
|
||||
var height = Math.Max(1, rect.Height);
|
||||
return new Rect(rect.X, rect.Y, width, height);
|
||||
}
|
||||
}
|
||||
205
LanMountainDesktop/DesktopEditing/DesktopEditSession.cs
Normal file
205
LanMountainDesktop/DesktopEditing/DesktopEditSession.cs
Normal file
@@ -0,0 +1,205 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
|
||||
namespace LanMountainDesktop.DesktopEditing;
|
||||
|
||||
internal enum DesktopEditSessionMode
|
||||
{
|
||||
None = 0,
|
||||
PendingNew,
|
||||
DraggingNew,
|
||||
DraggingExisting,
|
||||
ResizingExisting
|
||||
}
|
||||
|
||||
internal readonly record struct DesktopEditSession
|
||||
{
|
||||
public DesktopEditSessionMode Mode { get; init; }
|
||||
public string? ComponentId { get; init; }
|
||||
public string? PlacementId { get; init; }
|
||||
public int PageIndex { get; init; }
|
||||
public int WidthCells { get; init; }
|
||||
public int HeightCells { get; init; }
|
||||
public Point StartPointerInViewport { get; init; }
|
||||
public Point CurrentPointerInViewport { get; init; }
|
||||
public Point PointerOffsetInViewport { get; init; }
|
||||
public Rect? ComponentLibraryBounds { get; init; }
|
||||
public int TargetRow { get; init; }
|
||||
public int TargetColumn { get; init; }
|
||||
|
||||
public bool IsActive => Mode != DesktopEditSessionMode.None;
|
||||
public bool IsPendingNew => Mode == DesktopEditSessionMode.PendingNew;
|
||||
public bool IsDraggingNew => Mode == DesktopEditSessionMode.DraggingNew;
|
||||
public bool IsDraggingExisting => Mode == DesktopEditSessionMode.DraggingExisting;
|
||||
public bool IsResizingExisting => Mode == DesktopEditSessionMode.ResizingExisting;
|
||||
public bool HasTargetCell => TargetRow >= 0 && TargetColumn >= 0;
|
||||
|
||||
public double PointerTravelDistance => DesktopPlacementMath.Distance(StartPointerInViewport, CurrentPointerInViewport);
|
||||
|
||||
public bool HasExceededThreshold(double threshold)
|
||||
{
|
||||
return DesktopPlacementMath.HasExceededThreshold(StartPointerInViewport, CurrentPointerInViewport, threshold);
|
||||
}
|
||||
|
||||
public bool IsPointerInsideComponentLibrary()
|
||||
{
|
||||
return DesktopPlacementMath.IsOccludedByComponentLibrary(CurrentPointerInViewport, ComponentLibraryBounds);
|
||||
}
|
||||
|
||||
public bool IsPreviewOccludedByComponentLibrary(Rect previewRect)
|
||||
{
|
||||
return DesktopPlacementMath.IsOccludedByComponentLibrary(previewRect, ComponentLibraryBounds);
|
||||
}
|
||||
|
||||
public bool CanCommit => IsActive && HasTargetCell;
|
||||
|
||||
public Rect GetPreviewRect(DesktopGridGeometry grid)
|
||||
{
|
||||
if (HasTargetCell)
|
||||
{
|
||||
return DesktopPlacementMath.GetCellRect(
|
||||
grid,
|
||||
TargetColumn,
|
||||
TargetRow,
|
||||
Math.Max(1, WidthCells),
|
||||
Math.Max(1, HeightCells));
|
||||
}
|
||||
|
||||
var freePreviewOrigin = DesktopPlacementMath.Subtract(CurrentPointerInViewport, PointerOffsetInViewport);
|
||||
return new Rect(
|
||||
freePreviewOrigin,
|
||||
new Size(
|
||||
Math.Max(1, WidthCells) * grid.CellSize + Math.Max(0, Math.Max(1, WidthCells) - 1) * grid.CellGap,
|
||||
Math.Max(1, HeightCells) * grid.CellSize + Math.Max(0, Math.Max(1, HeightCells) - 1) * grid.CellGap));
|
||||
}
|
||||
|
||||
public DesktopEditSession WithCurrentPointer(Point pointerInViewport)
|
||||
{
|
||||
return this with { CurrentPointerInViewport = pointerInViewport };
|
||||
}
|
||||
|
||||
public DesktopEditSession WithComponentLibraryBounds(Rect? componentLibraryBounds)
|
||||
{
|
||||
return this with { ComponentLibraryBounds = componentLibraryBounds };
|
||||
}
|
||||
|
||||
public DesktopEditSession WithTargetCell(int row, int column)
|
||||
{
|
||||
return this with { TargetRow = row, TargetColumn = column };
|
||||
}
|
||||
|
||||
public DesktopEditSession PromoteToDraggingNew()
|
||||
{
|
||||
return this with { Mode = DesktopEditSessionMode.DraggingNew };
|
||||
}
|
||||
|
||||
public DesktopEditSession PromoteToDraggingExisting()
|
||||
{
|
||||
return this with { Mode = DesktopEditSessionMode.DraggingExisting };
|
||||
}
|
||||
|
||||
public DesktopEditSession PromoteToResizingExisting()
|
||||
{
|
||||
return this with { Mode = DesktopEditSessionMode.ResizingExisting };
|
||||
}
|
||||
|
||||
public static DesktopEditSession CreatePendingNew(
|
||||
string componentId,
|
||||
int pageIndex,
|
||||
int widthCells,
|
||||
int heightCells,
|
||||
Point startPointerInViewport,
|
||||
Point pointerOffsetInViewport,
|
||||
Rect? componentLibraryBounds)
|
||||
{
|
||||
return new DesktopEditSession
|
||||
{
|
||||
Mode = DesktopEditSessionMode.PendingNew,
|
||||
ComponentId = componentId,
|
||||
PageIndex = pageIndex,
|
||||
WidthCells = Math.Max(1, widthCells),
|
||||
HeightCells = Math.Max(1, heightCells),
|
||||
StartPointerInViewport = startPointerInViewport,
|
||||
CurrentPointerInViewport = startPointerInViewport,
|
||||
PointerOffsetInViewport = pointerOffsetInViewport,
|
||||
ComponentLibraryBounds = componentLibraryBounds,
|
||||
TargetRow = -1,
|
||||
TargetColumn = -1
|
||||
};
|
||||
}
|
||||
|
||||
public static DesktopEditSession CreateDraggingNew(
|
||||
string componentId,
|
||||
int pageIndex,
|
||||
int widthCells,
|
||||
int heightCells,
|
||||
Point startPointerInViewport,
|
||||
Point pointerOffsetInViewport,
|
||||
Rect? componentLibraryBounds)
|
||||
{
|
||||
return CreatePendingNew(
|
||||
componentId,
|
||||
pageIndex,
|
||||
widthCells,
|
||||
heightCells,
|
||||
startPointerInViewport,
|
||||
pointerOffsetInViewport,
|
||||
componentLibraryBounds) with
|
||||
{
|
||||
Mode = DesktopEditSessionMode.DraggingNew
|
||||
};
|
||||
}
|
||||
|
||||
public static DesktopEditSession CreateDraggingExisting(
|
||||
string componentId,
|
||||
string placementId,
|
||||
int pageIndex,
|
||||
int widthCells,
|
||||
int heightCells,
|
||||
Point startPointerInViewport,
|
||||
Point pointerOffsetInViewport,
|
||||
Rect? componentLibraryBounds)
|
||||
{
|
||||
return new DesktopEditSession
|
||||
{
|
||||
Mode = DesktopEditSessionMode.DraggingExisting,
|
||||
ComponentId = componentId,
|
||||
PlacementId = placementId,
|
||||
PageIndex = pageIndex,
|
||||
WidthCells = Math.Max(1, widthCells),
|
||||
HeightCells = Math.Max(1, heightCells),
|
||||
StartPointerInViewport = startPointerInViewport,
|
||||
CurrentPointerInViewport = startPointerInViewport,
|
||||
PointerOffsetInViewport = pointerOffsetInViewport,
|
||||
ComponentLibraryBounds = componentLibraryBounds,
|
||||
TargetRow = -1,
|
||||
TargetColumn = -1
|
||||
};
|
||||
}
|
||||
|
||||
public static DesktopEditSession CreateResizingExisting(
|
||||
string componentId,
|
||||
string placementId,
|
||||
int pageIndex,
|
||||
int widthCells,
|
||||
int heightCells,
|
||||
Point startPointerInViewport,
|
||||
Rect? componentLibraryBounds)
|
||||
{
|
||||
return new DesktopEditSession
|
||||
{
|
||||
Mode = DesktopEditSessionMode.ResizingExisting,
|
||||
ComponentId = componentId,
|
||||
PlacementId = placementId,
|
||||
PageIndex = pageIndex,
|
||||
WidthCells = Math.Max(1, widthCells),
|
||||
HeightCells = Math.Max(1, heightCells),
|
||||
StartPointerInViewport = startPointerInViewport,
|
||||
CurrentPointerInViewport = startPointerInViewport,
|
||||
PointerOffsetInViewport = default,
|
||||
ComponentLibraryBounds = componentLibraryBounds,
|
||||
TargetRow = -1,
|
||||
TargetColumn = -1
|
||||
};
|
||||
}
|
||||
}
|
||||
176
LanMountainDesktop/DesktopEditing/DesktopPlacementMath.cs
Normal file
176
LanMountainDesktop/DesktopEditing/DesktopPlacementMath.cs
Normal file
@@ -0,0 +1,176 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
|
||||
namespace LanMountainDesktop.DesktopEditing;
|
||||
|
||||
internal readonly record struct DesktopGridGeometry(
|
||||
Point Origin,
|
||||
double CellSize,
|
||||
double CellGap,
|
||||
int ColumnCount,
|
||||
int RowCount)
|
||||
{
|
||||
public double Pitch => CellSize + CellGap;
|
||||
|
||||
public bool IsValid =>
|
||||
CellSize > 0 &&
|
||||
ColumnCount > 0 &&
|
||||
RowCount > 0 &&
|
||||
Pitch > 0;
|
||||
}
|
||||
|
||||
internal static class DesktopPlacementMath
|
||||
{
|
||||
public static double ComputeDragStartThreshold(double cellSize)
|
||||
{
|
||||
return Math.Max(10d, Math.Max(0d, cellSize) * 0.18d);
|
||||
}
|
||||
|
||||
public static double Distance(Point start, Point end)
|
||||
{
|
||||
return Math.Sqrt(DistanceSquared(start, end));
|
||||
}
|
||||
|
||||
public static double DistanceSquared(Point start, Point end)
|
||||
{
|
||||
var deltaX = end.X - start.X;
|
||||
var deltaY = end.Y - start.Y;
|
||||
return deltaX * deltaX + deltaY * deltaY;
|
||||
}
|
||||
|
||||
public static bool HasExceededThreshold(Point start, Point end, double threshold)
|
||||
{
|
||||
if (threshold <= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return DistanceSquared(start, end) >= threshold * threshold;
|
||||
}
|
||||
|
||||
public static Point Add(Point left, Point right)
|
||||
{
|
||||
return new Point(left.X + right.X, left.Y + right.Y);
|
||||
}
|
||||
|
||||
public static Point Subtract(Point left, Point right)
|
||||
{
|
||||
return new Point(left.X - right.X, left.Y - right.Y);
|
||||
}
|
||||
|
||||
public static bool ContainsPoint(Rect rect, Point point)
|
||||
{
|
||||
return rect.Contains(point);
|
||||
}
|
||||
|
||||
public static bool Intersects(Rect left, Rect right)
|
||||
{
|
||||
return left.Intersects(right);
|
||||
}
|
||||
|
||||
public static bool HasCellPositionChanged(int originalRow, int originalColumn, int targetRow, int targetColumn)
|
||||
{
|
||||
return originalRow != targetRow || originalColumn != targetColumn;
|
||||
}
|
||||
|
||||
public static bool HasCellSpanChanged(int originalWidthCells, int originalHeightCells, int targetWidthCells, int targetHeightCells)
|
||||
{
|
||||
return originalWidthCells != targetWidthCells || originalHeightCells != targetHeightCells;
|
||||
}
|
||||
|
||||
public static bool IsOccludedByComponentLibrary(Point point, Rect? componentLibraryBounds)
|
||||
{
|
||||
return componentLibraryBounds.HasValue && ContainsPoint(componentLibraryBounds.Value, point);
|
||||
}
|
||||
|
||||
public static bool IsOccludedByComponentLibrary(Rect previewRect, Rect? componentLibraryBounds)
|
||||
{
|
||||
return componentLibraryBounds.HasValue && Intersects(previewRect, componentLibraryBounds.Value);
|
||||
}
|
||||
|
||||
public static bool CanCommitPlacement(Rect placementRect, Rect? componentLibraryBounds)
|
||||
{
|
||||
return !IsOccludedByComponentLibrary(placementRect, componentLibraryBounds);
|
||||
}
|
||||
|
||||
public static Rect GetGridBounds(DesktopGridGeometry grid)
|
||||
{
|
||||
if (!grid.IsValid)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
var width = grid.ColumnCount * grid.CellSize + Math.Max(0, grid.ColumnCount - 1) * grid.CellGap;
|
||||
var height = grid.RowCount * grid.CellSize + Math.Max(0, grid.RowCount - 1) * grid.CellGap;
|
||||
return new Rect(grid.Origin, new Size(width, height));
|
||||
}
|
||||
|
||||
public static Rect GetCellRect(
|
||||
DesktopGridGeometry grid,
|
||||
int column,
|
||||
int row,
|
||||
int widthCells = 1,
|
||||
int heightCells = 1)
|
||||
{
|
||||
var safeWidthCells = Math.Max(1, widthCells);
|
||||
var safeHeightCells = Math.Max(1, heightCells);
|
||||
var safeColumn = Math.Max(0, column);
|
||||
var safeRow = Math.Max(0, row);
|
||||
var pitch = grid.Pitch;
|
||||
var x = grid.Origin.X + safeColumn * pitch;
|
||||
var y = grid.Origin.Y + safeRow * pitch;
|
||||
var width = safeWidthCells * grid.CellSize + Math.Max(0, safeWidthCells - 1) * grid.CellGap;
|
||||
var height = safeHeightCells * grid.CellSize + Math.Max(0, safeHeightCells - 1) * grid.CellGap;
|
||||
return new Rect(x, y, width, height);
|
||||
}
|
||||
|
||||
public static Rect GetSnappedCellRect(
|
||||
DesktopGridGeometry grid,
|
||||
Point pointerInViewport,
|
||||
Point pointerOffset,
|
||||
int widthCells,
|
||||
int heightCells)
|
||||
{
|
||||
return TryGetSnappedCell(grid, pointerInViewport, pointerOffset, widthCells, heightCells, out var column, out var row)
|
||||
? GetCellRect(grid, column, row, widthCells, heightCells)
|
||||
: default;
|
||||
}
|
||||
|
||||
public static bool TryGetSnappedCell(
|
||||
DesktopGridGeometry grid,
|
||||
Point pointerInViewport,
|
||||
Point pointerOffset,
|
||||
int widthCells,
|
||||
int heightCells,
|
||||
out int column,
|
||||
out int row)
|
||||
{
|
||||
column = 0;
|
||||
row = 0;
|
||||
|
||||
if (!grid.IsValid)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var safeWidthCells = Math.Max(1, widthCells);
|
||||
var safeHeightCells = Math.Max(1, heightCells);
|
||||
var maxColumn = Math.Max(0, grid.ColumnCount - safeWidthCells);
|
||||
var maxRow = Math.Max(0, grid.RowCount - safeHeightCells);
|
||||
var pitch = grid.Pitch;
|
||||
if (pitch <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var previewOrigin = Subtract(pointerInViewport, pointerOffset);
|
||||
var relativeX = previewOrigin.X - grid.Origin.X;
|
||||
var relativeY = previewOrigin.Y - grid.Origin.Y;
|
||||
|
||||
column = (int)Math.Floor(relativeX / pitch);
|
||||
row = (int)Math.Floor(relativeY / pitch);
|
||||
column = Math.Clamp(column, 0, maxColumn);
|
||||
row = Math.Clamp(row, 0, maxRow);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,12 @@
|
||||
</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.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj" ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
@@ -55,6 +61,10 @@
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" 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="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="MaterialColorUtilities" Version="0.3.0" />
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
"tray.menu.restart": "Restart App",
|
||||
"tray.menu.exit": "Exit App",
|
||||
"button.back_to_windows": "Back to Windows",
|
||||
"button.back_to_platform": "Back to {0}",
|
||||
"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",
|
||||
"settings.title": "Settings",
|
||||
"settings.shell.title": "Settings",
|
||||
@@ -20,7 +25,7 @@
|
||||
"settings.nav.group_system": "System",
|
||||
"settings.nav.group_extensions": "Extensions",
|
||||
"settings.nav.wallpaper": "Wallpaper",
|
||||
"settings.nav.grid": "Grid",
|
||||
"settings.nav.grid": "Components",
|
||||
"settings.nav.color": "Color",
|
||||
"settings.nav.status_bar": "Status Bar",
|
||||
"settings.nav.weather": "Weather",
|
||||
@@ -33,6 +38,27 @@
|
||||
"settings.wallpaper.title": "Wallpaper",
|
||||
"settings.wallpaper.description": "Pick an image or video to apply as the app window wallpaper immediately.",
|
||||
"settings.wallpaper.current_label": "Current Wallpaper",
|
||||
"settings.wallpaper.type_label": "Wallpaper Type",
|
||||
"settings.wallpaper.type.image": "Image",
|
||||
"settings.wallpaper.type.solid_color": "Solid Color",
|
||||
"settings.wallpaper.type.system": "System Wallpaper",
|
||||
"settings.wallpaper.system.label": "System Wallpaper",
|
||||
"settings.wallpaper.system.unavailable": "Unable to read system wallpaper",
|
||||
"settings.wallpaper.refresh_interval": "Refresh Interval",
|
||||
"settings.wallpaper.refresh_now": "Refresh Now",
|
||||
"settings.wallpaper.refresh.30s": "30 seconds",
|
||||
"settings.wallpaper.refresh.1m": "1 minute",
|
||||
"settings.wallpaper.refresh.5m": "5 minutes",
|
||||
"settings.wallpaper.refresh.10m": "10 minutes",
|
||||
"settings.wallpaper.refresh.15m": "15 minutes",
|
||||
"settings.wallpaper.refresh.30m": "30 minutes",
|
||||
"settings.wallpaper.refresh.1h": "1 hour",
|
||||
"settings.wallpaper.refresh.2h": "2 hours",
|
||||
"settings.wallpaper.refresh.4h": "4 hours",
|
||||
"settings.wallpaper.refresh.8h": "8 hours",
|
||||
"settings.wallpaper.refresh.12h": "12 hours",
|
||||
"settings.wallpaper.refresh.24h": "24 hours",
|
||||
"settings.wallpaper.color_label": "Wallpaper Color",
|
||||
"settings.wallpaper.placement_label": "Placement",
|
||||
"settings.wallpaper.placement_desc": "Adjust how the image fills the desktop.",
|
||||
"settings.wallpaper.pick_button": "Browse Files",
|
||||
@@ -86,6 +112,8 @@
|
||||
"settings.status_bar.description": "Choose which components appear on the top status bar.",
|
||||
"settings.status_bar.clock_header": "Clock Component",
|
||||
"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_desc": "Adjust spacing between status bar components.",
|
||||
"settings.status_bar.spacing_mode_compact": "Compact",
|
||||
@@ -210,7 +238,14 @@
|
||||
"schedule.settings.unnamed": "Unnamed Schedule",
|
||||
"schedule.settings.delete": "Delete",
|
||||
"schedule.settings.picker_title": "Select ClassIsland schedule file",
|
||||
"schedule.settings.picker_file_type": "ClassIsland CSES schedule",
|
||||
"schedule.settings.picker_file_type.all": "ClassIsland Schedule Files",
|
||||
"schedule.settings.picker_file_type.json": "ClassIsland Profile (JSON)",
|
||||
"schedule.settings.picker_file_type.cses": "CSES Schedule (YAML)",
|
||||
"schedule.settings.semester.title": "Semester Settings",
|
||||
"schedule.settings.semester.start_date": "Semester Start Date",
|
||||
"schedule.settings.semester.week_cycle": "Week Cycle",
|
||||
"schedule.settings.semester.week_cycle_desc": "Set the week rotation cycle for multi-week schedules (e.g., 2 for odd/even weeks).",
|
||||
"schedule.settings.semester.week_cycle_format": "{0}-week rotation",
|
||||
"worldclock.settings.title": "World Clock Settings",
|
||||
"worldclock.settings.desc": "Choose a time zone for each of the four clocks.",
|
||||
"worldclock.settings.clock_1": "Clock 1",
|
||||
@@ -241,6 +276,7 @@
|
||||
"settings.region.language_label": "Language",
|
||||
"settings.region.language_zh": "Chinese",
|
||||
"settings.region.language_en": "English",
|
||||
"settings.region.language_ja": "Japanese",
|
||||
"settings.region.timezone_header": "Time Zone",
|
||||
"settings.region.timezone_desc": "Select a time zone. Clock and calendar widgets will follow this zone.",
|
||||
"settings.region.applied_format": "Language switched to: {0}",
|
||||
@@ -296,8 +332,17 @@
|
||||
"settings.status_bar.clock_format.hm": "Hour:Minute",
|
||||
"settings.status_bar.clock_format.hms": "Hour:Minute:Second",
|
||||
"settings.components.title": "Components",
|
||||
"settings.components.description": "Adjust desktop grid density and widget placement.",
|
||||
"settings.components.grid_header": "Grid Layout",
|
||||
"settings.components.description": "Adjust component layout and corner design.",
|
||||
"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.current_version_label": "Current Version",
|
||||
"settings.update.latest_version_label": "Latest Release",
|
||||
@@ -403,6 +448,7 @@
|
||||
"common.monet": "Monet",
|
||||
"desktop.page_index_format": "Desktop {0}",
|
||||
"launcher.title": "App Launcher",
|
||||
"launcher.folder": "Folder",
|
||||
"launcher.subtitle": "Apps and folders from Windows Start Menu",
|
||||
"launcher.subtitle_linux": "Installed apps discovered from Linux desktop entries",
|
||||
"launcher.empty": "No Start Menu entries found.",
|
||||
@@ -588,6 +634,19 @@
|
||||
"component.blackboard_landscape": "Blackboard (Landscape)",
|
||||
"component.browser": "Browser",
|
||||
"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.study_environment": "Environment",
|
||||
"component.study_session_control": "Study Session Control",
|
||||
@@ -789,6 +848,20 @@
|
||||
"study.environment.settings.show_display_db": "Show display dB",
|
||||
"study.environment.settings.show_dbfs": "Show dBFS",
|
||||
"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.stop": "Stop Study Session",
|
||||
"study.session_control.idle_hint": "Tap the right button to start",
|
||||
|
||||
973
LanMountainDesktop/Localization/ja-JP.json
Normal file
973
LanMountainDesktop/Localization/ja-JP.json
Normal file
@@ -0,0 +1,973 @@
|
||||
{
|
||||
"app.title": "LanMountainDesktop",
|
||||
"tray.tooltip": "LanMountainDesktop",
|
||||
"tray.menu.show_desktop": "デスクトップを開く",
|
||||
"tray.menu.settings": "設定",
|
||||
"tray.menu.component_library": "ウィジェットライブラリ",
|
||||
"tray.menu.restart": "アプリを再起動",
|
||||
"tray.menu.exit": "アプリを終了",
|
||||
"button.back_to_windows": "Windowsに戻る",
|
||||
"button.back_to_platform": "{0}に戻る",
|
||||
"tooltip.back_to_windows": "Windowsに戻る",
|
||||
"tooltip.back_to_platform": "{0}に戻る",
|
||||
"platform.windows": "Windows",
|
||||
"platform.linux": "Linux",
|
||||
"platform.macos": "macOS",
|
||||
"tooltip.open_settings": "設定",
|
||||
"settings.title": "設定",
|
||||
"settings.shell.title": "設定",
|
||||
"settings.shell.subtitle": "LanMountainDesktop 独立設定モジュール",
|
||||
"settings.shell.sidebar_hint": "カテゴリを選択して、アプリの動作、デスクトップレイアウト、外観を調整します。",
|
||||
"settings.shell.footer_hint": "トレイから開く設定は、この独立設定モジュールで管理されます。",
|
||||
"settings.back_to_desktop": "デスクトップに戻る",
|
||||
"settings.nav_header": "設定",
|
||||
"settings.nav.group_desktop": "デスクトップ",
|
||||
"settings.nav.group_system": "システム",
|
||||
"settings.nav.group_extensions": "拡張機能",
|
||||
"settings.nav.wallpaper": "壁紙",
|
||||
"settings.nav.grid": "コンポーネント",
|
||||
"settings.nav.color": "カラー",
|
||||
"settings.nav.status_bar": "ステータスバー",
|
||||
"settings.nav.weather": "天気",
|
||||
"settings.nav.region": "地域",
|
||||
"settings.nav.update": "アップデート",
|
||||
"settings.nav.privacy": "プライバシー",
|
||||
"settings.nav.launcher": "アプリランチャー",
|
||||
"settings.nav.plugins": "プラグイン",
|
||||
"settings.nav.about": "について",
|
||||
"settings.wallpaper.title": "壁紙",
|
||||
"settings.wallpaper.description": "画像または動画を選択して、アプリウィンドウの壁紙としてすぐに適用します。",
|
||||
"settings.wallpaper.current_label": "現在の壁紙",
|
||||
"settings.wallpaper.type_label": "壁紙タイプ",
|
||||
"settings.wallpaper.type.image": "画像",
|
||||
"settings.wallpaper.type.solid_color": "単色",
|
||||
"settings.wallpaper.type.system": "システム壁紙",
|
||||
"settings.wallpaper.system.label": "システム壁紙",
|
||||
"settings.wallpaper.system.unavailable": "システム壁紙を読み込めません",
|
||||
"settings.wallpaper.refresh_interval": "更新間隔",
|
||||
"settings.wallpaper.refresh_now": "今すぐ更新",
|
||||
"settings.wallpaper.refresh.30s": "30秒",
|
||||
"settings.wallpaper.refresh.1m": "1分",
|
||||
"settings.wallpaper.refresh.5m": "5分",
|
||||
"settings.wallpaper.refresh.10m": "10分",
|
||||
"settings.wallpaper.refresh.15m": "15分",
|
||||
"settings.wallpaper.refresh.30m": "30分",
|
||||
"settings.wallpaper.refresh.1h": "1時間",
|
||||
"settings.wallpaper.refresh.2h": "2時間",
|
||||
"settings.wallpaper.refresh.4h": "4時間",
|
||||
"settings.wallpaper.refresh.8h": "8時間",
|
||||
"settings.wallpaper.refresh.12h": "12時間",
|
||||
"settings.wallpaper.refresh.24h": "24時間",
|
||||
"settings.wallpaper.color_label": "壁紙の色",
|
||||
"settings.wallpaper.placement_label": "配置",
|
||||
"settings.wallpaper.placement_desc": "画像がデスクトップにどのように表示されるかを調整します。",
|
||||
"settings.wallpaper.pick_button": "ファイルを参照",
|
||||
"settings.wallpaper.clear_button": "単色にリセット",
|
||||
"settings.wallpaper.no_selection": "壁紙が選択されていません。",
|
||||
"settings.wallpaper.storage_unavailable": "ストレージプロバイダが利用できません。",
|
||||
"settings.wallpaper.import_failed": "壁紙ファイルのインポートに失敗しました。",
|
||||
"settings.wallpaper.image_applied": "画像の壁紙が適用されました。",
|
||||
"settings.wallpaper.video_applied": "動画の壁紙が適用されました。",
|
||||
"settings.wallpaper.unsupported_file": "選択されたファイルタイプはサポートされていません。",
|
||||
"settings.wallpaper.apply_failed_format": "壁紙の適用に失敗しました: {0}",
|
||||
"settings.wallpaper.mode_format": "壁紙モード: {0}。",
|
||||
"settings.wallpaper.video_mode": "動画の壁紙は自動フィルモードを使用します。",
|
||||
"settings.wallpaper.cleared": "背景が単色にリセットされました。",
|
||||
"settings.wallpaper.default_status": "現在の背景は単色を使用しています。",
|
||||
"settings.wallpaper.saved_not_found": "保存された壁紙ファイルが見つかりません。単色の背景を使用しています。",
|
||||
"settings.wallpaper.restored": "保存された設定から壁紙が復元されました。",
|
||||
"settings.wallpaper.video_restored": "保存された設定から動画の壁紙が復元されました。",
|
||||
"settings.wallpaper.restore_failed": "保存された壁紙の復元に失敗しました。単色の背景を使用しています。",
|
||||
"settings.wallpaper.video_not_found": "動画の壁紙ファイルが見つかりません。",
|
||||
"settings.wallpaper.video_player_unavailable": "動画プレーヤーが利用できません。",
|
||||
"settings.wallpaper.video_play_failed_format": "動画の壁紙の再生に失敗しました: {0}",
|
||||
"settings.grid.title": "グリッドレイアウト",
|
||||
"settings.grid.description": "すべてのコンポーネントは少なくとも1つのセルを占有する必要があります(最小1x1)。",
|
||||
"settings.grid.short_side_label": "短辺のセル数",
|
||||
"settings.grid.spacing_label": "グリッドの間隔",
|
||||
"settings.grid.spacing_relaxed": "ゆとりあり(iOS)",
|
||||
"settings.grid.spacing_compact": "コンパクト(Android)",
|
||||
"settings.grid.edge_inset_label": "画面の余白",
|
||||
"settings.grid.edge_inset_px_format": "≈ {0:F1}px",
|
||||
"settings.grid.apply_button": "適用",
|
||||
"settings.grid.info_format": "グリッド: {0}列 x {1}行 | セル {2:F1}px (1:1)",
|
||||
"settings.color.title": "カラー",
|
||||
"settings.color.description": "昼夜モードを切り替え、アプリのアクセントカラーを選択します。",
|
||||
"settings.color.day_night_label": "昼夜モード",
|
||||
"settings.color.day_night_on": "夜",
|
||||
"settings.color.day_night_off": "昼",
|
||||
"settings.color.recommended_label": "おすすめの色",
|
||||
"settings.color.system_monet_label": "システムMonetカラー",
|
||||
"settings.color.refresh_button": "更新",
|
||||
"settings.color.mode_night": "夜モードが有効",
|
||||
"settings.color.mode_day": "昼モードが有効",
|
||||
"settings.color.mode_status_format": "テーマモード: {0}。",
|
||||
"settings.color.monet_refreshed": "Monetカラーが更新されました。",
|
||||
"settings.color.theme_ready_format": "テーマカラーの準備完了: {0}。",
|
||||
"settings.color.theme_applied_format": "{0}カラーが適用されました: {1}。",
|
||||
"settings.color.theme_updated_wallpaper": "壁紙が更新されました。Monetカラーが更新されました。",
|
||||
"settings.color.theme_updated_video": "動画の壁紙が更新されました。テーマカラーが更新されました。",
|
||||
"settings.color.theme_cleared_wallpaper": "壁紙がクリアされました。Monetカラーが更新されました。",
|
||||
"settings.status_bar.title": "ステータスバー",
|
||||
"settings.status_bar.description": "上部のステータスバーに表示するコンポーネントを選択します。",
|
||||
"settings.status_bar.clock_header": "時計コンポーネント",
|
||||
"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_desc": "ステータスバーコンポーネント間の間隔を調整します。",
|
||||
"settings.status_bar.spacing_mode_compact": "コンパクト",
|
||||
"settings.status_bar.spacing_mode_relaxed": "ゆとりあり",
|
||||
"settings.status_bar.spacing_mode_custom": "カスタム",
|
||||
"settings.status_bar.spacing_custom_label": "カスタム間隔(%)",
|
||||
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
|
||||
"settings.privacy.title": "プライバシー",
|
||||
"settings.privacy.description": "アプリの改善に役立つオプションの匿名アップロードを管理します。",
|
||||
"settings.privacy.crash_upload_title": "匿名クラッシュデータのアップロード",
|
||||
"settings.privacy.crash_upload_description": "アプリケーションの安定性向上にご協力ください。",
|
||||
"settings.privacy.usage_upload_title": "匿名使用データのアップロード",
|
||||
"settings.privacy.usage_upload_description": "アプリケーション機能の改善にご協力ください。",
|
||||
"settings.privacy.device_id_title": "デバイスID",
|
||||
"settings.privacy.device_id_description": "このデバイスの一意識別子。更新をクリックして再生成します。",
|
||||
"settings.privacy.refresh_device_id": "更新",
|
||||
"settings.privacy.policy_hint_prefix": "詳細については、",
|
||||
"settings.privacy.view_policy": "プライバシーポリシーをご覧ください",
|
||||
"settings.weather.title": "天気",
|
||||
"settings.weather.description": "天気の場所、Xiaomi天気プレビュー、起動時の位置情報取得動作を設定します。",
|
||||
"settings.weather.location_source_header": "位置情報ソース",
|
||||
"settings.weather.location_source_desc": "天気ウィジェットが場所を解決する方法を選択します。",
|
||||
"settings.weather.mode_city_search": "都市検索",
|
||||
"settings.weather.mode_coordinates": "座標",
|
||||
"settings.weather.auto_refresh": "起動時に位置情報を自動更新",
|
||||
"settings.weather.city_search_header": "都市検索",
|
||||
"settings.weather.city_search_desc": "都市を検索し、天気の場所を適用します。",
|
||||
"settings.weather.search_placeholder": "例: 東京",
|
||||
"settings.weather.search_button": "検索",
|
||||
"settings.weather.apply_city_button": "都市を適用",
|
||||
"settings.weather.search_hint": "都市名で検索し、場所を適用します。",
|
||||
"settings.weather.search_required": "都市のキーワードを入力してください。",
|
||||
"settings.weather.search_no_results": "場所が見つかりませんでした。",
|
||||
"settings.weather.search_failed_format": "検索に失敗しました: {0}",
|
||||
"settings.weather.search_result_count_format": "{0}件の場所が見つかりました。",
|
||||
"settings.weather.search_select_required": "検索結果から場所を1つ選択してください。",
|
||||
"settings.weather.search_applied_format": "場所が適用されました: {0}",
|
||||
"settings.weather.coordinates_header": "座標",
|
||||
"settings.weather.coordinates_desc": "緯度/経度とオプションのキー/名前を設定します。",
|
||||
"settings.weather.latitude_label": "緯度",
|
||||
"settings.weather.longitude_label": "経度",
|
||||
"settings.weather.location_key_placeholder": "場所キー(オプション)",
|
||||
"settings.weather.location_name_placeholder": "表示名(オプション)",
|
||||
"settings.weather.apply_coordinates_button": "座標を適用",
|
||||
"settings.weather.coordinates_saved_format": "座標が保存されました: {0:F4}, {1:F4}",
|
||||
"settings.weather.coordinates_default_name_format": "座標 {0:F4}, {1:F4}",
|
||||
"settings.weather.location_services_header": "位置情報サービス",
|
||||
"settings.weather.location_services_desc": "現在のWindowsの場所を使用し、起動時に自動的に更新するかどうかを決定します。",
|
||||
"settings.weather.use_current_location": "現在地を使用",
|
||||
"settings.weather.location_unsupported": "現在のプラットフォームは現在地の取得をサポートしていません。",
|
||||
"settings.weather.location_ready": "現在のWindowsの場所を使用できます。",
|
||||
"settings.weather.location_refreshing": "現在地を取得中...",
|
||||
"settings.weather.location_refresh_success_format": "現在地が適用されました: {0}",
|
||||
"settings.weather.location_refresh_failed_format": "現在地の取得に失敗しました: {0}",
|
||||
"settings.weather.preview_header": "接続テスト",
|
||||
"settings.weather.preview_desc": "テストリクエストを送信して現在の設定を確認します。",
|
||||
"settings.weather.preview_button": "テスト取得",
|
||||
"settings.weather.preview_section": "天気プレビュー",
|
||||
"settings.weather.settings_section": "設定",
|
||||
"settings.weather.preview_panel_header": "天気プレビュー",
|
||||
"settings.weather.preview_panel_desc": "現在の天気サービスの状態を更新して確認します。",
|
||||
"settings.weather.refresh_button": "更新",
|
||||
"settings.weather.preview_updated_format": "{0}に更新",
|
||||
"settings.weather.preview_hint": "テスト取得を使用して天気の設定を確認します。",
|
||||
"settings.weather.preview_missing_location": "テストする前に天気の場所を適用してください。",
|
||||
"settings.weather.preview_success_format": "テスト成功: {0} · {1} · {2}",
|
||||
"settings.weather.preview_failed_format": "テスト取得に失敗しました: {0}",
|
||||
"settings.weather.preview_unknown": "不明",
|
||||
"settings.weather.alert_filter_header": "除外するアラート",
|
||||
"settings.weather.alert_filter_desc": "これらの単語を含むアラートは表示されません。1行に1つのルール。",
|
||||
"settings.weather.alert_filter_placeholder": "1行に1つのキーワード",
|
||||
"settings.weather.icon_style_header": "天気アイコンスタイル",
|
||||
"settings.weather.icon_style_desc": "天気シンボルのFluentアイコンスタイルを選択します。",
|
||||
"settings.weather.icon_style_fluent_regular": "Fluent Regular",
|
||||
"settings.weather.icon_style_fluent_filled": "Fluent Filled",
|
||||
"settings.weather.no_tls_header": "TLSなしの天気リクエスト",
|
||||
"settings.weather.no_tls_desc": "推奨されません。互換性のないネットワーク環境でのみ有効にしてください。",
|
||||
"settings.weather.status_city_empty": "都市の場所が設定されていません。",
|
||||
"settings.weather.status_city_format": "モード: {0} | {1} | キー: {2}",
|
||||
"settings.weather.status_coordinates_format": "モード: {0} | 緯度 {1:F4}, 経度 {2:F4} | キー: {3}",
|
||||
"settings.weather.city_selection_label": "都市選択",
|
||||
"settings.weather.coordinates_selection_label": "座標の場所",
|
||||
"settings.weather.location_city_summary_desc": "天気の照会に使用される現在の都市を選択します。",
|
||||
"settings.weather.location_coordinates_summary_desc": "天気の照会に使用される緯度/経度とオプションの場所名を設定します。",
|
||||
"settings.weather.location_not_selected": "場所が選択されていません",
|
||||
"settings.weather.alert_list_label": "除外リスト",
|
||||
"settings.weather.alert_list_desc": "1行に1つの除外ルール。",
|
||||
"settings.weather.no_tls_toggle": "非TLSリクエストのフォールバックを許可",
|
||||
"settings.weather.footer_hint": "デスクトップ天気ウィジェットは、ここで設定された場所とアラート除外設定を再利用します。",
|
||||
"settings.weather.location_header": "天気の場所",
|
||||
"settings.weather.location_desc": "天気ウィジェットで使用する場所を設定します。",
|
||||
"settings.weather.location_placeholder": "例: 東京",
|
||||
"settings.weather.location_apply": "保存",
|
||||
"settings.weather.location_empty": "天気の場所が設定されていません。",
|
||||
"settings.weather.location_required": "天気の場所は空にできません。",
|
||||
"settings.weather.location_current_format": "現在の天気の場所: {0}",
|
||||
"settings.weather.location_saved_format": "天気の場所が保存されました: {0}",
|
||||
"weather.widget.location_not_configured": "天気の場所が設定されていません",
|
||||
"weather.widget.configure_hint": "設定 > 天気を開いて設定",
|
||||
"weather.widget.loading": "読み込み中...",
|
||||
"weather.widget.fetch_failed": "天気の取得に失敗しました",
|
||||
"weather.widget.retrying": "自動的に再試行中",
|
||||
"weather.widget.location_unknown": "不明な場所",
|
||||
"weather.widget.condition_clear": "晴れ",
|
||||
"weather.widget.condition_cloudy": "曇り",
|
||||
"weather.widget.condition_rain": "雨",
|
||||
"weather.widget.condition_storm": "雷雨",
|
||||
"weather.widget.condition_snow": "雪",
|
||||
"weather.widget.condition_fog": "霧",
|
||||
"weather.widget.condition_unknown": "不明",
|
||||
"weather.widget.range_unknown": "-- / --",
|
||||
"weather.widget.range_format": "{0} / {1}",
|
||||
"schedule.widget.no_source": "ClassIslandのスケジュールデータが見つかりません",
|
||||
"schedule.widget.no_class_today": "今日の授業はありません",
|
||||
"schedule.widget.layout_missing": "スケジュールの時間レイアウトがありません",
|
||||
"schedule.widget.subject_fallback": "無題の授業",
|
||||
"schedule.widget.detail_fallback": "詳細なし",
|
||||
"schedule.settings.title": "スケジュールのインポート",
|
||||
"schedule.settings.desc": "ClassIsland CSESスケジュールをインポートし、有効にするものを選択します。",
|
||||
"schedule.settings.add": "スケジュールを追加",
|
||||
"schedule.settings.empty": "インポートされたスケジュールはありません",
|
||||
"schedule.settings.unnamed": "無題のスケジュール",
|
||||
"schedule.settings.delete": "削除",
|
||||
"schedule.settings.picker_title": "ClassIslandスケジュールファイルを選択",
|
||||
"schedule.settings.picker_file_type.all": "ClassIslandスケジュールファイル",
|
||||
"schedule.settings.picker_file_type.json": "ClassIslandプロファイル(JSON)",
|
||||
"schedule.settings.picker_file_type.cses": "CSESスケジュール(YAML)",
|
||||
"schedule.settings.semester.title": "学期設定",
|
||||
"schedule.settings.semester.start_date": "学期開始日",
|
||||
"schedule.settings.semester.week_cycle": "週サイクル",
|
||||
"schedule.settings.semester.week_cycle_desc": "複数週スケジュールの週ローテーションサイクルを設定します(例: 奇数週/偶数週の場合は2)。",
|
||||
"schedule.settings.semester.week_cycle_format": "{0}週ローテーション",
|
||||
"worldclock.settings.title": "世界時計の設定",
|
||||
"worldclock.settings.desc": "4つの時計それぞれのタイムゾーンを選択します。",
|
||||
"worldclock.settings.clock_1": "時計 1",
|
||||
"worldclock.settings.clock_2": "時計 2",
|
||||
"worldclock.settings.clock_3": "時計 3",
|
||||
"worldclock.settings.clock_4": "時計 4",
|
||||
"worldclock.settings.second_mode_label": "秒針",
|
||||
"worldclock.widget.today": "今日",
|
||||
"worldclock.widget.yesterday": "昨日",
|
||||
"worldclock.widget.tomorrow": "明日",
|
||||
"worldclock.widget.offset_same": "0時間",
|
||||
"worldclock.widget.offset_ahead_hours": "{0}時間進む",
|
||||
"worldclock.widget.offset_behind_hours": "{0}時間遅れる",
|
||||
"worldclock.widget.offset_ahead_hm": "{0}時間{1}分進む",
|
||||
"worldclock.widget.offset_behind_hm": "{0}時間{1}分遅れる",
|
||||
"weather.widget.aqi_unknown": "AQI --",
|
||||
"weather.widget.aqi_format": "AQI {0}",
|
||||
"weather.widget.updated_format": "{0:HH:mm}に更新",
|
||||
"weather.hourly.now": "現在",
|
||||
"weather.hourly.sunset": "日没",
|
||||
"weather.multiday.today": "今日",
|
||||
"weather.multiday.tomorrow": "明日",
|
||||
"weather.multiday.aqi_format": "空気質 {0}",
|
||||
"weather.multiday.aqi_unknown": "空気質 --",
|
||||
"settings.region.title": "地域",
|
||||
"settings.region.description": "言語を選択し、設定と主要なUIにすぐに適用します。",
|
||||
"settings.region.language_header": "言語",
|
||||
"settings.region.language_label": "言語",
|
||||
"settings.region.language_zh": "中国語",
|
||||
"settings.region.language_en": "英語",
|
||||
"settings.region.language_ja": "日本語",
|
||||
"settings.region.timezone_header": "タイムゾーン",
|
||||
"settings.region.timezone_desc": "タイムゾーンを選択します。時計とカレンダーウィジェットはこのゾーンに従います。",
|
||||
"settings.region.applied_format": "言語が切り替わりました: {0}",
|
||||
"settings.region.follow_system": "システムの既定に従う",
|
||||
"settings.general.title": "一般",
|
||||
"settings.general.description": "言語、タイムゾーン、ランタイムの動作を調整します。",
|
||||
"settings.general.basic_header": "基本設定",
|
||||
"settings.general.runtime_header": "ランタイム",
|
||||
"settings.general.preview_header": "日時プレビュー",
|
||||
"settings.general.preview_time_label": "時刻",
|
||||
"settings.general.preview_date_label": "日付",
|
||||
"settings.general.render_mode_restart_message": "レンダリングモードの変更にはアプリの再起動が必要です。",
|
||||
"settings.appearance.title": "外観",
|
||||
"settings.appearance.description": "テーマソース、システムマテリアル、ウィンドウクロームを調整します。",
|
||||
"settings.appearance.theme_header": "テーマ",
|
||||
"settings.color.enable_night_mode_toggle": "夜モードを有効にする",
|
||||
"settings.color.use_system_chrome_toggle": "システムのウィンドウクロームを使用",
|
||||
"settings.color.theme_color_label": "テーマのアクセントカラー",
|
||||
"settings.appearance.theme_color_mode_label": "テーマカラーソース",
|
||||
"settings.appearance.theme_color_mode.neutral": "デフォルトニュートラル",
|
||||
"settings.appearance.theme_color_mode.user": "ユーザーテーマカラーMonet",
|
||||
"settings.appearance.theme_color_mode.wallpaper": "壁紙Monet",
|
||||
"settings.appearance.theme_color_mode_desc.neutral": "ライトモードとダークモードにデフォルトの白と黒のニュートラルサーフェスを使用します。",
|
||||
"settings.appearance.theme_color_mode_desc.user": "選択したテーマカラーをシェル全体のMonetシードとして使用します。",
|
||||
"settings.appearance.theme_color_mode_desc.wallpaper": "壁紙の色を使用します。アプリの壁紙が優先され、次にシステムの壁紙が使用されます。",
|
||||
"settings.appearance.theme_color_preview.app": "現在、アプリの壁紙から抽出された色をプレビューしています。",
|
||||
"settings.appearance.theme_color_preview.system": "現在、システムの壁紙から抽出された色をプレビューしています。",
|
||||
"settings.appearance.theme_color_preview.fallback": "使用可能な壁紙が見つかりませんでした。アプリはフォールバックのアクセントを使用しています。",
|
||||
"component.color_scheme.follow_system": "システムのカラースキームに従う",
|
||||
"component.color_scheme.native": "コンポーネントのカスタムカラースキームを使用",
|
||||
"settings.appearance.system_material.none": "なし",
|
||||
"settings.appearance.system_material.mica": "Mica",
|
||||
"settings.appearance.system_material.acrylic": "Acrylic",
|
||||
"settings.appearance.system_material_desc.switchable": "選択したマテリアルをウィンドウ、Dock、ステータスバー、コンポーネントホストに適用します。",
|
||||
"settings.appearance.system_material_desc.fixed": "現在のシステムは、ここにリストされているマテリアルモードのみを公開しています。",
|
||||
"settings.appearance.restart_message": "テーマソースとシステムマテリアルの変更にはアプリの再起動が必要です。",
|
||||
"settings.appearance.preview.primary": "プライマリ",
|
||||
"settings.appearance.preview.secondary": "セカンダリ",
|
||||
"settings.appearance.preview.tertiary": "ターシャリ",
|
||||
"settings.appearance.preview.neutral": "ニュートラル",
|
||||
"settings.appearance.preview.seed": "シード",
|
||||
"settings.appearance.preview.neutral_light": "白",
|
||||
"settings.appearance.preview.neutral_dark": "黒",
|
||||
"settings.appearance.preview.apply_seed": "適用",
|
||||
"settings.appearance.preview.wallpaper_candidates": "壁紙シード候補",
|
||||
"settings.appearance.preview.wallpaper_current": "現在",
|
||||
"settings.wallpaper.placement.fill": "フィル",
|
||||
"settings.wallpaper.placement.fit": "フィット",
|
||||
"settings.wallpaper.placement.stretch": "ストレッチ",
|
||||
"settings.wallpaper.placement.center": "中央",
|
||||
"settings.wallpaper.placement.tile": "タイル",
|
||||
"settings.status_bar.clock_format_label": "時計の形式",
|
||||
"settings.status_bar.clock_format.hm": "時:分",
|
||||
"settings.status_bar.clock_format.hms": "時:分:秒",
|
||||
"settings.components.title": "コンポーネント",
|
||||
"settings.components.description": "コンポーネントのレイアウトとコーナーデザインを調整します。",
|
||||
"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.current_version_label": "現在のバージョン",
|
||||
"settings.update.latest_version_label": "最新リリース",
|
||||
"settings.update.published_at_label": "公開日",
|
||||
"settings.update.options_header": "アップデートオプション",
|
||||
"settings.update.options_desc": "アップデートチェックとリリースチャンネルを設定します。",
|
||||
"settings.update.auto_check_toggle": "起動時に自動的にアップデートを確認",
|
||||
"settings.update.include_prerelease_toggle": "プレリリース版を含める",
|
||||
"settings.update.channel_label": "アップデートチャンネル",
|
||||
"settings.update.channel_stable": "安定版",
|
||||
"settings.update.channel_preview": "プレビュー",
|
||||
"settings.update.actions_header": "アップデートアクション",
|
||||
"settings.update.actions_desc": "リリースを確認し、インストーラーをダウンロードし、アップデートを開始します。",
|
||||
"settings.update.check_button": "アップデートを確認",
|
||||
"settings.update.download_install_button": "ダウンロードしてインストール",
|
||||
"settings.update.download_progress_idle": "ダウンロード進捗: -",
|
||||
"settings.update.download_progress_format": "ダウンロード進捗: {0:F0}%",
|
||||
"settings.update.status_ready": "アップデートを確認する準備ができました。",
|
||||
"settings.update.status_channel_changed": "アップデートチャンネルが変更されました。再度確認してください。",
|
||||
"settings.update.status_channel_changed_format": "アップデートチャンネルが{0}に切り替わりました。再度確認してください。",
|
||||
"settings.update.status_windows_only": "自動インストーラーアップデートは現在Windowsでのみ利用可能です。",
|
||||
"settings.update.status_checking": "GitHubリリースを確認中...",
|
||||
"settings.update.status_check_failed_format": "アップデートの確認に失敗しました: {0}",
|
||||
"settings.update.status_up_to_date": "最新バージョンを使用しています。",
|
||||
"settings.update.status_asset_missing": "新しいリリースが利用可能ですが、互換性のあるインストーラーが見つかりませんでした。",
|
||||
"settings.update.status_available_format": "新しいバージョン{0}が利用可能です。ダウンロードしてインストールをクリックしてください。",
|
||||
"settings.update.status_downloading": "インストーラーをダウンロード中...",
|
||||
"settings.update.status_download_failed_format": "ダウンロードに失敗しました: {0}",
|
||||
"settings.update.status_launching_installer": "ダウンロード完了。インストーラーを起動中...",
|
||||
"settings.update.status_installer_missing": "ダウンロード後にインストーラーファイルが見つかりませんでした。",
|
||||
"settings.update.status_installer_started": "インストーラーが開始されました。アプリはアップデートのために終了します。",
|
||||
"settings.update.status_elevation_cancelled": "管理者権限が付与されませんでした。アップデートはキャンセルされました。",
|
||||
"settings.update.status_launch_failed_format": "インストーラーの起動に失敗しました: {0}",
|
||||
"settings.about.title": "について",
|
||||
"settings.about.version_format": "バージョン: {0}",
|
||||
"settings.about.codename_format": "コードネーム: {0}",
|
||||
"settings.about.font_format": "フォント: {0}",
|
||||
"settings.about.startup_header": "Windowsのスタートアップ",
|
||||
"settings.about.startup_desc": "Windowsへのサインイン時にアプリを自動的に起動します。",
|
||||
"settings.about.startup_toggle": "Windowsサインイン時に起動",
|
||||
"settings.about.render_mode_header": "アプリのレンダリングモード",
|
||||
"settings.about.render_mode_desc": "レンダリングバックエンドを選択します。このオプションを変更した後、アプリを再起動します。サポートされていないモードはソフトウェアにフォールバックします。",
|
||||
"settings.about.render_mode.default": "デフォルト",
|
||||
"settings.about.render_mode.software": "ソフトウェア",
|
||||
"settings.about.render_mode.angle_egl": "angleEgl",
|
||||
"settings.about.render_mode.wgl": "WGL",
|
||||
"settings.about.render_mode.vulkan": "Vulkan",
|
||||
"settings.about.render_mode.unknown": "不明",
|
||||
"settings.about.render_mode.current_label": "現在の実際のバックエンド",
|
||||
"settings.about.render_mode.current_format": "現在のバックエンド: {0}",
|
||||
"settings.about.render_mode.impl_format": "ランタイム実装: {0}",
|
||||
"settings.about.render_mode.impl_unavailable": "ランタイム実装の詳細は利用できません。",
|
||||
"settings.about.description": "アプリケーションの詳細。",
|
||||
"settings.update.description": "リリースを確認し、アップデートチャンネルとダウンロードソースを選択し、アップデートのインストール方法を制御します。",
|
||||
"settings.update.status_card_title": "アップデートステータス",
|
||||
"settings.update.status_card_description": "アップデートを確認し、リリースの詳細を確認し、新しいバージョンが利用可能な場合はダウンロードまたはインストールを続行します。",
|
||||
"settings.update.preferences_header": "アップデート設定",
|
||||
"settings.update.preferences_description": "リリースチャンネル、インストーラーのダウンロードソース、インストール動作、ダウンロードの並列度を選択します。",
|
||||
"settings.update.last_checked_label": "最終確認日時",
|
||||
"settings.update.source_label": "ダウンロードソース",
|
||||
"settings.update.source_github": "GitHub",
|
||||
"settings.update.source_ghproxy": "gh-proxy",
|
||||
"settings.update.source_github_desc": "GitHubからリリースアセットを直接ダウンロードします。",
|
||||
"settings.update.source_ghproxy_desc": "GitHubリリースアセットをダウンロードする際にgh-proxyミラーを使用します。",
|
||||
"settings.update.mode_label": "アップデートモード",
|
||||
"settings.update.mode_manual": "手動アップデート",
|
||||
"settings.update.mode_download_then_confirm": "サイレントダウンロード",
|
||||
"settings.update.mode_silent_on_exit": "サイレントインストール",
|
||||
"settings.update.mode_manual_desc": "アップデートの確認のみ。ダウンロードとインストールのタイミングを決定します。",
|
||||
"settings.update.mode_download_then_confirm_desc": "バックグラウンドでアップデートをダウンロードし、インストール前に確認を求めます。",
|
||||
"settings.update.mode_silent_on_exit_desc": "バックグラウンドでアップデートをダウンロードし、アプリの終了時にインストールします。",
|
||||
"settings.update.channel_stable_desc": "安定ビルドは信頼性を重視し、ほとんどのユーザーにおすすめです。",
|
||||
"settings.update.channel_preview_desc": "プレビュービルドは新しい機能が含まれる可能性がありますが、安定性が低い場合があります。",
|
||||
"settings.update.download_threads_label": "ダウンロードスレッド",
|
||||
"settings.update.download_threads_desc": "アプリケーションのアップデートパッケージの並列ダウンロードスレッド数を設定します。",
|
||||
"settings.update.install_now_button": "今すぐインストール",
|
||||
"settings.update.status_downloaded_confirm": "アップデートがダウンロードされました。確認してインストールのタイミングを選択してください。",
|
||||
"settings.update.status_downloaded_exit": "アップデートがダウンロードされました。アプリの終了時にインストールされます。",
|
||||
"settings.about.app_info_header": "アプリケーション情報",
|
||||
"settings.about.update_header": "アップデート",
|
||||
"settings.about.version_label": "バージョン",
|
||||
"settings.about.codename_label": "コードネーム",
|
||||
"settings.about.render_backend_label": "レンダーバックエンド",
|
||||
"settings.about.render_backend_format": "レンダーバックエンド: {0}",
|
||||
"settings.restart_dialog.title": "再起動が必要",
|
||||
"settings.restart_dialog.render_mode_message": "レンダリングモードを「{0}」から「{1}」に切り替えるには、アプリを再起動します。今すぐ再起動しますか?",
|
||||
"settings.restart_dialog.restart": "今すぐ再起動",
|
||||
"settings.restart_dialog.later": "後で",
|
||||
"settings.restart_dialog.cancel": "キャンセル",
|
||||
"settings.restart_dock.title": "再起動が必要",
|
||||
"settings.restart_dock.description": "一部の変更はアプリの再起動後に有効になります。",
|
||||
"settings.restart_dock.button": "アプリを再起動",
|
||||
"settings.footer": "LanMountainDesktop 設定",
|
||||
"filepicker.title": "壁紙を選択",
|
||||
"filepicker.image_files": "画像ファイル",
|
||||
"filepicker.video_files": "動画ファイル",
|
||||
"common.day": "昼",
|
||||
"common.night": "夜",
|
||||
"common.back": "戻る",
|
||||
"common.close": "閉じる",
|
||||
"common.unknown": "不明なエラー",
|
||||
"common.recommended": "おすすめ",
|
||||
"common.monet": "Monet",
|
||||
"desktop.page_index_format": "デスクトップ {0}",
|
||||
"launcher.title": "アプリランチャー",
|
||||
"launcher.folder": "フォルダ",
|
||||
"launcher.subtitle": "Windowsスタートメニューからのアプリとフォルダ",
|
||||
"launcher.subtitle_linux": "Linuxデスクトップエントリから発見されたインストール済みアプリ",
|
||||
"launcher.empty": "スタートメニューのエントリが見つかりません。",
|
||||
"launcher.empty_linux": "Linuxデスクトップエントリが見つかりませんでした。",
|
||||
"launcher.empty_folder": "このフォルダは空です。",
|
||||
"launcher.folder_items_format": "{0}個のアプリ",
|
||||
"launcher.context.hide_icon": "アイコンを非表示",
|
||||
"launcher.action.hide": "非表示",
|
||||
"settings.launcher.title": "アプリランチャー",
|
||||
"settings.launcher.description": "アプリランチャーの非表示アプリとフォルダを管理します。",
|
||||
"settings.launcher.hidden_header": "非表示アイテム",
|
||||
"settings.launcher.hidden_desc": "非表示のランチャーエントリを確認し、再度表示します。",
|
||||
"settings.launcher.hidden_hint": "デスクトップ編集モードで、ランチャーアイコンを選択して非表示をクリックします。非表示のエントリはここに表示されます。",
|
||||
"settings.launcher.hidden_empty": "非表示アイテムはありません。",
|
||||
"settings.launcher.hidden_summary_format": "{0}個の非表示アイテム",
|
||||
"settings.launcher.hidden_type_folder": "フォルダ",
|
||||
"settings.launcher.hidden_type_shortcut": "アプリ",
|
||||
"settings.launcher.restore_button": "再表示",
|
||||
"settings.plugins.title": "プラグイン",
|
||||
"settings.plugins.runtime_header": "プラグインランタイム",
|
||||
"settings.plugins.runtime_desc": "プラグインランタイムの状態とロード結果を確認します。",
|
||||
"settings.plugins.runtime_hint": "このページには、インストールされたプラグインの発見ステータス、ロード結果、ランタイム診断が表示されます。",
|
||||
"settings.plugins.runtime_status": "プラグインの発見が完了すると、プラグインランタイムのステータスがここに表示されます。",
|
||||
"settings.plugins.description": "インストールされたプラグインを管理し、ランタイムの状態を確認します。",
|
||||
"settings.plugins.initial_status": "プラグインの状態を更新して、最新のインストール済みプラグインを確認してください。",
|
||||
"settings.plugins.refresh_button": "プラグインを更新",
|
||||
"settings.plugins.refresh_success_installed_format": "{0}個のインストール済みプラグインをロードしました。",
|
||||
"settings.plugins.refresh_success_format": "{0}個のインストール済みプラグインと{1}個のマーケットプレイスエントリをロードしました。",
|
||||
"settings.plugins.refresh_failed": "プラグインマーケットインデックスのロードに失敗しました。",
|
||||
"settings.plugins.marketplace_header": "マーケットプレイス",
|
||||
"settings.plugins.marketplace_empty": "現在、マーケットプレイスのプラグインはありません。",
|
||||
"settings.plugins.delete_button_short": "削除",
|
||||
"settings.plugins.install_button_short": "インストール",
|
||||
"settings.plugins.restart_required": "プラグインの変更は再起動後に有効になります。",
|
||||
"settings.plugins.toggle_unchanged_format": "プラグイン「{0}」は変更されませんでした。",
|
||||
"settings.plugins.delete_failed_name_format": "プラグイン「{0}」の削除に失敗しました。",
|
||||
"settings.plugins.install_failed_name_format": "「{0}」のインストールに失敗しました。",
|
||||
"settings.plugins.installed_header": "インストール済みプラグイン",
|
||||
"settings.plugins.installed_desc": "インストール済みプラグインを確認し、ここで削除します。",
|
||||
"settings.plugins.import_header": "パッケージからインストール",
|
||||
"settings.plugins.import_desc": ".laappパッケージを開き、ローカルプラグインディレクトリにステージングします。",
|
||||
"settings.plugins.restart_hint": "プラグインのインストールと削除の変更は、アプリの再起動後に有効になります。",
|
||||
"settings.plugins.empty": "プラグインが見つかりません。",
|
||||
"settings.plugins.runtime_unavailable": "プラグインランタイムは利用できません。",
|
||||
"settings.plugins.summary_format": "{0}個のプラグインを検出; 有効 {1}; ロード済み {2}; 設定ページ {3}; ウィジェット {4}; 失敗 {5}。",
|
||||
"settings.plugins.summary_item_format": "{0} v{1} | {2}",
|
||||
"settings.plugins.state.enabled": "有効",
|
||||
"settings.plugins.state.enabled_failed": "有効 / ロード失敗",
|
||||
"settings.plugins.state.disabled": "無効",
|
||||
"settings.plugins.state.loaded": "ロード済み",
|
||||
"settings.plugins.state.load_failed": "ロード失敗",
|
||||
"settings.plugins.toggle_on": "有効",
|
||||
"settings.plugins.toggle_off": "無効",
|
||||
"settings.plugins.toggle_result_format": "プラグイン「{0}」は次回起動時に{1}になりました。ページとウィジェットの変更を適用するには、アプリを再起動してください。",
|
||||
"settings.plugins.toggle_state_enabled": "有効",
|
||||
"settings.plugins.toggle_state_disabled": "無効",
|
||||
"settings.plugins.toggle_failed_detail_format": "プラグイン「{0}」の更新に失敗しました: {1}",
|
||||
"settings.plugins.install_button": ".laappパッケージを開く",
|
||||
"settings.plugins.install_unavailable": "プラグインランタイムが利用できないため、.laappパッケージをインストールできません。",
|
||||
"settings.plugins.install_hint_format": ".laappパッケージを開いて次にインストールします: {0}",
|
||||
"settings.plugins.install_picker_title": "プラグインパッケージを選択",
|
||||
"settings.plugins.install_file_type": ".laappプラグインパッケージ",
|
||||
"settings.plugins.install_picker_unavailable": "ストレージプロバイダが利用できません。",
|
||||
"settings.plugins.install_copy_failed": "選択した.laappパッケージのコピーに失敗しました。",
|
||||
"settings.plugins.install_success_format": "プラグイン「{0}」がインストールされました。新しく追加された設定ページとウィジェットを適用するには、アプリを再起動してください。",
|
||||
"settings.plugins.install_failed_format": "プラグインパッケージのインストールに失敗しました: {0}",
|
||||
"settings.plugins.delete_button": "プラグインを削除",
|
||||
"settings.plugins.delete_success_format": "プラグイン「{0}」は削除のためにステージングされました。削除を完了するには、アプリを再起動してください。",
|
||||
"settings.plugins.delete_failed_format": "プラグインの削除に失敗しました: {0}",
|
||||
"settings.plugins.delete_failed_detail_format": "プラグイン「{0}」の削除に失敗しました: {1}",
|
||||
"settings.plugins.publisher_format": "パブリッシャー: {0}",
|
||||
"settings.plugins.publisher_unknown": "不明なパブリッシャー",
|
||||
"settings.plugins.source_package": ".laappパッケージ",
|
||||
"settings.plugins.source_manifest": "ルーズマニフェスト",
|
||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||
"settings.plugins.detail_format": "設定ページ: {0} | ウィジェット: {1}",
|
||||
"settings.nav.plugin_market": "プラグインマーケット",
|
||||
"settings.plugin_market.title": "プラグインマーケット",
|
||||
"settings.plugin_market.subtitle": "公式LanAirAppソースからプラグインを参照し、インストールをステージングします。",
|
||||
"settings.plugin_market.unavailable": "プラグインランタイムが利用できないため、公式マーケットを開けません。",
|
||||
"settings.update.status_idle": "アップデートの確認はまだ実行されていません。",
|
||||
"settings.update.status_preferences_saved": "アップデート設定が保存されました。",
|
||||
"settings.update.status_check_failed": "アップデートの確認に失敗しました。",
|
||||
"settings.update.status_available_summary_format": "アップデートあり: {0}(現在: {1})",
|
||||
"settings.update.status_up_to_date_format": "最新版です({0})。",
|
||||
"settings.window.drawer_default": "詳細",
|
||||
"market.toolbar.search_placeholder": "プラグインを検索",
|
||||
"market.toolbar.refresh": "更新",
|
||||
"market.status.loading": "公式プラグインマーケットをロード中...",
|
||||
"market.status.loaded_network_format": "公式ソースから{0}個のプラグインをロードしました。",
|
||||
"market.status.loaded_cache_format": "公式ソースが利用できません。キャッシュから{0}個のプラグインをロードしました。理由: {1}",
|
||||
"market.status.load_failed_format": "プラグインマーケットのロードに失敗しました: {0}",
|
||||
"market.status.installing_format": "プラグイン「{0}」をダウンロードしてステージング中...",
|
||||
"market.status.install_success_format": "プラグイン「{0}」がステージングされました。適用するにはアプリを再起動してください。",
|
||||
"market.status.install_failed_format": "プラグインのインストールに失敗しました: {0}",
|
||||
"market.status.host_incompatible_format": "このホストは古すぎます。バージョン{0}以降が必要です。",
|
||||
"market.list.empty": "プラグインマーケットはまだロードされていません。",
|
||||
"market.list.no_results": "現在の検索に一致するプラグインはありません。",
|
||||
"market.card.subtitle_format": "{0} | v{1}",
|
||||
"market.card.loaded": "ロード済み",
|
||||
"market.card.pending_restart": "再起動が必要",
|
||||
"market.detail.placeholder": "左側のプラグインを選択して詳細を確認します。",
|
||||
"market.detail.author": "パブリッシャー",
|
||||
"market.detail.version": "バージョン",
|
||||
"market.detail.api_version": "APIバージョン",
|
||||
"market.detail.min_host_version": "最小ホストバージョン",
|
||||
"market.detail.installed_version": "インストール済みバージョン",
|
||||
"market.detail.not_installed": "未インストール",
|
||||
"market.detail.readme": "README",
|
||||
"market.detail.plugin_information": "プラグイン情報",
|
||||
"market.detail.author_subtitle_format": "{0}作成",
|
||||
"market.detail.package_size": "パッケージサイズ",
|
||||
"market.detail.published_at": "公開日",
|
||||
"market.detail.updated_at": "更新日",
|
||||
"market.detail.tags": "タグ",
|
||||
"market.detail.project": "プロジェクト",
|
||||
"market.detail.state": "インストール状態",
|
||||
"market.detail.market_source": "マーケットソース",
|
||||
"market.detail.homepage": "ホームページ",
|
||||
"market.detail.repository": "リポジトリ",
|
||||
"market.detail.release_notes": "リリースノート",
|
||||
"market.detail.dependencies": "依存関係",
|
||||
"market.detail.dependencies_empty": "このプラグインは共有コントラクトの依存関係を宣言していません。",
|
||||
"market.detail.readme_loading": "READMEをロード中...",
|
||||
"market.detail.readme_empty": "READMEは空です。",
|
||||
"market.detail.readme_error_format": "READMEをロードできませんでした: {0}",
|
||||
"market.detail.state.not_installed": "未インストール",
|
||||
"market.detail.state.update_available": "アップデートあり",
|
||||
"market.detail.state.installed": "インストール済み",
|
||||
"market.detail.unknown": "不明",
|
||||
"market.button.install": "インストール",
|
||||
"market.button.update": "アップデート",
|
||||
"market.button.installed": "インストール済み",
|
||||
"market.button.installing": "インストール中...",
|
||||
"market.button.restart": "再起動して適用",
|
||||
"button.component_library": "デスクトップを編集",
|
||||
"tooltip.component_library": "デスクトップを編集",
|
||||
"component_library.title": "ウィジェット",
|
||||
"component_library.empty": "スワイプしてカテゴリを選択し、タップして開き、ウィジェットをデスクトップにドラッグします。",
|
||||
"component_library.drag_hint": "ドラッグして配置",
|
||||
"component.delete": "削除",
|
||||
"component.edit": "編集",
|
||||
"component.editor.instance_scope": "変更はこのコンポーネントインスタンスにのみ適用されます。",
|
||||
"component.editor.info_header": "コンポーネント情報",
|
||||
"component.editor.id_label": "コンポーネントID",
|
||||
"component.editor.placement_label": "配置ID",
|
||||
"component.editor.scope_label": "スコープ",
|
||||
"component.editor.scope_instance": "インスタンススコープのエディタ",
|
||||
"component_category.clock": "時計",
|
||||
"component_category.date": "カレンダー",
|
||||
"component_category.weather": "天気",
|
||||
"component_category.board": "ボード",
|
||||
"component_category.media": "メディア",
|
||||
"component_category.info": "情報",
|
||||
"component_category.calculator": "計算機",
|
||||
"component_category.study": "学習",
|
||||
"component_category.file": "ファイル",
|
||||
"component.date": "カレンダー",
|
||||
"component.month_calendar": "月間カレンダー",
|
||||
"component.lunar_calendar": "旧暦カレンダー",
|
||||
"component.desktop_clock": "時計",
|
||||
"component.weather_clock": "天気時計",
|
||||
"component.world_clock": "世界時計",
|
||||
"component.desktop_timer": "タイマー",
|
||||
"component.desktop_weather": "天気",
|
||||
"component.hourly_weather": "時間別天気",
|
||||
"component.multiday_weather": "数日間天気",
|
||||
"component.extended_weather": "拡張天気",
|
||||
"component.class_schedule": "時間割",
|
||||
"component.music_control": "音楽コントロール",
|
||||
"component.audio_recorder": "レコーダー",
|
||||
"component.daily_poetry": "今日の詩",
|
||||
"component.daily_artwork": "今日のアート",
|
||||
"component.daily_word": "今日の言葉",
|
||||
"component.daily_word_2x2": "今日の言葉 2x2",
|
||||
"component.cnr_daily_news": "CNRヘッドライン",
|
||||
"component.ifeng_news": "iFengニュース",
|
||||
"component.bilibili_hot_search": "Bilibiliトレンド",
|
||||
"component.baidu_hot_search": "Baiduトレンド",
|
||||
"component.stcn24_forum": "STCN 24",
|
||||
"component.exchange_rate_converter": "為替レート変換",
|
||||
"component.whiteboard": "黒板(縦向き)",
|
||||
"component.blackboard_landscape": "黒板(横向き)",
|
||||
"component.browser": "ブラウザ",
|
||||
"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相互運用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": "すべてのソースを無効にすると、少なくとも1つのソースが再度有効になるまで、このウィジェットは空のままになります。",
|
||||
"component.removable_storage": "リムーバブルストレージ",
|
||||
"component.holiday_calendar": "祝日カレンダー",
|
||||
"component.study_environment": "環境",
|
||||
"component.study_session_control": "学習セッション制御",
|
||||
"component.study_session_history": "セッション履歴",
|
||||
"component.study_noise_curve": "ノイズカーブ",
|
||||
"component.study_noise_distribution": "ノイズ分布",
|
||||
"component.study_score_overview": "学習スコア概要",
|
||||
"component.study_deduction_reasons": "減点理由",
|
||||
"component.study_interrupt_density": "中断密度",
|
||||
"desktop_clock.settings.title": "時計の設定",
|
||||
"desktop_clock.settings.desc": "単一時計のタイムゾーンを選択します。",
|
||||
"desktop_clock.settings.timezone_label": "タイムゾーン",
|
||||
"desktop_clock.settings.second_mode_label": "秒針",
|
||||
"clock.second_mode.tick": "ティック",
|
||||
"clock.second_mode.sweep": "スイープ",
|
||||
"poetry.widget.loading_content": "詩を読み込み中...",
|
||||
"poetry.widget.loading_author": "読み込み中...",
|
||||
"poetry.widget.fetch_failed": "詩の取得に失敗しました",
|
||||
"poetry.widget.fallback_content": "今日の詩は一時的に利用できません。",
|
||||
"poetry.widget.fallback_author": "後でもう一度お試しください",
|
||||
"poetry.widget.unknown_author": "不明",
|
||||
"artwork.widget.loading": "読み込み中...",
|
||||
"artwork.widget.loading_title": "今日のアート",
|
||||
"artwork.widget.loading_subtitle": "今日の傑作を取得中",
|
||||
"artwork.widget.fetch_failed": "アートの取得に失敗しました",
|
||||
"artwork.widget.fallback_title": "今日のアート",
|
||||
"artwork.widget.fallback_artist": "おすすめサービスは利用できません",
|
||||
"artwork.widget.fallback_year": "後でもう一度お試しください",
|
||||
"artwork.widget.unknown_artist": "不明なアーティスト",
|
||||
"dailyword.widget.loading": "読み込み中...",
|
||||
"dailyword.widget.loading_word": "今日の言葉",
|
||||
"dailyword.widget.loading_pronunciation": "発音を取得中...",
|
||||
"dailyword.widget.loading_meaning": "意味を取得中...",
|
||||
"dailyword.widget.loading_example": "例文を取得中...",
|
||||
"dailyword.widget.loading_example_translation": "読み込み中...",
|
||||
"dailyword.widget.fetch_failed": "今日の言葉の取得に失敗しました",
|
||||
"dailyword.widget.fallback_word": "今日の言葉",
|
||||
"dailyword.widget.fallback_pronunciation": "発音は利用できません",
|
||||
"dailyword.widget.fallback_meaning": "Youdao辞書は一時的に利用できません。",
|
||||
"dailyword.widget.fallback_example": "更新ボタンをタップして再試行してください。",
|
||||
"dailyword.widget.fallback_example_translation": "ネットワークが回復すると再試行します。",
|
||||
"dailyword2x2.widget.tap_to_show": "タップして意味を表示",
|
||||
"cnrnews.widget.loading": "読み込み中...",
|
||||
"cnrnews.widget.loading_title": "CNRヘッドラインを取得中",
|
||||
"cnrnews.widget.loading_subtitle": "お待ちください",
|
||||
"cnrnews.widget.fetch_failed": "ニュースの取得に失敗しました",
|
||||
"cnrnews.widget.fallback_title": "CNRニュースは一時的に利用できません",
|
||||
"cnrnews.widget.fallback_subtitle": "更新をタップして再試行してください",
|
||||
"cnrnews.widget.hot_label": "ホット",
|
||||
"bilihot.widget.brand": "Bilibiliトレンド",
|
||||
"bilihot.widget.top_right_label": "Bilibiliトレンド",
|
||||
"bilihot.widget.search_entry": "検索",
|
||||
"bilihot.widget.search_placeholder": "トレンドトピックを検索",
|
||||
"bilihot.widget.loading": "読み込み中...",
|
||||
"bilihot.widget.loading_item": "読み込み中...",
|
||||
"bilihot.widget.fetch_failed": "トレンドの取得に失敗しました",
|
||||
"bilihot.widget.fallback_item": "トレンドデータなし",
|
||||
"bilihot.widget.more_hot": "もっとトレンドを見る",
|
||||
"baiduhot.widget.brand": "Baiduトレンド",
|
||||
"baiduhot.widget.loading": "読み込み中...",
|
||||
"baiduhot.widget.loading_item": "読み込み中...",
|
||||
"baiduhot.widget.fetch_failed": "トレンドの取得に失敗しました",
|
||||
"baiduhot.widget.fallback_item": "トレンドデータなし",
|
||||
"baiduhot.widget.refresh_tooltip": "更新",
|
||||
"ifeng.widget.brand": "iFengニュース",
|
||||
"ifeng.widget.loading": "読み込み中...",
|
||||
"ifeng.widget.loading_item": "読み込み中...",
|
||||
"ifeng.widget.fetch_failed": "ニュースの取得に失敗しました",
|
||||
"ifeng.widget.fallback_item": "ニュースデータなし",
|
||||
"ifeng.widget.refresh_tooltip": "更新",
|
||||
"dailyword.settings.title": "今日の言葉の設定",
|
||||
"dailyword.settings.desc": "自動更新と更新間隔を設定します。",
|
||||
"dailyword.settings.auto_refresh_label": "自動更新",
|
||||
"dailyword.settings.auto_refresh_enabled": "自動更新を有効にする",
|
||||
"dailyword.settings.frequency_label": "更新間隔",
|
||||
"bilihot.settings.title": "Bilibiliトレンドの設定",
|
||||
"bilihot.settings.desc": "自動更新と更新間隔を設定します。",
|
||||
"bilihot.settings.auto_refresh_label": "自動更新",
|
||||
"bilihot.settings.auto_refresh_enabled": "自動更新を有効にする",
|
||||
"bilihot.settings.frequency_label": "更新間隔",
|
||||
"baiduhot.settings.title": "Baiduトレンドの設定",
|
||||
"baiduhot.settings.desc": "ソース、自動更新、更新間隔を設定します。",
|
||||
"baiduhot.settings.source_label": "データソース",
|
||||
"baiduhot.settings.source_official": "公式ソース",
|
||||
"baiduhot.settings.source_rss": "サードパーティRSS",
|
||||
"baiduhot.settings.auto_refresh_label": "自動更新",
|
||||
"baiduhot.settings.auto_refresh_enabled": "自動更新を有効にする",
|
||||
"baiduhot.settings.frequency_label": "更新間隔",
|
||||
"ifeng.settings.title": "iFengニュースの設定",
|
||||
"ifeng.settings.desc": "チャンネル、自動更新、更新間隔を設定します。",
|
||||
"ifeng.settings.channel_label": "ニュースチャンネル",
|
||||
"ifeng.settings.channel_comprehensive": "総合",
|
||||
"ifeng.settings.channel_mainland": "中国本土",
|
||||
"ifeng.settings.channel_taiwan": "台湾",
|
||||
"ifeng.settings.auto_refresh_label": "自動更新",
|
||||
"ifeng.settings.auto_refresh_enabled": "自動更新を有効にする",
|
||||
"ifeng.settings.frequency_label": "更新間隔",
|
||||
"refresh.frequency.5m": "5分",
|
||||
"refresh.frequency.10m": "10分",
|
||||
"refresh.frequency.12m": "12分",
|
||||
"refresh.frequency.15m": "15分",
|
||||
"refresh.frequency.20m": "20分",
|
||||
"refresh.frequency.30m": "30分",
|
||||
"refresh.frequency.40m": "40分",
|
||||
"refresh.frequency.1h": "1時間",
|
||||
"refresh.frequency.3h": "3時間",
|
||||
"refresh.frequency.6h": "6時間",
|
||||
"refresh.frequency.12h": "12時間",
|
||||
"refresh.frequency.24h": "24時間",
|
||||
"weather.widget.settings.title": "天気ウィジェットの設定",
|
||||
"weather.widget.settings.desc": "すべての天気ウィジェットの自動更新と更新間隔を設定します。",
|
||||
"weather.widget.settings.auto_refresh_label": "自動更新",
|
||||
"weather.widget.settings.auto_refresh_enabled": "自動更新を有効にする",
|
||||
"weather.widget.settings.frequency_label": "更新間隔",
|
||||
"weather.widget.settings.frequency_10m": "10分",
|
||||
"weather.widget.settings.frequency_12m": "12分",
|
||||
"weather.widget.settings.frequency_15m": "15分",
|
||||
"weather.widget.settings.frequency_30m": "30分",
|
||||
"weather.widget.settings.frequency_1h": "1時間",
|
||||
"weather.widget.settings.frequency_3h": "3時間",
|
||||
"stcn24.widget.loading": "読み込み中...",
|
||||
"stcn24.widget.loading_item": "読み込み中...",
|
||||
"stcn24.widget.fetch_failed": "フォーラム投稿の取得に失敗しました",
|
||||
"stcn24.widget.fallback_item": "投稿なし",
|
||||
"stcn24.settings.title": "STCN 24の設定",
|
||||
"stcn24.settings.desc": "情報ソース、自動更新、更新間隔を設定します。",
|
||||
"stcn24.settings.source_label": "情報ソース",
|
||||
"stcn24.settings.source_latest_created": "最新の投稿",
|
||||
"stcn24.settings.source_latest_activity": "最新のアクティビティ",
|
||||
"stcn24.settings.source_most_replies": "返信数順",
|
||||
"stcn24.settings.source_earliest_created": "最古の投稿",
|
||||
"stcn24.settings.source_earliest_activity": "最古のアクティビティ",
|
||||
"stcn24.settings.source_least_replies": "返信が少ない順",
|
||||
"stcn24.settings.source_frontpage_latest": "フロントページ最新",
|
||||
"stcn24.settings.source_frontpage_earliest": "フロントページ最古",
|
||||
"stcn24.settings.auto_refresh_label": "自動更新",
|
||||
"stcn24.settings.auto_refresh_enabled": "自動更新を有効にする",
|
||||
"stcn24.settings.frequency_label": "更新間隔",
|
||||
"stcn24.settings.frequency_5m": "5分",
|
||||
"stcn24.settings.frequency_10m": "10分",
|
||||
"stcn24.settings.frequency_20m": "20分",
|
||||
"stcn24.settings.frequency_30m": "30分",
|
||||
"stcn24.settings.frequency_1h": "1時間",
|
||||
"stcn24.settings.frequency_3h": "3時間",
|
||||
"exchange.widget.loading": "為替レートを読み込み中...",
|
||||
"exchange.widget.fetch_failed": "為替レートの取得に失敗しました",
|
||||
"cnrnews.settings.title": "CNRの設定",
|
||||
"cnrnews.settings.desc": "自動ローテーションと更新間隔を設定します。",
|
||||
"cnrnews.settings.auto_rotate_label": "自動ローテーション",
|
||||
"cnrnews.settings.auto_rotate_enabled": "自動ローテーションを有効にする",
|
||||
"cnrnews.settings.frequency_label": "ローテーション間隔",
|
||||
"cnrnews.settings.frequency_5m": "5分",
|
||||
"cnrnews.settings.frequency_10m": "10分",
|
||||
"cnrnews.settings.frequency_40m": "40分",
|
||||
"cnrnews.settings.frequency_1h": "1時間",
|
||||
"cnrnews.settings.frequency_12h": "12時間",
|
||||
"cnrnews.settings.frequency_24h": "24時間",
|
||||
"artwork.settings.title": "今日のアートの設定",
|
||||
"artwork.settings.desc": "今日のアートで使用されるデータソースを切り替えます。",
|
||||
"artwork.settings.source_label": "ミラーソース",
|
||||
"artwork.settings.source_domestic": "国内ミラー",
|
||||
"artwork.settings.source_overseas": "海外ミラー",
|
||||
"artwork.settings.source_status_domestic": "現在のソース: 国内ミラー(中国ネットワーク向けに最適化)",
|
||||
"artwork.settings.source_status_overseas": "現在のソース: 海外ミラー(美術館のおすすめ)",
|
||||
"music.widget.unsupported": "このプラットフォームでは音楽コントロールはサポートされていません",
|
||||
"music.widget.unsupported_hint": "このウィジェットにはWindows SMTCが必要です",
|
||||
"music.widget.no_session": "音楽ソースなし",
|
||||
"music.widget.no_session_hint": "アプリストアからQQ音楽/酷狗/網易雲音楽をインストールしてください",
|
||||
"music.widget.open_player": "プレーヤーを開く",
|
||||
"music.widget.unknown_title": "不明なタイトル",
|
||||
"music.widget.unknown_artist": "不明なアーティスト",
|
||||
"music.widget.status.opened": "開かれました",
|
||||
"music.widget.status.changing": "変更中",
|
||||
"music.widget.status.stopped": "停止",
|
||||
"music.widget.status.playing": "再生中",
|
||||
"music.widget.status.paused": "一時停止",
|
||||
"recording.widget.title": "レコーダー",
|
||||
"recording.widget.hint.ready": "赤いボタンをタップして録音",
|
||||
"recording.widget.hint.recording": "録音中",
|
||||
"recording.widget.hint.paused": "一時停止",
|
||||
"recording.widget.hint.unsupported": "マイクが利用できません",
|
||||
"recording.widget.hint.error": "録音に失敗しました",
|
||||
"recording.widget.hint.saved_format": "保存しました {0}",
|
||||
"recording.widget.save_picker_title": "録音ファイルを保存",
|
||||
"recording.widget.save_picker_type": "WAVオーディオ",
|
||||
"study.environment.status_label": "環境",
|
||||
"study.environment.status.initializing": "初期化中",
|
||||
"study.environment.status.ready": "準備完了",
|
||||
"study.environment.status.quiet": "静か",
|
||||
"study.environment.status.noisy": "うるさい",
|
||||
"study.environment.status.paused": "一時停止",
|
||||
"study.environment.status.error": "エラー",
|
||||
"study.environment.status.unsupported": "未対応",
|
||||
"study.environment.value.unavailable": "--",
|
||||
"study.environment.value.display_format": "{0:F1} dB",
|
||||
"study.environment.value.dbfs_format": "{0:F1} dBFS",
|
||||
"study.environment.settings.title": "環境ウィジェットの設定",
|
||||
"study.environment.settings.desc": "右側のリアルタイムノイズ値表示を設定します。",
|
||||
"study.environment.settings.show_display_db": "表示dBを表示",
|
||||
"study.environment.settings.show_dbfs": "dBFSを表示",
|
||||
"study.environment.settings.hint": "少なくとも1つの表示モードを有効にしておく必要があります。",
|
||||
"removable_storage.settings.desc": "接続されたUSBドライブを表示し、クイックオープンと取り出しアクションを提供します。",
|
||||
"removable_storage.settings.behavior_title": "動作",
|
||||
"removable_storage.settings.behavior_desc": "ウィジェットはリムーバブルドライブを自動的に監視し、最新の挿入されたUSBドライブに切り替わります。",
|
||||
"removable_storage.action.open": "開く",
|
||||
"removable_storage.action.eject": "取り出し",
|
||||
"removable_storage.widget.default_name": "リムーバブルドライブ",
|
||||
"removable_storage.widget.empty_title": "デバイスが挿入されていません",
|
||||
"removable_storage.widget.empty_subtitle": "USBドライブを挿入してここに表示します。",
|
||||
"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.session_control.action.start": "学習セッションを開始",
|
||||
"study.session_control.action.stop": "学習セッションを停止",
|
||||
"study.session_control.idle_hint": "右のボタンをタップして開始",
|
||||
"study.session_control.report_preview": "レポートをプレビュー",
|
||||
"study.session_control.report_confirm_hint": "右のボタンをタップして確認",
|
||||
"study.session_control.running_elapsed_format": "経過 {0}",
|
||||
"study.session_control.last_session_format": "前回 {0}",
|
||||
"study.session_control.start_failed": "セッションを開始できません",
|
||||
"study.session_control.stop_failed": "セッションを停止できません",
|
||||
"study.session_history.title": "セッション履歴",
|
||||
"study.session_history.empty": "セッション履歴なし",
|
||||
"study.session_history.select_failed": "セッションを切り替えられません",
|
||||
"study.session_history.rename_failed": "セッション名を変更できません",
|
||||
"study.session_history.delete_failed": "セッションを削除できません",
|
||||
"study.session_history.rename_placeholder": "セッション名を入力",
|
||||
"study.session_history.rename_confirm": "名前変更を確認",
|
||||
"study.session_history.rename_cancel": "名前変更をキャンセル",
|
||||
"study.session_history.loading": "データを読み込み中...",
|
||||
"study.session_history.loaded": "データが読み込まれました",
|
||||
"study.session_history.duration_format": "{0:hh\\:mm\\:ss}",
|
||||
"study.session_history.meta_format": "{0} · 平均 {1:F1}",
|
||||
"study.session_history.action.view": "表示",
|
||||
"study.session_history.action.rename": "名前変更",
|
||||
"study.session_history.action.delete": "削除",
|
||||
"study.session_history.dialog.rename_title": "セッション名を変更",
|
||||
"study.session_history.dialog.rename_message": "「{0}」の新しい名前を入力してください。",
|
||||
"study.session_history.dialog.delete_title": "セッションを削除",
|
||||
"study.session_history.dialog.delete_message": "「{0}」を削除しますか?これは元に戻せません。",
|
||||
"study.session_history.dialog.delete_confirm": "削除",
|
||||
"study.noise_curve.value_format": "{0:F1} dB",
|
||||
"study.noise_curve.axis.now": "現在",
|
||||
"study.noise_distribution.title": "ノイズレベル分布",
|
||||
"study.noise_distribution.mode.realtime": "リアルタイム",
|
||||
"study.noise_distribution.mode.session": "セッション",
|
||||
"study.noise_distribution.summary.mainly_format": "主に: {0}",
|
||||
"study.noise_distribution.summary.latest_format": "最新: {0}",
|
||||
"study.noise_distribution.summary.compact_format": "主 {0} · 新 {1}",
|
||||
"study.noise_distribution.level.quiet": "静か",
|
||||
"study.noise_distribution.level.normal": "普通",
|
||||
"study.noise_distribution.level.noisy": "うるさい",
|
||||
"study.noise_distribution.level.extreme": "極端",
|
||||
"study.noise_distribution.axis.extreme": "極端",
|
||||
"study.noise_distribution.axis.noisy": "うるさい",
|
||||
"study.noise_distribution.axis.normal": "普通",
|
||||
"study.noise_distribution.axis.quiet": "静か",
|
||||
"study.noise_distribution.axis.now": "現在",
|
||||
"study.score_overview.title": "学習スコア",
|
||||
"study.score_overview.mode.realtime": "リアルタイム",
|
||||
"study.score_overview.mode.session": "セッション",
|
||||
"study.score_overview.current": "現在",
|
||||
"study.score_overview.average": "平均",
|
||||
"study.score_overview.minimum": "最小",
|
||||
"study.score_overview.maximum": "最大",
|
||||
"study.score_overview.average_short": "平均",
|
||||
"study.score_overview.minimum_short": "最小",
|
||||
"study.score_overview.maximum_short": "最大",
|
||||
"study.score_overview.unavailable": "--",
|
||||
"study.deduction.title": "減点理由",
|
||||
"study.deduction.mode.realtime": "リアルタイム",
|
||||
"study.deduction.mode.session": "セッション",
|
||||
"study.deduction.reason.sustained": "持続ノイズ",
|
||||
"study.deduction.reason.time": "閾値超過時間",
|
||||
"study.deduction.reason.segment": "中断頻度",
|
||||
"study.deduction.reason.sustained_short": "持続",
|
||||
"study.deduction.reason.time_short": "時間",
|
||||
"study.deduction.reason.segment_short": "中断",
|
||||
"study.deduction.metric.sustained_format": "p50 {0:F1} dBFS",
|
||||
"study.deduction.metric.sustained_short_format": "p50 {0:F1}",
|
||||
"study.deduction.metric.time_format": "{0:F1}%超過",
|
||||
"study.deduction.metric.time_short_format": "{0:F1}%",
|
||||
"study.deduction.metric.segment_format": "{0:F1}/分",
|
||||
"study.deduction.metric.segment_short_format": "{0:F1}/分",
|
||||
"study.deduction.loss_format": "-{0:F1}",
|
||||
"study.deduction.total_loss_format": "合計 -{0:F1}",
|
||||
"study.deduction.total_score_format": "スコア {0:F1}",
|
||||
"study.deduction.total_loss_unavailable": "合計 {0}",
|
||||
"study.deduction.total_score_unavailable": "スコア {0}",
|
||||
"study.deduction.unavailable": "--",
|
||||
"study.interrupt_density.title": "中断密度",
|
||||
"study.interrupt_density.mode.realtime": "リアルタイム",
|
||||
"study.interrupt_density.mode.session": "セッション",
|
||||
"study.interrupt_density.unit": "/分",
|
||||
"study.interrupt_density.segment_count": "中断回数",
|
||||
"study.interrupt_density.segment_count_short": "回数",
|
||||
"study.interrupt_density.duration": "期間",
|
||||
"study.interrupt_density.duration_short": "時間",
|
||||
"study.interrupt_density.density_value_format": "{0:F1}",
|
||||
"study.interrupt_density.segment_count_value_format": "{0}",
|
||||
"study.interrupt_density.level_format": "レベル {0}",
|
||||
"study.interrupt_density.level.calm": "穏やか",
|
||||
"study.interrupt_density.level.normal": "普通",
|
||||
"study.interrupt_density.level.frequent": "頻繁",
|
||||
"study.interrupt_density.level.severe": "深刻",
|
||||
"study.interrupt_density.threshold_format": "ペナルティ閾値 {0:F1}/分",
|
||||
"study.interrupt_density.unavailable": "--",
|
||||
"desktop.add_page": "ページを追加",
|
||||
"desktop.delete_page": "ページを削除",
|
||||
"placement.fill": "フィル",
|
||||
"placement.fit": "フィット",
|
||||
"placement.stretch": "ストレッチ",
|
||||
"placement.center": "中央",
|
||||
"placement.tile": "タイル",
|
||||
"single_instance.notice.title": "アプリは既に実行中",
|
||||
"single_instance.notice.description": "アプリは既に実行中です。複数回クリックして開く必要はありません。",
|
||||
"single_instance.notice.button": "OK",
|
||||
"market.status.install_success_restart_format": "✓ プラグイン「{0}」が正常にインストールされました!有効にするには、アプリケーションを再起動してください。",
|
||||
"market.dialog.restart_message_format": "プラグイン「{0}」が正常にインストールされました。\n\nこのプラグインを使用するには、今すぐアプリケーションを再起動する必要があります。\n\n再起動しますか?",
|
||||
"component.settings.color_scheme": "カラースキーム"
|
||||
}
|
||||
@@ -7,7 +7,12 @@
|
||||
"tray.menu.restart": "重启应用",
|
||||
"tray.menu.exit": "退出应用",
|
||||
"button.back_to_windows": "回到Windows",
|
||||
"button.back_to_platform": "回到{0}",
|
||||
"tooltip.back_to_windows": "回到Windows",
|
||||
"tooltip.back_to_platform": "回到{0}",
|
||||
"platform.windows": "Windows",
|
||||
"platform.linux": "Linux",
|
||||
"platform.macos": "macOS",
|
||||
"tooltip.open_settings": "设置",
|
||||
"settings.title": "设置",
|
||||
"settings.shell.title": "设置",
|
||||
@@ -20,7 +25,7 @@
|
||||
"settings.nav.group_system": "系统",
|
||||
"settings.nav.group_extensions": "扩展",
|
||||
"settings.nav.wallpaper": "壁纸",
|
||||
"settings.nav.grid": "网格",
|
||||
"settings.nav.grid": "组件",
|
||||
"settings.nav.color": "颜色",
|
||||
"settings.nav.status_bar": "状态栏",
|
||||
"settings.nav.weather": "天气",
|
||||
@@ -36,6 +41,23 @@
|
||||
"settings.wallpaper.type_label": "壁纸类型",
|
||||
"settings.wallpaper.type.image": "图片",
|
||||
"settings.wallpaper.type.solid_color": "纯色",
|
||||
"settings.wallpaper.type.system": "系统壁纸",
|
||||
"settings.wallpaper.system.label": "系统壁纸",
|
||||
"settings.wallpaper.system.unavailable": "无法读取系统壁纸",
|
||||
"settings.wallpaper.refresh_interval": "刷新频率",
|
||||
"settings.wallpaper.refresh_now": "立即刷新",
|
||||
"settings.wallpaper.refresh.30s": "30 秒",
|
||||
"settings.wallpaper.refresh.1m": "1 分钟",
|
||||
"settings.wallpaper.refresh.5m": "5 分钟",
|
||||
"settings.wallpaper.refresh.10m": "10 分钟",
|
||||
"settings.wallpaper.refresh.15m": "15 分钟",
|
||||
"settings.wallpaper.refresh.30m": "30 分钟",
|
||||
"settings.wallpaper.refresh.1h": "1 小时",
|
||||
"settings.wallpaper.refresh.2h": "2 小时",
|
||||
"settings.wallpaper.refresh.4h": "4 小时",
|
||||
"settings.wallpaper.refresh.8h": "8 小时",
|
||||
"settings.wallpaper.refresh.12h": "12 小时",
|
||||
"settings.wallpaper.refresh.24h": "24 小时",
|
||||
"settings.wallpaper.color_label": "壁纸颜色",
|
||||
"settings.wallpaper.custom_color_tooltip": "自定义颜色",
|
||||
"settings.wallpaper.custom_color_apply": "应用",
|
||||
@@ -85,6 +107,8 @@
|
||||
"settings.status_bar.description": "选择顶部状态栏显示的组件。",
|
||||
"settings.status_bar.clock_header": "时间组件",
|
||||
"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_desc": "调整状态栏组件之间的间距。",
|
||||
"settings.status_bar.spacing_mode_compact": "紧凑",
|
||||
@@ -209,7 +233,14 @@
|
||||
"schedule.settings.unnamed": "未命名课表",
|
||||
"schedule.settings.delete": "删除",
|
||||
"schedule.settings.picker_title": "选择 ClassIsland 课表文件",
|
||||
"schedule.settings.picker_file_type": "ClassIsland CSES 课表",
|
||||
"schedule.settings.picker_file_type.all": "ClassIsland 课表文件",
|
||||
"schedule.settings.picker_file_type.json": "ClassIsland 档案 (JSON)",
|
||||
"schedule.settings.picker_file_type.cses": "CSES 课表 (YAML)",
|
||||
"schedule.settings.semester.title": "学期设置",
|
||||
"schedule.settings.semester.start_date": "学期开始日期",
|
||||
"schedule.settings.semester.week_cycle": "周循环",
|
||||
"schedule.settings.semester.week_cycle_desc": "设置多周课表轮换周期,用于计算当前是第几周。",
|
||||
"schedule.settings.semester.week_cycle_format": "{0} 周轮换",
|
||||
"worldclock.settings.title": "世界时钟设置",
|
||||
"worldclock.settings.desc": "分别为四个时钟选择时区。",
|
||||
"worldclock.settings.clock_1": "时钟 1",
|
||||
@@ -240,6 +271,7 @@
|
||||
"settings.region.language_label": "语言",
|
||||
"settings.region.language_zh": "中文",
|
||||
"settings.region.language_en": "英文",
|
||||
"settings.region.language_ja": "日文",
|
||||
"settings.region.timezone_header": "时区",
|
||||
"settings.region.timezone_desc": "选择时区。时钟与日历组件会使用该时区。",
|
||||
"settings.region.applied_format": "语言已切换为:{0}",
|
||||
@@ -294,9 +326,18 @@
|
||||
"settings.status_bar.clock_format_label": "时钟格式",
|
||||
"settings.status_bar.clock_format.hm": "时:分",
|
||||
"settings.status_bar.clock_format.hms": "时:分:秒",
|
||||
"settings.components.title": "网格",
|
||||
"settings.components.description": "调整桌面网格与布局。",
|
||||
"settings.components.grid_header": "网格布局",
|
||||
"settings.components.title": "组件",
|
||||
"settings.components.description": "调整组件布局与圆角设计。",
|
||||
"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.current_version_label": "当前版本",
|
||||
"settings.update.latest_version_label": "最新发布",
|
||||
@@ -401,6 +442,7 @@
|
||||
"common.monet": "莫奈",
|
||||
"desktop.page_index_format": "桌面 {0}",
|
||||
"launcher.title": "应用启动台",
|
||||
"launcher.folder": "文件夹",
|
||||
"launcher.subtitle": "按 Windows 开始菜单结构显示所有应用与文件夹",
|
||||
"launcher.subtitle_linux": "显示从 Linux .desktop 条目扫描到的已安装应用",
|
||||
"launcher.empty": "未找到开始菜单条目。",
|
||||
@@ -586,6 +628,18 @@
|
||||
"component.blackboard_landscape": "横向小黑板",
|
||||
"component.browser": "浏览器",
|
||||
"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.study_environment": "环境",
|
||||
"component.study_session_control": "自习时段控制",
|
||||
@@ -782,6 +836,21 @@
|
||||
"study.environment.value.unavailable": "--",
|
||||
"study.environment.value.display_format": "{0:F1} dB",
|
||||
"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.desc": "配置右侧实时噪音值显示内容。",
|
||||
"study.environment.settings.show_display_db": "显示 display dB",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
@@ -16,6 +17,8 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public bool UseSystemChrome { get; set; }
|
||||
|
||||
public double GlobalCornerRadiusScale { get; set; } = GlobalAppearanceSettings.DefaultCornerRadiusScale;
|
||||
|
||||
public string ThemeColorMode { get; set; } = "default_neutral";
|
||||
|
||||
public string SystemMaterialMode { get; set; } = "none";
|
||||
@@ -30,6 +33,8 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public string WallpaperPlacement { get; set; } = "Fill";
|
||||
|
||||
public int SystemWallpaperRefreshIntervalSeconds { get; set; } = 300;
|
||||
|
||||
public int SettingsTabIndex { get; set; } = 0;
|
||||
|
||||
public string? SettingsTabTag { get; set; }
|
||||
@@ -68,9 +73,11 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public bool UploadAnonymousUsageData { get; set; }
|
||||
|
||||
public string? DeviceId { get; set; }
|
||||
public string? TelemetryInstallId { get; set; }
|
||||
|
||||
public string? PersistentUserId { get; set; }
|
||||
public string? TelemetryId { get; set; }
|
||||
|
||||
public bool HasReportedTelemetryBaseline { get; set; }
|
||||
|
||||
public string UpdateChannel { get; set; } = "stable";
|
||||
|
||||
@@ -101,6 +108,8 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public string ClockDisplayFormat { get; set; } = "HourMinuteSecond";
|
||||
|
||||
public bool StatusBarClockTransparentBackground { get; set; }
|
||||
|
||||
public string StatusBarSpacingMode { get; set; } = "Relaxed";
|
||||
|
||||
public int StatusBarCustomSpacingPercent { get; set; } = 12;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
@@ -12,6 +13,10 @@ public sealed class ComponentSettingsSnapshot
|
||||
|
||||
public string ActiveImportedClassScheduleId { get; set; } = string.Empty;
|
||||
|
||||
public DateOnly? SemesterStartDate { get; set; }
|
||||
|
||||
public int SemesterWeekCycle { get; set; } = 1;
|
||||
|
||||
public bool StudyEnvironmentShowDisplayDb { get; set; } = true;
|
||||
|
||||
public bool StudyEnvironmentShowDbfs { get; set; }
|
||||
@@ -58,12 +63,16 @@ public sealed class ComponentSettingsSnapshot
|
||||
|
||||
public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12;
|
||||
|
||||
public int WhiteboardNoteRetentionDays { get; set; } = 15;
|
||||
|
||||
public bool Stcn24ForumAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int Stcn24ForumAutoRefreshIntervalMinutes { get; set; } = 20;
|
||||
|
||||
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
|
||||
|
||||
public List<string>? OfficeRecentDocumentsEnabledSources { get; set; }
|
||||
|
||||
public ComponentSettingsSnapshot Clone()
|
||||
{
|
||||
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
|
||||
@@ -91,6 +100,9 @@ public sealed class ComponentSettingsSnapshot
|
||||
clone.WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 }
|
||||
? new List<string>(WorldClockTimeZoneIds)
|
||||
: [];
|
||||
clone.OfficeRecentDocumentsEnabledSources = OfficeRecentDocumentsEnabledSources is not null
|
||||
? new List<string>(OfficeRecentDocumentsEnabledSources)
|
||||
: null;
|
||||
|
||||
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,14 +4,14 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.WebView.Desktop;
|
||||
using LanMountainDesktop.DesktopHost;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using Sentry;
|
||||
|
||||
namespace LanMountainDesktop;
|
||||
|
||||
sealed class Program
|
||||
public sealed class Program
|
||||
{
|
||||
internal static string StartupRenderMode { get; private set; } = AppRenderingModeHelper.Default;
|
||||
|
||||
@@ -20,9 +20,6 @@ sealed class Program
|
||||
{
|
||||
AppLogger.Initialize();
|
||||
RegisterGlobalExceptionLogging();
|
||||
InitializeDeviceId();
|
||||
InitializeCrashReporting();
|
||||
InitializeUserBehaviorAnalytics();
|
||||
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
|
||||
|
||||
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
|
||||
@@ -41,6 +38,12 @@ sealed class Program
|
||||
return;
|
||||
}
|
||||
|
||||
DesktopBootstrap.InitializeStartupServices(
|
||||
InitializeTelemetryIdentity,
|
||||
InitializeCrashTelemetry,
|
||||
InitializeUsageTelemetry,
|
||||
ScheduleWhiteboardNoteStartupCleanup);
|
||||
|
||||
var diagnostics = StartupDiagnosticsService.Run(args);
|
||||
StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics);
|
||||
|
||||
@@ -50,7 +53,6 @@ sealed class Program
|
||||
StartupRenderMode = renderMode;
|
||||
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
|
||||
App.CurrentSingleInstanceService = singleInstance;
|
||||
App.AnalyticsServices = (_userBehaviorAnalyticsService, _crashReportService);
|
||||
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
|
||||
AppLogger.Info("Startup", "Application exited normally.");
|
||||
}
|
||||
@@ -65,7 +67,12 @@ sealed class Program
|
||||
}
|
||||
}
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp(string renderMode = AppRenderingModeHelper.Default)
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
{
|
||||
return BuildAvaloniaApp(AppRenderingModeHelper.Default);
|
||||
}
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp(string renderMode)
|
||||
{
|
||||
var builder = AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
@@ -88,6 +95,25 @@ sealed class Program
|
||||
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)
|
||||
{
|
||||
var singleInstance = SingleInstanceService.CreateDefault();
|
||||
@@ -163,204 +189,90 @@ sealed class Program
|
||||
{
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
|
||||
{
|
||||
var exception = eventArgs.ExceptionObject as Exception
|
||||
?? new Exception(eventArgs.ExceptionObject?.ToString() ?? "Unhandled exception.");
|
||||
|
||||
AppLogger.Critical(
|
||||
"UnhandledException",
|
||||
$"Unhandled exception. IsTerminating={eventArgs.IsTerminating}",
|
||||
eventArgs.ExceptionObject as Exception);
|
||||
exception);
|
||||
|
||||
if (eventArgs.IsTerminating)
|
||||
try
|
||||
{
|
||||
SentrySdk.Flush(TimeSpan.FromSeconds(5));
|
||||
TelemetryServices.Crash?.CaptureUnhandledException(
|
||||
exception,
|
||||
"AppDomain.UnhandledException",
|
||||
eventArgs.IsTerminating);
|
||||
}
|
||||
catch (Exception telemetryException)
|
||||
{
|
||||
AppLogger.Warn("UnhandledException", "Failed to forward unhandled exception to crash telemetry.", telemetryException);
|
||||
}
|
||||
};
|
||||
|
||||
TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
|
||||
{
|
||||
AppLogger.Error("TaskScheduler", "Unobserved task exception.", eventArgs.Exception);
|
||||
|
||||
try
|
||||
{
|
||||
TelemetryServices.Crash?.CaptureTaskException(
|
||||
eventArgs.Exception,
|
||||
"TaskScheduler.UnobservedTaskException");
|
||||
}
|
||||
catch (Exception telemetryException)
|
||||
{
|
||||
AppLogger.Warn("TaskScheduler", "Failed to forward task exception to crash telemetry.", telemetryException);
|
||||
}
|
||||
|
||||
eventArgs.SetObserved();
|
||||
};
|
||||
}
|
||||
|
||||
private static void InitializeDeviceId()
|
||||
private static void InitializeTelemetryIdentity()
|
||||
{
|
||||
try
|
||||
{
|
||||
DeviceIdService.Initialize(HostSettingsFacadeProvider.GetOrCreate());
|
||||
AppLogger.Info("Startup", $"DeviceId initialized: {DeviceIdService.Instance.DeviceId}");
|
||||
TelemetryIdentityService.Initialize(HostSettingsFacadeProvider.GetOrCreate());
|
||||
AppLogger.Info(
|
||||
"Startup",
|
||||
$"Telemetry identity initialized. InstallId={TelemetryIdentityService.Instance.InstallId}; TelemetryId={TelemetryIdentityService.Instance.TelemetryId}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Startup", "Failed to initialize DeviceIdService.", ex);
|
||||
AppLogger.Warn("Startup", "Failed to initialize telemetry identity service.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void InitializeSentryForAnalytics()
|
||||
{
|
||||
try
|
||||
{
|
||||
var deviceId = DeviceIdService.Instance.DeviceId;
|
||||
|
||||
SentrySdk.Init(options =>
|
||||
{
|
||||
options.Dsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504";
|
||||
options.AutoSessionTracking = true;
|
||||
options.Release = GetAppVersion();
|
||||
options.Environment = GetEnvironment();
|
||||
});
|
||||
|
||||
SentrySdk.ConfigureScope(scope =>
|
||||
{
|
||||
scope.User = new SentryUser
|
||||
{
|
||||
Id = deviceId
|
||||
};
|
||||
|
||||
scope.SetTag("data_type", "analytics");
|
||||
scope.SetTag("device_id", deviceId);
|
||||
scope.SetTag("app_version", GetAppVersion());
|
||||
scope.SetTag("os_name", GetOsName());
|
||||
scope.SetTag("os_version", GetOsVersion());
|
||||
scope.SetTag("os_build", GetOsBuild());
|
||||
scope.SetTag("device_model", GetDeviceModel());
|
||||
scope.SetTag("device_arch", GetDeviceArchitecture());
|
||||
scope.SetTag("processor_count", GetProcessorCount().ToString());
|
||||
scope.SetTag("total_memory_mb", GetTotalMemoryMB().ToString());
|
||||
scope.SetTag("runtime_version", GetRuntimeVersion());
|
||||
scope.SetTag("language", GetSystemLanguage());
|
||||
scope.SetTag("clr_version", GetClrVersion());
|
||||
scope.SetTag("is_64bit", Environment.Is64BitOperatingSystem.ToString());
|
||||
});
|
||||
|
||||
SentrySdk.CaptureMessage("user_active");
|
||||
|
||||
AppLogger.Info("Startup", $"Analytics service initialized. DeviceId={deviceId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Startup", "Failed to initialize analytics service.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetAppVersion()
|
||||
{
|
||||
var version = typeof(Program).Assembly.GetName().Version;
|
||||
return version is null ? "1.0.0" : $"{version.Major}.{version.Minor}.{version.Build}";
|
||||
}
|
||||
|
||||
private static string GetOsName()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return "Windows";
|
||||
if (OperatingSystem.IsLinux()) return "Linux";
|
||||
if (OperatingSystem.IsMacOS()) return "macOS";
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
private static string GetOsVersion()
|
||||
{
|
||||
try { return Environment.OSVersion.VersionString ?? "Unknown"; }
|
||||
catch { return "Unknown"; }
|
||||
}
|
||||
|
||||
private static string GetOsBuild()
|
||||
{
|
||||
try { return Environment.OSVersion.Version.Build.ToString() ?? "Unknown"; }
|
||||
catch { return "Unknown"; }
|
||||
}
|
||||
|
||||
private static string GetDeviceName()
|
||||
{
|
||||
try { return Environment.MachineName ?? "Unknown"; }
|
||||
catch { return "Unknown"; }
|
||||
}
|
||||
|
||||
private static string GetDeviceModel()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return "Windows PC";
|
||||
if (OperatingSystem.IsLinux()) return "Linux PC";
|
||||
if (OperatingSystem.IsMacOS()) return "Mac";
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
private static string GetDeviceArchitecture()
|
||||
{
|
||||
return Environment.Is64BitOperatingSystem ? "x64" : "x86";
|
||||
}
|
||||
|
||||
private static int GetProcessorCount()
|
||||
{
|
||||
return Environment.ProcessorCount;
|
||||
}
|
||||
|
||||
private static long GetTotalMemoryMB()
|
||||
{
|
||||
try { return GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / (1024 * 1024); }
|
||||
catch { return 0; }
|
||||
}
|
||||
|
||||
private static string GetRuntimeVersion()
|
||||
{
|
||||
return Environment.Version.ToString();
|
||||
}
|
||||
|
||||
private static string GetSystemLanguage()
|
||||
{
|
||||
try { return System.Globalization.CultureInfo.CurrentUICulture.Name ?? "en-US"; }
|
||||
catch { return "en-US"; }
|
||||
}
|
||||
|
||||
private static string GetClrVersion()
|
||||
{
|
||||
return Environment.Version.ToString();
|
||||
}
|
||||
|
||||
private static CrashReportService? _crashReportService;
|
||||
private static UserBehaviorAnalyticsService? _userBehaviorAnalyticsService;
|
||||
|
||||
private static void InitializeCrashReporting()
|
||||
private static void InitializeCrashTelemetry()
|
||||
{
|
||||
try
|
||||
{
|
||||
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
_crashReportService = new CrashReportService(settingsFacade, DeviceIdService.Instance);
|
||||
_crashReportService.RefreshEnabledState();
|
||||
var crashTelemetry = new SentryCrashTelemetryService(settingsFacade);
|
||||
TelemetryServices.Crash = crashTelemetry;
|
||||
crashTelemetry.Initialize();
|
||||
AppLogger.Info("Startup", $"Crash telemetry initialized. Enabled={crashTelemetry.IsEnabled}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Startup", "Failed to initialize crash reporting service.", ex);
|
||||
AppLogger.Warn("Startup", "Failed to initialize crash telemetry service.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void InitializeUserBehaviorAnalytics()
|
||||
private static void InitializeUsageTelemetry()
|
||||
{
|
||||
try
|
||||
{
|
||||
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
_userBehaviorAnalyticsService = new UserBehaviorAnalyticsService(settingsFacade, DeviceIdService.Instance);
|
||||
_userBehaviorAnalyticsService.Initialize();
|
||||
var usageTelemetry = new PostHogUsageTelemetryService(settingsFacade);
|
||||
TelemetryServices.Usage = usageTelemetry;
|
||||
usageTelemetry.Initialize();
|
||||
AppLogger.Info("Startup", $"Usage telemetry initialized. Enabled={usageTelemetry.IsUsageEnabled}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Startup", "Failed to initialize user behavior analytics service.", ex);
|
||||
AppLogger.Warn("Startup", "Failed to initialize usage telemetry service.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetReleaseVersion()
|
||||
{
|
||||
var assembly = typeof(Program).Assembly;
|
||||
var version = assembly.GetName().Version;
|
||||
if (version is null)
|
||||
{
|
||||
return "1.0.0";
|
||||
}
|
||||
return version.Major >= 0 ? $"{version.Major}.{version.Minor}.{version.Build}" : "1.0.0";
|
||||
}
|
||||
|
||||
private static string GetEnvironment()
|
||||
{
|
||||
#if DEBUG
|
||||
return "development";
|
||||
#else
|
||||
return "production";
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user