mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
0.5.9
中文与插件市场
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -242,6 +242,23 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<GitHubReleaseInfo?> 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<GitHubReleaseInfo?> GetLatestStableReleaseAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var url = $"https://api.github.com/repos/{_owner}/{_repo}/releases/latest";
|
||||
|
||||
@@ -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<string, PluginCatalogEntry> _installedPlugins = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string> _readmeContents = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string> _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<AirAppMarketPluginEntry> 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<AirAppMarketPluginEntry> plugins)
|
||||
|
||||
@@ -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<AirAppMarketInstallResult> 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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
42
LanMountainDesktop/plugins/PluginMarketReadmeService.cs
Normal file
42
LanMountainDesktop/plugins/PluginMarketReadmeService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user