From 85f7a18cbcc09db46f1de77e4e4238f8496ed203 Mon Sep 17 00:00:00 2001 From: lincube Date: Tue, 10 Mar 2026 12:14:49 +0800 Subject: [PATCH] 0.5.9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文与插件市场 --- LanAirApp/README.md | 34 +- LanAirApp/docs/PLUGIN_DEVELOPMENT.md | 43 +- LanAirApp/docs/PLUGIN_PACKAGING.md | 36 +- .../LanMountainDesktop.SamplePlugin/README.md | 15 +- LanAirApp/samples/README.md | 16 +- LanAirApp/standards/README.md | 14 +- .../Assets/Fonts/MiSans-NOTICE.md | 37 +- .../Assets/Weather/ATTRIBUTION.md | 41 +- .../Assets/Weather/HyperOS3/ATTRIBUTION.md | 56 +- LanMountainDesktop/ComponentSystem/README.md | 99 +-- LanMountainDesktop/PACKAGING.md | 78 +- .../Services/GitHubReleaseUpdateService.cs | 17 + .../plugins/PluginMarketEmbeddedView.cs | 73 +- .../plugins/PluginMarketInstallService.cs | 8 +- .../plugins/PluginMarketModels.cs | 209 ++++- .../plugins/PluginMarketReadmeService.cs | 42 + .../PluginMarketReleaseResolverService.cs | 72 ++ LanMountainDesktop/plugins/README.md | 68 +- README.md | 77 +- airappmarket/README.md | 28 +- docs/CORNER_RADIUS_SPEC.md | 185 +--- docs/VISUAL_SPEC.md | 140 +-- noise.md | 796 +----------------- run.md | 63 +- 24 files changed, 804 insertions(+), 1443 deletions(-) create mode 100644 LanMountainDesktop/plugins/PluginMarketReadmeService.cs create mode 100644 LanMountainDesktop/plugins/PluginMarketReleaseResolverService.cs diff --git a/LanAirApp/README.md b/LanAirApp/README.md index 0e328c8..3f9de4b 100644 --- a/LanAirApp/README.md +++ b/LanAirApp/README.md @@ -1,27 +1,21 @@ # LanAirApp -`LanAirApp` 是阑山桌面插件生态的对外发布工作区。 +## 中文 -这里集中放置: -- 插件开发标准 -- 插件打包与构建工具 -- 插件开发与打包文档 -- 示例插件 +`LanAirApp` 是阑山桌面插件生态的对外工作区。这个目录是宿主仓库中的镜像副本,权威版本以独立 `LanAirApp` 仓库为准。 -目录结构: -- `docs/`:插件开发文档、打包文档 -- `plugins/`:第一方插件项目,例如插件市场插件 -- `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. diff --git a/LanAirApp/docs/PLUGIN_DEVELOPMENT.md b/LanAirApp/docs/PLUGIN_DEVELOPMENT.md index 7632928..9a3ee4e 100644 --- a/LanAirApp/docs/PLUGIN_DEVELOPMENT.md +++ b/LanAirApp/docs/PLUGIN_DEVELOPMENT.md @@ -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. diff --git a/LanAirApp/docs/PLUGIN_PACKAGING.md b/LanAirApp/docs/PLUGIN_PACKAGING.md index d71c037..d99e132 100644 --- a/LanAirApp/docs/PLUGIN_PACKAGING.md +++ b/LanAirApp/docs/PLUGIN_PACKAGING.md @@ -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. diff --git a/LanAirApp/samples/LanMountainDesktop.SamplePlugin/README.md b/LanAirApp/samples/LanMountainDesktop.SamplePlugin/README.md index 651f815..1dd7ce5 100644 --- a/LanAirApp/samples/LanMountainDesktop.SamplePlugin/README.md +++ b/LanAirApp/samples/LanMountainDesktop.SamplePlugin/README.md @@ -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. diff --git a/LanAirApp/samples/README.md b/LanAirApp/samples/README.md index 6e6bf40..f6c55fb 100644 --- a/LanAirApp/samples/README.md +++ b/LanAirApp/samples/README.md @@ -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`. diff --git a/LanAirApp/standards/README.md b/LanAirApp/standards/README.md index 3472230..2e0bcfe 100644 --- a/LanAirApp/standards/README.md +++ b/LanAirApp/standards/README.md @@ -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. diff --git a/LanMountainDesktop/Assets/Fonts/MiSans-NOTICE.md b/LanMountainDesktop/Assets/Fonts/MiSans-NOTICE.md index b836faf..d571848 100644 --- a/LanMountainDesktop/Assets/Fonts/MiSans-NOTICE.md +++ b/LanMountainDesktop/Assets/Fonts/MiSans-NOTICE.md @@ -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. diff --git a/LanMountainDesktop/Assets/Weather/ATTRIBUTION.md b/LanMountainDesktop/Assets/Weather/ATTRIBUTION.md index f67154f..c7ab638 100644 --- a/LanMountainDesktop/Assets/Weather/ATTRIBUTION.md +++ b/LanMountainDesktop/Assets/Weather/ATTRIBUTION.md @@ -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. diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/ATTRIBUTION.md b/LanMountainDesktop/Assets/Weather/HyperOS3/ATTRIBUTION.md index 001170d..1422532 100644 --- a/LanMountainDesktop/Assets/Weather/HyperOS3/ATTRIBUTION.md +++ b/LanMountainDesktop/Assets/Weather/HyperOS3/ATTRIBUTION.md @@ -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. diff --git a/LanMountainDesktop/ComponentSystem/README.md b/LanMountainDesktop/ComponentSystem/README.md index 1a27d83..c713c7c 100644 --- a/LanMountainDesktop/ComponentSystem/README.md +++ b/LanMountainDesktop/ComponentSystem/README.md @@ -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 diff --git a/LanMountainDesktop/PACKAGING.md b/LanMountainDesktop/PACKAGING.md index 8159797..1185de3 100644 --- a/LanMountainDesktop/PACKAGING.md +++ b/LanMountainDesktop/PACKAGING.md @@ -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/` +- 安装包或压缩包:`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 diff --git a/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs b/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs index 6075aef..f2bc437 100644 --- a/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs +++ b/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs @@ -242,6 +242,23 @@ public sealed class GitHubReleaseUpdateService : IDisposable } } + public async Task GetReleaseByTagAsync( + string tagName, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(tagName)) + { + 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 GetLatestStableReleaseAsync(CancellationToken cancellationToken) { var url = $"https://api.github.com/repos/{_owner}/{_repo}/releases/latest"; diff --git a/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs b/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs index 64604c9..1793665 100644 --- a/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs +++ b/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs @@ -27,6 +27,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable private readonly PluginRuntimeService _runtime; private readonly AirAppMarketIndexService _indexService; private readonly AirAppMarketInstallService _installService; + private readonly AirAppMarketReadmeService _readmeService; private readonly Version? _hostVersion; private readonly TextBox _searchTextBox; @@ -38,7 +39,10 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable private AirAppMarketIndexDocument? _document; private AirAppMarketPluginEntry? _selectedPlugin; private Dictionary _installedPlugins = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _readmeContents = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _readmeErrors = new(StringComparer.OrdinalIgnoreCase); private string _marketSourceDisplay = AirAppMarketDefaults.DefaultIndexUrl; + private string? _loadingReadmePluginId; private bool _isRefreshing; private bool _isInstalling; private bool _hasLoadedOnce; @@ -49,6 +53,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable var dataDirectory = Path.Combine(AppContext.BaseDirectory, "Data", "AirAppMarket"); _indexService = new AirAppMarketIndexService(new AirAppMarketCacheService(dataDirectory)); _installService = new AirAppMarketInstallService(runtime, dataDirectory); + _readmeService = new AirAppMarketReadmeService(); _hostVersion = typeof(App).Assembly.GetName().Version; _searchTextBox = new TextBox @@ -114,6 +119,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable public void Dispose() { + _readmeService.Dispose(); _installService.Dispose(); _indexService.Dispose(); } @@ -223,6 +229,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable SetStatus(statusMessage, result.Source == AirAppMarketLoadSource.Cache ? WarningBrush : SuccessBrush); RebuildSurface(); + await EnsureReadmeLoadedAsync(_selectedPlugin); } finally { @@ -245,6 +252,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable BuildPluginList(filteredPlugins); BuildDetailPanel(); + _ = EnsureReadmeLoadedAsync(_selectedPlugin); } private List GetFilteredPlugins() @@ -372,10 +380,11 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable } }; - button.Click += (_, _) => + button.Click += async (_, _) => { _selectedPlugin = plugin; RebuildSurface(); + await EnsureReadmeLoadedAsync(plugin); }; return button; @@ -454,11 +463,12 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable CreateInfoRow(T("market.detail.min_host_version", "最低宿主版本"), plugin.MinHostVersion), CreateInfoRow(T("market.detail.installed_version", "当前已安装版本"), installedPlugin?.Manifest.Version ?? T("market.detail.not_installed", "未安装")), CreateInfoRow(T("market.detail.market_source", "市场源"), _marketSourceDisplay), + CreateInfoRow(T("market.detail.project", "Project"), plugin.ProjectUrl), CreateInfoRow(T("market.detail.homepage", "主页"), plugin.HomepageUrl), CreateInfoRow(T("market.detail.repository", "仓库"), plugin.RepositoryUrl), new TextBlock { - Text = T("market.detail.release_notes", "发布说明"), + Text = T("market.detail.readme", "README"), FontSize = 18, FontWeight = FontWeight.SemiBold }, @@ -469,7 +479,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable Padding = new Thickness(14), Child = new TextBlock { - Text = plugin.ReleaseNotes, + Text = GetReadmeContent(plugin), TextWrapping = TextWrapping.Wrap } } @@ -540,6 +550,63 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable } } + private async Task EnsureReadmeLoadedAsync(AirAppMarketPluginEntry? plugin) + { + if (plugin is null || + _readmeContents.ContainsKey(plugin.Id) || + string.Equals(_loadingReadmePluginId, plugin.Id, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + _loadingReadmePluginId = plugin.Id; + _readmeErrors.Remove(plugin.Id); + BuildDetailPanel(); + + try + { + var readme = await _readmeService.LoadAsync(plugin); + _readmeContents[plugin.Id] = string.IsNullOrWhiteSpace(readme) + ? T("market.detail.readme_empty", "README is empty.") + : readme.Trim(); + } + catch (Exception ex) + { + _readmeErrors[plugin.Id] = ex.Message; + } + finally + { + _loadingReadmePluginId = null; + if (string.Equals(_selectedPlugin?.Id, plugin.Id, StringComparison.OrdinalIgnoreCase)) + { + BuildDetailPanel(); + } + } + } + + private string GetReadmeContent(AirAppMarketPluginEntry plugin) + { + if (_readmeContents.TryGetValue(plugin.Id, out var readme)) + { + return readme; + } + + if (_readmeErrors.TryGetValue(plugin.Id, out var error)) + { + return F( + "market.detail.readme_error_format", + "README could not be loaded: {0}", + error); + } + + if (string.Equals(_loadingReadmePluginId, plugin.Id, StringComparison.OrdinalIgnoreCase)) + { + return T("market.detail.readme_loading", "Loading README..."); + } + + return plugin.ReleaseNotes; + } + private AirAppMarketPluginEntry? ResolveSelectedPlugin( string? selectedPluginId, IReadOnlyList plugins) diff --git a/LanMountainDesktop/plugins/PluginMarketInstallService.cs b/LanMountainDesktop/plugins/PluginMarketInstallService.cs index af10935..4073259 100644 --- a/LanMountainDesktop/plugins/PluginMarketInstallService.cs +++ b/LanMountainDesktop/plugins/PluginMarketInstallService.cs @@ -14,6 +14,7 @@ internal sealed class AirAppMarketInstallService : IDisposable { private readonly PluginRuntimeService _runtime; private readonly HttpClient _httpClient; + private readonly AirAppMarketReleaseResolverService _releaseResolverService; private readonly string _downloadsDirectory; public AirAppMarketInstallService(PluginRuntimeService runtime, string dataDirectory) @@ -25,6 +26,7 @@ internal sealed class AirAppMarketInstallService : IDisposable Timeout = TimeSpan.FromMinutes(2) }; _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0"); + _releaseResolverService = new AirAppMarketReleaseResolverService(_httpClient); } public async Task InstallAsync( @@ -40,7 +42,9 @@ internal sealed class AirAppMarketInstallService : IDisposable try { - if (AirAppMarketDefaults.TryResolveWorkspaceFile(plugin.DownloadUrl, out var localPackagePath)) + var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, cancellationToken); + + if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath)) { await using var sourceStream = File.OpenRead(localPackagePath); await using var destinationStream = File.Create(downloadPath); @@ -49,7 +53,7 @@ internal sealed class AirAppMarketInstallService : IDisposable else { using var response = await _httpClient.GetAsync( - plugin.DownloadUrl, + resolvedDownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); diff --git a/LanMountainDesktop/plugins/PluginMarketModels.cs b/LanMountainDesktop/plugins/PluginMarketModels.cs index 1f93eff..41c5416 100644 --- a/LanMountainDesktop/plugins/PluginMarketModels.cs +++ b/LanMountainDesktop/plugins/PluginMarketModels.cs @@ -13,11 +13,25 @@ internal static class AirAppMarketDefaults public const string DefaultIndexUrl = "https://raw.githubusercontent.com/wwiinnddyy/LanAirApp/main/airappmarket/index.json"; - private const string RawGitHubLanAirAppPathPrefix = "/wwiinnddyy/LanAirApp/main/"; + 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 = TryGetWorkspaceLanAirAppRepositoryRoot(); + var repositoryRoot = TryGetWorkspaceRepositoryRoot("LanAirApp"); if (repositoryRoot is null) { return null; @@ -31,17 +45,24 @@ internal static class AirAppMarketDefaults { localPath = string.Empty; - var repositoryRoot = TryGetWorkspaceLanAirAppRepositoryRoot(); - if (repositoryRoot is null || - !Uri.TryCreate(url, UriKind.Absolute, out var uri) || - !string.Equals(uri.Host, "raw.githubusercontent.com", StringComparison.OrdinalIgnoreCase) || - !uri.AbsolutePath.StartsWith(RawGitHubLanAirAppPathPrefix, StringComparison.OrdinalIgnoreCase)) + 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 relativePath = Uri.UnescapeDataString(uri.AbsolutePath[RawGitHubLanAirAppPathPrefix.Length..]) - .Replace('/', Path.DirectorySeparatorChar); var candidatePath = Path.GetFullPath(Path.Combine(repositoryRoot, relativePath)); if (!File.Exists(candidatePath)) { @@ -52,13 +73,39 @@ internal static class AirAppMarketDefaults return true; } - private static string? TryGetWorkspaceLanAirAppRepositoryRoot() + 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, "LanAirApp"); - if (File.Exists(Path.Combine(candidate, "airappmarket", "index.json"))) + var candidate = Path.Combine(current.FullName, repositoryName); + if (Directory.Exists(candidate)) { return candidate; } @@ -68,6 +115,60 @@ internal static class AirAppMarketDefaults 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 @@ -193,6 +294,24 @@ internal sealed class AirAppMarketIndexDocument 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) || @@ -203,6 +322,24 @@ internal sealed class AirAppMarketIndexDocument } } + 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; @@ -260,6 +397,14 @@ internal sealed class AirAppMarketPluginEntry 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; @@ -272,6 +417,10 @@ internal sealed class AirAppMarketPluginEntry public string ReleaseNotes { get; init; } = string.Empty; + public bool HasReleaseDownloadMetadata => + !string.IsNullOrWhiteSpace(ReleaseTag) && + !string.IsNullOrWhiteSpace(ReleaseAssetName); + public AirAppMarketPluginEntry ValidateAndNormalize(string sourceName) { var normalizedTags = (Tags ?? []) @@ -298,6 +447,14 @@ internal sealed class AirAppMarketPluginEntry 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)}'."); @@ -307,8 +464,30 @@ internal sealed class AirAppMarketPluginEntry 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); - AirAppMarketIndexDocument.EnsureUrl(normalizedRepositoryUrl, nameof(RepositoryUrl), 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) { @@ -339,6 +518,10 @@ internal sealed class AirAppMarketPluginEntry Sha256 = normalizedSha, PackageSizeBytes = PackageSizeBytes, IconUrl = normalizedIconUrl, + ReleaseTag = normalizedReleaseTag ?? string.Empty, + ReleaseAssetName = normalizedReleaseAssetName ?? string.Empty, + ProjectUrl = normalizedProjectUrl, + ReadmeUrl = normalizedReadmeUrl, HomepageUrl = normalizedHomepageUrl, RepositoryUrl = normalizedRepositoryUrl, Tags = normalizedTags, diff --git a/LanMountainDesktop/plugins/PluginMarketReadmeService.cs b/LanMountainDesktop/plugins/PluginMarketReadmeService.cs new file mode 100644 index 0000000..ee4d037 --- /dev/null +++ b/LanMountainDesktop/plugins/PluginMarketReadmeService.cs @@ -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 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(); + } +} diff --git a/LanMountainDesktop/plugins/PluginMarketReleaseResolverService.cs b/LanMountainDesktop/plugins/PluginMarketReleaseResolverService.cs new file mode 100644 index 0000000..38d7caa --- /dev/null +++ b/LanMountainDesktop/plugins/PluginMarketReleaseResolverService.cs @@ -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 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); + } +} diff --git a/LanMountainDesktop/plugins/README.md b/LanMountainDesktop/plugins/README.md index 9c1f225..0ebde62 100644 --- a/LanMountainDesktop/plugins/README.md +++ b/LanMountainDesktop/plugins/README.md @@ -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. diff --git a/README.md b/README.md index 11d9836..fbd77b5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/airappmarket/README.md b/airappmarket/README.md index be388a9..be92491 100644 --- a/airappmarket/README.md +++ b/airappmarket/README.md @@ -1,23 +1,17 @@ -# AirAppMarket +# AirApp Market 目录说明 -`airappmarket/` 是阑山桌面的官方插件市场源目录。 +## 中文 -当前阶段职责: -- 提供官方插件市场索引 `index.json` -- 提供索引 schema -- 提供静态图标资产 -- 提供本地与 CI 使用的索引校验工具 +这个目录是阑山桌面仓库里遗留的市场原型目录,只用于历史参考,不再作为官方权威市场源。 -Bootstrap 方式: -1. 用户先通过阑山桌面内置的 `设置 -> 插件 -> 打开 .laapp 插件包` 手动安装 `LanMountainDesktop.PluginMarketplace`。 -2. 市场插件启动后,会从这里的官方索引拉取插件列表。 -3. 后续插件安装与更新都通过市场插件完成。 +### 当前结论 -官方索引地址: +- 官方市场源以独立 `LanAirApp` 仓库中的 `airappmarket/index.json` 为准 +- 阑山桌面程序应连接 `LanAirApp` 仓库,而不是以本目录为权威数据源 +- 如无特殊需要,不应继续向这里添加正式市场数据 -`https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/airappmarket/index.json` +## English -约束: -- 这里只维护官方市场源,不做多源聚合。 -- 第一阶段不提供独立 GitHub Pages 页面。 -- 索引中的下载链接默认指向本仓库已提交的 `.laapp` 发布包。 +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. diff --git a/docs/CORNER_RADIUS_SPEC.md b/docs/CORNER_RADIUS_SPEC.md index 09b92f2..e0daa89 100644 --- a/docs/CORNER_RADIUS_SPEC.md +++ b/docs/CORNER_RADIUS_SPEC.md @@ -1,177 +1,38 @@ -# 圆角设计规范 (Corner Radius Design System) +# 圆角设计规范 -> 基于小米澎湃OS 3 (HyperOS) 设计语言 +## 中文 -## 设计理念 +本规范用于统一阑山桌面不同层级容器和控件的圆角尺度。 -澎湃OS 3 采用**"生命感美学"**设计语言,强调: -- **全局圆角设计** - 所有界面元素均采用圆角 -- **视觉舒适统一** - 柔和、现代、细腻 -- **多级渲染** - 配合模糊混色与阴影 -- **层级分明** - 大容器使用大圆角,小元素使用小圆角 +### 基础层级 -## 圆角数值体系 +- Level 1:12px,小元素和图标容器 +- Level 2:16px,小型色块和紧凑控件 +- Level 3:20px,普通按钮 +- Level 4:24px,输入面板和小型容器 +- Level 5:28px,普通玻璃面板 +- Level 6:32px,强化容器 +- Level 7:36px,大容器、窗口、任务栏 -### 核心数值 +### 使用建议 -| 级别 | 圆角值 (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 - - - - - -``` - -### 使用样式类 - -```xml - - - - - - - - - -``` - -### 动态圆角(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 diff --git a/docs/VISUAL_SPEC.md b/docs/VISUAL_SPEC.md index 1eb5622..1a972f9 100644 --- a/docs/VISUAL_SPEC.md +++ b/docs/VISUAL_SPEC.md @@ -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 - - - - - -