Compare commits

...

4 Commits

Author SHA1 Message Date
lincube
e1be072b97 0.5.11
插件市场UI优化
2026-03-10 16:35:43 +08:00
lincube
4df740e3df 0.5.10
多线程
2026-03-10 14:56:05 +08:00
lincube
85f7a18cbc 0.5.9
中文与插件市场
2026-03-10 12:14:49 +08:00
lincube
cdffaa16eb 0.5.8
插件市场
2026-03-10 09:55:49 +08:00
63 changed files with 5337 additions and 1844 deletions

View File

@@ -0,0 +1,27 @@
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

View File

@@ -1,26 +1,21 @@
# LanAirApp
`LanAirApp` 是阑山桌面插件生态的对外发布工作区。
## 中文
这里集中放置:
- 插件开发标准
- 插件打包与构建工具
- 插件开发与打包文档
- 示例插件
`LanAirApp` 是阑山桌面插件生态的对外工作区。这个目录是宿主仓库中的镜像副本,权威版本以独立 `LanAirApp` 仓库为准。
目录结构:
- `docs/`:插件开发文档、打包文档
- `releases/`:已经打包完成、可直接分享与安装的 `.laapp` 插件包
- `samples/`:示例插件,其中 `LanMountainDesktop.SamplePlugin` 是示例开发插件
- `standards/`:插件标准文件与模板
- `tools/`:插件打包与构建工具
### 目录说明
面向用户的安装流程:
1. 将插件构建或打包为 `.laapp` 文件
2. 打开 `设置 -> 插件`
3. 点击 `打开 .laapp 插件包`
4. 选择插件包完成安装。
- `docs/`:插件开发与打包文档。
- `samples/`:示例插件与参考项目
- `standards/`:插件清单和目录结构约定
- `tools/`:插件打包与辅助工具
宿主侧的插件加载、安装、发现、解析与设置页接入逻辑,保留在 `LanMountainDesktop/plugins/`
### 与宿主的关系
`LanMountainDesktop.PluginSdk` 仅作为插件开发 SDK 使用,提供 `IPlugin``IPluginContext`、清单模型与扩展注册接口。
- 宿主程序只连接独立 `LanAirApp` 仓库中的官方市场索引。
- 每个插件项目应在仓库根目录提供 `.laapp``README.md`
## English
`LanAirApp` is the external-facing workspace for the LanMountainDesktop plugin ecosystem. This copy is only a mirror inside the host repository; the standalone `LanAirApp` repository remains the source of truth.

View File

@@ -1,41 +1,16 @@
# 插件开发文档
# 插件开发指南
LanMountainDesktop 插件基于 `LanMountainDesktop.PluginSdk` 开发。
## 中文
`LanAirApp/` 负责对外发布插件开发标准、示例插件和打包工具;宿主应用内部的插件加载与解析逻辑位于 `LanMountainDesktop/plugins/`
`LanMountainDesktop.PluginSdk` 只提供插件作者需要依赖的开发契约,不再承载宿主侧运行时加载实现。
使用 `LanMountainDesktop.PluginSdk` 开发插件时,至少需要准备:
## 必需文件
- `plugin.json`
- `plugin.json` 中声明的入口程序集
- 使用插件入口特性标记的入口类
- 插件入口程序集
- 入口类
- 本地化资源
## 推荐开发流程
1.`LanAirApp/samples/LanMountainDesktop.SamplePlugin` 为起点。
2. 修改 `plugin.json`,填写你自己的插件 `id`、名称、作者、版本和入口程序集。
3. 实现 `IPlugin` 或继承 `PluginBase`
4. 通过 `IPluginContext` 注册服务、设置页和桌面组件。
5. 将输出内容打包为 `.laapp` 文件。
推荐从示例插件开始,先完成清单、入口、设置页和桌面组件,再逐步扩展业务逻辑。
## 运行时能力
- 插件可以注册自己的设置页。
- 插件可以注册自己的桌面组件。
- 插件可以注册自己的服务,并通过插件消息总线进行通信。
- 宿主优先加载 `.laapp` 包,其次才是散装清单。
## English
## 多语言建议
- 插件应当内置 `Localization/zh-CN.json``Localization/en-US.json`
- 插件界面文案、组件文案、状态文案建议统一通过插件本地化层读取。
- 建议优先读取宿主传入的语言代码,再回退到插件默认语言。
## 目录建议
一个标准插件项目建议至少包含:
- `plugin.json`
- `Localization/zh-CN.json`
- `Localization/en-US.json`
- 插件程序集与依赖文件
## 示例项目与工具
- 示例插件:`LanAirApp/samples/LanMountainDesktop.SamplePlugin`
- 打包工具:`LanAirApp/tools/LanMountainDesktop.PluginPackager`
- 标准模板:`LanAirApp/standards/plugin.template.json`
To build a plugin with `LanMountainDesktop.PluginSdk`, prepare the manifest, plugin assembly, entrance class, and localization resources first.

View File

@@ -1,34 +1,14 @@
# 插件打包文档
# 插件打包指南
LanMountainDesktop 插件的安装包格式固定为 `.laapp`
## 中文
`LanAirApp/` 负责提供打包标准与打包工具;`.laapp` 的安装、发现和运行时加载由 `LanMountainDesktop/plugins/` 负责。
阑山桌面插件的标准安装格式为 `.laapp`。插件项目应在仓库根目录提供:
## `.laapp` 格式说明
- 本质上是一个标准 zip 压缩包
- 包根目录必须包含 `plugin.json`
- 包根目录还必须包含入口程序集及其依赖
- `.laapp` 安装包
- `README.md`
## 建议打包内容
- `plugin.json`
- `YourPlugin.dll`
- 依赖程序集
- `Localization/zh-CN.json`
- `Localization/en-US.json`
- 插件运行所需的其他资源文件
官方市场索引只负责记录链接和校验信息。
## 使用打包工具
```powershell
dotnet run --project .\LanAirApp\tools\LanMountainDesktop.PluginPackager -- --input .\path\to\plugin-output --output .\artifacts\YourPlugin.laapp --overwrite
```
## English
## 应用内安装流程
1. 打开 `设置 -> 插件`
2. 点击 `打开 .laapp 插件包`
3. 选择要安装的插件包
4. 如果插件注册了设置页或组件,安装后重启应用
## 注意事项
- `plugin.json` 中的 `entranceAssembly` 必须能在包内找到。
- 包内应尽量避免无关开发产物。
- `.laapp` 是标准安装格式,建议不要对外分发散装目录。
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.

View File

@@ -1,16 +1,9 @@
# LanMountainDesktop.SamplePlugin
这是阑山桌面的**示例开发插件**。
## 中文
它用于演示以下能力:
- 插件入口与 `plugin.json` 清单
- 插件服务注册
- 插件设置页注册
- 插件桌面组件注册
- 插件内通信与状态更新
- `.laapp` 打包与安装流程
- 插件多语言资源组织方式
这是阑山桌面的标准示例插件,用于演示插件清单、设置页、桌面组件、服务注册、本地化和 `.laapp` 打包流程。
如果你要开发自己的插件,建议以这个目录为模板开始。
## English
这个目录仅用于示例开发与打包发布,不承载宿主应用内部的插件加载逻辑。
This is the standard sample plugin used to demonstrate manifests, settings pages, desktop components, service registration, localization, and `.laapp` packaging.

View File

@@ -1,11 +1,11 @@
# 示例插件
# 示例插件目录
本目录用于存放阑山桌面的示例开发插件。
## 中文
当前示例:
- `LanMountainDesktop.SamplePlugin`
本目录用于存放阑山桌面的示例插件和参考实现。
说明:
- 这个插件是**示例开发插件**,用于演示插件项目结构、服务注册、设置页注册、桌面组件注册、`.laapp` 打包与安装流程。
- 开发新插件时,建议直接从这个示例插件复制一份再修改。
- 示例插件属于 `LanAirApp/` 对外开发工作区;宿主应用里的插件运行时与解析实现位于 `LanMountainDesktop/plugins/`
当前标准示例为 `LanMountainDesktop.SamplePlugin`
## English
This directory stores sample plugins and reference implementations. The current standard sample is `LanMountainDesktop.SamplePlugin`.

View File

@@ -1,11 +1,9 @@
# 插件标准文件
# 插件标准说明
这里存放 LanMountainDesktop 插件开发所使用的标准模板与约定文件。
## 中文
当前标准:
- 安装包扩展名:`.laapp`
- 插件清单文件名:`plugin.json`
- 多语言资源目录:`Localization/`
- 建议内置语言文件:`zh-CN.json``en-US.json`
本目录存放插件开发需要遵循的基础约定,包括 `.laapp``plugin.json``Localization/` 以及仓库根目录 README 和安装包等要求。
创建新插件时,建议优先参考本目录中的模板文件。
## English
This directory stores the baseline conventions for plugin development, including `.laapp`, `plugin.json`, `Localization/`, and repository-root deliverables.

View File

@@ -0,0 +1,8 @@
namespace LanMountainDesktop.PluginSdk;
public interface IPluginPackageManager
{
IReadOnlyList<InstalledPluginInfo> GetInstalledPlugins();
PluginPackageInstallResult InstallPackage(string packagePath);
}

View File

@@ -0,0 +1,8 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record InstalledPluginInfo(
PluginManifest Manifest,
bool IsEnabled,
bool IsLoaded,
bool IsPackage,
string? ErrorMessage);

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record PluginPackageInstallResult(
PluginManifest Manifest,
bool ReplacedExisting,
bool RestartRequired);

View File

@@ -16,23 +16,6 @@
<local:ViewLocator/>
</Application.DataTemplates>
<TrayIcon.Icons>
<TrayIcons>
<TrayIcon Icon="/Assets/avalonia-logo.ico"
ToolTipText="LanMountainDesktop">
<TrayIcon.Menu>
<NativeMenu>
<NativeMenuItem Header="设置" Click="OnTraySettingsClick" />
<NativeMenuItemSeparator />
<NativeMenuItem Header="重启应用" Click="OnTrayRestartClick" />
<NativeMenuItemSeparator />
<NativeMenuItem Header="退出应用" Click="OnTrayExitClick" />
</NativeMenu>
</TrayIcon.Menu>
</TrayIcon>
</TrayIcons>
</TrayIcon.Icons>
<Application.Styles>
<sty:FluentAvaloniaTheme />
<mi:MaterialIconStyles />

View File

@@ -1,4 +1,5 @@
using Avalonia;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins;
@@ -6,6 +7,7 @@ using System;
using System.Diagnostics;
using System.Linq;
using Avalonia.Markup.Xaml;
using Avalonia.Platform;
using Avalonia.Threading;
using LanMountainDesktop.Services;
using LanMountainDesktop.ViewModels;
@@ -16,7 +18,11 @@ namespace LanMountainDesktop;
public partial class App : Application
{
private readonly AppSettingsService _appSettingsService = new();
private readonly LocalizationService _localizationService = new();
private SettingsWindow? _traySettingsWindow;
private TrayIcons? _trayIcons;
private PluginRuntimeService? _pluginRuntimeService;
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
@@ -32,13 +38,20 @@ public partial class App : Application
{
LinuxDesktopEntryInstaller.EnsureInstalled();
InitializePluginRuntime();
AppSettingsService.SettingsSaved += OnAppSettingsSaved;
InitializeTrayIcon();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
// 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 += (_, _) =>
{
AppSettingsService.SettingsSaved -= OnAppSettingsSaved;
DisposeTrayIcon();
};
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(),
@@ -50,6 +63,8 @@ public partial class App : Application
private void OnTrayExitClick(object? sender, EventArgs e)
{
DisposeTrayIcon();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.Shutdown();
@@ -99,6 +114,17 @@ public partial class App : Application
AppRestartService.TryRestartApplication();
}
private void OnAppSettingsSaved(string _)
{
Dispatcher.UIThread.Post(() =>
{
if (_trayIcons is not null)
{
InitializeTrayIcon();
}
}, DispatcherPriority.Background);
}
private void DisableAvaloniaDataAnnotationValidation()
{
// Get an array of plugins to remove
@@ -152,4 +178,74 @@ public partial class App : Application
Debug.WriteLine($"[PluginRuntime] Failed to initialize plugin runtime: {ex}");
}
}
private void InitializeTrayIcon()
{
try
{
DisposeTrayIcon();
using var iconStream = AssetLoader.Open(new Uri("avares://LanMountainDesktop/Assets/avalonia-logo.ico"));
var trayIcon = new TrayIcon
{
Icon = new WindowIcon(iconStream),
ToolTipText = L("tray.tooltip", "LanMountainDesktop"),
Menu = BuildTrayMenu(),
IsVisible = true
};
_trayIcons = [trayIcon];
TrayIcon.SetIcons(this, _trayIcons);
}
catch (Exception ex)
{
Debug.WriteLine($"[TrayIcon] Failed to initialize tray icon: {ex}");
}
}
private NativeMenu BuildTrayMenu()
{
var menu = new NativeMenu();
var settingsItem = new NativeMenuItem(L("tray.menu.settings", "ÉèÖÃ"));
settingsItem.Click += OnTraySettingsClick;
menu.Items.Add(settingsItem);
menu.Items.Add(new NativeMenuItemSeparator());
var restartItem = new NativeMenuItem(L("tray.menu.restart", "ÖØÆôÓ¦ÓÃ"));
restartItem.Click += OnTrayRestartClick;
menu.Items.Add(restartItem);
menu.Items.Add(new NativeMenuItemSeparator());
var exitItem = new NativeMenuItem(L("tray.menu.exit", "Í˳öÓ¦ÓÃ"));
exitItem.Click += OnTrayExitClick;
menu.Items.Add(exitItem);
return menu;
}
private void DisposeTrayIcon()
{
if (_trayIcons is null)
{
return;
}
TrayIcon.SetIcons(this, null);
foreach (var trayIcon in _trayIcons)
{
trayIcon.Dispose();
}
_trayIcons = null;
}
private string L(string key, string fallback)
{
var snapshot = _appSettingsService.Load();
var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
return _localizationService.GetString(languageCode, key, fallback);
}
}

View File

@@ -1,22 +1,35 @@
# MiSans Font Notice
# MiSans 字体说明
This app bundles MiSans fonts for consistent cross-device rendering.
## 中文
## Included files
本项目内置 MiSans 字体,用于在不同设备上保持相对一致的文字渲染效果。
### 包含文件
- `MiSans-Regular.ttf`
- `MiSans-Semibold.ttf`
- `MiSans-Bold.ttf`
## Source
### 来源
- 上游仓库https://github.com/dsrkafuu/misans
- 上游所引用的小米字体页面https://hyperos.mi.com/font/zh/
### 许可与使用说明
- 上游脚本或打包仓库使用 Apache-2.0 许可。
- MiSans 字体本身的版权和补充使用条款以小米官方说明为准:
- https://hyperos.mi.com/font-download/MiSans%E5%AD%97%E4%BD%93%E7%9F%A5%E8%AF%86%E4%BA%A7%E6%9D%83%E8%AE%B8%E5%8F%AF%E5%8D%8F%E8%AE%AE.pdf
在重新分发本项目时,请自行确认并遵守 MiSans 字体的相关条款。
## English
This project bundles MiSans fonts for more consistent cross-device rendering.
### Sources
- Upstream package repository: https://github.com/dsrkafuu/misans
- Original font source referenced by upstream: https://hyperos.mi.com/font/zh/
- Xiaomi font source page: https://hyperos.mi.com/font/zh/
## License and usage notes
- Script/package license in upstream repository: Apache-2.0
- MiSans font copyright and additional usage terms:
https://hyperos.mi.com/font-download/MiSans%E5%AD%97%E4%BD%93%E7%9F%A5%E8%AF%86%E4%BA%A7%E6%9D%83%E8%AE%B8%E5%8F%AF%E5%8D%8F%E8%AE%AE.pdf
Please review and comply with the MiSans font terms when distributing this app.
Please review and comply with the MiSans font terms before redistributing this application.

View File

@@ -1,9 +1,12 @@
# Weather Background Assets
# 天气背景资源署名
Weather card background images are sourced from **Pexels** and used under the Pexels license:
https://www.pexels.com/license/
## 中文
## Sources
本目录中的天气背景图像主要来自 **Pexels**,并按 Pexels License 使用:
- License: https://www.pexels.com/license/
### 原始来源
- `clear_sky.jpg`
- https://www.pexels.com/photo/a-clear-blue-sky-with-few-clouds-on-a-sunny-day-29390199/
@@ -14,16 +17,24 @@ https://www.pexels.com/license/
- `storm.jpg`
- https://www.pexels.com/photo/sea-under-a-stormy-sky-4609228/
## Derived Variants (for widget scene mapping)
### 派生资源
The following files are generated from the above base assets by color grading/brightness adjustments to match the ColorOS-like weather card style:
以下文件由上述基础图片经过色彩、亮度或风格调整后生成,用于适配阑山桌面的天气组件视觉:
- `clear_day.jpg` (from `clear_sky.jpg`)
- `clear_night.jpg` (from `clear_sky.jpg`)
- `cloudy_day.jpg` (from `clear_sky.jpg`)
- `cloudy_night.jpg` (from `clear_sky.jpg`)
- `rain_light.jpg` (from `rain.jpg`)
- `rain_heavy.jpg` (from `rain.jpg`)
- `storm_dark.jpg` (from `storm.jpg`)
- `fog_haze.jpg` (from `storm.jpg`)
- `snow_soft.jpg` (from `snow.jpg`)
- `clear_day.jpg`
- `clear_night.jpg`
- `cloudy_day.jpg`
- `cloudy_night.jpg`
- `rain_light.jpg`
- `rain_heavy.jpg`
- `storm_dark.jpg`
- `fog_haze.jpg`
- `snow_soft.jpg`
## English
The weather background images in this directory are primarily sourced from **Pexels** and used under the Pexels License:
- License: https://www.pexels.com/license/
Derived variants in this repository are adjusted from the listed base assets for widget presentation.

View File

@@ -1,45 +1,23 @@
# HyperOS3 Weather Assets (Official Xiaomi Package)
# HyperOS3 天气资源署名
## 中文
本目录中的 HyperOS3 风格天气资源来自用户提供的 Xiaomi Weather 安装包提取内容,以及基于该视觉方向制作的项目内派生资源。
### 提取来源
These assets were extracted from the official Xiaomi Weather APK provided by the user:
- Source APK: `c:\Program Files\Netease\GameViewer\Download\MI SKY 12.apk`
- Package: `com.miui.weather2` (Mi Weather)
- Extraction date: 2026-03-03
- Package: `com.miui.weather2`
- Extraction date: `2026-03-03`
Extracted source paths inside APK:
- `assets/map_custom/particle/sun_0.png` -> `hyper_sun_core.png`
- `assets/map_custom/particle/sun_1.png` -> `hyper_sun_ring.png`
- `assets/map_custom/particle/fog.png` -> `hyper_fog.png`
- `assets/map_custom/particle/haze.png` -> `hyper_haze.png`
- `assets/map_custom/particle/rain.png` -> `hyper_rain_drop.png`
- `assets/map_custom/particle/snow.png` -> `hyper_snow_flake.png`
- `assets/map_custom/skybox/top.png` -> `hyper_sky_top.png`
- `assets/map_custom/skybox/back.png` -> `hyper_sky_back.png`
- `assets/map_custom/skybox/front.png` -> `hyper_sky_front.png`
- `assets/map_custom/skybox/left.png` -> `hyper_sky_left.png`
- `assets/map_custom/skybox/right.png` -> `hyper_sky_right.png`
- `assets/map_custom/skybox/bottom.png` -> `hyper_sky_bottom.png`
- `assets/map_assets/VM3DRes/cross_sky_day.png` -> `hyper_cross_sky_day.png`
- `assets/map_assets/VM3DRes/cross_sky_night.png` -> `hyper_cross_sky_night.png`
### 用途说明
Extracted weather icon paths inside APK (`res/*.webp`):
- `res/aO.webp` -> `Icons/icon_sunny_day.webp`
- `res/k2.webp` -> `Icons/icon_moon_clear.webp`
- `res/Ip.webp` -> `Icons/icon_partly_cloudy_day.webp`
- `res/HI.webp` -> `Icons/icon_partly_cloudy_night.webp`
- `res/E4.webp` -> `Icons/icon_cloudy.webp`
- `res/5f.webp` -> `Icons/icon_rain_light.webp`
- `res/fO.webp` -> `Icons/icon_rain_heavy.webp`
- `res/lV1.webp` -> `Icons/icon_thunder.webp`
- `res/mH1.webp` -> `Icons/icon_snow.webp`
- `res/jB.webp` -> `Icons/icon_sleet.webp`
- `res/Wl.webp` -> `Icons/icon_haze.webp`
- `res/Mg.webp` -> `Icons/icon_windy.webp`
- 这些资源仅用于项目内部视觉研究、原型还原和界面适配。
- 使用时应遵守小米相关许可与使用条款。
Use only according to Xiaomi's applicable license and usage terms.
### 额外派生资源
## Soft Widget Icon Set (2026-03-05)
To better match the Xiaomi weather time-card visual hierarchy, an additional local icon set was generated for this project:
以下文件为项目内基于上述视觉方向制作的派生素材:
- `Icons/icon_hero_sun_soft.png`
- `Icons/icon_hero_moon_soft.png`
@@ -52,4 +30,8 @@ To better match the Xiaomi weather time-card visual hierarchy, an additional loc
- `Icons/icon_mini_snow_soft.png`
- `Icons/icon_mini_fog_soft.png`
These files are original derivative assets generated in-repo with local tooling, using the extracted Xiaomi package visual direction as reference (soft glow hero icon + lightweight forecast icons).
## English
The HyperOS3-style weather assets in this directory were extracted from a Xiaomi Weather APK provided by the user, together with additional derivative assets created in-repo to match the same visual direction.
Use these resources only in accordance with Xiaomi's applicable license and usage terms.

View File

@@ -1,77 +1,38 @@
# 组件系统模块Component System Module
# 组件系统说明
本目录提供组件系统的模块化基础,用于支持内置组件管理与第三方扩展接入。
This directory provides the modular foundation for built-in component management and third-party extension integration.
## 中文
## 核心文件职责Core Files
- `BuiltInComponentIds.cs`:内置组件 ID 常量(例如 `Clock`)。
Built-in component ID constants (for example `Clock`).
- `DesktopComponentDefinition.cs`:组件元数据定义(名称、类别、最小尺寸、可放置区域等)。
Component metadata model (name, category, minimum size, placement permissions).
- `ComponentPlacementRules.cs`:组件放置规则(最小尺寸、状态栏高度限制、网格边界约束)。
Placement rules (minimum size, status-bar height rule, grid clamping).
- `ComponentRegistry.cs`:组件注册中心,负责内置组件与扩展组件合并。
Registry that merges built-in and extension components.
- `Extensions/IComponentExtensionProvider.cs`:扩展提供者接口契约。
Extension provider interface contract.
- `Extensions/JsonComponentExtensionProvider.cs`:基于 JSON 的扩展加载器。
JSON-based extension loader.
`ComponentSystem/` 提供阑山桌面组件定义、注册和扩展的基础能力。
## 第三方扩展契约Extension Contract
- 第三方可通过实现 `IComponentExtensionProvider` 提供组件定义。
Third parties can provide component definitions via `IComponentExtensionProvider`.
- 当前内置了 JSON 提供者,运行时扫描目录:
Built-in JSON provider scans at runtime:
- `Extensions/Components/*.json`(相对应用输出目录)
`Extensions/Components/*.json` (relative to app output directory)
### 主要职责
## 加载流程Load Flow
1. `ComponentRegistry.CreateDefault()` 先注册内置组件。
Register built-in components first via `ComponentRegistry.CreateDefault()`.
2. 调用 `.RegisterExtensions(...)` 合并扩展组件。
Merge extension components via `.RegisterExtensions(...)`.
3. 主窗口通过注册中心校验组件合法性与放置权限。
Main window validates component identity and placement permission through the registry.
- 管理内置组件 ID 和元数据
- 约束组件最小尺寸与可放置区域
- 合并内置组件与扩展组件
- 通过 JSON 或扩展提供者接入第三方组件
## JSON 清单格式Manifest Schema
JSON 文件为数组,每一项代表一个组件定义。
The JSON file is an array, where each item represents one component definition.
### 关键文件
```json
[
{
"id": "Weather",
"displayName": "Weather",
"iconKey": "WeatherSunny",
"category": "Status",
"minWidthCells": 1,
"minHeightCells": 1,
"allowStatusBarPlacement": true,
"allowDesktopPlacement": true
}
]
```
- `BuiltInComponentIds.cs`:内置组件 ID 常量
- `DesktopComponentDefinition.cs`:组件元数据模型
- `ComponentPlacementRules.cs`:放置规则
- `ComponentRegistry.cs`:组件注册中心
- `Extensions/IComponentExtensionProvider.cs`:扩展提供者接口
- `Extensions/JsonComponentExtensionProvider.cs`JSON 扩展加载器
字段说明Field notes
- `id`:组件唯一 ID建议英文、稳定不变
Unique component ID (prefer stable English key).
- `displayName`:显示名。
Display name.
- `iconKey`:图标键(由上层 UI 解释)。
Icon key resolved by UI layer.
- `category`:组件分类。
Component category.
- `minWidthCells` / `minHeightCells`:最小占格,必须满足 `>= 1`
Minimum cell size, must satisfy `>= 1`.
- `allowStatusBarPlacement`:是否允许放到顶部状态栏。
Whether placing in top status bar is allowed.
- `allowDesktopPlacement`:是否允许放到桌面区域。
Whether placing in desktop area is allowed.
### 扩展方式
## 放置规则摘要Placement Rules Summary
- 最小尺寸约束:`minWidthCells >= 1``minHeightCells >= 1`
Minimum size constraint: `minWidthCells >= 1` and `minHeightCells >= 1`.
- 状态栏约束:状态栏组件高度必须为 `1` 格。
Status bar constraint: component height must be exactly `1` cell.
- 越界约束所有组件坐标会被网格边界钳制clamp
Out-of-bounds constraint: component coordinates are clamped to grid bounds.
- 当前默认扫描 `Extensions/Components/*.json`
- 组件清单定义显示名、分类、最小尺寸和可放置区域
- 主程序通过注册中心统一验证组件是否合法
## English
`ComponentSystem/` contains the foundation for component definition, registration, and extension in LanMountainDesktop.
### Responsibilities
- manage built-in component IDs and metadata
- enforce placement rules
- merge built-in and extension components
- support third-party registration through JSON or provider contracts

View File

@@ -1,11 +1,22 @@
{
"app.title": "LanMountainDesktop",
"tray.tooltip": "LanMountainDesktop",
"tray.menu.settings": "Settings",
"tray.menu.restart": "Restart App",
"tray.menu.exit": "Exit App",
"button.back_to_windows": "Back to Windows",
"tooltip.back_to_windows": "Back to Windows",
"tooltip.open_settings": "Settings",
"settings.title": "Settings",
"settings.shell.title": "Application Settings",
"settings.shell.subtitle": "LanMountainDesktop standalone preferences",
"settings.shell.sidebar_hint": "Choose a category to adjust application behavior, desktop layout, and appearance.",
"settings.shell.footer_hint": "Tray-opened settings are managed in this standalone window.",
"settings.back_to_desktop": "Back to Desktop",
"settings.nav_header": "Settings",
"settings.nav.group_desktop": "Desktop",
"settings.nav.group_system": "System",
"settings.nav.group_extensions": "Extensions",
"settings.nav.wallpaper": "Wallpaper",
"settings.nav.grid": "Grid",
"settings.nav.color": "Color",
@@ -109,6 +120,8 @@
"settings.weather.preview_header": "Connection Test",
"settings.weather.preview_desc": "Send one test request to verify current settings.",
"settings.weather.preview_button": "Test Fetch",
"settings.weather.preview_section": "Weather Preview",
"settings.weather.settings_section": "Settings",
"settings.weather.preview_panel_header": "Weather Preview",
"settings.weather.preview_panel_desc": "Refresh and verify current weather service status.",
"settings.weather.refresh_button": "Refresh",
@@ -129,6 +142,15 @@
"settings.weather.status_city_empty": "No city location is configured.",
"settings.weather.status_city_format": "Mode: {0} | {1} | Key: {2}",
"settings.weather.status_coordinates_format": "Mode: {0} | Lat {1:F4}, Lon {2:F4} | Key: {3}",
"settings.weather.city_selection_label": "City Selection",
"settings.weather.coordinates_selection_label": "Coordinate Location",
"settings.weather.location_city_summary_desc": "Select the current city used for weather queries.",
"settings.weather.location_coordinates_summary_desc": "Set latitude/longitude and optional location name used for weather queries.",
"settings.weather.location_not_selected": "No location selected",
"settings.weather.alert_list_label": "Exclude List",
"settings.weather.alert_list_desc": "One exclusion rule per line.",
"settings.weather.no_tls_toggle": "Allow non-TLS request fallback",
"settings.weather.footer_hint": "Desktop weather widgets will reuse the location and alert exclusion settings configured here.",
"settings.weather.location_header": "Weather Location",
"settings.weather.location_desc": "Set the location used by weather widgets.",
"settings.weather.location_placeholder": "e.g. Beijing",
@@ -320,6 +342,53 @@
"settings.plugins.source_manifest": "Loose manifest",
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
"settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}",
"settings.nav.plugin_market": "Plugin Market",
"settings.plugin_market.title": "Plugin Market",
"settings.plugin_market.subtitle": "Browse plugins from the official LanAirApp source and stage installs.",
"settings.plugin_market.unavailable": "Plugin runtime is not available, so the official market cannot be opened right now.",
"market.toolbar.search_placeholder": "Search plugins",
"market.toolbar.refresh": "Refresh",
"market.status.loading": "Loading the official plugin market...",
"market.status.loaded_network_format": "Loaded {0} plugin(s) from the official source.",
"market.status.loaded_cache_format": "Official source unavailable. Loaded {0} plugin(s) from cache. Reason: {1}",
"market.status.load_failed_format": "Failed to load the plugin market: {0}",
"market.status.installing_format": "Downloading and staging plugin '{0}'...",
"market.status.install_success_format": "Plugin '{0}' has been staged. Restart the app to apply it.",
"market.status.install_failed_format": "Failed to install plugin: {0}",
"market.status.host_incompatible_format": "This host is too old. Version {0} or newer is required.",
"market.list.empty": "The plugin market has not been loaded yet.",
"market.list.no_results": "No plugins match the current search.",
"market.card.subtitle_format": "{0} | v{1}",
"market.card.loaded": "Loaded",
"market.card.pending_restart": "Restart required",
"market.detail.placeholder": "Select a plugin on the left to inspect details.",
"market.detail.author": "Author",
"market.detail.version": "Version",
"market.detail.api_version": "API Version",
"market.detail.min_host_version": "Minimum Host Version",
"market.detail.installed_version": "Installed Version",
"market.detail.not_installed": "Not installed",
"market.detail.readme": "README",
"market.detail.plugin_information": "Plugin Information",
"market.detail.author_subtitle_format": "By {0}",
"market.detail.package_size": "Package Size",
"market.detail.published_at": "Published At",
"market.detail.updated_at": "Updated At",
"market.detail.tags": "Tags",
"market.detail.project": "Project",
"market.detail.state": "Install State",
"market.detail.market_source": "Market Source",
"market.detail.homepage": "Homepage",
"market.detail.repository": "Repository",
"market.detail.release_notes": "Release Notes",
"market.detail.state.not_installed": "Not installed",
"market.detail.state.update_available": "Update available",
"market.detail.state.installed": "Installed",
"market.detail.unknown": "Unknown",
"market.button.install": "Install",
"market.button.update": "Update",
"market.button.installed": "Installed",
"market.button.installing": "Installing...",
"button.component_library": "Edit Desktop",
"tooltip.component_library": "Edit Desktop",
"component_library.title": "Widgets",

View File

@@ -1,11 +1,22 @@
{
"app.title": "LanMountainDesktop",
"tray.tooltip": "LanMountainDesktop",
"tray.menu.settings": "设置",
"tray.menu.restart": "重启应用",
"tray.menu.exit": "退出应用",
"button.back_to_windows": "回到Windows",
"tooltip.back_to_windows": "回到Windows",
"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": "颜色",
@@ -109,6 +120,8 @@
"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": "刷新",
@@ -129,6 +142,15 @@
"settings.weather.status_city_empty": "尚未配置城市位置。",
"settings.weather.status_city_format": "模式:{0}{1}Key{2}",
"settings.weather.status_coordinates_format": "模式:{0}|纬度 {1:F4},经度 {2:F4}Key{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": "一行一条排除项。",
"settings.weather.no_tls_toggle": "允许在兼容性较差的网络环境下回退到非 TLS 请求",
"settings.weather.footer_hint": "桌面上的天气组件会共享这里配置的天气位置与预警排除规则。",
"settings.weather.location_header": "天气位置",
"settings.weather.location_desc": "设置天气组件使用的位置。",
"settings.weather.location_placeholder": "例如:北京",
@@ -320,6 +342,53 @@
"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": "插件运行时不可用,暂时无法打开官方市场。",
"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.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": "安装中...",
"button.component_library": "桌面编辑",
"tooltip.component_library": "桌面编辑",
"component_library.title": "桌面编辑",

View File

@@ -1,75 +1,51 @@
# Desktop Packaging Guide
# 桌面端打包指南
## Prerequisites
- Install `.NET SDK 10`
- Windows installer build only:
- Install `Inno Setup 6` (`ISCC.exe`)
## 中文
## Local packaging commands
本指南说明阑山桌面的本地打包和 CI 打包流程。
### 前置条件
- 安装 .NET SDK 10
- Windows 安装包需要 Inno Setup 6`ISCC.exe`
### 本地打包命令
#### Windows 安装包
### Windows installer (`win-x64`)
```powershell
.\scripts\package.ps1 -RuntimeIdentifier win-x64 -Version 1.0.1
```
Output:
- Published files: `artifacts/publish/win-x64`
- Installer: `artifacts/installer`
#### Linux 包
### Linux package (`linux-x64`)
```powershell
pwsh ./scripts/package.ps1 -RuntimeIdentifier linux-x64 -Version 1.0.1
```
Output:
- Published files: `artifacts/publish/linux-x64`
- Zip package: `artifacts/packages/LanMountainDesktop-1.0.1-linux-x64.zip`
#### macOS 包
### macOS package (`osx-x64`)
```powershell
pwsh ./scripts/package.ps1 -RuntimeIdentifier osx-x64 -Version 1.0.1
```
Output:
- Published files: `artifacts/publish/osx-x64`
- Zip package: `artifacts/packages/LanMountainDesktop-1.0.1-osx-x64.zip`
### 产物位置
## Optional script flags
```powershell
# Publish only (skip Windows installer step)
.\scripts\package.ps1 -RuntimeIdentifier win-x64 -SkipInstaller
- 发布目录:`artifacts/publish/<rid>`
- 安装包或压缩包:`artifacts/installer``artifacts/packages`
# Publish only (skip Linux/macOS zip package step)
pwsh ./scripts/package.ps1 -RuntimeIdentifier linux-x64 -SkipArchive
```
### CI 流程
## Runtime dependency notes
- Linux build does not bundle a native `libvlc` package from NuGet.
- Install VLC runtime on target machine, for example:
- Ubuntu/Debian: `sudo apt install vlc libvlc-dev`
- macOS packaging target in CI is currently `osx-x64`.
- 工作流文件:`.github/workflows/windows-ci.yml`
- 日常构建会验证桌面端可编译
- 手动触发或 `v*` 标签可生成正式包并上传到 Release
## CI workflow
- Workflow file: `.github/workflows/windows-ci.yml`
- Workflow name: `Desktop CI`
## English
Jobs:
- `Validate Build (Windows)` runs on every push and pull request.
- Package flow runs on manual trigger or `v*` tag push:
- `Resolve Package Version` (single shared version source)
- `Package (Windows)` (`win-x64` installer)
- `Package (Linux)` (`linux-x64` zip)
- `Package (macOS)` (`osx-x64` zip)
- On `v*` tags, `Attach Artifacts to GitHub Release` uploads Windows/Linux/macOS packages to the release.
This guide covers local packaging and CI packaging for LanMountainDesktop.
### Trigger manual packaging
1. Open GitHub Actions.
2. Choose `Desktop CI`.
3. Click `Run workflow`.
4. Optional: set `version` input, for example `1.0.1`.
### Key points
### Trigger by tag
```powershell
git tag v1.0.1
git push origin v1.0.1
```
- use `scripts/package.ps1` with the target runtime identifier
- Windows installer requires Inno Setup
- CI can publish artifacts and attach them to GitHub Releases

View File

@@ -45,6 +45,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
private readonly string _owner;
private readonly string _repo;
private readonly HttpClient _httpClient;
private readonly ResumableDownloadService _downloadService;
private readonly bool _ownsHttpClient;
public GitHubReleaseUpdateService(
@@ -69,6 +70,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
_ownsHttpClient = false;
}
_downloadService = new ResumableDownloadService(_httpClient);
if (!_httpClient.DefaultRequestHeaders.UserAgent.Any())
{
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-Updater/1.0");
@@ -187,59 +190,37 @@ public sealed class GitHubReleaseUpdateService : IDisposable
return new UpdateDownloadResult(false, null, "Destination file path is empty.");
}
try
var progressAdapter = progress is null
? null
: new Progress<DownloadProgressInfo>(info => progress.Report(info.Progress));
var result = await _downloadService.DownloadAsync(
asset.BrowserDownloadUrl,
destinationFilePath,
new DownloadOptions(ExpectedSizeBytes: asset.SizeBytes > 0 ? asset.SizeBytes : null),
progressAdapter,
cancellationToken);
return result.Success
? new UpdateDownloadResult(true, result.FilePath ?? destinationFilePath, null)
: new UpdateDownloadResult(false, null, result.ErrorMessage);
}
public async Task<GitHubReleaseInfo?> GetReleaseByTagAsync(
string tagName,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(tagName))
{
var directory = Path.GetDirectoryName(destinationFilePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
using var response = await _httpClient.GetAsync(
asset.BrowserDownloadUrl,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
return new UpdateDownloadResult(
false,
null,
$"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}");
}
var contentLength = response.Content.Headers.ContentLength ??
(asset.SizeBytes > 0 ? asset.SizeBytes : -1);
await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var destinationStream = File.Create(destinationFilePath);
var buffer = new byte[81920];
long totalRead = 0;
int read;
while ((read = await sourceStream.ReadAsync(buffer, cancellationToken)) > 0)
{
await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
totalRead += read;
if (contentLength > 0)
{
progress?.Report(Math.Clamp(totalRead / (double)contentLength, 0d, 1d));
}
}
progress?.Report(1d);
return new UpdateDownloadResult(true, destinationFilePath, null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new UpdateDownloadResult(false, null, ex.Message);
return null;
}
var url =
$"https://api.github.com/repos/{_owner}/{_repo}/releases/tags/{Uri.EscapeDataString(tagName.Trim())}";
var responseText = await GetResponseTextAsync(url, cancellationToken);
using var document = JsonDocument.Parse(responseText);
return ParseRelease(document.RootElement);
}
private async Task<GitHubReleaseInfo?> GetLatestStableReleaseAsync(CancellationToken cancellationToken)

View File

@@ -0,0 +1,940 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
public sealed record DownloadProgressInfo(
long DownloadedBytes,
long? TotalBytes,
double Progress,
bool IsResuming,
bool IsParallel);
public sealed record DownloadOptions(
long? ExpectedSizeBytes = null,
int MaxParallelSegments = 4,
int ParallelThresholdBytes = 8 * 1024 * 1024,
int BufferSize = 128 * 1024);
public sealed record DownloadResult(
bool Success,
string? FilePath,
string? ErrorMessage,
bool UsedResume,
bool UsedParallelDownload);
public sealed class ResumableDownloadService
{
private static readonly JsonSerializerOptions MetadataSerializerOptions = new()
{
WriteIndented = false
};
private readonly HttpClient _httpClient;
public ResumableDownloadService(HttpClient httpClient)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
}
public async Task<DownloadResult> DownloadAsync(
string source,
string destinationFilePath,
DownloadOptions? options = null,
IProgress<DownloadProgressInfo>? progress = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(source);
ArgumentException.ThrowIfNullOrWhiteSpace(destinationFilePath);
var normalizedOptions = NormalizeOptions(options);
try
{
if (File.Exists(source))
{
return await CopyLocalFileAsync(
source,
destinationFilePath,
normalizedOptions,
progress,
cancellationToken);
}
if (!Uri.TryCreate(source, UriKind.Absolute, out var sourceUri) ||
(sourceUri.Scheme != Uri.UriSchemeHttp && sourceUri.Scheme != Uri.UriSchemeHttps))
{
return new DownloadResult(false, null, $"Unsupported download source '{source}'.", false, false);
}
return await DownloadRemoteFileAsync(
sourceUri,
destinationFilePath,
normalizedOptions,
progress,
cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new DownloadResult(false, null, ex.Message, false, false);
}
}
private async Task<DownloadResult> CopyLocalFileAsync(
string sourceFilePath,
string destinationFilePath,
DownloadOptions options,
IProgress<DownloadProgressInfo>? progress,
CancellationToken cancellationToken)
{
var fullSourcePath = Path.GetFullPath(sourceFilePath);
var fullDestinationPath = Path.GetFullPath(destinationFilePath);
var totalBytes = new FileInfo(fullSourcePath).Length;
var tempFilePath = BuildTempFilePath(fullDestinationPath);
var metadataFilePath = BuildMetadataFilePath(fullDestinationPath);
PrepareDestination(fullDestinationPath);
if (CanReuseCompletedDestination(fullDestinationPath, totalBytes))
{
progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, 1d, false, false));
CleanupPartialArtifacts(tempFilePath, metadataFilePath);
return new DownloadResult(true, fullDestinationPath, null, false, false);
}
long existingBytes = 0;
if (File.Exists(tempFilePath))
{
existingBytes = new FileInfo(tempFilePath).Length;
if (existingBytes > totalBytes)
{
ResetPartialArtifacts(tempFilePath, metadataFilePath);
existingBytes = 0;
}
}
if (!File.Exists(tempFilePath))
{
await using var tempCreateStream = new FileStream(
tempFilePath,
FileMode.Create,
FileAccess.Write,
FileShare.Read,
options.BufferSize,
FileOptions.Asynchronous | FileOptions.SequentialScan);
}
if (existingBytes >= totalBytes)
{
CompleteDownload(tempFilePath, fullDestinationPath, metadataFilePath);
progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, 1d, existingBytes > 0, false));
return new DownloadResult(true, fullDestinationPath, null, existingBytes > 0, false);
}
await using var sourceStream = new FileStream(
fullSourcePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
options.BufferSize,
FileOptions.Asynchronous | FileOptions.SequentialScan);
await using var destinationStream = new FileStream(
tempFilePath,
FileMode.Open,
FileAccess.Write,
FileShare.Read,
options.BufferSize,
FileOptions.Asynchronous | FileOptions.SequentialScan);
if (existingBytes > 0)
{
sourceStream.Seek(existingBytes, SeekOrigin.Begin);
destinationStream.Seek(existingBytes, SeekOrigin.Begin);
}
await CopyStreamAsync(
sourceStream,
destinationStream,
existingBytes,
totalBytes,
isResuming: existingBytes > 0,
isParallel: false,
options.BufferSize,
progress,
cancellationToken);
CompleteDownload(tempFilePath, fullDestinationPath, metadataFilePath);
return new DownloadResult(true, fullDestinationPath, null, existingBytes > 0, false);
}
private async Task<DownloadResult> DownloadRemoteFileAsync(
Uri sourceUri,
string destinationFilePath,
DownloadOptions options,
IProgress<DownloadProgressInfo>? progress,
CancellationToken cancellationToken)
{
var fullDestinationPath = Path.GetFullPath(destinationFilePath);
var tempFilePath = BuildTempFilePath(fullDestinationPath);
var metadataFilePath = BuildMetadataFilePath(fullDestinationPath);
PrepareDestination(fullDestinationPath);
var probe = await ProbeRemoteFileAsync(sourceUri, cancellationToken);
var totalBytes = probe.TotalBytes ?? options.ExpectedSizeBytes;
if (CanReuseCompletedDestination(fullDestinationPath, totalBytes))
{
progress?.Report(new DownloadProgressInfo(
totalBytes ?? new FileInfo(fullDestinationPath).Length,
totalBytes,
1d,
false,
false));
CleanupPartialArtifacts(tempFilePath, metadataFilePath);
return new DownloadResult(true, fullDestinationPath, null, false, false);
}
var canUseParallel = probe.SupportsRanges &&
totalBytes is > 0 &&
totalBytes.Value >= options.ParallelThresholdBytes &&
options.MaxParallelSegments > 1;
try
{
var result = canUseParallel
? await DownloadRemoteInParallelAsync(
sourceUri,
fullDestinationPath,
tempFilePath,
metadataFilePath,
totalBytes!.Value,
options,
progress,
cancellationToken)
: await DownloadRemoteSequentiallyAsync(
sourceUri,
fullDestinationPath,
tempFilePath,
metadataFilePath,
totalBytes,
probe.SupportsRanges,
options,
progress,
cancellationToken);
return result;
}
catch (RangeRequestNotSupportedException)
{
ResetPartialArtifacts(tempFilePath, metadataFilePath);
return await DownloadRemoteSequentiallyAsync(
sourceUri,
fullDestinationPath,
tempFilePath,
metadataFilePath,
totalBytes,
allowResume: false,
options,
progress,
cancellationToken);
}
}
private async Task<DownloadResult> DownloadRemoteSequentiallyAsync(
Uri sourceUri,
string destinationFilePath,
string tempFilePath,
string metadataFilePath,
long? totalBytes,
bool allowResume,
DownloadOptions options,
IProgress<DownloadProgressInfo>? progress,
CancellationToken cancellationToken)
{
long existingBytes = 0;
if (File.Exists(tempFilePath))
{
existingBytes = new FileInfo(tempFilePath).Length;
if (totalBytes is > 0 && existingBytes > totalBytes.Value)
{
ResetPartialArtifacts(tempFilePath, metadataFilePath);
existingBytes = 0;
}
}
if (!allowResume && existingBytes > 0)
{
ResetPartialArtifacts(tempFilePath, metadataFilePath);
existingBytes = 0;
}
if (totalBytes is > 0 && existingBytes >= totalBytes.Value)
{
CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath);
progress?.Report(new DownloadProgressInfo(totalBytes.Value, totalBytes, 1d, existingBytes > 0, false));
return new DownloadResult(true, destinationFilePath, null, existingBytes > 0, false);
}
using var request = new HttpRequestMessage(HttpMethod.Get, sourceUri);
if (allowResume && existingBytes > 0)
{
request.Headers.Range = new RangeHeaderValue(existingBytes, null);
}
using var response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (allowResume && existingBytes > 0)
{
if (response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable && totalBytes is > 0 && existingBytes == totalBytes)
{
CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath);
progress?.Report(new DownloadProgressInfo(totalBytes.Value, totalBytes, 1d, true, false));
return new DownloadResult(true, destinationFilePath, null, true, false);
}
if (response.StatusCode != HttpStatusCode.PartialContent)
{
throw new RangeRequestNotSupportedException("The server did not honor the resume range request.");
}
}
response.EnsureSuccessStatusCode();
await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var destinationStream = new FileStream(
tempFilePath,
existingBytes > 0 ? FileMode.Open : FileMode.Create,
FileAccess.Write,
FileShare.Read,
options.BufferSize,
FileOptions.Asynchronous | FileOptions.SequentialScan);
if (existingBytes > 0)
{
destinationStream.Seek(existingBytes, SeekOrigin.Begin);
}
var effectiveTotalBytes = totalBytes;
if (effectiveTotalBytes is null && response.Content.Headers.ContentLength is > 0)
{
effectiveTotalBytes = existingBytes + response.Content.Headers.ContentLength.Value;
}
await CopyStreamAsync(
sourceStream,
destinationStream,
existingBytes,
effectiveTotalBytes,
isResuming: existingBytes > 0,
isParallel: false,
options.BufferSize,
progress,
cancellationToken);
CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath);
return new DownloadResult(true, destinationFilePath, null, existingBytes > 0, false);
}
private async Task<DownloadResult> DownloadRemoteInParallelAsync(
Uri sourceUri,
string destinationFilePath,
string tempFilePath,
string metadataFilePath,
long totalBytes,
DownloadOptions options,
IProgress<DownloadProgressInfo>? progress,
CancellationToken cancellationToken)
{
var requestedSegments = Math.Min(options.MaxParallelSegments, CalculateRecommendedSegments(totalBytes));
var metadata = await LoadOrCreateMetadataAsync(
sourceUri,
tempFilePath,
metadataFilePath,
totalBytes,
requestedSegments,
cancellationToken);
await using (var tempStream = new FileStream(
tempFilePath,
FileMode.OpenOrCreate,
FileAccess.Write,
FileShare.ReadWrite,
options.BufferSize,
FileOptions.Asynchronous | FileOptions.RandomAccess))
{
if (tempStream.Length != totalBytes)
{
tempStream.SetLength(totalBytes);
}
}
var initialDownloadedBytes = metadata.Segments.Sum(segment => segment.CompletedBytes);
ReportProgress(progress, initialDownloadedBytes, totalBytes, initialDownloadedBytes > 0, true);
if (initialDownloadedBytes >= totalBytes)
{
CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath);
return new DownloadResult(true, destinationFilePath, null, initialDownloadedBytes > 0, true);
}
long downloadedBytes = initialDownloadedBytes;
var metadataWriter = new MetadataWriter(metadataFilePath, metadata);
try
{
var tasks = metadata.Segments
.Where(segment => segment.CompletedBytes < segment.Length)
.Select(segment => DownloadSegmentAsync(
sourceUri,
tempFilePath,
segment,
options.BufferSize,
delta =>
{
var currentDownloaded = Interlocked.Add(ref downloadedBytes, delta);
ReportProgress(progress, currentDownloaded, totalBytes, initialDownloadedBytes > 0, true);
},
metadataWriter,
cancellationToken))
.ToArray();
await Task.WhenAll(tasks);
await metadataWriter.FlushAsync(cancellationToken);
}
catch
{
await metadataWriter.FlushAsync(cancellationToken);
throw;
}
CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath);
ReportProgress(progress, totalBytes, totalBytes, initialDownloadedBytes > 0, true);
return new DownloadResult(true, destinationFilePath, null, initialDownloadedBytes > 0, true);
}
private async Task DownloadSegmentAsync(
Uri sourceUri,
string tempFilePath,
DownloadSegmentState segment,
int bufferSize,
Action<int> reportDownloadedBytes,
MetadataWriter metadataWriter,
CancellationToken cancellationToken)
{
var rangeStart = segment.Start + segment.CompletedBytes;
if (rangeStart > segment.EndInclusive)
{
return;
}
using var request = new HttpRequestMessage(HttpMethod.Get, sourceUri);
request.Headers.Range = new RangeHeaderValue(rangeStart, segment.EndInclusive);
using var response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (response.StatusCode != HttpStatusCode.PartialContent)
{
throw new RangeRequestNotSupportedException(
$"The server returned HTTP {(int)response.StatusCode} for range {rangeStart}-{segment.EndInclusive}.");
}
response.EnsureSuccessStatusCode();
var contentRange = response.Content.Headers.ContentRange;
if (contentRange?.From != rangeStart || contentRange.To != segment.EndInclusive)
{
throw new RangeRequestNotSupportedException("The server returned an unexpected content range.");
}
await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var destinationStream = new FileStream(
tempFilePath,
FileMode.Open,
FileAccess.Write,
FileShare.ReadWrite,
bufferSize,
FileOptions.Asynchronous | FileOptions.RandomAccess);
destinationStream.Seek(rangeStart, SeekOrigin.Begin);
var buffer = ArrayPool<byte>.Shared.Rent(bufferSize);
try
{
while (segment.CompletedBytes < segment.Length)
{
var remainingBytes = segment.Length - segment.CompletedBytes;
var readSize = (int)Math.Min(buffer.Length, remainingBytes);
var read = await sourceStream.ReadAsync(buffer.AsMemory(0, readSize), cancellationToken);
if (read <= 0)
{
throw new EndOfStreamException(
$"Unexpected end of stream while downloading range {segment.Start}-{segment.EndInclusive}.");
}
await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
segment.CompletedBytes += read;
reportDownloadedBytes(read);
metadataWriter.MarkDirty();
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
private async Task<RemoteProbeResult> ProbeRemoteFileAsync(Uri sourceUri, CancellationToken cancellationToken)
{
long? totalBytes = null;
var supportsRanges = false;
try
{
using var headRequest = new HttpRequestMessage(HttpMethod.Head, sourceUri);
using var headResponse = await _httpClient.SendAsync(
headRequest,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (headResponse.IsSuccessStatusCode)
{
totalBytes = headResponse.Content.Headers.ContentLength;
supportsRanges = headResponse.Headers.AcceptRanges.Any(
value => string.Equals(value, "bytes", StringComparison.OrdinalIgnoreCase));
}
}
catch
{
// Fall back to a small range probe when HEAD is unsupported or blocked.
}
if (supportsRanges && totalBytes is > 0)
{
return new RemoteProbeResult(totalBytes, true);
}
using var rangeRequest = new HttpRequestMessage(HttpMethod.Get, sourceUri);
rangeRequest.Headers.Range = new RangeHeaderValue(0, 0);
using var rangeResponse = await _httpClient.SendAsync(
rangeRequest,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (rangeResponse.StatusCode == HttpStatusCode.PartialContent)
{
totalBytes = rangeResponse.Content.Headers.ContentRange?.Length ?? totalBytes;
return new RemoteProbeResult(totalBytes, true);
}
rangeResponse.EnsureSuccessStatusCode();
totalBytes ??= rangeResponse.Content.Headers.ContentLength;
return new RemoteProbeResult(totalBytes, false);
}
private static async Task CopyStreamAsync(
Stream sourceStream,
Stream destinationStream,
long initialDownloadedBytes,
long? totalBytes,
bool isResuming,
bool isParallel,
int bufferSize,
IProgress<DownloadProgressInfo>? progress,
CancellationToken cancellationToken)
{
var buffer = ArrayPool<byte>.Shared.Rent(bufferSize);
var downloadedBytes = initialDownloadedBytes;
try
{
ReportProgress(progress, downloadedBytes, totalBytes, isResuming, isParallel);
while (true)
{
var read = await sourceStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken);
if (read <= 0)
{
break;
}
await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
downloadedBytes += read;
ReportProgress(progress, downloadedBytes, totalBytes, isResuming, isParallel);
}
await destinationStream.FlushAsync(cancellationToken);
ReportProgress(progress, downloadedBytes, totalBytes, isResuming, isParallel);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
private static void ReportProgress(
IProgress<DownloadProgressInfo>? progress,
long downloadedBytes,
long? totalBytes,
bool isResuming,
bool isParallel)
{
if (progress is null)
{
return;
}
double normalizedProgress;
if (totalBytes is > 0)
{
normalizedProgress = Math.Clamp(downloadedBytes / (double)totalBytes.Value, 0d, 1d);
}
else
{
normalizedProgress = 0d;
}
progress.Report(new DownloadProgressInfo(
downloadedBytes,
totalBytes,
normalizedProgress,
isResuming,
isParallel));
}
private static async Task<DownloadMetadata> LoadOrCreateMetadataAsync(
Uri sourceUri,
string tempFilePath,
string metadataFilePath,
long totalBytes,
int segmentCount,
CancellationToken cancellationToken)
{
if (File.Exists(metadataFilePath))
{
try
{
var json = await File.ReadAllTextAsync(metadataFilePath, cancellationToken);
var metadata = JsonSerializer.Deserialize<SerializableDownloadMetadata>(json);
if (metadata is not null)
{
var normalizedMetadata = metadata.ToRuntime();
if (string.Equals(normalizedMetadata.Source, sourceUri.ToString(), StringComparison.OrdinalIgnoreCase) &&
normalizedMetadata.TotalBytes == totalBytes &&
normalizedMetadata.Segments.Count > 0)
{
return normalizedMetadata.Normalize();
}
}
}
catch
{
// Reset invalid metadata below.
}
}
ResetPartialArtifacts(tempFilePath, metadataFilePath);
var createdMetadata = DownloadMetadata.Create(sourceUri.ToString(), totalBytes, segmentCount);
var serialized = JsonSerializer.Serialize(createdMetadata.ToSerializable(), MetadataSerializerOptions);
await File.WriteAllTextAsync(metadataFilePath, serialized, cancellationToken);
return createdMetadata;
}
private static DownloadOptions NormalizeOptions(DownloadOptions? options)
{
var normalized = options ?? new DownloadOptions();
var maxParallelSegments = Math.Clamp(normalized.MaxParallelSegments, 1, 8);
var parallelThresholdBytes = Math.Max(1_048_576, normalized.ParallelThresholdBytes);
var bufferSize = Math.Max(16 * 1024, normalized.BufferSize);
return normalized with
{
MaxParallelSegments = maxParallelSegments,
ParallelThresholdBytes = parallelThresholdBytes,
BufferSize = bufferSize
};
}
private static int CalculateRecommendedSegments(long totalBytes)
{
if (totalBytes < 16 * 1024 * 1024)
{
return 2;
}
if (totalBytes < 64 * 1024 * 1024)
{
return 4;
}
return 6;
}
private static bool CanReuseCompletedDestination(string destinationFilePath, long? expectedSizeBytes)
{
if (!File.Exists(destinationFilePath))
{
return false;
}
if (expectedSizeBytes is not > 0)
{
return false;
}
return new FileInfo(destinationFilePath).Length == expectedSizeBytes.Value;
}
private static void PrepareDestination(string destinationFilePath)
{
var directory = Path.GetDirectoryName(destinationFilePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
}
private static void CompleteDownload(string tempFilePath, string destinationFilePath, string metadataFilePath)
{
if (!File.Exists(tempFilePath))
{
return;
}
File.Move(tempFilePath, destinationFilePath, overwrite: true);
if (File.Exists(metadataFilePath))
{
File.Delete(metadataFilePath);
}
}
private static void CleanupPartialArtifacts(string tempFilePath, string metadataFilePath)
{
if (File.Exists(tempFilePath))
{
File.Delete(tempFilePath);
}
if (File.Exists(metadataFilePath))
{
File.Delete(metadataFilePath);
}
}
private static void ResetPartialArtifacts(string tempFilePath, string metadataFilePath)
{
CleanupPartialArtifacts(tempFilePath, metadataFilePath);
}
private static string BuildTempFilePath(string destinationFilePath) => destinationFilePath + ".part";
private static string BuildMetadataFilePath(string destinationFilePath) => destinationFilePath + ".part.json";
private sealed record RemoteProbeResult(long? TotalBytes, bool SupportsRanges);
private sealed class RangeRequestNotSupportedException : InvalidOperationException
{
public RangeRequestNotSupportedException(string message)
: base(message)
{
}
}
private sealed class MetadataWriter
{
private readonly string _metadataFilePath;
private readonly DownloadMetadata _metadata;
private readonly SemaphoreSlim _writeGate = new(1, 1);
private long _lastPersistedTickCount;
private int _dirty;
public MetadataWriter(string metadataFilePath, DownloadMetadata metadata)
{
_metadataFilePath = metadataFilePath;
_metadata = metadata;
_lastPersistedTickCount = Environment.TickCount64;
}
public void MarkDirty()
{
Interlocked.Exchange(ref _dirty, 1);
var now = Environment.TickCount64;
if (now - Interlocked.Read(ref _lastPersistedTickCount) < 750)
{
return;
}
_ = Task.Run(async () =>
{
try
{
await FlushAsync(CancellationToken.None);
}
catch
{
// The final flush still runs on completion/cancellation.
}
});
}
public async Task FlushAsync(CancellationToken cancellationToken)
{
if (Interlocked.Exchange(ref _dirty, 0) == 0 && File.Exists(_metadataFilePath))
{
return;
}
await _writeGate.WaitAsync(cancellationToken);
try
{
var json = JsonSerializer.Serialize(_metadata.ToSerializable(), MetadataSerializerOptions);
await File.WriteAllTextAsync(_metadataFilePath, json, cancellationToken);
Interlocked.Exchange(ref _lastPersistedTickCount, Environment.TickCount64);
}
finally
{
_writeGate.Release();
}
}
}
private sealed class DownloadMetadata
{
public string Source { get; init; } = string.Empty;
public long TotalBytes { get; init; }
public List<DownloadSegmentState> Segments { get; init; } = [];
public static DownloadMetadata Create(string source, long totalBytes, int segmentCount)
{
var segments = SplitIntoSegments(totalBytes, segmentCount)
.Select(range => new DownloadSegmentState(range.Start, range.EndInclusive, 0))
.ToList();
return new DownloadMetadata
{
Source = source,
TotalBytes = totalBytes,
Segments = segments
};
}
public DownloadMetadata Normalize()
{
foreach (var segment in Segments)
{
segment.CompletedBytes = Math.Clamp(segment.CompletedBytes, 0, segment.Length);
}
return this;
}
public SerializableDownloadMetadata ToSerializable()
{
return new SerializableDownloadMetadata
{
Source = Source,
TotalBytes = TotalBytes,
Segments = Segments
.Select(segment => new SerializableDownloadSegment
{
Start = segment.Start,
EndInclusive = segment.EndInclusive,
CompletedBytes = segment.CompletedBytes
})
.ToList()
};
}
}
private sealed class DownloadSegmentState
{
public DownloadSegmentState(long start, long endInclusive, long completedBytes)
{
Start = start;
EndInclusive = endInclusive;
CompletedBytes = completedBytes;
}
public long Start { get; }
public long EndInclusive { get; }
public long Length => EndInclusive - Start + 1;
public long CompletedBytes { get; set; }
}
private sealed class SerializableDownloadMetadata
{
public string Source { get; init; } = string.Empty;
public long TotalBytes { get; init; }
public List<SerializableDownloadSegment> Segments { get; init; } = [];
public DownloadMetadata ToRuntime()
{
return new DownloadMetadata
{
Source = Source,
TotalBytes = TotalBytes,
Segments = Segments
.Select(segment => new DownloadSegmentState(
segment.Start,
segment.EndInclusive,
segment.CompletedBytes))
.ToList()
};
}
}
private sealed class SerializableDownloadSegment
{
public long Start { get; init; }
public long EndInclusive { get; init; }
public long CompletedBytes { get; init; }
}
private static IEnumerable<(long Start, long EndInclusive)> SplitIntoSegments(long totalBytes, int segmentCount)
{
if (totalBytes <= 0)
{
yield break;
}
var normalizedSegmentCount = Math.Max(1, segmentCount);
var segmentSize = totalBytes / normalizedSegmentCount;
var remainder = totalBytes % normalizedSegmentCount;
long start = 0;
for (var index = 0; index < normalizedSegmentCount; index++)
{
var currentSegmentSize = segmentSize + (index < remainder ? 1 : 0);
if (currentSegmentSize <= 0)
{
continue;
}
var endInclusive = start + currentSegmentSize - 1;
yield return (start, endInclusive);
start = endInclusive + 1;
}
}
}

View File

@@ -119,6 +119,7 @@ public partial class MainWindow
SettingsNavUpdateItem.Content = L("settings.nav.update", "Update");
SettingsNavLauncherItem.Content = L("settings.nav.launcher", "App Launcher");
SettingsNavPluginsItem.Content = L("settings.nav.plugins", "Plugins");
SettingsNavPluginMarketItem.Content = L("settings.nav.plugin_market", "Plugin Market");
WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "Personalize your wallpaper");
WallpaperPlacementSettingsExpander.Header = L("settings.wallpaper.placement_label", "Placement");
@@ -180,6 +181,14 @@ public partial class MainWindow
StatusBarSpacingCustomPanel.Content = L("settings.status_bar.spacing_custom_label", "Custom spacing (%)");
WeatherPanelTitleTextBlock.Text = L("settings.weather.title", "Weather");
WeatherPreviewSectionTextBlock.Text = L("settings.weather.preview_section", "Weather Preview");
WeatherSettingsSectionTextBlock.Text = L("settings.weather.settings_section", "Settings");
WeatherPreviewSettingsExpander.Header = L("settings.weather.preview_panel_header", "Weather Preview");
WeatherPreviewSettingsExpander.Description = L(
"settings.weather.preview_panel_desc",
"Refresh and verify current weather service status.");
WeatherPreviewButton.Content = L("settings.weather.refresh_button", "Refresh");
WeatherLocationSettingsExpander.Header = L("settings.weather.location_source_header", "Location Source");
WeatherLocationSettingsExpander.Description = L(
"settings.weather.location_source_desc",
@@ -189,6 +198,10 @@ public partial class MainWindow
WeatherLocationModeCityChipItem.Content = L("settings.weather.mode_city_search", "City Search");
WeatherLocationModeCoordinatesChipItem.Content = L("settings.weather.mode_coordinates", "Coordinates");
WeatherAutoRefreshToggleSwitch.Content = L("settings.weather.auto_refresh", "Auto refresh location on startup");
WeatherLocationSelectionTitleTextBlock.Text = L("settings.weather.city_selection_label", "City Selection");
WeatherLocationSelectionDescriptionTextBlock.Text = L(
"settings.weather.location_city_summary_desc",
"Select the current city used for weather queries.");
WeatherCitySearchSettingsExpander.Header = L("settings.weather.city_search_header", "City Search");
WeatherCitySearchSettingsExpander.Description = L(
@@ -208,24 +221,12 @@ public partial class MainWindow
WeatherLocationNameTextBox.Watermark = L("settings.weather.location_name_placeholder", "Display name (optional)");
WeatherApplyCoordinatesButton.Content = L("settings.weather.apply_coordinates_button", "Apply Coordinates");
WeatherPreviewSettingsExpander.Header = L("settings.weather.preview_panel_header", "Weather Preview");
WeatherPreviewSettingsExpander.Description = L(
"settings.weather.preview_panel_desc",
"Refresh and verify current weather service status.");
WeatherPreviewButton.Content = L("settings.weather.refresh_button", "Refresh");
WeatherLocationSettingsExpander.Header = L("settings.weather.location_msg_header", "Location Source");
WeatherLocationSettingsExpander.Description = L(
"settings.weather.location_msg_desc",
"Choose how weather widgets resolve location.");
WeatherLocationModeCityChipItem.Content = L("settings.weather.mode_city", "City Search");
WeatherLocationModeCoordinatesChipItem.Content = L("settings.weather.mode_coordinates", "Coordinates");
WeatherAutoRefreshToggleSwitch.Content = L("settings.weather.auto_location_toggle", "Auto refresh location on startup");
WeatherAlertFilterSettingsExpander.Header = L("settings.weather.alert_filter_header", "Excluded Alerts");
WeatherAlertFilterSettingsExpander.Description = L(
"settings.weather.alert_filter_desc",
"Alerts containing these words will not be shown. One rule per line.");
WeatherAlertListTitleTextBlock.Text = L("settings.weather.alert_list_label", "Exclude List");
WeatherAlertListDescriptionTextBlock.Text = L("settings.weather.alert_list_desc", "One exclusion rule per line.");
WeatherExcludedAlertsTextBox.Watermark = L("settings.weather.alert_filter_placeholder", "One keyword per line");
WeatherIconPackSettingsExpander.Header = L("settings.weather.icon_style_header", "Weather Icon Style");
@@ -239,6 +240,10 @@ public partial class MainWindow
WeatherNoTlsSettingsExpander.Description = L(
"settings.weather.no_tls_desc",
"Not recommended. Enable only for incompatible network environments.");
WeatherNoTlsToggleSwitch.Content = L("settings.weather.no_tls_toggle", "Allow non-TLS request fallback");
WeatherFooterHintTextBlock.Text = L(
"settings.weather.footer_hint",
"Desktop weather widgets will reuse the location and alert exclusion settings configured here.");
if (string.IsNullOrWhiteSpace(_weatherSearchKeyword))
{
@@ -279,6 +284,7 @@ public partial class MainWindow
LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items.");
ApplyPluginSettingsLocalization();
ApplyPluginMarketSettingsLocalization();
SettingsNavAboutItem.Content = L("settings.nav.about", "About");
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
@@ -418,6 +424,7 @@ public partial class MainWindow
WeatherLocationStatusTextBlock.Text = L(
"settings.weather.status_city_empty",
"No city location is configured.");
UpdateWeatherLocationSummaryCard();
return;
}
@@ -430,6 +437,7 @@ public partial class MainWindow
modeText,
locationName,
_weatherLocationKey);
UpdateWeatherLocationSummaryCard();
return;
}
@@ -442,6 +450,7 @@ public partial class MainWindow
string.IsNullOrWhiteSpace(_weatherLocationKey)
? BuildCoordinateLocationKey(_weatherLatitude, _weatherLongitude)
: _weatherLocationKey);
UpdateWeatherLocationSummaryCard();
}
}

View File

@@ -115,7 +115,8 @@ public partial class MainWindow
UpdateSettingsPanel is null ||
LauncherSettingsPanel is null ||
AboutSettingsPanel is null ||
PluginSettingsPanel is null)
PluginSettingsPanel is null ||
PluginMarketSettingsPanel is null)
{
return;
}
@@ -133,6 +134,7 @@ public partial class MainWindow
AboutSettingsPanel.IsVisible = tag == "About";
LauncherSettingsPanel.IsVisible = tag == "Launcher";
PluginSettingsPanel.IsVisible = tag == "Plugins";
PluginMarketSettingsPanel.IsVisible = tag == "PluginMarket";
UpdatePluginSettingsPageVisibility(tag);
if (tag == "Launcher")
@@ -140,6 +142,16 @@ public partial class MainWindow
RenderLauncherHiddenItemsList();
}
if (tag == "Plugins")
{
PluginSettingsPanel.RefreshFromRuntime();
}
if (tag == "PluginMarket")
{
PluginMarketSettingsPanel.RefreshFromRuntime();
}
if (tag == "Grid")
{
UpdateGridPreviewLayout();
@@ -1396,6 +1408,8 @@ public partial class MainWindow
{
WeatherCoordinateSettingsExpander.IsVisible = _weatherLocationMode == WeatherLocationMode.Coordinates;
}
UpdateWeatherLocationSummaryCard();
}
private void OnWeatherLocationModeSelectionChanged(object? sender, SelectionChangedEventArgs e)
@@ -1879,7 +1893,7 @@ public partial class MainWindow
var weather = snapshot.Current.WeatherText ??
L("settings.weather.preview_unknown", "Unknown");
var temperature = snapshot.Current.TemperatureC.HasValue
? string.Create(CultureInfo.InvariantCulture, $"{snapshot.Current.TemperatureC.Value:F1} C")
? FormatWeatherPreviewTemperature(snapshot.Current.TemperatureC.Value)
: "--";
var updatedAt = snapshot.ObservationTime ?? snapshot.FetchedAt;
@@ -1922,6 +1936,14 @@ public partial class MainWindow
private void UpdateWeatherPreviewSummary(int? weatherCode, string temperatureText, DateTimeOffset? updatedAt)
{
if (WeatherPreviewIconImage is not null)
{
var kind = HyperOS3WeatherTheme.ResolveVisualKind(weatherCode, _isNightMode);
WeatherPreviewIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage(
HyperOS3WeatherTheme.ResolveIconAsset(kind)) ??
HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveHeroIconAsset(kind));
}
if (WeatherPreviewIconSymbol is not null)
{
WeatherPreviewIconSymbol.Symbol = ResolveWeatherPreviewSymbol(weatherCode, _isNightMode);
@@ -1941,10 +1963,15 @@ public partial class MainWindow
}
WeatherPreviewUpdatedTextBlock.Text = updatedAt.HasValue
? Lf("weather.widget.updated_format", "Updated {0:HH:mm}", updatedAt.Value.LocalDateTime)
? updatedAt.Value.LocalDateTime.ToString("yyyy/M/d HH:mm:ss", CultureInfo.InvariantCulture)
: "-";
}
private static string FormatWeatherPreviewTemperature(double temperatureC)
{
return string.Create(CultureInfo.InvariantCulture, $"{temperatureC:0.#}°C");
}
private static Symbol ResolveWeatherPreviewSymbol(int? weatherCode, bool isNight)
{
return weatherCode switch
@@ -1960,6 +1987,38 @@ public partial class MainWindow
};
}
private void UpdateWeatherLocationSummaryCard()
{
if (WeatherLocationSelectionTitleTextBlock is null ||
WeatherLocationSelectionDescriptionTextBlock is null ||
WeatherLocationValueTextBlock is null)
{
return;
}
if (_weatherLocationMode == WeatherLocationMode.Coordinates)
{
WeatherLocationSelectionTitleTextBlock.Text = L("settings.weather.coordinates_selection_label", "Coordinate Location");
WeatherLocationSelectionDescriptionTextBlock.Text = L(
"settings.weather.location_coordinates_summary_desc",
"Set latitude/longitude and optional location name used for weather queries.");
WeatherLocationValueTextBlock.Text = string.IsNullOrWhiteSpace(_weatherLocationName)
? string.Create(CultureInfo.InvariantCulture, $"{_weatherLatitude:F4}, {_weatherLongitude:F4}")
: _weatherLocationName;
return;
}
WeatherLocationSelectionTitleTextBlock.Text = L("settings.weather.city_selection_label", "City Selection");
WeatherLocationSelectionDescriptionTextBlock.Text = L(
"settings.weather.location_city_summary_desc",
"Select the current city used for weather queries.");
WeatherLocationValueTextBlock.Text = !string.IsNullOrWhiteSpace(_weatherLocationName)
? _weatherLocationName
: !string.IsNullOrWhiteSpace(_weatherLocationKey)
? _weatherLocationKey
: L("settings.weather.location_not_selected", "No location selected");
}
private void SetWeatherSearchBusy(bool isBusy)
{
if (WeatherSearchButton is not null)
@@ -2661,6 +2720,8 @@ public partial class MainWindow
// --- WeatherSettingsPage ---
internal TextBlock WeatherPanelTitleTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPanelTitleTextBlock")!;
internal TextBlock WeatherPreviewSectionTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPreviewSectionTextBlock")!;
internal TextBlock WeatherSettingsSectionTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherSettingsSectionTextBlock")!;
internal FluentAvalonia.UI.Controls.SettingsExpander WeatherPreviewSettingsExpander => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("WeatherPreviewSettingsExpander")!;
internal FluentAvalonia.UI.Controls.SettingsExpander WeatherLocationSettingsExpander => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("WeatherLocationSettingsExpander")!;
internal FluentAvalonia.UI.Controls.SettingsExpander WeatherCitySearchSettingsExpander => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("WeatherCitySearchSettingsExpander")!;
@@ -2691,6 +2752,7 @@ public partial class MainWindow
internal FluentAvalonia.UI.Controls.NumberBox WeatherLongitudeNumberBox => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.NumberBox>("WeatherLongitudeNumberBox")!;
internal TextBlock WeatherCoordinateStatusTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherCoordinateStatusTextBlock")!;
internal TextBlock WeatherPreviewResultTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPreviewResultTextBlock")!;
internal Image WeatherPreviewIconImage => WeatherSettingsPanel.FindControl<Image>("WeatherPreviewIconImage")!;
internal FluentIcons.Avalonia.Fluent.SymbolIcon WeatherPreviewIconSymbol => WeatherSettingsPanel.FindControl<FluentIcons.Avalonia.Fluent.SymbolIcon>("WeatherPreviewIconSymbol")!;
internal TextBlock WeatherPreviewTemperatureTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPreviewTemperatureTextBlock")!;
internal TextBlock WeatherPreviewUpdatedTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPreviewUpdatedTextBlock")!;
@@ -2698,7 +2760,13 @@ public partial class MainWindow
internal FluentAvalonia.UI.Controls.ProgressRing WeatherPreviewProgressRing => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.ProgressRing>("WeatherPreviewProgressRing")!;
internal ComboBoxItem WeatherIconPackFluentRegularItem => WeatherSettingsPanel.FindControl<ComboBoxItem>("WeatherIconPackFluentRegularItem")!;
internal ComboBoxItem WeatherIconPackFluentFilledItem => WeatherSettingsPanel.FindControl<ComboBoxItem>("WeatherIconPackFluentFilledItem")!;
internal TextBlock WeatherLocationSelectionTitleTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherLocationSelectionTitleTextBlock")!;
internal TextBlock WeatherLocationSelectionDescriptionTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherLocationSelectionDescriptionTextBlock")!;
internal TextBlock WeatherLocationValueTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherLocationValueTextBlock")!;
internal TextBlock WeatherLocationStatusTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherLocationStatusTextBlock")!;
internal TextBlock WeatherAlertListTitleTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherAlertListTitleTextBlock")!;
internal TextBlock WeatherAlertListDescriptionTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherAlertListDescriptionTextBlock")!;
internal TextBlock WeatherFooterHintTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherFooterHintTextBlock")!;
// --- UpdateSettingsPage ---
internal TextBlock UpdatePanelTitleTextBlock => UpdateSettingsPanel.FindControl<TextBlock>("UpdatePanelTitleTextBlock")!;

View File

@@ -436,6 +436,11 @@
<ic:SymbolIconSource Symbol="PuzzlePiece" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavPluginMarketItem" Content="插件市场" Tag="PluginMarket" ToolTip.Tip="插件市场">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="PuzzlePiece" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
</ui:NavigationView.MenuItems>
<ScrollViewer x:Name="SettingsContentScrollViewer"
@@ -459,6 +464,7 @@
<pages:LauncherSettingsPage x:Name="LauncherSettingsPanel" IsVisible="False" />
<pages:AboutSettingsPage x:Name="AboutSettingsPanel" IsVisible="False" />
<pages:PluginSettingsPage x:Name="PluginSettingsPanel" IsVisible="False" />
<pages:PluginMarketSettingsPage x:Name="PluginMarketSettingsPanel" IsVisible="False" />
</Grid>
</ScrollViewer>
</ui:NavigationView>

View File

@@ -205,8 +205,7 @@ public partial class MainWindow : Window
GridEdgeInsetSlider.ValueChanged += OnGridEdgeInsetSliderChanged;
ApplyGridButton.Click += OnApplyGridSizeClick;
NightModeToggleSwitch.Checked += OnNightModeChecked;
NightModeToggleSwitch.Unchecked += OnNightModeUnchecked;
NightModeToggleSwitch.IsCheckedChanged += OnNightModeIsCheckedChanged;
RecommendedColorButton1.Click += OnRecommendedColorClick;
RecommendedColorButton2.Click += OnRecommendedColorClick;
RecommendedColorButton3.Click += OnRecommendedColorClick;
@@ -221,40 +220,67 @@ public partial class MainWindow : Window
MonetColorButton5.Click += OnMonetColorClick;
MonetColorButton6.Click += OnMonetColorClick;
StatusBarClockToggleSwitch.Checked += OnStatusBarClockChecked;
StatusBarClockToggleSwitch.Unchecked += OnStatusBarClockUnchecked;
ClockFormatHMSSRadio.Checked += OnClockFormatChanged;
ClockFormatHMRadio.Checked += OnClockFormatChanged;
StatusBarClockToggleSwitch.IsCheckedChanged += OnStatusBarClockIsCheckedChanged;
ClockFormatHMSSRadio.IsCheckedChanged += OnClockFormatChanged;
ClockFormatHMRadio.IsCheckedChanged += OnClockFormatChanged;
StatusBarSpacingModeComboBox.SelectionChanged += OnStatusBarSpacingModeChanged;
StatusBarSpacingSlider.ValueChanged += OnStatusBarSpacingSliderChanged;
WeatherPreviewButton.Click += OnTestWeatherRequestClick;
WeatherLocationModeComboBox.SelectionChanged += OnWeatherLocationModeSelectionChanged;
WeatherLocationModeChipListBox.SelectionChanged += OnWeatherLocationModeChipSelectionChanged;
WeatherAutoRefreshToggleSwitch.Checked += OnWeatherAutoRefreshToggled;
WeatherAutoRefreshToggleSwitch.Unchecked += OnWeatherAutoRefreshToggled;
WeatherAutoRefreshToggleSwitch.IsCheckedChanged += OnWeatherAutoRefreshToggled;
WeatherSearchButton.Click += OnSearchWeatherCityClick;
WeatherApplyCityButton.Click += OnApplyWeatherCitySelectionClick;
WeatherApplyCoordinatesButton.Click += OnApplyWeatherCoordinatesClick;
WeatherExcludedAlertsTextBox.LostFocus += OnWeatherExcludedAlertsLostFocus;
WeatherIconPackComboBox.SelectionChanged += OnWeatherIconPackSelectionChanged;
WeatherNoTlsToggleSwitch.Checked += OnWeatherNoTlsToggled;
WeatherNoTlsToggleSwitch.Unchecked += OnWeatherNoTlsToggled;
WeatherNoTlsToggleSwitch.IsCheckedChanged += OnWeatherNoTlsToggled;
LanguageComboBox.SelectionChanged += OnLanguageSelectionChanged;
TimeZoneComboBox.SelectionChanged += OnTimeZoneSelectionChanged;
AutoCheckUpdatesToggleSwitch.Checked += OnAutoCheckUpdatesToggled;
AutoCheckUpdatesToggleSwitch.Unchecked += OnAutoCheckUpdatesToggled;
AutoCheckUpdatesToggleSwitch.IsCheckedChanged += OnAutoCheckUpdatesToggled;
UpdateChannelChipListBox.SelectionChanged += OnUpdateChannelSelectionChanged;
CheckForUpdatesButton.Click += OnCheckForUpdatesClick;
DownloadAndInstallUpdateButton.Click += OnDownloadAndInstallUpdateClick;
AutoStartWithWindowsToggleSwitch.Checked += OnAutoStartWithWindowsToggled;
AutoStartWithWindowsToggleSwitch.Unchecked += OnAutoStartWithWindowsToggled;
AutoStartWithWindowsToggleSwitch.IsCheckedChanged += OnAutoStartWithWindowsToggled;
AppRenderModeComboBox.SelectionChanged += OnAppRenderModeSelectionChanged;
}
private void OnNightModeIsCheckedChanged(object? sender, RoutedEventArgs e)
{
if (sender is not ToggleButton toggleButton)
{
return;
}
if (toggleButton.IsChecked == true)
{
OnNightModeChecked(sender, e);
return;
}
OnNightModeUnchecked(sender, e);
}
private void OnStatusBarClockIsCheckedChanged(object? sender, RoutedEventArgs e)
{
if (sender is not ToggleButton toggleButton)
{
return;
}
if (toggleButton.IsChecked == true)
{
OnStatusBarClockChecked(sender, e);
return;
}
OnStatusBarClockUnchecked(sender, e);
}
protected override void OnOpened(EventArgs e)
{
base.OnOpened(e);
@@ -787,6 +813,11 @@ public partial class MainWindow : Window
return;
}
if (radioButton.IsChecked != true)
{
return;
}
_clockDisplayFormat = formatTag == "Hm"
? ClockDisplayFormat.HourMinute
: ClockDisplayFormat.HourMinuteSecond;

View File

@@ -4,91 +4,116 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="1200"
mc:Ignorable="d" d:DesignWidth="860" d:DesignHeight="1200"
x:Class="LanMountainDesktop.Views.SettingsPages.WeatherSettingsPage">
<UserControl.Styles>
<Style Selector="StackPanel.weather-settings-root TextBlock.section-eyebrow">
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextSecondaryBrush}" />
</Style>
<Style Selector="StackPanel.weather-settings-root Border.preview-icon-shell">
<Setter Property="Width" Value="62" />
<Setter Property="Height" Value="62" />
<Setter Property="CornerRadius" Value="18" />
<Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveButtonBorderBrush}" />
<Setter Property="Padding" Value="10" />
</Style>
<Style Selector="StackPanel.weather-settings-root Border.settings-note-shell">
<Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
<Setter Property="Padding" Value="14,12" />
</Style>
<Style Selector="StackPanel.weather-settings-root Border.settings-expander-shell">
<Setter Property="Margin" Value="0" />
</Style>
</UserControl.Styles>
<StackPanel x:Name="WeatherSettingsContentPanel"
Classes="settings-animated-intro weather-settings-root"
Margin="0,0,8,0"
Spacing="16">
Spacing="12">
<TextBlock x:Name="WeatherPanelTitleTextBlock"
FontSize="24"
FontSize="28"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="Weather" />
<!-- Weather Preview Card -->
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="WeatherPreviewSettingsExpander"
Header="Weather Preview"
Description="Refresh and verify current weather service status."
IsExpanded="True">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="WeatherSunny" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Name="WeatherPreviewButton"
Padding="12,8"
Content="Refresh" />
<ui:ProgressRing x:Name="WeatherPreviewProgressRing"
Width="20"
Height="20"
IsActive="True"
IsVisible="False" />
</StackPanel>
</ui:SettingsExpander.Footer>
<ui:SettingsExpanderItem>
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12">
<Border Width="44"
Height="44"
CornerRadius="{DynamicResource DesignCornerRadiusXs}"
BorderThickness="1"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
Background="{DynamicResource AdaptiveButtonBackgroundBrush}">
<fi:SymbolIcon x:Name="WeatherPreviewIconSymbol"
Symbol="WeatherSunny"
IconVariant="Regular"
FontSize="22"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
<StackPanel Spacing="8">
<TextBlock x:Name="WeatherPreviewSectionTextBlock"
Classes="section-eyebrow"
Text="Weather Preview" />
<Border Classes="settings-expander-shell"
Padding="18,16">
<Grid RowDefinitions="Auto,Auto"
RowSpacing="10">
<Grid ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="14">
<Border Classes="preview-icon-shell">
<Image x:Name="WeatherPreviewIconImage"
Stretch="Uniform" />
</Border>
<StackPanel Grid.Column="1"
VerticalAlignment="Center"
Spacing="2">
Spacing="3">
<TextBlock x:Name="WeatherPreviewTemperatureTextBlock"
FontSize="22"
FontSize="34"
FontWeight="SemiBold"
Text="--°" />
Text="--" />
<TextBlock x:Name="WeatherPreviewUpdatedTextBlock"
FontSize="12"
FontSize="13"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="-" />
</StackPanel>
<StackPanel Grid.Column="2"
Orientation="Horizontal"
Spacing="8"
VerticalAlignment="Center">
<Button x:Name="WeatherPreviewButton"
Padding="16,8"
Content="Refresh" />
<ui:ProgressRing x:Name="WeatherPreviewProgressRing"
Width="20"
Height="20"
IsActive="True"
IsVisible="False" />
</StackPanel>
</Grid>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem>
<TextBlock x:Name="WeatherPreviewResultTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Grid.Row="1"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="Use refresh to verify your weather configuration." />
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
</Border>
</Grid>
</Border>
</StackPanel>
<Border Background="{DynamicResource SurfaceStrokeColorDefaultBrush}"
Height="1" />
<TextBlock x:Name="WeatherSettingsSectionTextBlock"
Classes="section-eyebrow"
Text="Settings" />
<!-- Location Source Card -->
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="WeatherLocationSettingsExpander"
Header="Location Source"
Description="Choose how weather widgets resolve location."
IsExpanded="True">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Location" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ListBox x:Name="WeatherLocationModeChipListBox"
Classes="settings-chip-list"
HorizontalAlignment="Right"
SelectionMode="Single">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
@@ -104,6 +129,39 @@
</ListBox>
</ui:SettingsExpander.Footer>
<ui:SettingsExpanderItem>
<Grid ColumnDefinitions="*,Auto"
ColumnSpacing="18">
<StackPanel Spacing="4">
<TextBlock x:Name="WeatherLocationSelectionTitleTextBlock"
FontSize="17"
FontWeight="SemiBold"
Text="City Selection" />
<TextBlock x:Name="WeatherLocationSelectionDescriptionTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
TextWrapping="Wrap"
Text="Select the current city used for weather queries." />
</StackPanel>
<StackPanel Grid.Column="1"
MaxWidth="420"
HorizontalAlignment="Right"
Spacing="4">
<TextBlock x:Name="WeatherLocationValueTextBlock"
FontSize="17"
FontWeight="SemiBold"
TextAlignment="Right"
TextWrapping="Wrap"
Text="No location selected" />
<TextBlock x:Name="WeatherLocationStatusTextBlock"
FontSize="12"
TextAlignment="Right"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
</StackPanel>
</Grid>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem>
<ui:SettingsExpanderItem.Footer>
<ToggleSwitch x:Name="WeatherAutoRefreshToggleSwitch"
@@ -111,16 +169,18 @@
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<!-- ComboBox hidden as in original -->
<ComboBox x:Name="WeatherLocationModeComboBox"
IsVisible="False">
<ComboBoxItem x:Name="WeatherLocationModeCityItem" Tag="CitySearch" Content="City Search" />
<ComboBoxItem x:Name="WeatherLocationModeCoordinatesItem" Tag="Coordinates" Content="Coordinates" />
<ComboBoxItem x:Name="WeatherLocationModeCityItem"
Tag="CitySearch"
Content="City Search" />
<ComboBoxItem x:Name="WeatherLocationModeCoordinatesItem"
Tag="Coordinates"
Content="Coordinates" />
</ComboBox>
</ui:SettingsExpander>
</Border>
<!-- City Search Card -->
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="WeatherCitySearchSettingsExpander"
Header="City Search"
@@ -128,38 +188,42 @@
IsExpanded="True">
<ui:SettingsExpander.Footer>
<Button x:Name="WeatherApplyCityButton"
Padding="12,8"
Padding="14,8"
Content="Apply City" />
</ui:SettingsExpander.Footer>
<ui:SettingsExpanderItem Content="Advanced Filters">
<StackPanel Spacing="10">
<Grid ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<ui:SettingsExpanderItem>
<StackPanel Spacing="12">
<Grid ColumnDefinitions="*,Auto,Auto"
ColumnSpacing="10">
<TextBox x:Name="WeatherCitySearchTextBox"
Watermark="e.g. Beijing" />
<ui:ProgressRing x:Name="WeatherSearchProgressRing"
Grid.Column="1"
Width="24"
Height="24"
Width="22"
Height="22"
IsActive="True"
IsVisible="False" />
<Button x:Name="WeatherSearchButton"
Grid.Column="2"
Padding="12,8"
Padding="14,8"
Content="Search" />
</Grid>
<ComboBox x:Name="WeatherCityResultsComboBox"
Width="320" />
HorizontalAlignment="Stretch"
MinWidth="320" />
<TextBlock x:Name="WeatherSearchStatusTextBlock"
FontSize="12"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
TextWrapping="Wrap"
Text="Search by city name and apply one location." />
</StackPanel>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
</Border>
<!-- Coordinates Card -->
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="WeatherCoordinateSettingsExpander"
Header="Coordinates"
@@ -168,13 +232,14 @@
IsExpanded="True">
<ui:SettingsExpander.Footer>
<Button x:Name="WeatherApplyCoordinatesButton"
Padding="12,8"
Padding="14,8"
Content="Apply Coordinates" />
</ui:SettingsExpander.Footer>
<ui:SettingsExpanderItem>
<StackPanel Spacing="12">
<Grid ColumnDefinitions="*,*" ColumnSpacing="10">
<Grid ColumnDefinitions="*,*"
ColumnSpacing="10">
<ui:NumberBox x:Name="WeatherLatitudeNumberBox"
Grid.Column="0"
Header="Latitude"
@@ -194,65 +259,96 @@
LargeChange="1"
Value="116.4074" />
</Grid>
<TextBox x:Name="WeatherLocationKeyTextBox"
Watermark="Location key (optional)" />
<TextBox x:Name="WeatherLocationNameTextBox"
Watermark="Display name (optional)" />
<TextBlock x:Name="WeatherCoordinateStatusTextBlock"
FontSize="12"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
TextWrapping="Wrap" />
</StackPanel>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
</Border>
<!-- Excluded Alerts Card -->
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="WeatherAlertFilterSettingsExpander"
Header="Excluded Alerts"
Description="Alerts containing these words will not be shown. One rule per line."
IsExpanded="True">
<ui:SettingsExpanderItem>
<TextBox x:Name="WeatherExcludedAlertsTextBox"
MinHeight="96"
MaxHeight="220"
Width="360"
TextWrapping="Wrap"
AcceptsReturn="True" />
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="20">
<StackPanel Width="220"
Spacing="4">
<TextBlock x:Name="WeatherAlertListTitleTextBlock"
FontSize="17"
FontWeight="SemiBold"
Text="Exclude List" />
<TextBlock x:Name="WeatherAlertListDescriptionTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
TextWrapping="Wrap"
Text="One exclusion rule per line." />
</StackPanel>
<TextBox x:Name="WeatherExcludedAlertsTextBox"
Grid.Column="1"
MinHeight="96"
MaxHeight="220"
HorizontalAlignment="Stretch"
AcceptsReturn="True"
TextWrapping="Wrap"
Watermark="One keyword per line" />
</Grid>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
</Border>
<!-- Weather Style Card -->
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="WeatherIconPackSettingsExpander"
Header="Weather Icon Style"
Description="Choose Fluent Icon style for weather symbols."
IsExpanded="True">
<ui:SettingsExpander.Footer>
<ComboBox x:Name="WeatherIconPackComboBox"
Width="240">
<ComboBoxItem x:Name="WeatherIconPackFluentRegularItem" Tag="FluentRegular" Content="Fluent Regular" />
<ComboBoxItem x:Name="WeatherIconPackFluentFilledItem" Tag="FluentFilled" Content="Fluent Filled" />
</ComboBox>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Border>
<!-- No TLS Card -->
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="WeatherNoTlsSettingsExpander"
Header="No TLS Weather Request"
Description="Not recommended. Enable only for incompatible network environments."
IsExpanded="True">
<ui:SettingsExpander.Footer>
<ToggleSwitch x:Name="WeatherNoTlsToggleSwitch" />
<ToggleSwitch x:Name="WeatherNoTlsToggleSwitch"
Content="Allow non-TLS request fallback" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Border>
<TextBlock x:Name="WeatherLocationStatusTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="No city location is configured." />
<Border Classes="settings-note-shell">
<TextBlock x:Name="WeatherFooterHintTextBlock"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="Desktop weather widgets will reuse the location and alert exclusion settings configured here." />
</Border>
<Grid IsVisible="False">
<ui:SettingsExpander x:Name="WeatherPreviewSettingsExpander"
Header="Weather Preview"
Description="Refresh and verify current weather service status." />
<fi:SymbolIcon x:Name="WeatherPreviewIconSymbol"
Symbol="WeatherSunny"
IconVariant="Regular" />
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="WeatherIconPackSettingsExpander"
Header="Weather Icon Style"
Description="Choose Fluent Icon style for weather symbols.">
<ui:SettingsExpander.Footer>
<ComboBox x:Name="WeatherIconPackComboBox"
Width="220">
<ComboBoxItem x:Name="WeatherIconPackFluentRegularItem"
Tag="FluentRegular"
Content="Fluent Regular" />
<ComboBoxItem x:Name="WeatherIconPackFluentFilledItem"
Tag="FluentFilled"
Content="Fluent Filled" />
</ComboBox>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Border>
</Grid>
</StackPanel>
</UserControl>

View File

@@ -126,6 +126,8 @@ public partial class SettingsWindow
// --- WeatherSettingsPage ---
internal TextBlock WeatherPanelTitleTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPanelTitleTextBlock")!;
internal TextBlock WeatherPreviewSectionTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPreviewSectionTextBlock")!;
internal TextBlock WeatherSettingsSectionTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherSettingsSectionTextBlock")!;
internal FluentAvalonia.UI.Controls.SettingsExpander WeatherPreviewSettingsExpander => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("WeatherPreviewSettingsExpander")!;
internal FluentAvalonia.UI.Controls.SettingsExpander WeatherLocationSettingsExpander => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("WeatherLocationSettingsExpander")!;
internal FluentAvalonia.UI.Controls.SettingsExpander WeatherCitySearchSettingsExpander => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("WeatherCitySearchSettingsExpander")!;
@@ -156,6 +158,7 @@ public partial class SettingsWindow
internal FluentAvalonia.UI.Controls.NumberBox WeatherLongitudeNumberBox => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.NumberBox>("WeatherLongitudeNumberBox")!;
internal TextBlock WeatherCoordinateStatusTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherCoordinateStatusTextBlock")!;
internal TextBlock WeatherPreviewResultTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPreviewResultTextBlock")!;
internal Image WeatherPreviewIconImage => WeatherSettingsPanel.FindControl<Image>("WeatherPreviewIconImage")!;
internal FluentIcons.Avalonia.Fluent.SymbolIcon WeatherPreviewIconSymbol => WeatherSettingsPanel.FindControl<FluentIcons.Avalonia.Fluent.SymbolIcon>("WeatherPreviewIconSymbol")!;
internal TextBlock WeatherPreviewTemperatureTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPreviewTemperatureTextBlock")!;
internal TextBlock WeatherPreviewUpdatedTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPreviewUpdatedTextBlock")!;
@@ -163,7 +166,13 @@ public partial class SettingsWindow
internal FluentAvalonia.UI.Controls.ProgressRing WeatherPreviewProgressRing => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.ProgressRing>("WeatherPreviewProgressRing")!;
internal ComboBoxItem WeatherIconPackFluentRegularItem => WeatherSettingsPanel.FindControl<ComboBoxItem>("WeatherIconPackFluentRegularItem")!;
internal ComboBoxItem WeatherIconPackFluentFilledItem => WeatherSettingsPanel.FindControl<ComboBoxItem>("WeatherIconPackFluentFilledItem")!;
internal TextBlock WeatherLocationSelectionTitleTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherLocationSelectionTitleTextBlock")!;
internal TextBlock WeatherLocationSelectionDescriptionTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherLocationSelectionDescriptionTextBlock")!;
internal TextBlock WeatherLocationValueTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherLocationValueTextBlock")!;
internal TextBlock WeatherLocationStatusTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherLocationStatusTextBlock")!;
internal TextBlock WeatherAlertListTitleTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherAlertListTitleTextBlock")!;
internal TextBlock WeatherAlertListDescriptionTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherAlertListDescriptionTextBlock")!;
internal TextBlock WeatherFooterHintTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherFooterHintTextBlock")!;
// --- UpdateSettingsPage ---
internal TextBlock UpdatePanelTitleTextBlock => UpdateSettingsPanel.FindControl<TextBlock>("UpdatePanelTitleTextBlock")!;

View File

@@ -48,10 +48,167 @@ public partial class SettingsWindow
base.OnClosed(e);
}
private void OnSettingsNavSelectionChanged(object? sender, FluentAvalonia.UI.Controls.NavigationViewSelectionChangedEventArgs e)
private void InitializeSettingsNavigation()
{
_settingsNavItems.Clear();
_pluginSettingsNavItems.Clear();
SettingsPrimaryNavHost.Children.Clear();
SettingsSecondaryNavHost.Children.Clear();
SettingsPluginNavHost.Children.Clear();
SettingsPluginNavSection.IsVisible = false;
AddSettingsNavItem(SettingsPrimaryNavHost, "Wallpaper", Symbol.Wallpaper, "Wallpaper");
AddSettingsNavItem(SettingsPrimaryNavHost, "Grid", Symbol.Grid, "Grid");
AddSettingsNavItem(SettingsPrimaryNavHost, "Color", Symbol.Color, "Color");
AddSettingsNavItem(SettingsPrimaryNavHost, "StatusBar", Symbol.Status, "Status Bar");
AddSettingsNavItem(SettingsPrimaryNavHost, "Weather", Symbol.WeatherSunny, "Weather");
AddSettingsNavItem(SettingsSecondaryNavHost, "Region", Symbol.Globe, "Region");
AddSettingsNavItem(SettingsSecondaryNavHost, "Launcher", Symbol.Apps, "App Launcher");
AddSettingsNavItem(SettingsSecondaryNavHost, "Update", Symbol.ArrowSync, "Update");
AddSettingsNavItem(SettingsSecondaryNavHost, "About", Symbol.Info, "About");
AddSettingsNavItem(SettingsSecondaryNavHost, "Plugins", Symbol.PuzzlePiece, "Plugins");
AddSettingsNavItem(SettingsSecondaryNavHost, "PluginMarket", Symbol.PuzzlePiece, "Plugin Market");
}
private void OnSettingsNavItemClick(object? sender, RoutedEventArgs e)
{
if (sender is not Button button || button.Tag is not string tag)
{
return;
}
SelectSettingsTab(tag, persistSelection: true);
}
private Button AddSettingsNavItem(Panel host, string tag, Symbol symbol, string title)
{
var button = CreateSettingsNavItem(tag, symbol, title);
host.Children.Add(button);
_settingsNavItems[tag] = button;
return button;
}
private Button CreateSettingsNavItem(string tag, Symbol symbol, string title)
{
var icon = new SymbolIcon
{
Symbol = symbol,
IconVariant = IconVariant.Regular
};
icon.Classes.Add("settings-nav-icon");
var iconShell = new Border
{
Child = icon,
Classes = { "settings-sidebar-icon-shell" }
};
var label = new TextBlock
{
Text = title,
Classes = { "settings-nav-label" }
};
var contentGrid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
ColumnSpacing = 12
};
contentGrid.Children.Add(iconShell);
contentGrid.Children.Add(label);
Grid.SetColumn(label, 1);
var button = new Button
{
Tag = tag,
Content = contentGrid,
Classes = { "settings-sidebar-item" }
};
button.Click += OnSettingsNavItemClick;
return button;
}
private IEnumerable<Button> EnumerateSettingsNavItems()
{
foreach (var button in SettingsPrimaryNavHost.Children.OfType<Button>())
{
yield return button;
}
foreach (var button in SettingsSecondaryNavHost.Children.OfType<Button>())
{
yield return button;
}
foreach (var button in SettingsPluginNavHost.Children.OfType<Button>())
{
yield return button;
}
}
private Button? GetSettingsNavItem(string tag)
{
if (_settingsNavItems.TryGetValue(tag, out var builtIn))
{
return builtIn;
}
return _pluginSettingsNavItems.GetValueOrDefault(tag);
}
private static void SetSettingsNavItemLabel(Button? button, string text)
{
if (button?.Content is Grid grid)
{
var label = grid.Children
.OfType<TextBlock>()
.FirstOrDefault(textBlock => textBlock.Classes.Contains("settings-nav-label"));
if (label is not null)
{
label.Text = text;
}
}
}
private void SelectSettingsTab(string? tag, bool persistSelection)
{
if (string.IsNullOrWhiteSpace(tag))
{
return;
}
var selectedButton = GetSettingsNavItem(tag);
if (selectedButton is null)
{
return;
}
_selectedSettingsTabTag = tag;
foreach (var button in EnumerateSettingsNavItems())
{
var isSelected = ReferenceEquals(button, selectedButton);
if (isSelected)
{
if (!button.Classes.Contains("nav-selected"))
{
button.Classes.Add("nav-selected");
}
}
else
{
button.Classes.Remove("nav-selected");
}
}
UpdateSettingsTabContent();
PersistSettings();
if (persistSelection)
{
PersistSettings();
}
}
private int GetSettingsTabIndex()
@@ -61,13 +218,7 @@ public partial class SettingsWindow
private void UpdateSettingsTabContent()
{
if (SettingsNavView is null)
{
return;
}
var selectedItem = SettingsNavView.SelectedItem as FluentAvalonia.UI.Controls.NavigationViewItem;
var tag = selectedItem?.Tag?.ToString();
var tag = GetSelectedSettingsTabTag();
WallpaperSettingsPanel.IsVisible = tag == "Wallpaper";
GridSettingsPanel.IsVisible = tag == "Grid";
@@ -79,6 +230,7 @@ public partial class SettingsWindow
AboutSettingsPanel.IsVisible = tag == "About";
LauncherSettingsPanel.IsVisible = tag == "Launcher";
PluginSettingsPanel.IsVisible = tag == "Plugins";
PluginMarketSettingsPanel.IsVisible = tag == "PluginMarket";
UpdatePluginSettingsPageVisibility(tag);
if (tag == "Launcher")
@@ -86,6 +238,16 @@ public partial class SettingsWindow
RenderLauncherHiddenItemsList();
}
if (tag == "Plugins")
{
PluginSettingsPanel.RefreshFromRuntime();
}
if (tag == "PluginMarket")
{
PluginMarketSettingsPanel.RefreshFromRuntime();
}
if (tag == "Grid")
{
UpdateGridPreviewLayout();
@@ -279,6 +441,11 @@ public partial class SettingsWindow
return;
}
if (radioButton.IsChecked != true)
{
return;
}
_clockDisplayFormat = formatTag == "Hm"
? ClockDisplayFormat.HourMinute
: ClockDisplayFormat.HourMinuteSecond;
@@ -375,8 +542,7 @@ public partial class SettingsWindow
private TaskbarContext GetCurrentTaskbarContext()
{
var selectedItem = SettingsNavView?.SelectedItem as FluentAvalonia.UI.Controls.NavigationViewItem;
return selectedItem?.Tag?.ToString() switch
return GetSelectedSettingsTabTag() switch
{
"Wallpaper" => TaskbarContext.SettingsWallpaper,
"Grid" => TaskbarContext.SettingsGrid,

View File

@@ -83,11 +83,6 @@ public partial class SettingsWindow
private void OnGridSpacingPresetSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (_suppressGridSpacingEvents)
{
return;
}
UpdateGridPreviewLayout();
}

View File

@@ -48,20 +48,33 @@ public partial class SettingsWindow
private void ApplyLocalization()
{
Title = L("settings.title", "Settings");
WindowTitleTextBlock.Text = L("settings.title", "Settings");
WindowSubtitleTextBlock.Text = L("settings.footer", "LanMountainDesktop Settings");
Title = L("settings.shell.title", "Application Settings");
WindowTitleTextBlock.Text = L("settings.shell.title", "Application Settings");
WindowSubtitleTextBlock.Text = L("settings.shell.subtitle", "LanMountainDesktop standalone preferences");
WindowVersionBadgeTextBlock.Text = GetAppVersionText();
WindowCodeNameBadgeTextBlock.Text = AppCodeName;
SettingsSidebarTitleTextBlock.Text = L("settings.nav_header", "Settings");
SettingsSidebarHintTextBlock.Text = L(
"settings.shell.sidebar_hint",
"Choose a category to adjust application behavior and desktop appearance.");
SettingsPrimaryGroupTextBlock.Text = L("settings.nav.group_desktop", "Desktop");
SettingsSecondaryGroupTextBlock.Text = L("settings.nav.group_system", "System");
SettingsPluginGroupTextBlock.Text = L("settings.nav.group_extensions", "Extensions");
SettingsSidebarFooterTextBlock.Text = L(
"settings.shell.footer_hint",
"Tray-opened settings are managed in this standalone window.");
SettingsNavWallpaperItem.Content = L("settings.nav.wallpaper", "Wallpaper");
SettingsNavGridItem.Content = L("settings.nav.grid", "Grid");
SettingsNavColorItem.Content = L("settings.nav.color", "Color");
SettingsNavStatusBarItem.Content = L("settings.nav.status_bar", "Status Bar");
SettingsNavWeatherItem.Content = L("settings.nav.weather", "Weather");
SettingsNavRegionItem.Content = L("settings.nav.region", "Region");
SettingsNavUpdateItem.Content = L("settings.nav.update", "Update");
SettingsNavAboutItem.Content = L("settings.nav.about", "About");
SettingsNavLauncherItem.Content = L("settings.nav.launcher", "App Launcher");
SettingsNavPluginsItem.Content = L("settings.nav.plugins", "Plugins");
SetSettingsNavItemLabel(GetSettingsNavItem("Wallpaper"), L("settings.nav.wallpaper", "Wallpaper"));
SetSettingsNavItemLabel(GetSettingsNavItem("Grid"), L("settings.nav.grid", "Grid"));
SetSettingsNavItemLabel(GetSettingsNavItem("Color"), L("settings.nav.color", "Color"));
SetSettingsNavItemLabel(GetSettingsNavItem("StatusBar"), L("settings.nav.status_bar", "Status Bar"));
SetSettingsNavItemLabel(GetSettingsNavItem("Weather"), L("settings.nav.weather", "Weather"));
SetSettingsNavItemLabel(GetSettingsNavItem("Region"), L("settings.nav.region", "Region"));
SetSettingsNavItemLabel(GetSettingsNavItem("Update"), L("settings.nav.update", "Update"));
SetSettingsNavItemLabel(GetSettingsNavItem("About"), L("settings.nav.about", "About"));
SetSettingsNavItemLabel(GetSettingsNavItem("Launcher"), L("settings.nav.launcher", "App Launcher"));
SetSettingsNavItemLabel(GetSettingsNavItem("Plugins"), L("settings.nav.plugins", "Plugins"));
SetSettingsNavItemLabel(GetSettingsNavItem("PluginMarket"), L("settings.nav.plugin_market", "Plugin Market"));
WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "Personalize your wallpaper");
WallpaperPlacementSettingsExpander.Header = L("settings.wallpaper.placement_label", "Placement");
@@ -96,6 +109,60 @@ public partial class SettingsWindow
StatusBarSpacingModeCustomItem.Content = L("settings.status_bar.spacing_mode_custom", "Custom");
StatusBarSpacingCustomPanel.Content = L("settings.status_bar.spacing_custom_label", "Custom spacing (%)");
WeatherPanelTitleTextBlock.Text = L("settings.weather.title", "Weather");
WeatherPreviewSectionTextBlock.Text = L("settings.weather.preview_section", "Weather Preview");
WeatherSettingsSectionTextBlock.Text = L("settings.weather.settings_section", "Settings");
WeatherPreviewButton.Content = L("settings.weather.refresh_button", "Refresh");
WeatherPreviewResultTextBlock.Text = L("settings.weather.preview_hint", "Use refresh to verify your weather configuration.");
WeatherLocationSettingsExpander.Header = L("settings.weather.location_source_header", "Location Source");
WeatherLocationSettingsExpander.Description = L(
"settings.weather.location_source_desc",
"Choose how weather widgets resolve location.");
WeatherLocationModeCityItem.Content = L("settings.weather.mode_city_search", "City Search");
WeatherLocationModeCoordinatesItem.Content = L("settings.weather.mode_coordinates", "Coordinates");
WeatherLocationModeCityChipItem.Content = L("settings.weather.mode_city_search", "City Search");
WeatherLocationModeCoordinatesChipItem.Content = L("settings.weather.mode_coordinates", "Coordinates");
WeatherAutoRefreshToggleSwitch.Content = L("settings.weather.auto_refresh", "Auto refresh location on startup");
WeatherCitySearchSettingsExpander.Header = L("settings.weather.city_search_header", "City Search");
WeatherCitySearchSettingsExpander.Description = L(
"settings.weather.city_search_desc",
"Search cities and apply one weather location.");
WeatherCitySearchTextBox.Watermark = L("settings.weather.search_placeholder", "e.g. Beijing");
WeatherSearchButton.Content = L("settings.weather.search_button", "Search");
WeatherApplyCityButton.Content = L("settings.weather.apply_city_button", "Apply City");
WeatherSearchStatusTextBlock.Text = L("settings.weather.search_hint", "Search by city name and apply one location.");
WeatherCoordinateSettingsExpander.Header = L("settings.weather.coordinates_header", "Coordinates");
WeatherCoordinateSettingsExpander.Description = L(
"settings.weather.coordinates_desc",
"Set latitude/longitude and optional key/name.");
WeatherLatitudeNumberBox.Header = L("settings.weather.latitude_label", "Latitude");
WeatherLongitudeNumberBox.Header = L("settings.weather.longitude_label", "Longitude");
WeatherLocationKeyTextBox.Watermark = L("settings.weather.location_key_placeholder", "Location key (optional)");
WeatherLocationNameTextBox.Watermark = L("settings.weather.location_name_placeholder", "Display name (optional)");
WeatherApplyCoordinatesButton.Content = L("settings.weather.apply_coordinates_button", "Apply Coordinates");
WeatherAlertFilterSettingsExpander.Header = L("settings.weather.alert_filter_header", "Excluded Alerts");
WeatherAlertFilterSettingsExpander.Description = L(
"settings.weather.alert_filter_desc",
"Alerts containing these words will not be shown. One rule per line.");
WeatherAlertListTitleTextBlock.Text = L("settings.weather.alert_list_label", "Exclude List");
WeatherAlertListDescriptionTextBlock.Text = L("settings.weather.alert_list_desc", "One exclusion rule per line.");
WeatherExcludedAlertsTextBox.Watermark = L("settings.weather.alert_filter_placeholder", "One keyword per line");
WeatherNoTlsSettingsExpander.Header = L("settings.weather.no_tls_header", "No TLS Weather Request");
WeatherNoTlsSettingsExpander.Description = L(
"settings.weather.no_tls_desc",
"Not recommended. Enable only for incompatible network environments.");
WeatherNoTlsToggleSwitch.Content = L("settings.weather.no_tls_toggle", "Allow non-TLS request fallback");
WeatherFooterHintTextBlock.Text = L(
"settings.weather.footer_hint",
"Desktop weather widgets will reuse the location and alert exclusion settings configured here.");
WeatherIconPackSettingsExpander.Header = L("settings.weather.icon_style_header", "Weather Icon Style");
WeatherIconPackSettingsExpander.Description = L(
"settings.weather.icon_style_desc",
"Choose Fluent Icon style for weather symbols.");
WeatherIconPackFluentRegularItem.Content = L("settings.weather.icon_style_fluent_regular", "Fluent Regular");
WeatherIconPackFluentFilledItem.Content = L("settings.weather.icon_style_fluent_filled", "Fluent Filled");
UpdateWeatherLocationStatusText();
RegionPanelTitleTextBlock.Text = L("settings.region.title", "Region");
LanguageSettingsExpander.Header = L("settings.region.language_header", "Language");
LanguageSettingsExpander.Description = L("settings.region.language_desc", "Select application language. Changes apply immediately.");
@@ -111,6 +178,7 @@ public partial class SettingsWindow
LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items.");
ApplyPluginSettingsLocalization();
ApplyPluginMarketSettingsLocalization();
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
VersionTextBlock.Text = Lf("settings.about.version_format", "Version: {0}", GetAppVersionText());

View File

@@ -14,6 +14,7 @@ using Avalonia.Threading;
using FluentIcons.Common;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using LanMountainDesktop.Views.Components;
namespace LanMountainDesktop.Views;
@@ -225,6 +226,7 @@ public partial class SettingsWindow
{
WeatherCitySearchSettingsExpander.IsVisible = _weatherLocationMode == WeatherLocationMode.CitySearch;
WeatherCoordinateSettingsExpander.IsVisible = _weatherLocationMode == WeatherLocationMode.Coordinates;
UpdateWeatherLocationSummaryCard();
}
private void OnWeatherLocationModeSelectionChanged(object? sender, SelectionChangedEventArgs e)
@@ -584,7 +586,7 @@ public partial class SettingsWindow
: snapshot.LocationName;
var weather = snapshot.Current.WeatherText ?? L("settings.weather.preview_unknown", "Unknown");
var temperature = snapshot.Current.TemperatureC.HasValue
? string.Create(CultureInfo.InvariantCulture, $"{snapshot.Current.TemperatureC.Value:F1} C")
? FormatWeatherPreviewTemperature(snapshot.Current.TemperatureC.Value)
: "--";
var updatedAt = snapshot.ObservationTime ?? snapshot.FetchedAt;
@@ -605,16 +607,25 @@ public partial class SettingsWindow
private void UpdateWeatherPreviewSummary(int? weatherCode, string temperatureText, DateTimeOffset? updatedAt)
{
var kind = HyperOS3WeatherTheme.ResolveVisualKind(weatherCode, _isNightMode);
WeatherPreviewIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage(
HyperOS3WeatherTheme.ResolveIconAsset(kind)) ??
HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveHeroIconAsset(kind));
WeatherPreviewIconSymbol.Symbol = ResolveWeatherPreviewSymbol(weatherCode, _isNightMode);
WeatherPreviewIconSymbol.IconVariant = string.Equals(_weatherIconPackId, "FluentFilled", StringComparison.OrdinalIgnoreCase)
? IconVariant.Filled
: IconVariant.Regular;
WeatherPreviewTemperatureTextBlock.Text = string.IsNullOrWhiteSpace(temperatureText) ? "--" : temperatureText;
WeatherPreviewUpdatedTextBlock.Text = updatedAt.HasValue
? Lf("weather.widget.updated_format", "Updated {0:HH:mm}", updatedAt.Value.LocalDateTime)
? updatedAt.Value.LocalDateTime.ToString("yyyy/M/d HH:mm:ss", CultureInfo.InvariantCulture)
: "-";
}
private static string FormatWeatherPreviewTemperature(double temperatureC)
{
return string.Create(CultureInfo.InvariantCulture, $"{temperatureC:0.#}°C");
}
private static Symbol ResolveWeatherPreviewSymbol(int? weatherCode, bool isNight)
{
return weatherCode switch
@@ -658,11 +669,13 @@ public partial class SettingsWindow
if (string.IsNullOrWhiteSpace(_weatherLocationKey))
{
WeatherLocationStatusTextBlock.Text = L("settings.weather.status_city_empty", "No city location is configured.");
UpdateWeatherLocationSummaryCard();
return;
}
var locationName = string.IsNullOrWhiteSpace(_weatherLocationName) ? _weatherLocationKey : _weatherLocationName;
WeatherLocationStatusTextBlock.Text = Lf("settings.weather.status_city_format", "Mode: {0} | {1} | Key: {2}", modeText, locationName, _weatherLocationKey);
UpdateWeatherLocationSummaryCard();
return;
}
@@ -673,6 +686,34 @@ public partial class SettingsWindow
_weatherLatitude,
_weatherLongitude,
string.IsNullOrWhiteSpace(_weatherLocationKey) ? BuildCoordinateLocationKey(_weatherLatitude, _weatherLongitude) : _weatherLocationKey);
UpdateWeatherLocationSummaryCard();
}
private void UpdateWeatherLocationSummaryCard()
{
if (_weatherLocationMode == WeatherLocationMode.Coordinates)
{
WeatherLocationSelectionTitleTextBlock.Text = L("settings.weather.coordinates_selection_label", "Coordinate Location");
WeatherLocationSelectionDescriptionTextBlock.Text = L(
"settings.weather.location_coordinates_summary_desc",
"Set latitude/longitude and optional location name used for weather queries.");
var locationName = string.IsNullOrWhiteSpace(_weatherLocationName)
? string.Create(CultureInfo.InvariantCulture, $"{_weatherLatitude:F4}, {_weatherLongitude:F4}")
: _weatherLocationName;
WeatherLocationValueTextBlock.Text = locationName;
return;
}
WeatherLocationSelectionTitleTextBlock.Text = L("settings.weather.city_selection_label", "City Selection");
WeatherLocationSelectionDescriptionTextBlock.Text = L(
"settings.weather.location_city_summary_desc",
"Select the current city used for weather queries.");
WeatherLocationValueTextBlock.Text = !string.IsNullOrWhiteSpace(_weatherLocationName)
? _weatherLocationName
: !string.IsNullOrWhiteSpace(_weatherLocationKey)
? _weatherLocationKey
: L("settings.weather.location_not_selected", "No location selected");
}
private void InitializeLauncherVisibilitySettings(LauncherSettingsSnapshot snapshot)

View File

@@ -1,6 +1,5 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia"
xmlns:ic="using:FluentIcons.Avalonia.Fluent"
xmlns:pages="using:LanMountainDesktop.Views.SettingsPages"
@@ -8,16 +7,94 @@
x:Class="LanMountainDesktop.Views.SettingsWindow"
Title="Settings"
Icon="/Assets/avalonia-logo.ico"
Width="1360"
Height="900"
MinWidth="1120"
MinHeight="760"
Width="1520"
Height="960"
MinWidth="1240"
MinHeight="820"
ShowInTaskbar="True"
WindowStartupLocation="CenterScreen"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaChromeHints="SystemChrome"
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
<Window.Styles>
<Style Selector="Border.settings-shell-card">
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="28" />
<Setter Property="BoxShadow" Value="0 10 28 #12000000" />
</Style>
<Style Selector="TextBlock.settings-shell-eyebrow">
<Setter Property="FontSize" Value="12" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextSecondaryBrush}" />
</Style>
<Style Selector="TextBlock.settings-shell-hint">
<Setter Property="FontSize" Value="13" />
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextSecondaryBrush}" />
</Style>
<Style Selector="StackPanel.settings-sidebar-host Button.settings-sidebar-item">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="18" />
<Setter Property="Padding" Value="14,12" />
<Setter Property="Margin" Value="0,0,0,8" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.22,1,0.36,1" />
<BrushTransition Property="BorderBrush" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.22,1,0.36,1" />
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.22,1,0.36,1" />
</Transitions>
</Setter>
</Style>
<Style Selector="StackPanel.settings-sidebar-host Button.settings-sidebar-item:pointerover">
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonHoverBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveButtonBorderBrush}" />
<Setter Property="RenderTransform" Value="scale(1.01)" />
</Style>
<Style Selector="StackPanel.settings-sidebar-host Button.settings-sidebar-item.nav-selected">
<Setter Property="Background" Value="{DynamicResource AdaptiveNavItemSelectedBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveAccentBrush}" />
</Style>
<Style Selector="Border.settings-sidebar-icon-shell">
<Setter Property="Width" Value="34" />
<Setter Property="Height" Value="34" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveButtonBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Button.settings-sidebar-item.nav-selected Border.settings-sidebar-icon-shell">
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveAccentBrush}" />
</Style>
<Style Selector="TextBlock.settings-nav-label">
<Setter Property="FontSize" Value="16" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="ic|SymbolIcon.settings-nav-icon">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="FontSize" Value="18" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Button.settings-sidebar-item.nav-selected ic|SymbolIcon.settings-nav-icon">
<Setter Property="Foreground" Value="White" />
</Style>
</Window.Styles>
<Grid x:Name="DesktopHost">
<Border x:Name="DesktopWallpaperLayer"
Background="{DynamicResource AdaptiveSurfaceBaseBrush}" />
@@ -27,121 +104,142 @@
IsVisible="True"
Opacity="1"
Margin="20">
<Border x:Name="SettingsContentPanel"
Background="Transparent"
BorderThickness="0"
Margin="0"
Padding="0"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Grid RowDefinitions="Auto,*">
<Border Grid.Row="0"
Classes="mica-strong"
CornerRadius="24,24,0,0"
Padding="20,16">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Width="40"
Height="40"
CornerRadius="20"
Background="{DynamicResource AdaptiveAccentBrush}">
<fi:FluentIcon Icon="Settings"
IconVariant="Regular"
Foreground="White"
FontSize="18"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1"
Margin="14,0,0,0"
Spacing="2"
VerticalAlignment="Center">
<TextBlock x:Name="WindowTitleTextBlock"
FontSize="24"
<Grid x:Name="SettingsContentPanel"
RowDefinitions="Auto,*"
RowSpacing="18">
<Border Grid.Row="0"
Classes="settings-shell-card"
Padding="20,18">
<Grid ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="18">
<Border Width="52"
Height="52"
CornerRadius="18"
Background="{DynamicResource AdaptiveAccentBrush}">
<TextBlock Text="LMD"
FontSize="16"
FontWeight="Bold"
Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1"
Spacing="3"
VerticalAlignment="Center">
<TextBlock x:Name="WindowTitleTextBlock"
FontSize="28"
FontWeight="SemiBold"
Text="Application Settings" />
<TextBlock x:Name="WindowSubtitleTextBlock"
Classes="settings-shell-hint"
Text="LanMountainDesktop" />
</StackPanel>
<StackPanel Grid.Column="2"
Orientation="Horizontal"
Spacing="10"
VerticalAlignment="Center">
<Border Classes="settings-shell-card"
Padding="12,8"
CornerRadius="18">
<TextBlock x:Name="WindowVersionBadgeTextBlock"
FontSize="14"
FontWeight="SemiBold"
Text="1.0.0" />
</Border>
<Border Classes="settings-shell-card"
Padding="12,8"
CornerRadius="18">
<TextBlock x:Name="WindowCodeNameBadgeTextBlock"
Classes="settings-shell-hint"
Text="Administrate" />
</Border>
</StackPanel>
</Grid>
</Border>
<Grid Grid.Row="1"
ColumnDefinitions="300,20,*">
<Border Grid.Column="0"
Classes="settings-shell-card"
Padding="18,18,18,16">
<Grid RowDefinitions="Auto,*,Auto"
RowSpacing="18">
<StackPanel Spacing="6">
<TextBlock x:Name="SettingsSidebarTitleTextBlock"
Classes="settings-shell-eyebrow"
Text="Settings" />
<TextBlock x:Name="WindowSubtitleTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="LanMountainDesktop preferences" />
<TextBlock x:Name="SettingsSidebarHintTextBlock"
Classes="settings-shell-hint"
TextWrapping="Wrap"
Text="Choose a category to adjust application behavior and desktop appearance." />
</StackPanel>
<Button Grid.Column="2"
Padding="10,8"
HorizontalAlignment="Right"
Click="OnCloseWindowClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:FluentIcon Icon="Dismiss" IconVariant="Regular" />
<TextBlock Text="Close" VerticalAlignment="Center" />
<ScrollViewer Grid.Row="1"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="20">
<StackPanel Spacing="10">
<TextBlock x:Name="SettingsPrimaryGroupTextBlock"
Classes="settings-shell-eyebrow"
Text="Desktop" />
<StackPanel x:Name="SettingsPrimaryNavHost"
Classes="settings-sidebar-host"
Spacing="0" />
</StackPanel>
<Border Background="{DynamicResource SurfaceStrokeColorDefaultBrush}"
Height="1" />
<StackPanel Spacing="10">
<TextBlock x:Name="SettingsSecondaryGroupTextBlock"
Classes="settings-shell-eyebrow"
Text="System" />
<StackPanel x:Name="SettingsSecondaryNavHost"
Classes="settings-sidebar-host"
Spacing="0" />
</StackPanel>
<StackPanel x:Name="SettingsPluginNavSection"
IsVisible="False"
Spacing="10">
<Border Background="{DynamicResource SurfaceStrokeColorDefaultBrush}"
Height="1" />
<TextBlock x:Name="SettingsPluginGroupTextBlock"
Classes="settings-shell-eyebrow"
Text="Extensions" />
<StackPanel x:Name="SettingsPluginNavHost"
Classes="settings-sidebar-host"
Spacing="0" />
</StackPanel>
</StackPanel>
</Button>
</ScrollViewer>
<Border Grid.Row="2"
Classes="settings-shell-card"
Padding="14,12"
CornerRadius="22">
<StackPanel Spacing="4">
<TextBlock Text="LanMountainDesktop"
FontWeight="SemiBold" />
<TextBlock x:Name="SettingsSidebarFooterTextBlock"
Classes="settings-shell-hint"
TextWrapping="Wrap"
Text="Tray-opened settings are managed in this standalone window." />
</StackPanel>
</Border>
</Grid>
</Border>
<Border Grid.Row="1"
Classes="mica-strong"
CornerRadius="0,0,24,24"
Padding="18">
<Grid RowDefinitions="*,Auto"
RowSpacing="14">
<ui:NavigationView x:Name="SettingsNavView"
Grid.Row="0"
PaneDisplayMode="Left"
IsSettingsVisible="False"
OpenPaneLength="240"
SelectionChanged="OnSettingsNavSelectionChanged">
<ui:NavigationView.MenuItems>
<ui:NavigationViewItem x:Name="SettingsNavWallpaperItem" Content="壁纸" Tag="Wallpaper">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="Wallpaper" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavGridItem" Content="网格" Tag="Grid">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="Grid" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavColorItem" Content="颜色" Tag="Color">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="Color" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavStatusBarItem" Content="状态栏" Tag="StatusBar">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="Status" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavWeatherItem" Content="天气" Tag="Weather">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="WeatherSunny" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavRegionItem" Content="地区" Tag="Region">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="Globe" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavUpdateItem" Content="更新" Tag="Update">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="ArrowSync" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavAboutItem" Content="关于" Tag="About">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="Info" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavLauncherItem" Content="应用启动台" Tag="Launcher">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="Apps" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavPluginsItem" Content="插件" Tag="Plugins">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="PuzzlePiece" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
</ui:NavigationView.MenuItems>
<Grid Grid.Column="2"
RowDefinitions="*,Auto"
RowSpacing="14">
<Border Grid.Row="0"
Classes="settings-shell-card"
Padding="0">
<ScrollViewer x:Name="SettingsContentScrollViewer"
Padding="0,0,16,0"
Padding="30,28,30,30"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<Grid x:Name="SettingsContentPagesHost">
@@ -155,25 +253,26 @@
<pages:LauncherSettingsPage x:Name="LauncherSettingsPanel" IsVisible="False" />
<pages:AboutSettingsPage x:Name="AboutSettingsPanel" IsVisible="False" />
<pages:PluginSettingsPage x:Name="PluginSettingsPanel" IsVisible="False" />
<pages:PluginMarketSettingsPage x:Name="PluginMarketSettingsPanel" IsVisible="False" />
</Grid>
</ScrollViewer>
</ui:NavigationView>
</Border>
<Border x:Name="PendingRestartDock"
Grid.Row="1"
IsVisible="False"
Classes="glass-panel"
CornerRadius="18"
Padding="14,12">
Classes="settings-shell-card"
Padding="16,14"
CornerRadius="24">
<Grid ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="12">
<Border Width="34"
Height="34"
CornerRadius="17"
ColumnSpacing="14">
<Border Width="38"
Height="38"
CornerRadius="14"
Background="{DynamicResource AdaptiveAccentBrush}">
<fi:FluentIcon Icon="ArrowSync"
IconVariant="Regular"
FontSize="16"
FontSize="18"
Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
@@ -182,7 +281,7 @@
Spacing="2"
VerticalAlignment="Center">
<TextBlock x:Name="PendingRestartDockTitleTextBlock"
FontSize="13"
FontSize="14"
FontWeight="SemiBold"
Text="Restart required" />
<TextBlock x:Name="PendingRestartDockDescriptionTextBlock"
@@ -192,7 +291,7 @@
</StackPanel>
<Button x:Name="PendingRestartDockButton"
Grid.Column="2"
Padding="14,8"
Padding="16,8"
Click="OnPendingRestartDockButtonClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:FluentIcon Icon="ArrowSync"
@@ -204,10 +303,9 @@
</Button>
</Grid>
</Border>
</Grid>
</Border>
</Grid>
</Grid>
</Border>
</Grid>
</Grid>
<Grid IsVisible="False">

View File

@@ -102,13 +102,14 @@ public partial class SettingsWindow : Window
private readonly HashSet<string> _hiddenLauncherFolderPaths = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _hiddenLauncherAppPaths = new(StringComparer.OrdinalIgnoreCase);
private readonly Stack<StartMenuFolderNode> _launcherFolderStack = [];
private readonly Dictionary<string, Button> _settingsNavItems = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, Button> _pluginSettingsNavItems = new(StringComparer.OrdinalIgnoreCase);
private StartMenuFolderNode _startMenuRoot = new("All Apps", string.Empty);
private byte[]? _launcherFolderIconPngBytes;
private Bitmap? _launcherFolderIconBitmap;
private int _targetShortSideCells;
private bool _isSettingsOpen = true;
private bool _isNightMode;
private bool _enableDynamicTaskbarActions;
private bool _suppressThemeToggleEvents;
@@ -116,7 +117,6 @@ public partial class SettingsWindow : Window
private bool _suppressTimeZoneSelectionEvents;
private bool _suppressWeatherLocationEvents;
private bool _suppressSettingsPersistence;
private bool _suppressGridSpacingEvents;
private bool _suppressGridInsetEvents;
private bool _suppressStatusBarSpacingEvents;
private bool _suppressAutoStartToggleEvents;
@@ -135,8 +135,6 @@ public partial class SettingsWindow : Window
private IReadOnlyList<Color> _monetColors = Array.Empty<Color>();
private Color _selectedThemeColor = Color.Parse("#FF3B82F6");
private double _currentDesktopCellSize;
private double _currentDesktopCellGap;
private double _currentDesktopEdgeInset;
private string _gridSpacingPreset = "Relaxed";
private string _statusBarSpacingMode = "Relaxed";
private int _statusBarCustomSpacingPercent = 12;
@@ -156,6 +154,7 @@ public partial class SettingsWindow : Window
private bool _weatherNoTlsRequests;
private bool _autoStartWithWindows;
private string _weatherSearchKeyword = string.Empty;
private string _selectedSettingsTabTag = "Wallpaper";
private bool _isWeatherSearchInProgress;
private bool _isWeatherPreviewInProgress;
@@ -163,6 +162,7 @@ public partial class SettingsWindow : Window
{
_componentRegistry = DesktopComponentRegistryFactory.Create((Application.Current as App)?.PluginRuntimeService);
InitializeComponent();
InitializeSettingsNavigation();
InitializePluginSettingsNavigation();
_fluentAvaloniaTheme = Application.Current?.Styles.OfType<FluentAvaloniaTheme>().FirstOrDefault();
RequestedThemeVariant = Application.Current?.RequestedThemeVariant ?? ThemeVariant.Default;
@@ -184,8 +184,7 @@ public partial class SettingsWindow : Window
GridSpacingPresetComboBox.SelectionChanged += OnGridSpacingPresetSelectionChanged;
GridEdgeInsetSlider.ValueChanged += OnGridEdgeInsetSliderChanged;
ApplyGridButton.Click += OnApplyGridSizeClick;
NightModeToggleSwitch.Checked += OnNightModeChecked;
NightModeToggleSwitch.Unchecked += OnNightModeUnchecked;
NightModeToggleSwitch.IsCheckedChanged += OnNightModeIsCheckedChanged;
RecommendedColorButton1.Click += OnRecommendedColorClick;
RecommendedColorButton2.Click += OnRecommendedColorClick;
RecommendedColorButton3.Click += OnRecommendedColorClick;
@@ -199,37 +198,64 @@ public partial class SettingsWindow : Window
MonetColorButton4.Click += OnMonetColorClick;
MonetColorButton5.Click += OnMonetColorClick;
MonetColorButton6.Click += OnMonetColorClick;
StatusBarClockToggleSwitch.Checked += OnStatusBarClockChecked;
StatusBarClockToggleSwitch.Unchecked += OnStatusBarClockUnchecked;
ClockFormatHMSSRadio.Checked += OnClockFormatChanged;
ClockFormatHMRadio.Checked += OnClockFormatChanged;
StatusBarClockToggleSwitch.IsCheckedChanged += OnStatusBarClockIsCheckedChanged;
ClockFormatHMSSRadio.IsCheckedChanged += OnClockFormatChanged;
ClockFormatHMRadio.IsCheckedChanged += OnClockFormatChanged;
StatusBarSpacingModeComboBox.SelectionChanged += OnStatusBarSpacingModeChanged;
StatusBarSpacingSlider.ValueChanged += OnStatusBarSpacingSliderChanged;
WeatherPreviewButton.Click += OnTestWeatherRequestClick;
WeatherLocationModeComboBox.SelectionChanged += OnWeatherLocationModeSelectionChanged;
WeatherLocationModeChipListBox.SelectionChanged += OnWeatherLocationModeChipSelectionChanged;
WeatherAutoRefreshToggleSwitch.Checked += OnWeatherAutoRefreshToggled;
WeatherAutoRefreshToggleSwitch.Unchecked += OnWeatherAutoRefreshToggled;
WeatherAutoRefreshToggleSwitch.IsCheckedChanged += OnWeatherAutoRefreshToggled;
WeatherSearchButton.Click += OnSearchWeatherCityClick;
WeatherApplyCityButton.Click += OnApplyWeatherCitySelectionClick;
WeatherApplyCoordinatesButton.Click += OnApplyWeatherCoordinatesClick;
WeatherExcludedAlertsTextBox.LostFocus += OnWeatherExcludedAlertsLostFocus;
WeatherIconPackComboBox.SelectionChanged += OnWeatherIconPackSelectionChanged;
WeatherNoTlsToggleSwitch.Checked += OnWeatherNoTlsToggled;
WeatherNoTlsToggleSwitch.Unchecked += OnWeatherNoTlsToggled;
WeatherNoTlsToggleSwitch.IsCheckedChanged += OnWeatherNoTlsToggled;
LanguageComboBox.SelectionChanged += OnLanguageSelectionChanged;
TimeZoneComboBox.SelectionChanged += OnTimeZoneSelectionChanged;
AutoCheckUpdatesToggleSwitch.Checked += OnAutoCheckUpdatesToggled;
AutoCheckUpdatesToggleSwitch.Unchecked += OnAutoCheckUpdatesToggled;
AutoCheckUpdatesToggleSwitch.IsCheckedChanged += OnAutoCheckUpdatesToggled;
UpdateChannelChipListBox.SelectionChanged += OnUpdateChannelSelectionChanged;
CheckForUpdatesButton.Click += OnCheckForUpdatesClick;
DownloadAndInstallUpdateButton.Click += OnDownloadAndInstallUpdateClick;
AutoStartWithWindowsToggleSwitch.Checked += OnAutoStartWithWindowsToggled;
AutoStartWithWindowsToggleSwitch.Unchecked += OnAutoStartWithWindowsToggled;
AutoStartWithWindowsToggleSwitch.IsCheckedChanged += OnAutoStartWithWindowsToggled;
AppRenderModeComboBox.SelectionChanged += OnAppRenderModeSelectionChanged;
Opened += OnWindowOpened;
}
private void OnNightModeIsCheckedChanged(object? sender, RoutedEventArgs e)
{
if (sender is not ToggleButton toggleButton)
{
return;
}
if (toggleButton.IsChecked == true)
{
OnNightModeChecked(sender, e);
return;
}
OnNightModeUnchecked(sender, e);
}
private void OnStatusBarClockIsCheckedChanged(object? sender, RoutedEventArgs e)
{
if (sender is not ToggleButton toggleButton)
{
return;
}
if (toggleButton.IsChecked == true)
{
OnStatusBarClockChecked(sender, e);
return;
}
OnStatusBarClockUnchecked(sender, e);
}
private void OnWindowOpened(object? sender, EventArgs e)
{
Opened -= OnWindowOpened;
@@ -283,8 +309,6 @@ public partial class SettingsWindow : Window
EnsureSelectedThemeColor();
UpdateThemeColorSelectionState();
ThemeColorStatusTextBlock.Text = Lf("settings.color.theme_ready_format", "Theme color ready: {0}.", _selectedThemeColor);
WindowTitleTextBlock.Text = L("settings.title", "Settings");
WindowSubtitleTextBlock.Text = L("settings.footer", "LanMountainDesktop Settings");
_defaultDesktopBackground = DesktopWallpaperLayer.Background;
RestoreSettingsTabSelection(snapshot);
UpdateSettingsTabContent();

View File

@@ -24,6 +24,8 @@ AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\{#MyAppName}
DisableDirPage=no
UsePreviousAppDir=no
DefaultGroupName={#MyAppName}
UninstallDisplayIcon={app}\{#MyAppExeName}
OutputDir={#MyOutputDir}

View File

@@ -0,0 +1,9 @@
namespace LanMountainDesktop.Views;
public partial class MainWindow
{
private void ApplyPluginMarketSettingsLocalization()
{
PluginMarketSettingsPanel.RefreshFromRuntime();
}
}

View File

@@ -39,7 +39,7 @@ public partial class MainWindow
.GroupBy(contribution => contribution.Plugin.Manifest.Id, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase);
var insertIndex = SettingsNavView.MenuItems.IndexOf(SettingsNavPluginsItem) + 1;
var insertIndex = SettingsNavView.MenuItems.IndexOf(SettingsNavPluginMarketItem) + 1;
foreach (var contribution in contributions)
{
var tag = BuildPluginSettingsTag(contribution);

View File

@@ -0,0 +1,44 @@
using System;
using System.IO;
namespace LanMountainDesktop.Views.SettingsPages;
internal sealed class AirAppMarketCacheService
{
private readonly string _cacheDirectory;
public AirAppMarketCacheService(string dataDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
_cacheDirectory = Path.Combine(dataDirectory, "cache");
}
public string CacheFilePath => Path.Combine(_cacheDirectory, "index.json");
public void SaveIndexJson(string json)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
Directory.CreateDirectory(_cacheDirectory);
File.WriteAllText(CacheFilePath, json);
}
public bool TryReadIndexJson(out string json)
{
try
{
if (!File.Exists(CacheFilePath))
{
json = string.Empty;
return false;
}
json = File.ReadAllText(CacheFilePath);
return !string.IsNullOrWhiteSpace(json);
}
catch
{
json = string.Empty;
return false;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
using System;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
namespace LanMountainDesktop.Views.SettingsPages;
internal sealed class AirAppMarketIconService : IDisposable
{
private readonly HttpClient _httpClient;
public AirAppMarketIconService()
{
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(20)
};
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
}
public async Task<Bitmap> LoadAsync(
AirAppMarketPluginEntry plugin,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(plugin);
if (AirAppMarketDefaults.TryResolveWorkspaceFile(plugin.IconUrl, out var localIconPath))
{
return new Bitmap(localIconPath);
}
using var response = await _httpClient.GetAsync(plugin.IconUrl, cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var memory = new MemoryStream();
await stream.CopyToAsync(memory, cancellationToken);
memory.Position = 0;
return new Bitmap(memory);
}
public void Dispose()
{
_httpClient.Dispose();
}
}

View File

@@ -0,0 +1,121 @@
using System;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.Views.SettingsPages;
internal sealed class AirAppMarketIndexService : IDisposable
{
private readonly AirAppMarketCacheService _cacheService;
private readonly HttpClient _httpClient;
public AirAppMarketIndexService(AirAppMarketCacheService cacheService)
{
_cacheService = cacheService;
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(20)
};
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
_httpClient.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
}
public async Task<AirAppMarketLoadResult> LoadAsync(CancellationToken cancellationToken = default)
{
Exception? networkError = null;
if (AirAppMarketDefaults.TryGetWorkspaceIndexPath() is { } localIndexPath)
{
try
{
var json = await File.ReadAllTextAsync(localIndexPath, cancellationToken);
var document = AirAppMarketIndexDocument.Load(json, localIndexPath);
_cacheService.SaveIndexJson(json);
return new AirAppMarketLoadResult(
true,
document,
AirAppMarketLoadSource.Local,
localIndexPath,
null,
null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
networkError = ex;
}
}
try
{
using var response = await _httpClient.GetAsync(
AirAppMarketDefaults.DefaultIndexUrl,
cancellationToken);
var json = await response.Content.ReadAsStringAsync(cancellationToken);
response.EnsureSuccessStatusCode();
var document = AirAppMarketIndexDocument.Load(json, AirAppMarketDefaults.DefaultIndexUrl);
_cacheService.SaveIndexJson(json);
return new AirAppMarketLoadResult(
true,
document,
AirAppMarketLoadSource.Network,
AirAppMarketDefaults.DefaultIndexUrl,
null,
null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
networkError = ex;
}
if (_cacheService.TryReadIndexJson(out var cachedJson))
{
try
{
var cachedDocument = AirAppMarketIndexDocument.Load(cachedJson, _cacheService.CacheFilePath);
return new AirAppMarketLoadResult(
true,
cachedDocument,
AirAppMarketLoadSource.Cache,
_cacheService.CacheFilePath,
networkError?.Message,
null);
}
catch (Exception cacheEx)
{
return new AirAppMarketLoadResult(
false,
null,
null,
null,
null,
$"{networkError?.Message ?? "Unknown network error"} | Cached index invalid: {cacheEx.Message}");
}
}
return new AirAppMarketLoadResult(
false,
null,
null,
null,
null,
networkError?.Message ?? "Unknown network error");
}
public void Dispose()
{
_httpClient.Dispose();
}
}

View File

@@ -0,0 +1,109 @@
using System;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Security.Cryptography;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.SettingsPages;
internal sealed class AirAppMarketInstallService : IDisposable
{
private readonly PluginRuntimeService _runtime;
private readonly HttpClient _httpClient;
private readonly ResumableDownloadService _downloadService;
private readonly AirAppMarketReleaseResolverService _releaseResolverService;
private readonly string _downloadsDirectory;
public AirAppMarketInstallService(PluginRuntimeService runtime, string dataDirectory)
{
_runtime = runtime;
_downloadsDirectory = Path.Combine(dataDirectory, "downloads");
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromMinutes(2)
};
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
_downloadService = new ResumableDownloadService(_httpClient);
_releaseResolverService = new AirAppMarketReleaseResolverService(_httpClient);
}
public async Task<AirAppMarketInstallResult> InstallAsync(
AirAppMarketPluginEntry plugin,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(plugin);
Directory.CreateDirectory(_downloadsDirectory);
var downloadPath = Path.Combine(
_downloadsDirectory,
$"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}.laapp");
try
{
var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, cancellationToken);
if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath))
{
var localCopyResult = await _downloadService.DownloadAsync(
localPackagePath,
downloadPath,
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
cancellationToken: cancellationToken);
if (!localCopyResult.Success)
{
return new AirAppMarketInstallResult(false, null, localCopyResult.ErrorMessage);
}
}
else
{
var downloadResult = await _downloadService.DownloadAsync(
resolvedDownloadUrl,
downloadPath,
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
cancellationToken: cancellationToken);
if (!downloadResult.Success)
{
return new AirAppMarketInstallResult(false, null, downloadResult.ErrorMessage);
}
}
await using var hashStream = File.OpenRead(downloadPath);
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken);
var actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
{
File.Delete(downloadPath);
return new AirAppMarketInstallResult(
false,
null,
$"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}.");
}
var manifest = _runtime.InstallPluginPackage(downloadPath);
return new AirAppMarketInstallResult(true, manifest, null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new AirAppMarketInstallResult(false, null, ex.Message);
}
}
public void Dispose()
{
_httpClient.Dispose();
}
private static string SanitizeFileName(string value)
{
var invalidChars = Path.GetInvalidFileNameChars();
return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
}
}

View File

@@ -0,0 +1,544 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Views.SettingsPages;
internal static class AirAppMarketDefaults
{
public const string DefaultIndexUrl =
"https://raw.githubusercontent.com/wwiinnddyy/LanAirApp/main/airappmarket/index.json";
public static string BuildGitHubReleaseDownloadUrl(
string owner,
string repositoryName,
string releaseTag,
string assetName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(owner);
ArgumentException.ThrowIfNullOrWhiteSpace(repositoryName);
ArgumentException.ThrowIfNullOrWhiteSpace(releaseTag);
ArgumentException.ThrowIfNullOrWhiteSpace(assetName);
return string.Create(
CultureInfo.InvariantCulture,
$"https://github.com/{owner.Trim()}/{repositoryName.Trim()}/releases/download/{Uri.EscapeDataString(releaseTag.Trim())}/{Uri.EscapeDataString(assetName.Trim())}");
}
public static string? TryGetWorkspaceIndexPath()
{
var repositoryRoot = TryGetWorkspaceRepositoryRoot("LanAirApp");
if (repositoryRoot is null)
{
return null;
}
var candidatePath = Path.Combine(repositoryRoot, "airappmarket", "index.json");
return File.Exists(candidatePath) ? candidatePath : null;
}
public static bool TryResolveWorkspaceFile(string url, out string localPath)
{
localPath = string.Empty;
string repositoryName;
string relativePath;
if (TryParseGitHubReleaseDownloadUrl(url, out repositoryName, out var releaseAssetName))
{
relativePath = releaseAssetName;
}
else if (!TryParseRawGitHubUrl(url, out repositoryName, out relativePath))
{
return false;
}
var repositoryRoot = TryGetWorkspaceRepositoryRoot(repositoryName);
if (repositoryRoot is null)
{
return false;
}
var candidatePath = Path.GetFullPath(Path.Combine(repositoryRoot, relativePath));
if (!File.Exists(candidatePath))
{
return false;
}
localPath = candidatePath;
return true;
}
public static bool TryParseGitHubRepositoryUrl(
string? url,
out string owner,
out string repositoryName)
{
owner = string.Empty;
repositoryName = string.Empty;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
!string.Equals(uri.Host, "github.com", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var segments = uri.AbsolutePath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length != 2)
{
return false;
}
owner = segments[0];
repositoryName = segments[1];
return !string.IsNullOrWhiteSpace(owner) && !string.IsNullOrWhiteSpace(repositoryName);
}
private static string? TryGetWorkspaceRepositoryRoot(string repositoryName)
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
var candidate = Path.Combine(current.FullName, repositoryName);
if (Directory.Exists(candidate))
{
return candidate;
}
current = current.Parent;
}
return null;
}
private static bool TryParseRawGitHubUrl(
string url,
out string repositoryName,
out string relativePath)
{
repositoryName = string.Empty;
relativePath = string.Empty;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
!string.Equals(uri.Host, "raw.githubusercontent.com", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var segments = uri.AbsolutePath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length < 4)
{
return false;
}
repositoryName = segments[1];
relativePath = Path.Combine(segments[3..]).Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
return !string.IsNullOrWhiteSpace(repositoryName) && !string.IsNullOrWhiteSpace(relativePath);
}
private static bool TryParseGitHubReleaseDownloadUrl(
string url,
out string repositoryName,
out string assetName)
{
repositoryName = string.Empty;
assetName = string.Empty;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
!string.Equals(uri.Host, "github.com", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var segments = uri.AbsolutePath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length != 6 ||
!string.Equals(segments[2], "releases", StringComparison.OrdinalIgnoreCase) ||
!string.Equals(segments[3], "download", StringComparison.OrdinalIgnoreCase))
{
return false;
}
repositoryName = segments[1];
assetName = Uri.UnescapeDataString(segments[5]);
return !string.IsNullOrWhiteSpace(repositoryName) && !string.IsNullOrWhiteSpace(assetName);
}
}
internal enum AirAppMarketLoadSource
{
Local = 0,
Network = 1,
Cache = 2
}
internal enum AirAppMarketInstallState
{
NotInstalled = 0,
UpdateAvailable = 1,
Installed = 2
}
internal sealed record AirAppMarketLoadResult(
bool Success,
AirAppMarketIndexDocument? Document,
AirAppMarketLoadSource? Source,
string? SourceLocation,
string? WarningMessage,
string? ErrorMessage);
internal sealed record AirAppMarketInstallResult(
bool Success,
PluginManifest? Manifest,
string? ErrorMessage);
internal sealed class AirAppMarketIndexDocument
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
public string SchemaVersion { get; init; } = string.Empty;
public string SourceId { get; init; } = string.Empty;
public string SourceName { get; init; } = string.Empty;
public DateTimeOffset GeneratedAt { get; init; }
public List<AirAppMarketPluginEntry> Plugins { get; init; } = [];
public static AirAppMarketIndexDocument Load(string json, string sourceName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
var document = JsonSerializer.Deserialize<AirAppMarketIndexDocument>(
json.TrimStart('\uFEFF'),
SerializerOptions);
if (document is null)
{
throw new InvalidOperationException($"Failed to parse market index '{sourceName}'.");
}
return document.ValidateAndNormalize(sourceName);
}
private AirAppMarketIndexDocument ValidateAndNormalize(string sourceName)
{
var plugins = Plugins ?? [];
var normalizedPlugins = new List<AirAppMarketPluginEntry>(plugins.Count);
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var plugin in plugins)
{
var normalizedPlugin = plugin.ValidateAndNormalize(sourceName);
if (!seenIds.Add(normalizedPlugin.Id))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' contains duplicate plugin id '{normalizedPlugin.Id}'.");
}
normalizedPlugins.Add(normalizedPlugin);
}
return new AirAppMarketIndexDocument
{
SchemaVersion = RequireValue(SchemaVersion, nameof(SchemaVersion), sourceName),
SourceId = RequireValue(SourceId, nameof(SourceId), sourceName),
SourceName = RequireValue(SourceName, nameof(SourceName), sourceName),
GeneratedAt = GeneratedAt == default
? throw new InvalidOperationException($"Market index '{sourceName}' is missing a valid generatedAt timestamp.")
: GeneratedAt,
Plugins = normalizedPlugins
.OrderBy(plugin => plugin.Name, StringComparer.OrdinalIgnoreCase)
.ToList()
};
}
private static string RequireValue(string? value, string propertyName, string sourceName)
{
var normalized = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(normalized))
{
throw new InvalidOperationException($"Market index '{sourceName}' is missing required property '{propertyName}'.");
}
return normalized;
}
internal static string? NormalizeValue(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
internal static string NormalizeVersion(string? value, string propertyName, string sourceName)
{
var normalized = RequireValue(value, propertyName, sourceName);
if (!TryParseVersion(normalized, out _))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid version '{normalized}' for '{propertyName}'.");
}
return normalized;
}
internal static string NormalizeReleaseTag(string? value, string propertyName, string sourceName)
{
var normalized = RequireValue(value, propertyName, sourceName);
if (!normalized.StartsWith("v", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid release tag '{normalized}' for '{propertyName}'. Expected format 'v1.2.3'.");
}
if (!TryParseVersion(normalized[1..], out _))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid release tag '{normalized}' for '{propertyName}'.");
}
return normalized;
}
internal static void EnsureUrl(string url, string propertyName, string sourceName)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid URL '{url}' for '{propertyName}'.");
}
}
internal static string NormalizeGitHubRepositoryUrl(
string url,
string propertyName,
string sourceName)
{
EnsureUrl(url, propertyName, sourceName);
if (!AirAppMarketDefaults.TryParseGitHubRepositoryUrl(url, out var owner, out var repositoryName))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid GitHub repository url '{url}' for '{propertyName}'.");
}
return string.Create(
CultureInfo.InvariantCulture,
$"https://github.com/{owner}/{repositoryName}");
}
internal static bool TryParseVersion(string? value, out Version? version)
{
version = null;
var normalized = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(normalized))
{
return false;
}
if (normalized.StartsWith("v", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[1..];
}
var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']);
if (separatorIndex > 0)
{
normalized = normalized[..separatorIndex];
}
if (!Version.TryParse(normalized, out var parsed))
{
return false;
}
version = new Version(
Math.Max(0, parsed.Major),
Math.Max(0, parsed.Minor),
Math.Max(0, parsed.Build));
return true;
}
}
internal sealed class AirAppMarketPluginEntry
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Author { get; init; } = string.Empty;
public string Version { get; init; } = string.Empty;
public string ApiVersion { get; init; } = string.Empty;
public string MinHostVersion { get; init; } = string.Empty;
public string DownloadUrl { get; init; } = string.Empty;
public string Sha256 { get; init; } = string.Empty;
public long PackageSizeBytes { get; init; }
public string IconUrl { get; init; } = string.Empty;
public string ReleaseTag { get; init; } = string.Empty;
public string ReleaseAssetName { get; init; } = string.Empty;
public string ProjectUrl { get; init; } = string.Empty;
public string ReadmeUrl { get; init; } = string.Empty;
public string HomepageUrl { get; init; } = string.Empty;
public string RepositoryUrl { get; init; } = string.Empty;
public List<string> Tags { get; init; } = [];
public DateTimeOffset PublishedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
public string ReleaseNotes { get; init; } = string.Empty;
public bool HasReleaseDownloadMetadata =>
!string.IsNullOrWhiteSpace(ReleaseTag) &&
!string.IsNullOrWhiteSpace(ReleaseAssetName);
public AirAppMarketPluginEntry ValidateAndNormalize(string sourceName)
{
var normalizedTags = (Tags ?? [])
.Select(tag => AirAppMarketIndexDocument.NormalizeValue(tag))
.Where(tag => !string.IsNullOrWhiteSpace(tag))
.Select(tag => tag!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase)
.ToList();
var normalizedSha = AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant()
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(Sha256)}'.");
if (normalizedSha.Length != 64 || normalizedSha.Any(ch => !Uri.IsHexDigit(ch)))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid SHA-256 '{normalizedSha}' for plugin '{Id}'.");
}
var normalizedDownloadUrl = AirAppMarketIndexDocument.NormalizeValue(DownloadUrl)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(DownloadUrl)}'.");
var normalizedIconUrl = AirAppMarketIndexDocument.NormalizeValue(IconUrl)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(IconUrl)}'.");
var normalizedReleaseTag = AirAppMarketIndexDocument.NormalizeValue(ReleaseTag);
var normalizedReleaseAssetName = AirAppMarketIndexDocument.NormalizeValue(ReleaseAssetName);
var normalizedProjectUrl = AirAppMarketIndexDocument.NormalizeValue(ProjectUrl)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(ProjectUrl)}'.");
var normalizedReadmeUrl = AirAppMarketIndexDocument.NormalizeValue(ReadmeUrl)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(ReadmeUrl)}'.");
var normalizedHomepageUrl = AirAppMarketIndexDocument.NormalizeValue(HomepageUrl)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(HomepageUrl)}'.");
var normalizedRepositoryUrl = AirAppMarketIndexDocument.NormalizeValue(RepositoryUrl)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(RepositoryUrl)}'.");
AirAppMarketIndexDocument.EnsureUrl(normalizedDownloadUrl, nameof(DownloadUrl), sourceName);
AirAppMarketIndexDocument.EnsureUrl(normalizedIconUrl, nameof(IconUrl), sourceName);
normalizedProjectUrl = AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl(
normalizedProjectUrl,
nameof(ProjectUrl),
sourceName);
normalizedRepositoryUrl = AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl(
normalizedRepositoryUrl,
nameof(RepositoryUrl),
sourceName);
AirAppMarketIndexDocument.EnsureUrl(normalizedReadmeUrl, nameof(ReadmeUrl), sourceName);
AirAppMarketIndexDocument.EnsureUrl(normalizedHomepageUrl, nameof(HomepageUrl), sourceName);
if (string.IsNullOrWhiteSpace(normalizedReleaseTag) != string.IsNullOrWhiteSpace(normalizedReleaseAssetName))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' must declare both '{nameof(ReleaseTag)}' and '{nameof(ReleaseAssetName)}' together for plugin '{Id}'.");
}
if (!string.IsNullOrWhiteSpace(normalizedReleaseTag))
{
normalizedReleaseTag = AirAppMarketIndexDocument.NormalizeReleaseTag(
normalizedReleaseTag,
nameof(ReleaseTag),
sourceName);
}
if (PackageSizeBytes <= 0)
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid packageSizeBytes '{PackageSizeBytes}' for plugin '{Id}'.");
}
if (PublishedAt == default || UpdatedAt == default)
{
throw new InvalidOperationException(
$"Market index '{sourceName}' is missing valid publish timestamps for plugin '{Id}'.");
}
return new AirAppMarketPluginEntry
{
Id = AirAppMarketIndexDocument.NormalizeValue(Id)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin id."),
Name = AirAppMarketIndexDocument.NormalizeValue(Name)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin name."),
Description = AirAppMarketIndexDocument.NormalizeValue(Description)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin description."),
Author = AirAppMarketIndexDocument.NormalizeValue(Author)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin author."),
Version = AirAppMarketIndexDocument.NormalizeVersion(Version, nameof(Version), sourceName),
ApiVersion = AirAppMarketIndexDocument.NormalizeVersion(ApiVersion, nameof(ApiVersion), sourceName),
MinHostVersion = AirAppMarketIndexDocument.NormalizeVersion(MinHostVersion, nameof(MinHostVersion), sourceName),
DownloadUrl = normalizedDownloadUrl,
Sha256 = normalizedSha,
PackageSizeBytes = PackageSizeBytes,
IconUrl = normalizedIconUrl,
ReleaseTag = normalizedReleaseTag ?? string.Empty,
ReleaseAssetName = normalizedReleaseAssetName ?? string.Empty,
ProjectUrl = normalizedProjectUrl,
ReadmeUrl = normalizedReadmeUrl,
HomepageUrl = normalizedHomepageUrl,
RepositoryUrl = normalizedRepositoryUrl,
Tags = normalizedTags,
PublishedAt = PublishedAt,
UpdatedAt = UpdatedAt,
ReleaseNotes = AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing release notes for plugin '{Id}'.")
};
}
public string GetVersionSummary()
{
return string.Format(
CultureInfo.InvariantCulture,
"v{0} | API {1} | Host >= {2}",
Version,
ApiVersion,
MinHostVersion);
}
}

View File

@@ -0,0 +1,42 @@
using System;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.Views.SettingsPages;
internal sealed class AirAppMarketReadmeService : IDisposable
{
private readonly HttpClient _httpClient;
public AirAppMarketReadmeService()
{
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(20)
};
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
}
public async Task<string> LoadAsync(
AirAppMarketPluginEntry plugin,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(plugin);
if (AirAppMarketDefaults.TryResolveWorkspaceFile(plugin.ReadmeUrl, out var localReadmePath))
{
return await File.ReadAllTextAsync(localReadmePath, cancellationToken);
}
using var response = await _httpClient.GetAsync(plugin.ReadmeUrl, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cancellationToken);
}
public void Dispose()
{
_httpClient.Dispose();
}
}

View File

@@ -0,0 +1,72 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.SettingsPages;
internal sealed class AirAppMarketReleaseResolverService
{
private readonly HttpClient _httpClient;
public AirAppMarketReleaseResolverService(HttpClient httpClient)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
}
public async Task<string> ResolveDownloadUrlAsync(
AirAppMarketPluginEntry plugin,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(plugin);
if (!plugin.HasReleaseDownloadMetadata)
{
return plugin.DownloadUrl;
}
if (!TryGetRepositoryIdentity(plugin, out var owner, out var repositoryName))
{
return plugin.DownloadUrl;
}
var releaseDownloadUrl = AirAppMarketDefaults.BuildGitHubReleaseDownloadUrl(
owner,
repositoryName,
plugin.ReleaseTag,
plugin.ReleaseAssetName);
if (AirAppMarketDefaults.TryResolveWorkspaceFile(releaseDownloadUrl, out _))
{
return releaseDownloadUrl;
}
try
{
using var updateService = new GitHubReleaseUpdateService(owner, repositoryName, _httpClient);
var release = await updateService.GetReleaseByTagAsync(plugin.ReleaseTag, cancellationToken);
var asset = release?.Assets.FirstOrDefault(candidate =>
string.Equals(candidate.Name, plugin.ReleaseAssetName, StringComparison.OrdinalIgnoreCase));
return asset?.BrowserDownloadUrl ?? plugin.DownloadUrl;
}
catch
{
return plugin.DownloadUrl;
}
}
private static bool TryGetRepositoryIdentity(
AirAppMarketPluginEntry plugin,
out string owner,
out string repositoryName)
{
owner = string.Empty;
repositoryName = string.Empty;
return AirAppMarketDefaults.TryParseGitHubRepositoryUrl(plugin.RepositoryUrl, out owner, out repositoryName) ||
AirAppMarketDefaults.TryParseGitHubRepositoryUrl(plugin.ProjectUrl, out owner, out repositoryName);
}
}

View File

@@ -0,0 +1,25 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="960"
d:DesignHeight="1000"
x:Class="LanMountainDesktop.Views.SettingsPages.PluginMarketSettingsPage">
<StackPanel x:Name="PluginMarketPanel"
Spacing="16">
<TextBlock x:Name="PluginMarketPanelTitleTextBlock"
FontSize="24"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="Plugin Market" />
<TextBlock x:Name="PluginMarketPanelSubtitleTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
TextWrapping="Wrap"
Text="Browse plugins from the official LanAirApp source and stage installs." />
<ContentControl x:Name="PluginMarketContentHost" />
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,71 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.SettingsPages;
public partial class PluginMarketSettingsPage : UserControl
{
private readonly AppSettingsService _appSettingsService = new();
private readonly LocalizationService _localizationService = new();
private PluginMarketEmbeddedView? _pluginMarketView;
public PluginMarketSettingsPage()
{
InitializeComponent();
AttachedToVisualTree += (_, _) => RefreshFromRuntime();
}
public void RefreshFromRuntime()
{
PluginMarketPanelTitleTextBlock.Text = L("settings.plugin_market.title", "Plugin Market");
PluginMarketPanelSubtitleTextBlock.Text = L(
"settings.plugin_market.subtitle",
"Browse plugins from the official LanAirApp source and stage installs.");
var runtime = (Application.Current as App)?.PluginRuntimeService;
if (runtime is null)
{
PluginMarketContentHost.Content = CreateUnavailableState();
return;
}
if (_pluginMarketView is null)
{
_pluginMarketView = new PluginMarketEmbeddedView(runtime);
}
_pluginMarketView.RefreshLocalization();
_pluginMarketView.RefreshInstalledSnapshot();
if (!ReferenceEquals(PluginMarketContentHost.Content, _pluginMarketView))
{
PluginMarketContentHost.Content = _pluginMarketView;
}
}
private Control CreateUnavailableState()
{
return new Border
{
Background = new SolidColorBrush(Color.Parse("#14000000")),
CornerRadius = new CornerRadius(16),
Padding = new Thickness(16),
Child = new TextBlock
{
Text = L(
"settings.plugin_market.unavailable",
"Plugin runtime is not available, so the official market cannot be opened right now."),
TextWrapping = TextWrapping.Wrap,
Foreground = PluginMarketPanelSubtitleTextBlock.Foreground
}
};
}
private string L(string key, string fallback)
{
var snapshot = _appSettingsService.Load();
return _localizationService.GetString(snapshot.LanguageCode, key, fallback);
}
}

View File

@@ -18,6 +18,8 @@ public sealed class PluginRuntimeService : IDisposable
{
private readonly PluginLoader _loader;
private readonly AppSettingsService _appSettingsService = new();
private readonly IServiceProvider _hostServices;
private readonly IPluginPackageManager _packageManager;
private readonly List<LoadedPlugin> _loadedPlugins = [];
private readonly List<PluginLoadResult> _loadResults = [];
private readonly List<PluginCatalogEntry> _catalog = [];
@@ -27,6 +29,8 @@ public sealed class PluginRuntimeService : IDisposable
public PluginRuntimeService()
{
PluginsDirectory = Path.Combine(AppContext.BaseDirectory, "Extensions", "Plugins");
_packageManager = new PluginRuntimePackageManager(this);
_hostServices = new PluginHostServiceProvider(_packageManager);
_loader = new PluginLoader(CreateOptions());
}
@@ -96,11 +100,11 @@ public sealed class PluginRuntimeService : IDisposable
PluginCatalogSourceKind.Package => _loader.LoadFromPackage(
candidate.SourcePath,
PluginsDirectory,
services: null,
services: _hostServices,
hostProperties),
_ => _loader.LoadFromManifest(
candidate.SourcePath,
services: null,
services: _hostServices,
hostProperties)
};
@@ -179,6 +183,24 @@ public sealed class PluginRuntimeService : IDisposable
}
public PluginManifest InstallPluginPackage(string packagePath)
{
return InstallPluginPackageCore(packagePath).Manifest;
}
internal IReadOnlyList<InstalledPluginInfo> GetInstalledPluginsSnapshot()
{
return _catalog
.OrderBy(entry => entry.Manifest.Name, StringComparer.OrdinalIgnoreCase)
.Select(entry => new InstalledPluginInfo(
entry.Manifest,
entry.IsEnabled,
entry.IsLoaded,
entry.IsPackage,
entry.ErrorMessage))
.ToArray();
}
private PluginPackageInstallResult InstallPluginPackageCore(string packagePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
@@ -197,7 +219,7 @@ public sealed class PluginRuntimeService : IDisposable
Directory.CreateDirectory(PluginsDirectory);
var manifest = ReadManifestFromPackage(fullPackagePath);
RemoveExistingPluginPackages(manifest.Id, fullPackagePath);
var replacedExisting = RemoveExistingPluginPackages(manifest.Id, fullPackagePath);
var destinationPath = Path.Combine(PluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
if (!string.Equals(fullPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase))
@@ -205,7 +227,10 @@ public sealed class PluginRuntimeService : IDisposable
File.Copy(fullPackagePath, destinationPath, overwrite: true);
}
return manifest;
UpdateCatalogAfterPackageInstall(manifest, destinationPath);
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
return new PluginPackageInstallResult(manifest, replacedExisting, RestartRequired: true);
}
public void Dispose()
@@ -303,8 +328,9 @@ public sealed class PluginRuntimeService : IDisposable
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
}
private void RemoveExistingPluginPackages(string pluginId, string packagePathToKeep)
private bool RemoveExistingPluginPackages(string pluginId, string packagePathToKeep)
{
var replacedExisting = false;
foreach (var existingPackagePath in EnumerateCandidatePaths($"*{PluginSdkInfo.PackageFileExtension}"))
{
if (string.Equals(
@@ -324,12 +350,40 @@ public sealed class PluginRuntimeService : IDisposable
}
File.Delete(existingPackagePath);
replacedExisting = true;
}
catch
{
// Ignore unrelated or invalid packages during replacement.
}
}
return replacedExisting;
}
private void UpdateCatalogAfterPackageInstall(PluginManifest manifest, string destinationPath)
{
var isEnabled = !GetDisabledPluginIds().Contains(manifest.Id);
var entry = new PluginCatalogEntry(
manifest,
destinationPath,
IsPackage: true,
IsEnabled: isEnabled,
IsLoaded: false,
ErrorMessage: null,
SettingsPageCount: 0,
WidgetCount: 0);
for (var i = 0; i < _catalog.Count; i++)
{
if (string.Equals(_catalog[i].Manifest.Id, manifest.Id, StringComparison.OrdinalIgnoreCase))
{
_catalog[i] = entry;
return;
}
}
_catalog.Add(entry);
}
private static string BuildInstalledPackageFileName(string pluginId)
@@ -395,4 +449,41 @@ public sealed class PluginRuntimeService : IDisposable
string SourcePath,
PluginManifest Manifest,
PluginCatalogSourceKind SourceKind);
private sealed class PluginHostServiceProvider : IServiceProvider
{
private readonly IPluginPackageManager _packageManager;
public PluginHostServiceProvider(IPluginPackageManager packageManager)
{
_packageManager = packageManager;
}
public object? GetService(Type serviceType)
{
return serviceType == typeof(IPluginPackageManager)
? _packageManager
: null;
}
}
private sealed class PluginRuntimePackageManager : IPluginPackageManager
{
private readonly PluginRuntimeService _runtimeService;
public PluginRuntimePackageManager(PluginRuntimeService runtimeService)
{
_runtimeService = runtimeService;
}
public IReadOnlyList<InstalledPluginInfo> GetInstalledPlugins()
{
return _runtimeService.GetInstalledPluginsSnapshot();
}
public PluginPackageInstallResult InstallPackage(string packagePath)
{
return _runtimeService.InstallPluginPackageCore(packagePath);
}
}
}

View File

@@ -140,8 +140,7 @@ public partial class PluginSettingsPage : UserControl
VerticalAlignment = VerticalAlignment.Center
};
enabledToggle.Checked += (_, _) => OnPluginEnableChanged(runtime, entry, true);
enabledToggle.Unchecked += (_, _) => OnPluginEnableChanged(runtime, entry, false);
enabledToggle.IsCheckedChanged += (_, _) => OnPluginEnableChanged(runtime, entry, enabledToggle.IsChecked == true);
var header = new Grid
{
@@ -247,9 +246,6 @@ public partial class PluginSettingsPage : UserControl
}
var manifest = runtime.InstallPluginPackage(temporaryPackagePath);
runtime.LoadInstalledPlugins();
RefreshPluginNavigation(topLevel);
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
RefreshFromRuntime();
SetPackageImportStatus(
F(

View File

@@ -82,5 +82,6 @@
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Border>
</StackPanel>
</UserControl>

View File

@@ -1,33 +1,57 @@
# 宿主侧插件运行时
这个目录用于归档阑山桌面宿主侧的插件相关实现。
## 中文
职责范围:
- 已安装插件的发现
- `.laapp` 安装包安装与替换
- 插件运行时加载
- 插件贡献的设置页与桌面组件接入
- 宿主侧插件设置页的安装、显示与刷新
本目录保存阑山桌面宿主程序中的插件运行时实现。
### 主要职责
- 发现已安装插件
- 安装和替换 `.laapp` 插件包
- 加载插件程序集
- 接入插件贡献的设置页和桌面组件
- 在宿主设置界面中展示插件与市场信息
### 市场安装优先级
1. 宿主先连接 `LanAirApp/airappmarket/index.json`
2. 当条目同时提供 `releaseTag``releaseAssetName` 时,宿主优先按精确标签读取插件仓库的 GitHub Release 资产。
3. 如果 Release 不存在、资产缺失、GitHub API 失败,或当前是本地工作区测试但找不到远程资产,宿主会退回 `downloadUrl` 指向的仓库根目录 `.laapp`
4. 插件介绍始终读取仓库根目录 `README.md`
5. 安装完成后只做暂存,重启后生效,不在运行时热重载市场安装插件。
### 核心文件
当前宿主侧核心文件:
- `PluginLoader.cs`
- `PluginLoadContext.cs`
- `PluginLoaderOptions.cs`
- `PluginLoadResult.cs`
- `LoadedPlugin.cs`
- `PluginRuntimeService.cs`
- `PluginContributions.cs`
- `PluginCatalogEntry.cs`
- `PluginSettingsPage.axaml`
- `PluginSettingsPage.Host.cs`
- `MainWindow.PluginSettingsHost.cs`
- `SettingsWindow.PluginSettingsHost.cs`
- `MainWindow.PluginSettingsLocalization.cs`
- `SettingsWindow.PluginSettingsLocalization.cs`
- `MainWindow.PluginSettingsControls.cs`
- `SettingsWindow.PluginSettingsControls.cs`
- `PluginMarketIndexService.cs`
- `PluginMarketInstallService.cs`
说明:
- 插件开发标准、插件打包工具、示例插件与开发文档统一放在仓库根目录下的 `LanAirApp/`
- 宿主本体的插件加载、解析、安装与插件设置页接入逻辑统一放在 `LanMountainDesktop/plugins/`
- `LanMountainDesktop.PluginSdk` 只保留插件作者需要引用的契约、清单模型和扩展注册接口
### 与 `LanAirApp` 的分工
- `LanAirApp` 负责插件开发文档、示例、市场索引和校验工具。
- 宿主目录负责运行时发现、安装、加载和界面接入。
## English
This directory contains the host-side plugin runtime for LanMountainDesktop.
### Responsibilities
- discover installed plugins
- install and replace `.laapp` packages
- load plugin assemblies
- integrate plugin settings pages and desktop components
- expose market and plugin management in the host UI
### Market install order
1. The host reads `LanAirApp/airappmarket/index.json`.
2. If an entry declares both `releaseTag` and `releaseAssetName`, the host first resolves the exact GitHub Release asset.
3. If Release resolution fails, the host falls back to the repository root `.laapp` from `downloadUrl`.
4. Plugin details always come from the repository root `README.md`.
5. Market installs are staged and take effect after restart.

View File

@@ -0,0 +1,9 @@
namespace LanMountainDesktop.Views;
public partial class SettingsWindow
{
private void ApplyPluginMarketSettingsLocalization()
{
PluginMarketSettingsPanel.RefreshFromRuntime();
}
}

View File

@@ -1,11 +1,10 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using FluentAvalonia.UI.Controls;
using FluentIcons.Avalonia.Fluent;
using FluentIcons.Common;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
@@ -18,7 +17,7 @@ public partial class SettingsWindow
private void InitializePluginSettingsNavigation()
{
if (_pluginSettingsPageHosts.Count > 0 || SettingsNavView?.MenuItems is null)
if (_pluginSettingsPageHosts.Count > 0)
{
return;
}
@@ -32,6 +31,7 @@ public partial class SettingsWindow
if (contributions is not { Length: > 0 })
{
SettingsPluginNavSection.IsVisible = false;
return;
}
@@ -39,31 +39,23 @@ public partial class SettingsWindow
.GroupBy(contribution => contribution.Plugin.Manifest.Id, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase);
var insertIndex = SettingsNavView.MenuItems.IndexOf(SettingsNavPluginsItem) + 1;
foreach (var contribution in contributions)
{
var tag = BuildPluginSettingsTag(contribution);
var navigationTitle = BuildPluginSettingsNavigationTitle(contribution, pageCountsByPluginId);
var navItem = new NavigationViewItem
{
Content = navigationTitle,
Tag = tag,
IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource
{
Symbol = FluentIcons.Common.Symbol.PuzzlePiece,
IconVariant = FluentIcons.Common.IconVariant.Regular
}
};
var navItem = CreateSettingsNavItem(tag, Symbol.PuzzlePiece, navigationTitle);
ToolTip.SetTip(navItem, $"{contribution.Plugin.Manifest.Name} - {contribution.Registration.Title}");
SettingsNavView.MenuItems.Insert(insertIndex++, navItem);
SettingsPluginNavHost.Children.Add(navItem);
_pluginSettingsNavItems[tag] = navItem;
var pageHost = CreatePluginSettingsPageHost(contribution);
pageHost.IsVisible = false;
SettingsContentPagesHost.Children.Add(pageHost);
_pluginSettingsPageHosts[tag] = pageHost;
}
SettingsPluginNavSection.IsVisible = SettingsPluginNavHost.Children.Count > 0;
}
private static string BuildPluginSettingsTag(PluginSettingsPageContribution contribution)
@@ -141,43 +133,48 @@ public partial class SettingsWindow
internal void RefreshPluginSettingsNavigation()
{
if (SettingsNavView?.MenuItems is null)
{
return;
}
foreach (var pair in _pluginSettingsPageHosts.ToArray())
{
var navItem = SettingsNavView.MenuItems
.OfType<NavigationViewItem>()
.FirstOrDefault(item => string.Equals(item.Tag?.ToString(), pair.Key, StringComparison.OrdinalIgnoreCase));
if (navItem is not null)
if (_pluginSettingsNavItems.TryGetValue(pair.Key, out var navItem))
{
SettingsNavView.MenuItems.Remove(navItem);
SettingsPluginNavHost.Children.Remove(navItem);
}
SettingsContentPagesHost.Children.Remove(pair.Value);
}
_pluginSettingsPageHosts.Clear();
_pluginSettingsNavItems.Clear();
SettingsPluginNavSection.IsVisible = false;
InitializePluginSettingsNavigation();
if (GetSettingsNavItem(_selectedSettingsTabTag) is null)
{
SelectSettingsTab("Plugins", persistSelection: false);
}
else
{
SelectSettingsTab(_selectedSettingsTabTag, persistSelection: false);
}
}
private string? GetSelectedSettingsTabTag()
{
return (SettingsNavView?.SelectedItem as NavigationViewItem)?.Tag?.ToString();
return _selectedSettingsTabTag;
}
private int ResolveSelectedSettingsTabIndex()
{
if (SettingsNavView?.SelectedItem is null || SettingsNavView.MenuItems is null)
var selectedTag = GetSelectedSettingsTabTag();
if (string.IsNullOrWhiteSpace(selectedTag))
{
return 0;
}
for (var i = 0; i < SettingsNavView.MenuItems.Count; i++)
var buttons = EnumerateSettingsNavItems().ToList();
for (var i = 0; i < buttons.Count; i++)
{
if (ReferenceEquals(SettingsNavView.MenuItems[i], SettingsNavView.SelectedItem))
if (string.Equals(buttons[i].Tag?.ToString(), selectedTag, StringComparison.OrdinalIgnoreCase))
{
return i;
}
@@ -188,30 +185,21 @@ public partial class SettingsWindow
private void RestoreSettingsTabSelection(AppSettingsSnapshot snapshot)
{
if (SettingsNavView?.MenuItems is null || SettingsNavView.MenuItems.Count == 0)
var buttons = EnumerateSettingsNavItems().ToList();
if (buttons.Count == 0)
{
return;
}
if (!string.IsNullOrWhiteSpace(snapshot.SettingsTabTag))
if (!string.IsNullOrWhiteSpace(snapshot.SettingsTabTag) &&
GetSettingsNavItem(snapshot.SettingsTabTag) is not null)
{
var taggedItem = SettingsNavView.MenuItems
.OfType<NavigationViewItem>()
.FirstOrDefault(item => string.Equals(item.Tag?.ToString(), snapshot.SettingsTabTag, StringComparison.OrdinalIgnoreCase));
if (taggedItem is not null)
{
SettingsNavView.SelectedItem = taggedItem;
return;
}
SelectSettingsTab(snapshot.SettingsTabTag, persistSelection: false);
return;
}
var safeIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, Math.Max(0, SettingsNavView.MenuItems.Count - 1));
if (SettingsNavView.MenuItems[safeIndex] is NavigationViewItem navItem)
{
SettingsNavView.SelectedItem = navItem;
}
var safeIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, Math.Max(0, buttons.Count - 1));
var button = buttons[safeIndex];
SelectSettingsTab(button.Tag?.ToString() ?? "Wallpaper", persistSelection: false);
}
}

View File

@@ -1,47 +1,48 @@
# LanMountainDesktop
# 阑山桌面(LanMountainDesktop
> 你的桌面,不止一面。
## 中文
`LanMountainDesktop` 是一个基于 Avalonia 的桌面壳层项目,目标不是“做一个启动器,而是把桌面变成可编排的信息与交互空间。
阑山桌面是一个基于 Avalonia 的桌面壳层项目。它不是单纯的启动器,而是一个可编排、可扩展、可长期演进的桌面信息空间。
> ⚠️ **注意**:该项目使用 Vibe Coding介意勿用。
## 项目定位
- 以网格化布局组织桌面组件,支持多页桌面与组件自由摆放。
- 提供顶部状态栏 + 底部任务栏的桌面框架,强调信息密度与可读性平衡。
- 通过主题色、日夜模式、玻璃视觉与动画系统,形成统一的视觉语言。
- 通过组件注册机制与 JSON 扩展入口,让桌面能力可持续扩展。
### 核心目标
## 核心能力
- 桌面组件系统:天气、时钟、计时器、课程表、日历、白板、音乐控制、学习环境等组件可组合使用
- 壁纸系统:支持图片与视频壁纸,并可在设置中实时预览
- 主题系统支持日夜模式、主题色与调色联动Monet 风格色板)
- 个性化设置:网格密度、状态栏间距、任务栏布局、语言与时区等可持久化配置。
- 本地化:内置 `zh-CN``en-US` 资源。
- 通过网格化布局管理桌面组件。
- 提供状态栏、任务栏和多页桌面的统一外壳
- 通过主题、玻璃效果和动效塑造统一体验
- 通过组件系统和插件系统持续扩展能力
## 工程结构
- `LanMountainDesktop/`桌面端主程序Avalonia
- `LanMountainDesktop.RecommendationBackend/`推荐内容后端服务ASP.NET Core Minimal API
- `docs/`:视觉与圆角等规范文档。
- `LanMountainDesktop/ComponentSystem/`:组件定义、注册、放置规则与扩展入口。
### 当前工程结构
## 技术栈
- .NET 10`net10.0`
- Avalonia 11
- FluentAvalonia + FluentIcons.Avalonia
- LibVLCSharp用于视频相关能力
- WebView.Avalonia嵌入式网页组件能力
- `LanMountainDesktop/`:桌面主程序。
- `LanMountainDesktop.RecommendationBackend/`:推荐内容后端。
- `LanMountainDesktop/ComponentSystem/`:组件定义与注册系统。
- `LanMountainDesktop/plugins/`:宿主侧插件加载、安装和设置集成。
- `docs/`:视觉与设计规范。
- `LanAirApp/`:插件开发资料镜像,权威版本以独立 `LanAirApp` 仓库为准。
## 扩展机制(摘要)
- 组件系统通过 `ComponentRegistry` 合并内置组件与扩展组件。
- 运行时会扫描 `Extensions/Components/*.json`(相对应用输出目录)加载第三方组件清单。
- 扩展契约与字段说明见组件系统文档:`LanMountainDesktop/ComponentSystem/README.md`
### 生态关系
## 当前状态
- 项目包含桌面端与推荐后端两个子项目,并在同一 solution 中维护
- 通用应用配置默认写入本地:`%LOCALAPPDATA%\LanMountainDesktop\settings.json`
- 启动台与桌面布局已拆分到独立文件:`%LOCALAPPDATA%\LanMountainDesktop\launcher-settings.json``%LOCALAPPDATA%\LanMountainDesktop\desktop-layout-settings.json`
- 组件配置统一写入:`%LOCALAPPDATA%\LanMountainDesktop\component-settings.json`;同类组件按实例 `componentId::placementId` 隔离存储,同时预留插件专属配置区。
- 当前体验以 Windows 为主要目标平台。
- 宿主程序只连接 `LanAirApp` 仓库中的官方市场索引。
- 官方市场索引返回插件列表以及各插件项目根目录链接
- 插件项目根目录提供 `.laapp` 安装包和 `README.md`
## 运行说明
运行与环境准备已拆分到独立文档:[`run.md`](./run.md)
### 当前状态
- Windows 是当前主要目标平台。
- 已提供组件系统、插件系统、主题系统和设置系统。
- 中文为主语言,英文为附加扩展语言。
### 运行说明
运行方法见 [run.md](./run.md)。
## English
LanMountainDesktop is an Avalonia-based desktop shell. It is designed as a composable and extensible desktop environment rather than a simple launcher.
### Main goals
- manage desktop widgets with a grid-based layout
- provide a unified shell with status bar, taskbar, and multi-page desktop support
- build a consistent experience through themes, glass effects, and motion
- extend capabilities through the component and plugin systems

17
airappmarket/README.md Normal file
View File

@@ -0,0 +1,17 @@
# AirApp Market 目录说明
## 中文
这个目录是阑山桌面仓库里遗留的市场原型目录,只用于历史参考,不再作为官方权威市场源。
### 当前结论
- 官方市场源以独立 `LanAirApp` 仓库中的 `airappmarket/index.json` 为准
- 阑山桌面程序应连接 `LanAirApp` 仓库,而不是以本目录为权威数据源
- 如无特殊需要,不应继续向这里添加正式市场数据
## English
This directory is a legacy market prototype kept in the LanMountainDesktop repository for historical reference only.
The authoritative market source now lives in the standalone `LanAirApp` repository.

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Sample Plugin">
<defs>
<linearGradient id="sampleBg" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#F59E0B"/>
<stop offset="100%" stop-color="#EF4444"/>
</linearGradient>
</defs>
<rect x="8" y="8" width="112" height="112" rx="28" fill="url(#sampleBg)"/>
<path d="M52 32c0-6.627 5.373-12 12-12s12 5.373 12 12v8h8c6.627 0 12 5.373 12 12s-5.373 12-12 12h-8v32c0 6.627-5.373 12-12 12s-12-5.373-12-12V64h-8c-6.627 0-12-5.373-12-12s5.373-12 12-12h8v-8Z" fill="#FFFFFF" fill-opacity="0.92"/>
</svg>

After

Width:  |  Height:  |  Size: 618 B

31
airappmarket/index.json Normal file
View File

@@ -0,0 +1,31 @@
{
"schemaVersion": "1.0.0",
"sourceId": "official.lanmountaindesktop",
"sourceName": "LanMountainDesktop Official Market",
"generatedAt": "2026-03-10T11:10:00Z",
"plugins": [
{
"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",
"minHostVersion": "1.0.0",
"downloadUrl": "https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/LanAirApp/releases/LanMountainDesktop.SamplePlugin.1.0.0.laapp",
"sha256": "c092f9d215ee0f1e436bc49b919dd9a75b3838e950c72c46dd7e41807557125c",
"packageSizeBytes": 1703398,
"iconUrl": "https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/airappmarket/assets/sample-plugin.svg",
"homepageUrl": "https://github.com/wwiinnddyy/LanMountainDesktop/tree/main/LanAirApp/samples/LanMountainDesktop.SamplePlugin",
"repositoryUrl": "https://github.com/wwiinnddyy/LanMountainDesktop/tree/main/LanAirApp/samples/LanMountainDesktop.SamplePlugin",
"tags": [
"example",
"official",
"sdk"
],
"publishedAt": "2026-03-10T01:30:00Z",
"updatedAt": "2026-03-10T01:30:00Z",
"releaseNotes": "Reference plugin for SDK validation. Includes a settings page, a desktop widget, localization resources, service registration, and plugin message bus usage."
}
]
}

View File

@@ -0,0 +1,137 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/airappmarket/schema/airappmarket-index.schema.json",
"title": "AirAppMarket Index",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"sourceId",
"sourceName",
"generatedAt",
"plugins"
],
"properties": {
"schemaVersion": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
},
"sourceId": {
"type": "string",
"minLength": 1
},
"sourceName": {
"type": "string",
"minLength": 1
},
"generatedAt": {
"type": "string",
"format": "date-time"
},
"plugins": {
"type": "array",
"items": {
"$ref": "#/$defs/plugin"
}
}
},
"$defs": {
"plugin": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"name",
"description",
"author",
"version",
"apiVersion",
"minHostVersion",
"downloadUrl",
"sha256",
"packageSizeBytes",
"iconUrl",
"homepageUrl",
"repositoryUrl",
"tags",
"publishedAt",
"updatedAt",
"releaseNotes"
],
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"name": {
"type": "string",
"minLength": 1
},
"description": {
"type": "string",
"minLength": 1
},
"author": {
"type": "string",
"minLength": 1
},
"version": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+ ][A-Za-z0-9.-]+)?$"
},
"apiVersion": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+ ][A-Za-z0-9.-]+)?$"
},
"minHostVersion": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+ ][A-Za-z0-9.-]+)?$"
},
"downloadUrl": {
"type": "string",
"format": "uri"
},
"sha256": {
"type": "string",
"pattern": "^[a-fA-F0-9]{64}$"
},
"packageSizeBytes": {
"type": "integer",
"minimum": 1
},
"iconUrl": {
"type": "string",
"format": "uri"
},
"homepageUrl": {
"type": "string",
"format": "uri"
},
"repositoryUrl": {
"type": "string",
"format": "uri"
},
"tags": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"uniqueItems": true
},
"publishedAt": {
"type": "string",
"format": "date-time"
},
"updatedAt": {
"type": "string",
"format": "date-time"
},
"releaseNotes": {
"type": "string",
"minLength": 1
}
}
}
}
}

View File

@@ -0,0 +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>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,247 @@
using System.Text.Json;
return await RunAsync(args);
static Task<int> RunAsync(string[] args)
{
try
{
var indexPath = args.Length > 0
? Path.GetFullPath(args[0])
: Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "index.json"));
var schemaPath = args.Length > 1
? Path.GetFullPath(args[1])
: Path.GetFullPath(Path.Combine(Path.GetDirectoryName(indexPath)!, "schema", "airappmarket-index.schema.json"));
if (!File.Exists(indexPath))
{
throw new FileNotFoundException($"Market index '{indexPath}' was not found.", indexPath);
}
if (!File.Exists(schemaPath))
{
throw new FileNotFoundException($"Market schema '{schemaPath}' was not found.", schemaPath);
}
JsonDocument.Parse(File.ReadAllText(schemaPath));
var document = MarketIndex.Load(File.ReadAllText(indexPath), indexPath);
Console.WriteLine($"Validated '{indexPath}'.");
Console.WriteLine($"Source: {document.SourceName} ({document.SourceId})");
Console.WriteLine($"Plugins: {document.Plugins.Count}");
return Task.FromResult(0);
}
catch (Exception ex)
{
Console.Error.WriteLine(ex.Message);
return Task.FromResult(1);
}
}
internal sealed class MarketIndex
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
public string SchemaVersion { get; init; } = string.Empty;
public string SourceId { get; init; } = string.Empty;
public string SourceName { get; init; } = string.Empty;
public DateTimeOffset GeneratedAt { get; init; }
public List<MarketPlugin> Plugins { get; init; } = [];
public static MarketIndex Load(string json, string sourceName)
{
var document = JsonSerializer.Deserialize<MarketIndex>(
json.TrimStart('\uFEFF'),
SerializerOptions) ?? throw new InvalidOperationException($"Failed to parse market index '{sourceName}'.");
return document.ValidateAndNormalize(sourceName);
}
private MarketIndex ValidateAndNormalize(string sourceName)
{
var normalizedPlugins = new List<MarketPlugin>(Plugins.Count);
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var plugin in Plugins)
{
var normalizedPlugin = plugin.ValidateAndNormalize(sourceName);
if (!seenIds.Add(normalizedPlugin.Id))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' contains duplicate plugin id '{normalizedPlugin.Id}'.");
}
normalizedPlugins.Add(normalizedPlugin);
}
return new MarketIndex
{
SchemaVersion = RequireValue(SchemaVersion, nameof(SchemaVersion), sourceName),
SourceId = RequireValue(SourceId, nameof(SourceId), sourceName),
SourceName = RequireValue(SourceName, nameof(SourceName), sourceName),
GeneratedAt = GeneratedAt == default
? throw new InvalidOperationException($"Market index '{sourceName}' is missing a valid generatedAt timestamp.")
: GeneratedAt,
Plugins = normalizedPlugins
};
}
internal static string RequireValue(string? value, string propertyName, string sourceName)
{
var normalized = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(normalized))
{
throw new InvalidOperationException($"Market index '{sourceName}' is missing required property '{propertyName}'.");
}
return normalized;
}
internal static string? NormalizeValue(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
internal static string NormalizeVersion(string? value, string propertyName, string sourceName)
{
var normalized = RequireValue(value, propertyName, sourceName);
if (!TryParseVersion(normalized, out _))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid version '{normalized}' for '{propertyName}'.");
}
return normalized;
}
internal static bool TryParseVersion(string? value, out Version? version)
{
version = null;
var normalized = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(normalized))
{
return false;
}
if (normalized.StartsWith("v", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[1..];
}
var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']);
if (separatorIndex > 0)
{
normalized = normalized[..separatorIndex];
}
if (!Version.TryParse(normalized, out var parsed))
{
return false;
}
version = new Version(
Math.Max(0, parsed.Major),
Math.Max(0, parsed.Minor),
Math.Max(0, parsed.Build));
return true;
}
internal static void EnsureUrl(string? value, string propertyName, string sourceName)
{
var normalized = RequireValue(value, propertyName, sourceName);
if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid URL '{normalized}' for '{propertyName}'.");
}
}
}
internal sealed class MarketPlugin
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Author { get; init; } = string.Empty;
public string Version { get; init; } = string.Empty;
public string ApiVersion { get; init; } = string.Empty;
public string MinHostVersion { get; init; } = string.Empty;
public string DownloadUrl { get; init; } = string.Empty;
public string Sha256 { get; init; } = string.Empty;
public long PackageSizeBytes { get; init; }
public string IconUrl { get; init; } = string.Empty;
public string HomepageUrl { get; init; } = string.Empty;
public string RepositoryUrl { get; init; } = string.Empty;
public List<string> Tags { get; init; } = [];
public DateTimeOffset PublishedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
public string ReleaseNotes { get; init; } = string.Empty;
public MarketPlugin ValidateAndNormalize(string sourceName)
{
var tagSource = Tags ?? [];
var normalizedTags = tagSource
.Select(MarketIndex.NormalizeValue)
.Where(tag => !string.IsNullOrWhiteSpace(tag))
.Select(tag => tag!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (normalizedTags.Count != tagSource.Count(tag => !string.IsNullOrWhiteSpace(tag)))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' contains duplicate or blank tags for plugin '{Id}'.");
}
var normalizedSha = MarketIndex.RequireValue(Sha256, nameof(Sha256), sourceName).ToLowerInvariant();
if (normalizedSha.Length != 64 || normalizedSha.Any(ch => !Uri.IsHexDigit(ch)))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid SHA-256 '{normalizedSha}' for plugin '{Id}'.");
}
MarketIndex.EnsureUrl(DownloadUrl, nameof(DownloadUrl), sourceName);
MarketIndex.EnsureUrl(IconUrl, nameof(IconUrl), sourceName);
MarketIndex.EnsureUrl(HomepageUrl, nameof(HomepageUrl), sourceName);
MarketIndex.EnsureUrl(RepositoryUrl, nameof(RepositoryUrl), sourceName);
if (PackageSizeBytes <= 0)
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid packageSizeBytes '{PackageSizeBytes}' for plugin '{Id}'.");
}
if (PublishedAt == default || UpdatedAt == default)
{
throw new InvalidOperationException(
$"Market index '{sourceName}' is missing valid publish timestamps for plugin '{Id}'.");
}
return new MarketPlugin
{
Id = MarketIndex.RequireValue(Id, nameof(Id), sourceName),
Name = MarketIndex.RequireValue(Name, nameof(Name), sourceName),
Description = MarketIndex.RequireValue(Description, nameof(Description), sourceName),
Author = MarketIndex.RequireValue(Author, nameof(Author), sourceName),
Version = MarketIndex.NormalizeVersion(Version, nameof(Version), sourceName),
ApiVersion = MarketIndex.NormalizeVersion(ApiVersion, nameof(ApiVersion), sourceName),
MinHostVersion = MarketIndex.NormalizeVersion(MinHostVersion, nameof(MinHostVersion), sourceName),
DownloadUrl = MarketIndex.RequireValue(DownloadUrl, nameof(DownloadUrl), sourceName),
Sha256 = normalizedSha,
PackageSizeBytes = PackageSizeBytes,
IconUrl = MarketIndex.RequireValue(IconUrl, nameof(IconUrl), sourceName),
HomepageUrl = MarketIndex.RequireValue(HomepageUrl, nameof(HomepageUrl), sourceName),
RepositoryUrl = MarketIndex.RequireValue(RepositoryUrl, nameof(RepositoryUrl), sourceName),
Tags = normalizedTags,
PublishedAt = PublishedAt,
UpdatedAt = UpdatedAt,
ReleaseNotes = MarketIndex.RequireValue(ReleaseNotes, nameof(ReleaseNotes), sourceName)
};
}
}

View File

@@ -1,177 +1,38 @@
# 圆角设计规范 (Corner Radius Design System)
# 圆角设计规范
> 基于小米澎湃OS 3 (HyperOS) 设计语言
## 中文
## 设计理念
本规范用于统一阑山桌面不同层级容器和控件的圆角尺度。
澎湃OS 3 采用**"生命感美学"**设计语言,强调:
- **全局圆角设计** - 所有界面元素均采用圆角
- **视觉舒适统一** - 柔和、现代、细腻
- **多级渲染** - 配合模糊混色与阴影
- **层级分明** - 大容器使用大圆角,小元素使用小圆角
### 基础层级
## 圆角数值体系
- Level 112px小元素和图标容器
- Level 216px小型色块和紧凑控件
- Level 320px普通按钮
- Level 424px输入面板和小型容器
- Level 528px普通玻璃面板
- Level 632px强化容器
- Level 736px大容器、窗口、任务栏
### 核心数值
### 使用建议
| 级别 | 圆角值 (px) | 用途 |
|------|-------------|------|
| **Level 0** | 0 | 特殊场景(无圆角需求) |
| **Level 1** | 12 | 小元素、图标内边角、ListBoxItem |
| **Level 2** | 16 | 色块按钮、小组件 |
| **Level 3** | 20 | 普通按钮、组件预览 |
| **Level 4** | 24 | 输入框、小型面板 |
| **Level 5** | 28 | 面板/卡片 (glass-panel) |
| **Level 6** | 32 | Mica 风格面板 (mica-strong) |
| **Level 7** | 36 | 大容器 (glass-strong)、任务栏、窗口 |
- 同层级元素保持相同圆角。
- 大容器的圆角大于内部子面板。
- 动态尺寸组件可按 `cellSize` 计算圆角,但仍要落在统一范围内。
### 动态圆角
动态圆角根据格子大小cellSize动态计算
### 动态圆角建议
```csharp
// 小元素
CornerRadius = Math.Clamp(cellSize * 0.35, 16, 28);
// 小组件
CornerRadius = Math.Clamp(cellSize * 0.45, 24, 44);
// 大容器(任务栏/窗口)
CornerRadius = Math.Clamp(cellSize * 0.45, 24, 44);
```
**系数参考**
- 系数范围:`0.35 - 0.45`
- 最小值限制:`12 - 24 px`
- 最大值限制:`28 - 44 px`
## 组件圆角速查表
### 基础控件
| 控件 | 圆角值 | 代码位置 |
|------|--------|---------|
| Button | 20px | GlassModule.axaml |
| ToggleSwitch | 继承系统 | - |
| TextBox | 20px | glass-panel |
| ComboBox | 20px | glass-panel |
| NumberBox | 20px | glass-panel |
### 容器样式类
| 样式类 | 圆角值 | 说明 |
|--------|--------|------|
| `.glass-panel` | 28px | 普通玻璃面板 |
| `.glass-strong` | 36px | 加强玻璃面板(任务栏) |
| `.mica-strong` | 36px | Mica 风格面板(设置页) |
| `.glass-overlay` | 0px | 覆盖层(无圆角) |
### 特殊场景
| 场景 | 圆角值 | 说明 |
|------|--------|------|
| 窗口整体 | 36px | 组件库/设置窗口 |
| 窗口标题栏 | 36px | 仅顶部圆角 (`36,36,0,0`) |
| 颜色选择器色块 | 12px | Monet 颜色/推荐色 |
| 设置页 ListBoxItem | 12px | 导航项 |
| 预览视口 | 12-16px | 壁纸/网格预览 |
## 圆角层级视觉示例
```
┌─────────────────────────────────────────────────────────────────┐
│ │
│ Level 7: 大容器 (36px) │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Level 6: Mica 面板 (36px) │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ │ │ │
│ │ │ Level 5: 玻璃面板 (28px) │ │ │
│ │ │ ┌─────────────────────────────────────────────┐ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ Level 4: 输入面板 (24px) │ │ │ │
│ │ │ │ ┌─────────────────────────────────────┐ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ │ │ Level 3: 按钮 (20px) │ │ │ │ │
│ │ │ │ │ ┌─────────────────────────────┐ │ │ │ │ │
│ │ │ │ │ │ Level 2: 色块 (16px) │ │ │ │ │ │
│ │ │ │ │ │ ┌─────────────────────┐ │ │ │ │ │ │
│ │ │ │ │ │ │ Level 1: 小元素 │ │ │ │ │ │ │
│ │ │ │ │ │ │ (12px) │ │ │ │ │ │ │
│ │ │ │ │ │ └─────────────────────┘ │ │ │ │ │ │
│ │ │ │ │ └─────────────────────────────┘ │ │ │ │ │
│ │ │ │ └─────────────────────────────────────┘ │ │ │ │
│ │ │ └─────────────────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
## 在 XAML 中使用
### 直接使用固定值
```xml
<Border Classes="glass-strong" CornerRadius="36">
<!-- 内容 -->
</Border>
<Button CornerRadius="20">点击</Button>
```
### 使用样式类
```xml
<!-- 使用预定义的 glass-panel 样式 -->
<Border Classes="glass-panel">
<TextBlock Text="面板内容" />
</Border>
<!-- 组合多个样式类 -->
<Border Classes="glass-strong mica-strong">
<!-- 内容 -->
</Border>
```
### 动态圆角Code-Behind
```csharp
// 根据格子大小动态计算圆角
var cellSize = 100; // 假设格子大小
var cornerRadius = Math.Clamp(cellSize * 0.45, 24, 44);
BottomTaskbarContainer.CornerRadius = new CornerRadius(cornerRadius);
```
## 新增控件时的圆角规范
## English
1. **确定元素层级** - 根据容器大小选择合适的级别
2. **遵循视觉一致性** - 同层级的元素使用相同圆角
3. **考虑内容安全区** - 圆角不应遮挡重要内容
4. **响应式适配** - 大屏幕使用较大圆角,小屏幕使用较小圆角
This specification keeps corner radius usage consistent across containers and controls.
### 快速参考
### Reference levels
```
新控件圆角选择流程:
1. 是窗口/大容器? → Level 7 (36px)
2. 是面板/卡片? → Level 5-6 (28-36px)
3. 是按钮/输入框? → Level 3-4 (20-24px)
4. 是小组件/色块? → Level 2 (16px)
5. 是图标/小元素? → Level 1 (12px)
```
## 附录:修改历史
| 日期 | 修改人 | 说明 |
|------|--------|------|
| 2026-03-02 | AI Assistant | 初始规范基于澎湃OS 3 设计语言 |
## 参考资料
- 澎湃OS 生命感美学设计
- Xiaomi HyperOS Design Guidelines
- 小米小部件审核规范 (dev.mi.com)
- 12px for small elements
- 20px for common buttons
- 28px for normal glass panels
- 36px for large containers and windows

View File

@@ -1,126 +1,42 @@
# LanMountainDesktop 视觉规范(主题色 + 毛玻璃)
# 视觉规范
## 1. 主题色应用规范
## 中文
### 1.1 颜色角色定义
本规范用于统一阑山桌面的主题色、玻璃效果和基础视觉语义。
- `Primary`(主色):品牌主导色,用于主要操作、关键状态提示。
- `Secondary`(辅助色):主色的低权重变体,用于次级强调、辅助信息。
- `Accent`(强调色):可被用户替换的动态主题色,用于选中态、激活态、聚焦态。
- `OnAccent`:放在强调色背景上的文本/图标颜色。
- `SurfaceBase` / `SurfaceRaised` / `SurfaceOverlay`:基础背景、抬升层、遮罩层。
- `TextPrimary` / `TextSecondary` / `TextMuted` / `TextAccent`:文字语义层级。
### 颜色角色
### 1.2 UI 元素映射规则
- `Primary`:品牌主色
- `Secondary`:辅助色
- `Accent`:强调色与选中态主色
- `OnAccent`:强调色背景上的文字或图标
- `SurfaceBase` / `SurfaceRaised` / `SurfaceOverlay`:背景层级
- `TextPrimary` / `TextSecondary` / `TextMuted` / `TextAccent`:文本层级
- 主按钮、主导航选中态:`Accent` + `OnAccent`
- 次级按钮/输入控件:`AdaptiveButtonBackgroundBrush` + `TextPrimary`
- 页头标题:`TextPrimary`
- 说明/辅助文本:`TextSecondary` / `TextMuted`
- 设置页导航激活项:`AdaptiveNavItemSelectedBackgroundBrush` + `AdaptiveNavSelectedTextBrush`
### 使用规则
### 1.3 统一资源键(单一真相源)
- 主按钮和主要导航选中态使用 `Accent + OnAccent`
- 次级操作和输入控件优先使用语义背景色,不直接写死颜色
- 页面层只使用资源键和语义类名,不写业务颜色常量
- 主题核心:
- `AdaptivePrimaryBrush`
- `AdaptiveSecondaryBrush`
- `AdaptiveAccentBrush`
- `AdaptiveOnAccentBrush`
- 文本:
- `AdaptiveTextPrimaryBrush`
- `AdaptiveTextSecondaryBrush`
- `AdaptiveTextMutedBrush`
- `AdaptiveTextAccentBrush`
- 表面:
- `AdaptiveSurfaceBaseBrush`
- `AdaptiveSurfaceRaisedBrush`
- `AdaptiveSurfaceOverlayBrush`
### 玻璃效果层级
## 2. 毛玻璃Glassmorphism统一实现方案
- `glass-overlay`:最外层遮罩
- `glass-strong`:主要大容器
- `glass-panel`:子区域、小面板、卡片
### 2.1 分层标准
### 可访问性
- `glass-overlay`:最高层遮罩(设置页背板)
- `glass-strong`:主内容容器(设置页主体)
- `glass-panel`:子功能区、组件容器(网格卡片、按钮容器)
- 正文对比度目标不低于 `4.5:1`
- 大号文字和重点文字不低于 `3.0:1`
- 主题服务负责对前景色做自动对比度修正
### 2.2 参数标准(模拟毛玻璃,跨平台稳定)
## English
- 描边:统一去除(`BorderThickness = 0`
- 模糊半径资源(供样式/扩展复用):
- `AdaptiveGlassPanelBlurRadius`(日 18 / 夜 22
- `AdaptiveGlassStrongBlurRadius`(日 24 / 夜 28
- 透明度资源:
- `AdaptiveGlassPanelOpacity`(日 0.88 / 夜 0.92
- `AdaptiveGlassStrongOpacity`(日 0.92 / 夜 0.95
- 背景色:由 `GlassEffectService` 基于主题色动态混合,统一下发到:
- `AdaptiveGlassPanelBackgroundBrush`
- `AdaptiveGlassStrongBackgroundBrush`
- `AdaptiveGlassOverlayBackgroundBrush`
This specification defines the visual language of LanMountainDesktop, including theme roles, glass layers, and semantic color usage.
## 3. 视觉一致性策略
- 全局样式入口:`Styles/GlassModule.axaml`
- 全局主题入口:`ThemeColorSystemService` + `GlassEffectService`
- 页面侧仅使用语义资源键和 `glass-*` 类,不写硬编码颜色
- `MainWindow` 只负责编排:切换模式、选择主题色、触发资源重算
## 4. 可访问性WCAG
### 4.1 对比度目标
- 正文文本:`>= 4.5:1`
- 大号文本 / 强调文本:`>= 3.0:1`
### 4.2 实现方式
- `Theme/ColorMath.cs` 提供:
- 相对亮度计算
- 对比度计算
- `EnsureContrast(...)` 自动修正文本前景色
- `ThemeColorSystemService` 在生成 `TextPrimary/TextSecondary/TextMuted/NavText` 时强制走对比度校正
## 5. 跨尺寸与分辨率一致性
- 启用像素对齐:`UseLayoutRounding="True"` + `SnapsToDevicePixels="True"`
- 桌面网格布局通过统一计算函数输出 `row/col/cell`,主视图与预览共用算法
- 预览区域按窗口实际宽高比缩放,保持 Win11 风格比例一致性
- 关键尺寸自适应(字体、内边距、圆角)随 `cellSize` 动态计算
## 6. 实现代码示例
### 6.1 主题系统应用C#
```csharp
var context = new ThemeColorContext(
selectedAccent,
isLightBackground,
isLightNavBackground,
isNightMode);
ThemeColorSystemService.ApplyThemeResources(Resources, context);
GlassEffectService.ApplyGlassResources(Resources, context);
```
### 6.2 页面层使用语义资源AXAML
```xml
<Border Classes="glass-overlay" />
<Border Classes="glass-strong" CornerRadius="16">
<Border Classes="glass-panel" CornerRadius="10" Padding="14">
<TextBlock Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Button Background="{DynamicResource AdaptiveButtonBackgroundBrush}" />
</Border>
</Border>
```
### 6.3 无描边层级区分原则AXAML
```xml
<Style Selector="Border.glass-panel">
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassPanelOpacity}" />
</Style>
```
### Key rules
- use semantic resource keys instead of hard-coded colors
- keep glass layers visually distinct
- maintain contrast targets for readability

796
noise.md
View File

@@ -1,771 +1,67 @@
# 噪音计算与评分技术文档
# 噪音监测与评分说明
噪音监测系统不仅仅是一个简单的分贝计,它内置了一个基于心理声学与专注力理论的评分引擎。该引擎旨在客观、多维度地量化环境噪音对学习心流的干扰程度。
## 中文
本文档详细解析了该系统的计算原理、核心指标定义、评分算法及完整的技术架构
本文档描述阑山桌面中噪音监测能力的基本设计目标、核心指标和实现边界
## 目录
### 设计目标
1. [核心理念](#1-核心理念)
2. [系统架构](#2-系统架构)
3. [数据采集层](#3-数据采集层)
4. [数据聚合层](#4-数据聚合层)
5. [评分算法核心](#5-评分算法核心)
6. [数据存储层](#6-数据存储层)
7. [历史报告生成](#7-历史报告生成)
8. [流服务整合](#8-流服务整合)
9. [配置参数体系](#9-配置参数体系)
10. [类型定义](#10-类型定义)
- 用尽量稳定的方式度量环境噪音。
- 区分持续噪音、超阈值时长和高频打断。
- 将结果反馈到实时界面、历史报表和专注场景中。
---
### 核心指标
## 1. 核心理念
- `p50Dbfs`:持续噪音水平,反映环境底噪。
- `overRatioDbfs`:超阈值时间占比,反映噪音覆盖时长。
- `segmentCount`:独立噪音事件数,反映被打断频率。
### 1.1 设计原则
### 评分思路
系统认为,并非所有"响声"都是一样的。对于专注力而言
评分从 100 分开始,依据三类惩罚项扣分
- **持续的嗡嗡声**(如嘈杂的人群)比**偶尔的掉笔声**更具破坏性
- **频繁的打断**(如每分钟都有人说话)比**单次的大声喧哗**更让人烦躁
- **评分与校准分离**:评分使用原始 DBFS 数据,校准仅影响显示分贝
- 持续噪音惩罚
- 超阈值时长惩罚
- 打断频率惩罚
因此,评分系统采用了 **多维度加权扣分制**,满分 100 分,根据环境表现进行扣分。
常见固定参数:
### 1.2 评分与校准分离
- 帧间隔50ms
- 切片时长30s
- 评分阈值:`-50 dBFS`
- 事件合并窗口500ms
- 每分钟最大容忍事件数6
项目通过"原始数据(用于评分)"与"显示数据(用于展示)"的**严格分层**,杜绝了校准值导致的评分偏差:
### 数据流
1. **评分只依赖原始 DBFS设备输出的相对电平**
- 评分的三项核心指标(`p50Dbfs``overRatioDbfs``segmentCount`)都来自原始 `dbfs` 统计
- "超阈时长占比"判定条件固定为:`dbfs > scoreThresholdDbfs`(阈值默认 `-50 dBFS`),与校准无关
- 这意味着即使用户把"显示分贝基准"调高/调低,评分侧的 `dbfs` 不会变化,因此得分与超阈时长也不会被"调参刷分"
1. 麦克风采集音频。
2. 计算 RMS 与 dBFS。
3. 聚合为时间切片。
4. 为每个切片计算统计值和评分。
5. 写入本地历史数据。
6. 在 UI 中展示实时状态与历史报告。
2. **校准仅影响 Display dBUI 展示口径),不进入评分链路**
- 校准(`baselineRms` / `baselineDb`)只用于将 `rms` 映射为 `displayDb`,用于实时显示与报告中的"噪音等级分布"等图表展示
- 这些展示口径变化不会反向影响评分输入,也不会改变切片摘要中的 `raw.*` 字段
### 设计边界
3. **统计报告中"超阈时长"取自 raw.overRatioDbfs**
- 报告里展示的"超阈时长"是对每个切片 `raw.overRatioDbfs` 按有效采样时长加权汇总得到,仍然完全基于 DBFS
- 相比之下,"噪音等级分布"使用的是 `display.avgDb`(校准后的显示分贝),因此它会随校准变化——这是为了更贴近用户直觉的 dB 区间划分
- 评分使用原始统计口径,不应被显示层校准参数反向影响。
- 历史数据只存统计值,不存原始音频。
- 该系统用于环境质量评估,不作为专业声学测量工具。
---
## English
## 2. 系统架构
This document summarizes the noise monitoring and scoring model used by LanMountainDesktop.
### 2.1 整体架构图
### Main metrics
```
┌─────────────────────────────────────────────────────────────────┐
│ 用户界面层 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 实时监控组件 │ │ 噪音报告弹窗 │ │ 噪音历史列表 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ 订阅/发布
┌─────────────────────────────────────────────────────────────────┐
│ 流服务层 │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 噪音流服务 - 订阅管理、生命周期控制、设置热更新 │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ 帧数据流
┌─────────────────────────────────────────────────────────────────┐
│ 数据聚合层 │
│ ┌──────────────────┐ ┌────────────────────────────────────┐ │
│ │ 噪音帧处理器 │ │ 噪音切片聚合器 │ │
│ │ - RMS/dBFS 计算 │ │ - 切片聚合、统计指标、评分计算 │ │
│ │ - 50ms/帧 │ │ - 30秒/切片 │ │
│ └──────────────────┘ └────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 实时环形缓冲区 - 保留固定时长的实时数据 │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ 音频流
┌─────────────────────────────────────────────────────────────────┐
│ 数据采集层 │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 麦克风采集 - Web Audio API、滤波器、AnalyserNode │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ 物理音频
┌─────────────────────────────────────────────────────────────────┐
│ 数据存储层 │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 切片存储 - localStorage、时间清理、容量限制 │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ 历史数据
┌─────────────────────────────────────────────────────────────────┐
│ 历史报告层 │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 历史构建 - 课表关联、加权平均评分、覆盖率计算 │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
- `p50Dbfs`: sustained noise level
- `overRatioDbfs`: ratio of time above threshold
- `segmentCount`: number of distinct interruption events
### 2.2 模块说明
| 模块 | 功能 |
|------|------|
| 类型定义 | 核心类型定义 |
| 常量定义 | 分析参数常量、报告参数常量 |
| 麦克风采集 | 音频采集 |
| 帧处理器 | 帧处理 |
| 切片聚合器 | 切片聚合 |
| 环形缓冲区 | 实时数据 |
| 流服务 | 流管理 |
| 评分引擎 | 评分算法 |
| 切片服务 | 存储服务 |
| 历史构建 | 历史报告 |
| 设置管理 | 设置管理 |
---
## 3. 数据采集层
### 3.1 麦克风采集
#### 3.1.1 Web Audio API 使用
系统使用 Web Audio API 获取麦克风输入,构建完整的音频处理链路:
```
麦克风 → MediaStream → MediaStreamAudioSourceNode
→ 高通滤波器 (80Hz) → 低通滤波器 (8000Hz)
→ AnalyserNode (FFT Size 2048)
```
#### 3.1.2 音频滤波器配置
| 滤波器类型 | 截止频率 | 作用 |
|-----------|---------|------|
| 高通滤波器 | 80 Hz | 过滤低频噪音(如空调嗡嗡声) |
| 低通滤波器 | 8000 Hz | 过滤高频噪音(如电子设备啸叫) |
#### 3.1.3 AnalyserNode 配置
```typescript
analyser.fftSize = 2048; // FFT 窗口大小
analyser.smoothingTimeConstant = 0; // 无平滑,实时响应
```
#### 3.1.4 权限处理与错误处理
```typescript
// 麦克风权限请求配置
{
audio: {
echoCancellation: false, // 禁用回声消除
noiseSuppression: false, // 禁用降噪
autoGainControl: false, // 禁用自动增益
},
video: false
}
```
**浏览器兼容性说明:**
- 部分浏览器/设备可能忽略上述约束设置
- 建议在 UI 中提示用户实际生效的约束
- 需要测试矩阵验证Chrome/Firefox/Safari/Edge/iOS Safari/Android WebView
**错误处理:**
- `NotAllowedError` / `SecurityError` → 权限拒绝
- `AudioContext not supported` → 浏览器不支持
---
### 3.2 帧处理器
#### 3.2.1 采样频率
- **帧间隔**50ms约 20 fps
- **数据来源**AnalyserNode.getFloatTimeDomainData()
#### 3.2.2 RMS均方根计算
RMS 是衡量音频信号强度的标准方法:
**公式:**
$$ \text{RMS} = \sqrt{\frac{1}{N} \sum_{i=1}^{N} x_i^2} $$
#### 3.2.3 dBFS分贝满刻度转换
dBFS 是数字音频的标准分贝单位,范围 -100 到 0 dB
**公式:**
$$ \text{dBFS} = 20 \times \log_{10}(\text{RMS}) $$
**范围限制:**
- 最小值:-100 dBFS静音
- 最大值0 dBFS满刻度
#### 3.2.4 峰值检测
峰值用于检测突发噪音,在 RMS 计算过程中同时记录峰值。
---
## 4. 数据聚合层
### 4.1 切片聚合器
#### 4.1.1 切片时长
- **默认切片时长**30 秒
- **可配置范围**:≥ 1 秒
#### 4.1.2 统计指标计算
切片聚合器为每个切片计算以下统计指标:
| 指标 | 说明 | 计算方法 |
|------|------|---------|
| avgDbfs | 平均分贝 | 能量平均(线性域 RMS 平均后转回 dBFS |
| maxDbfs | 最大分贝 | 所有帧 dBFS 的最大值 |
| p50Dbfs | 中位数分贝 | 线性域分位数RMS 域计算后转回 dBFS |
| p95Dbfs | 95分位数分贝 | 线性域分位数RMS 域计算后转回 dBFS |
| overRatioDbfs | 超阈值比例 | 超阈值时长 / 采样时长 |
| segmentCount | 事件段数量 | 独立噪音事件次数 |
| sampledDurationMs | 采样时长 | 有效采样时间(排除缺口) |
| gapCount | 缺口数量 | 数据缺口次数 |
| maxGapMs | 最大缺口时长 | 最长数据缺口时长 |
#### 4.1.3 能量平均计算avgDbfs
**公式:**
$$ \text{avgDbfs} = 20 \times \log_{10}\left(\sqrt{\frac{1}{N} \sum_{i=1}^{N} 10^{\text{dBFS}_i / 10}}\right) $$
**物理意义:** 在线性域RMS上做平均符合能量守恒定律
#### 4.1.4 线性域分位数计算
**公式:**
$$ \text{quantileDbfs} = 20 \times \log_{10}(Q_{\text{RMS}}(p)) $$
其中 $Q_{\text{RMS}}(p)$ 是 RMS 域的分位数,使用线性插值计算:
$$ Q_{\text{RMS}}(p) = x_{\lfloor i \rfloor} \times (1 - w) + x_{\lceil i \rceil} \times w $$
- $i = (n-1) \times p$
- $w = i - \lfloor i \rfloor$
**物理意义:** 在线性域RMS上计算分位数符合能量统计的严谨性
#### 4.1.5 超阈值比例计算(时间加权)
**公式:**
$$ \text{overRatioDbfs} = \frac{\text{超阈值时长}}{\text{采样时长}} $$
**物理意义:** 使用实际时长而非帧数计算比例,更精确
#### 4.1.6 事件段检测与合并算法
事件段检测用于识别独立的噪音事件:
**合并规则:**
- **合并窗口**500ms默认
- 如果两次超阈值事件间隔 ≤ 500ms合并为同一事件段
- 否则计为新的独立事件段
**示例:**
```
时间轴: 0ms 200ms 400ms 600ms 800ms 1000ms
状态: [噪音] [噪音] [安静] [噪音] [噪音] [安静]
合并后: └─────── 事件段1 ───────┘ └── 事件段2 ──┘
```
#### 4.1.7 显示分贝映射(校准机制)
显示分贝用于用户界面展示,支持校准:
**公式(有校准):**
$$ \text{displayDb} = \text{baselineDb} + 20 \times \log_{10}\left(\frac{\text{rms}}{\text{baselineRms}}\right) $$
**公式(无校准):**
$$ \text{displayDb} = 20 \times \log_{10}\left(\frac{\text{rms}}{10^{-3}}\right) + 60 $$
**范围限制:** 20 dB ~ 100 dB
**校准流程说明:**
1. 使用标准声源(如 60 dB 的白噪音)
2. 测量对应的 RMS 值
3. 设置为 baselineRms
4. 设置对应的显示分贝为 baselineDb
#### 4.1.8 缺口检测与采样时长统计
**缺口阈值:** `max(1000ms, frameMs × 5)` = **1000ms**(默认)
当检测到数据缺口时,会触发切片完成并记录缺口信息。
#### 4.1.9 无效帧过滤
低于 -90 dBFS 的帧被视为静音/无效信号,不参与统计。
**常量说明:**
- `INVALID_DBFS_THRESHOLD = -90`:统计意义上的"静音"阈值
- `DBFS_MIN_POSSIBLE = -100`:物理最小可表示值(用于 clamp
- `DBFS_MAX_POSSIBLE = 0`:物理最大可表示值(用于 clamp
---
### 4.2 实时环形缓冲区
#### 4.2.1 数据结构设计
环形缓冲区使用固定容量数组实现,通过起始索引和当前长度管理数据。
#### 4.2.2 时间窗口裁剪策略
**裁剪规则:** 移除时间戳早于 `当前时间 - retentionMs` 的数据点
---
## 5. 评分算法核心
### 5.1 三大核心指标
评分引擎从以下三个维度对噪音数据进行分析:
#### A. 持续噪音水平 (Sustained Level)
- **定义**:剔除突发噪音后的环境"底噪"水平
- **算法**:使用时段内所有帧的中位数电平 (`p50Dbfs`)
- **意义**:反映环境本身是否安静。如果环境中有持续的风扇声或交谈声,该指标会升高
#### B. 超阈值时长占比 (Over Threshold Ratio)
- **定义**:原始 `DBFS` 超过评分阈值(`scoreThresholdDbfs`)的时间比例
- **算法**`超阈值时长 / 采样时长`(超标判定:`dbfs > scoreThresholdDbfs`
- **意义**:反映环境的"纯净度"。即使是 0.1 秒的尖叫也会被精确计入,无法被平均值掩盖
> **提示**:评分阈值(`scoreThresholdDbfs`,单位 dBFS与"界面报警/提示音"使用的显示分贝阈值(`maxLevelDb`,单位 dB不是同一个概念前者只用于评分后者用于判定 noisy/quiet 与提示音触发。
#### C. 打断次数密度 (Interruption Density)
- **定义**:单位时间内(每分钟)发生的独立噪音事件次数
- **智能合并算法**
- 系统设有 **500ms** (默认) 的合并窗口
- 如果两次响声间隔小于该窗口(如拉椅子的一连串声音),会被合并为 **1 次打断**
- 只有间隔较长的响声才会被计为新的打断
- **意义**:反映环境的干扰频率。频繁的打断(如断断续续的说话声)比连续的噪音更易打断心流
### 5.2 评分引擎
#### 5.2.1 三维度评分模型
评分系统从三个维度对噪音进行评估:
| 维度 | 权重 | 指标 | 满扣分条件 |
|------|------|------|-----------|
| **持续噪音** | 40% | p50Dbfs | 中位数超过阈值 6 dBFS |
| **超阈时长** | 30% | overRatioDbfs | 超阈时间占比 30% |
| **打断频次** | 30% | segmentCount | 6 次/分钟 |
#### 5.2.2 评分公式
**总惩罚系数:**
$$ \text{TotalPenalty} = 0.40 \times P_{\text{sustained}} + 0.30 \times P_{\text{time}} + 0.30 \times P_{\text{segment}} $$
**最终得分:**
$$ \text{Score} = 100 \times (1 - \text{TotalPenalty}) $$
#### 5.2.3 惩罚系数计算
##### A. 持续噪音惩罚
**公式:**
$$ P_{\text{sustained}} = \text{clamp}_{[0,1]}\left(\frac{\text{p50Dbfs} - \text{threshold}}{6}\right) $$
**满扣分条件:** `p50Dbfs - threshold ≥ 6 dBFS`
##### B. 超阈时长惩罚
**公式:**
$$ P_{\text{time}} = \text{clamp}_{[0,1]}\left(\frac{\text{overRatioDbfs}}{0.3}\right) $$
**满扣分条件:** `overRatioDbfs ≥ 30%`
##### C. 打断频次惩罚
**公式:**
$$ P_{\text{segment}} = \text{clamp}_{[0,1]}\left(\frac{\text{segmentCount} / \text{minutes}}{\text{maxSegmentsPerMin}}\right) $$
**满扣分条件:** `segmentsPerMin ≥ 6 次/分钟`
#### 5.2.4 权重解读
- **持续噪音 (40%)**:持续底噪仍会明显拉低分数
- **超阈时长 (30%)**:只要大部分时间安静,偶尔的噪音仍可被容忍
- **打断频次 (30%)**:强调"被频繁打断"对心流的破坏,提升对碎片化干扰的惩罚力度
#### 5.2.5 边界条件处理
- DBFS 范围限制:-100 到 0 dB
- 惩罚系数范围限制0 到 1
- 评分范围限制0 到 100 分
#### 5.2.6 有效时长处理
优先使用采样有效时长,不存在时回退到物理时长。
#### 5.2.7 评分示例
**场景 1安静环境**
- p50Dbfs = -60 dBFS, threshold = -50 dBFS
- overRatioDbfs = 0.05 (5%)
- segmentCount = 1, duration = 30s
```
sustainedPenalty = clamp01((-60 - (-50)) / 6) = clamp01(-10/6) = 0
timePenalty = clamp01(0.05 / 0.3) = 0.167
segmentPenalty = clamp01((1/0.5) / 6) = clamp01(2/6) = 0.333
TotalPenalty = 0.4×0 + 0.3×0.167 + 0.3×0.333 = 0.15
Score = 100 × (1 - 0.15) = 85 分
```
**场景 2嘈杂环境**
- p50Dbfs = -45 dBFS, threshold = -50 dBFS
- overRatioDbfs = 0.40 (40%)
- segmentCount = 8, duration = 30s
```
sustainedPenalty = clamp01((-45 - (-50)) / 6) = clamp01(5/6) = 0.833
timePenalty = clamp01(0.40 / 0.3) = 1.0
segmentPenalty = clamp01((8/0.5) / 6) = clamp01(16/6) = 1.0
TotalPenalty = 0.4×0.833 + 0.3×1.0 + 0.3×1.0 = 0.933
Score = 100 × (1 - 0.933) = 6.7 分
```
---
## 6. 数据存储层
### 6.1 切片服务
#### 6.1.1 localStorage 存储策略
存储键:`noise-slices`
**隐私说明:**
- 存储内容:时间戳、噪音统计(不包含音频数据)
- 风险:可能泄露位置/日程信息
- 建议:在 UI 中提供"清除历史"功能
#### 6.1.2 时间窗口清理
**默认保留时长:** 14 天
**可配置范围:** 1 ~ 365 天
使用新切片的结束时间作为基准计算 cutoff确保新切片不会被清理。
#### 6.1.3 容量限制
**容量上限:** 本地存储配额的 90%
#### 6.1.4 数据规范化与校验
**精度控制:**
- dBFS3 位小数
- overRatioDbfs4 位小数
- 显示分贝2 位小数
- 评分1 位小数
---
## 7. 历史报告生成
### 7.1 历史构建器
#### 7.1.1 与课表关联逻辑
**关联规则:**
1. 按日期分组切片
2. 对每个日期的每个课时,查找重叠的切片
3. 计算该课时的平均评分
#### 7.1.2 时段平均评分计算(加权平均)
**公式:**
$$ \text{avgScore} = \frac{\sum_{i} \text{score}_i \times \text{effectiveMs}_i}{\sum_{i} \text{effectiveMs}_i} $$
其中:
$$ \text{effectiveMs}_i = \text{sampledDurationMs}_i \times \frac{\text{overlapMs}_i}{\text{sliceMs}_i} $$
#### 7.1.3 覆盖率计算
**公式:**
$$ \text{coverageRatio} = \frac{\text{totalMs}}{\text{periodMs}} $$
**含义:** 课时内有效采样时长占课时总时长的比例
#### 7.1.4 日期时间处理
**时区说明:**
- 使用本地时区
- 内部存储使用 UTC 时间戳
- 对外展示使用本地时间
**日期格式:** `YYYY-MM-DD`
**时间格式:** `HH:MM`
#### 7.1.5 跨天课时处理
如果结束时间 ≤ 开始时间,则课时跨越到次日。
#### 7.1.6 报告中的图表
在噪音统计报告中,您可以直观地看到这些数据:
- **评分走势图**:展示了 `Score` 随时间的变化,帮助您回顾专注状态
- **噪音等级分布**:将每一帧归类为安静/正常/吵闹/极吵,直观展示时间占比
- **扣分归因**:直接显示上述三个维度的扣分比例,告诉您为什么分低(是因为一直吵,还是因为总被打断)
- **打断次数密度**:展示每分钟被干扰的次数
---
## 8. 流服务整合
### 8.1 噪音流服务
#### 8.1.1 订阅/发布模式
**模式:** 观察者模式
- 多个组件可同时订阅
- 最后一个订阅者取消时自动停止采集
#### 8.1.2 生命周期管理
流服务支持启动、停止和重启操作,自动管理采集资源的生命周期。
#### 8.1.3 预热帧处理
**目的:** 丢弃麦克风启动后的不稳定数据(约 500ms
#### 8.1.4 设置热更新响应
**需要重启的参数:**
- frameMs
- sliceSec
- scoreThresholdDbfs
- segmentMergeGapMs
- maxSegmentsPerMin
**无需重启的参数:**
- maxLevelDb
- showRealtimeDb
- alertSoundEnabled
- avgWindowSec
- baselineDb
#### 8.1.5 时间加权平均
**公式:**
$$ \text{avg} = \frac{\sum_{i} v_i \times (t_{i+1} - t_i)}{\sum_{i} (t_{i+1} - t_i)} $$
---
## 9. 配置参数体系
### 9.1 常量定义
#### 9.1.1 分析参数
```typescript
NOISE_ANALYSIS_SLICE_SEC = 30; // 切片时长 30 秒
NOISE_ANALYSIS_FRAME_MS = 50; // 帧间隔 50ms
NOISE_SCORE_THRESHOLD_DBFS = -50; // 评分阈值 -50dBFS
NOISE_SCORE_SEGMENT_MERGE_GAP_MS = 500; // 事件段合并间隔 500ms
NOISE_SCORE_MAX_SEGMENTS_PER_MIN = 6; // 每分钟最大事件段数 6
NOISE_REALTIME_CHART_SLICE_COUNT = 1; // 实时图表切片数 1
```
#### 9.1.2 报告参数
```typescript
DEFAULT_NOISE_REPORT_RETENTION_DAYS = 14; // 默认保留 14 天
MIN_NOISE_REPORT_RETENTION_DAYS = 1; // 最小保留 1 天
MAX_NOISE_REPORT_RETENTION_DAYS_FALLBACK = 365; // 最大保留 365 天
```
### 9.2 设置管理
#### 9.2.1 固定参数
为保证评分口径稳定,避免用户通过调整参数"刷分",以下参数固定为程序内常量:
- sliceSec
- frameMs
- scoreThresholdDbfs
- segmentMergeGapMs
- maxSegmentsPerMin
#### 9.2.2 可配置参数
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| maxLevelDb | number | 55 | 最大允许噪音级别(显示分贝) |
| baselineDb | number | 40 | 手动基准显示分贝 |
| showRealtimeDb | boolean | true | 是否显示实时分贝 |
| avgWindowSec | number | 1 | 噪音平均时间窗(秒) |
| alertSoundEnabled | boolean | false | 超阈值提示音开关 |
---
## 10. 类型定义
### 10.1 核心类型
#### 10.1.1 噪音帧采样
```typescript
interface NoiseFrameSample {
t: number; // 时间戳
rms: number; // 均方根值
dbfs: number; // 分贝值 (dBFS)
peak?: number; // 峰值
}
```
#### 10.1.2 噪音切片原始统计
```typescript
interface NoiseSliceRawStats {
avgDbfs: number; // 平均分贝
maxDbfs: number; // 最大分贝
p50Dbfs: number; // 中位数分贝
p95Dbfs: number; // 95分位数分贝
overRatioDbfs: number; // 超阈值比例
segmentCount: number; // 事件段数量
sampledDurationMs?: number; // 采样时长
gapCount?: number; // 缺口数量
maxGapMs?: number; // 最大缺口时长
}
```
#### 10.1.3 噪音切片显示统计
```typescript
interface NoiseSliceDisplayStats {
avgDb: number; // 平均显示分贝
p95Db: number; // 95分位数显示分贝
}
```
#### 10.1.4 噪音评分明细
```typescript
interface NoiseScoreBreakdown {
sustainedPenalty: number; // 持续噪音惩罚
timePenalty: number; // 时间惩罚
segmentPenalty: number; // 事件段惩罚
thresholdsUsed: {
scoreThresholdDbfs: number; // 使用的评分阈值
segmentMergeGapMs: number; // 使用的合并间隔
maxSegmentsPerMin: number; // 使用的最大事件段数
};
sustainedLevelDbfs: number; // 持续电平
overRatioDbfs: number; // 超阈值比例
segmentCount: number; // 事件段数量
minutes: number; // 时长(分钟)
durationMs?: number; // 物理时长
sampledDurationMs?: number; // 采样时长
coverageRatio?: number; // 覆盖率
}
```
#### 10.1.5 噪音切片摘要
```typescript
interface NoiseSliceSummary {
start: number; // 开始时间戳
end: number; // 结束时间戳
frames: number; // 帧数
raw: NoiseSliceRawStats; // 原始统计
display: NoiseSliceDisplayStats; // 显示统计
score: number; // 评分
scoreDetail: NoiseScoreBreakdown; // 评分明细
}
```
#### 10.1.6 实时数据点
```typescript
interface NoiseRealtimePoint {
t: number; // 时间戳
dbfs: number; // 分贝值 (dBFS)
displayDb: number; // 显示分贝
}
```
#### 10.1.7 噪音流快照
```typescript
interface NoiseStreamSnapshot {
status: NoiseStreamStatus; // 流状态
realtimeDisplayDb: number; // 实时显示分贝
realtimeDbfs: number; // 实时分贝 (dBFS)
maxLevelDb: number; // 最大允许级别
showRealtimeDb: boolean; // 是否显示实时分贝
alertSoundEnabled: boolean; // 提示音开关
ringBuffer: NoiseRealtimePoint[]; // 环形缓冲区快照
latestSlice: NoiseSliceSummary | null; // 最新切片
}
```
#### 10.1.8 噪音流状态
```typescript
type NoiseStreamStatus =
| "initializing" // 初始化中
| "quiet" // 安静
| "noisy" // 嘈杂
| "permission-denied" // 权限拒绝
| "error"; // 错误
```
---
## 附录
### A. 术语表
| 术语 | 英文 | 说明 |
|------|------|------|
| 均方根 | RMS (Root Mean Square) | 衡量音频信号强度的标准方法 |
| 分贝满刻度 | dBFS (Decibels relative to Full Scale) | 数字音频的标准分贝单位,范围 -100 到 0 dB |
| 显示分贝 | Display dB | 用于用户界面展示的分贝值,范围 20 到 100 dB |
| 切片 | Slice | 固定时间窗口(默认 30 秒)内的噪音数据聚合 |
| 帧 | Frame | 单次音频采样(默认 50ms |
| 事件段 | Segment | 独立的噪音事件通过合并窗口500ms合并 |
### B. 参数固定策略
为保证统计口径稳定,当前版本将"分析与评分"的高级参数固定为程序内常量:
| 参数 | 值 | 说明 |
|------|-----|------|
| frameMs | 50ms | 约 20fps |
| sliceSec | 30s | 切片时长 |
| scoreThresholdDbfs | -50 dBFS | 评分阈值 |
| segmentMergeGapMs | 500ms | 事件段合并间隔 |
| maxSegmentsPerMin | 6 | 每分钟最大事件段数 |
### C. 技术栈
- **音频处理**Web Audio API
- **数据存储**localStorage
- **前端框架**React 18
- **构建工具**Vite 5
- **类型系统**TypeScript 5.4
### Pipeline
1. capture microphone input
2. compute RMS and dBFS
3. aggregate frames into slices
4. score each slice
5. persist historical statistics
6. present realtime and historical views

63
run.md
View File

@@ -1,54 +1,55 @@
# LanMountainDesktop 运行指南
# 运行指南
本文档只负责“怎么跑起来”。项目介绍请看 [README.md](./README.md)。
## 中文
## 1. 环境准备
- 安装 .NET SDK 10`net10.0`)。
- 建议使用 Windows 运行桌面端(当前桌面体验以 Windows 为主)。
本文档只说明如何在本地运行阑山桌面。
## 2. 拉取依赖并构建
在仓库根目录执行:
### 环境准备
- 安装 .NET SDK 10。
- 桌面端建议在 Windows 上运行。
### 构建
```bash
dotnet restore
dotnet build LanMountainDesktop.sln -c Debug
```
## 3. 运行桌面端
### 运行桌面端
```bash
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
```
## 4. 推荐能力说明
桌面端已内置推荐数据服务(每日诗词 / 每日名画),默认无需额外启动本地推荐后端。
### 常见问题
## 5. 常见问题
- 启动失败提示 SDK 版本不匹配:确认 `dotnet --info` 中已安装 .NET 10 SDK
- 桌面端视频相关能力异常:优先在 Windows 环境下验证
- 配置重置:删除 `%LOCALAPPDATA%\LanMountainDesktop\settings.json` 后重启应用。
- 如果提示 SDK 版本不匹配,先检查 `dotnet --info`
- 如果视频能力异常,优先在 Windows 环境验证
- 如果要重置配置,可删除 `%LOCALAPPDATA%\LanMountainDesktop\settings.json` 后重启
## 6. Linux 音频功能依赖
### Linux 音依赖
如果在 Linux 上使用录音机组件或自习监测组件,需要安装以下音频库:
如果在 Linux 上使用录音机或自习监测相关能力,需要安装音频库:
- Debian/Ubuntu`sudo apt install libportaudio2 libasound2`
- Fedora/RHEL`sudo dnf install portaudio-libs alsa-lib`
- Arch Linux`sudo pacman -S portaudio alsa-lib`
- Alpine Linux`sudo apk add portaudio alsa-lib`
## English
This guide explains how to run LanMountainDesktop locally.
### Build
### Debian/Ubuntu
```bash
sudo apt install libportaudio2 libasound2
dotnet restore
dotnet build LanMountainDesktop.sln -c Debug
```
### Fedora/RHEL
```bash
sudo dnf install portaudio-libs alsa-lib
```
### Run
### Arch Linux
```bash
sudo pacman -S portaudio alsa-lib
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
```
### Alpine Linux
```bash
sudo apk add portaudio alsa-lib
```
> 注:如果未安装这些依赖,录音和自习监测功能将不可用,但应用其他功能可以正常运行。