feat: #203 添加插件市场

This commit is contained in:
denglihong2007
2025-07-20 16:16:45 +08:00
parent ee80792fcc
commit 3d33246bf7
13 changed files with 210 additions and 72 deletions

View File

@@ -1,15 +1,12 @@
using CRSim.Core.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using CRSim.Core.Models.Plugin;
namespace CRSim.Core.Abstractions
{
public interface INetworkService
{
Task<List<TrainStop>?> GetTimeTableAsync(string number);
Task<List<TrainStop>> GetTrainNumbersAsync(string name);
Task<List<TrainStop>?> GetTrainNumbersAsync(string name);
List<PluginManifest>? GetOnlinePlugins(string uri);
}
}

View File

@@ -11,12 +11,19 @@ public interface IPluginService
/// <summary>
/// 插件包文件扩展名。
/// </summary>
public static readonly string PluginPackageExtension = ".cp";
public static readonly string PluginPackageExtension = ".crsp";
internal static ObservableCollection<PluginInfo> LoadedPluginsInternal { get; } = [];
/// <summary>
/// 已加载的插件信息列表。
/// </summary>
public static IReadOnlyList<PluginInfo> LoadedPlugins => LoadedPluginsInternal;
public static ObservableCollection<PluginInfo> LoadedPlugins => LoadedPluginsInternal;
internal static ObservableCollection<PluginInfo> OnlinePluginsInternal { get; } = [];
public static ObservableCollection<PluginInfo> OnlinePlugins => OnlinePluginsInternal;
Task InstallPluginAsync(PluginInfo plugin);
void LoadOnlinePlugins();
}

View File

@@ -5,12 +5,14 @@
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TrimMode>partial</TrimMode>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="Downloader" Version="4.0.2" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.6" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>

View File

@@ -1,16 +1,41 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CRSim.Core.Abstractions;
using CRSim.Core.Enums;
using Downloader;
namespace CRSim.Core.Models.Plugin;
public partial class PluginInfo : ObservableRecipient
{
//private DownloadProgress? _downloadProgress;
//private bool _isAvailableOnMarket = false;
public DownloadService? DownloadService;
[ObservableProperty]
private int _downloadProgress = 0;
public bool IsAvailableOnMarket => IPluginService.OnlinePlugins.Any(x => x.Manifest.Id == Manifest.Id);
public bool IsUpdateAvailable
{
get
{
if (IsAvailableOnMarket)
{
if(IPluginService.OnlinePlugins.Where(x => x.Manifest.Id == Manifest.Id).FirstOrDefault() is PluginInfo onlinePlugin)
{
return onlinePlugin.Manifest.Version != Manifest.Version;
}
}
return false;
}
}
[ObservableProperty]
private PluginManifest _manifest = new();
[ObservableProperty]
private bool _restartRequired = false;
public PluginLoadStatus LoadStatus { get; internal set; } = PluginLoadStatus.NotLoaded;
public bool IsEnabled
{
get
@@ -32,6 +57,7 @@ public partial class PluginInfo : ObservableRecipient
OnPropertyChanged();
}
}
public string PluginFolderPath { get; internal set; } = "";
public string RealIconPath { get; set; } = "";
public bool IsUninstalling
@@ -45,17 +71,18 @@ public partial class PluginInfo : ObservableRecipient
var path = Path.Combine(PluginFolderPath, ".uninstall");
if (value)
{
RestartRequired = true;
File.WriteAllText(path, "");
}
else
{
RestartRequired = false;
File.Delete(path);
}
OnPropertyChanged();
}
}
public Exception? Exception { get; internal set; }
//private bool _isUpdateAvailable = false;
[ObservableProperty]
private StyleInfo? styleInfo;

View File

@@ -1,4 +1,5 @@
using CommunityToolkit.Mvvm.ComponentModel;
using System.Text.Json.Serialization;
namespace CRSim.Core.Models.Plugin;
@@ -11,50 +12,54 @@ public class PluginManifest
/// 入口程序集。加载插件时,将在此入口程序集中搜索插件类。
/// </summary>
/// <example>MyPlugin.dll</example>
[JsonPropertyName("entranceAssembly")]
public string EntranceAssembly { get; set; } = "";
/// <summary>
/// 插件显示名称。
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; } = "";
/// <summary>
/// 插件ID。
/// </summary>
[JsonPropertyName("id")]
public string Id { get; set; } = "";
/// <summary>
/// 插件图标路径。默认为icon.png。
/// </summary>
public string Icon { get; set; } = "icon.png";
/// <summary>
/// 插件自述。
/// </summary>
[JsonPropertyName("description")]
public string Description { get; set; } = "";
/// <summary>
/// 项目 Url
/// </summary>
[JsonPropertyName("url")]
public string? Url { get; set; }
/// <summary>
/// 插件版本
/// </summary>
[JsonPropertyName("version")]
public string Version { get; set; } = "";
/// <summary>
/// 插件目标 CRSim 版本
/// </summary>
[JsonPropertyName("apiVersion")]
public string ApiVersion { get; set; } = "";
/// <summary>
/// 插件作者
/// </summary>
[JsonPropertyName("author")]
public string Author { get; set; } = "";
/// <summary>
/// 插件类型
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; } = "";
}

View File

@@ -21,7 +21,7 @@ namespace CRSim.Core.Models
/// </summary>
public TimeSpan StopDisplayFromArrivalDuration { get; set; } = TimeSpan.FromMinutes(10);
public TimeSpan StopCheckInAdvanceDuration { get; set; } = TimeSpan.FromMinutes(2);
public string ApiUri { get; set; } = "http://47.122.74.193:25565";
public int MaxPages { get; set; } = 3;
public int SwitchPageSeconds { get; set; } = 20;
public string UserKey { get; set; } = "";

View File

@@ -1,14 +1,13 @@
using CRSim.Core.Abstractions;
using CRSim.Core.Models;
using CRSim.Core.Models.Plugin;
using System.Net;
using System.Text.Json;
namespace CRSim.Core.Services
{
public class NetworkService(IDatabaseService databaseService) : INetworkService
public class NetworkService() : INetworkService
{
private readonly IDatabaseService _databaseService = databaseService;
public async Task<List<TrainStop>?> GetTimeTableAsync(string number)
{
try
@@ -62,10 +61,10 @@ namespace CRSim.Core.Services
}
catch
{
return null;
}
return null;
}
private static TimeSpan? ParseTime(string timeStr)
{
timeStr = string.Concat(timeStr.AsSpan(0, 2), ":", timeStr.AsSpan(2, 2));
@@ -78,58 +77,79 @@ namespace CRSim.Core.Services
return null;
}
public async Task<List<TrainStop>> GetTrainNumbersAsync(string name)
public async Task<List<TrainStop>?> GetTrainNumbersAsync(string name)
{
var client = new HttpClient();
var stations = (await client.GetStringAsync("https://kyfw.12306.cn/otn/resources/js/framework/station_name.js")).Split("|||");
string tel = "";
foreach (string station in stations)
try
{
if (station.StartsWith("';")) continue;
if (station.Split("|")[1] == name)
var client = new HttpClient();
var stations = (await client.GetStringAsync("https://kyfw.12306.cn/otn/resources/js/framework/station_name.js")).Split("|||");
string tel = "";
foreach (string station in stations)
{
tel = station.Split("|")[2];
if (station.StartsWith("';")) continue;
if (station.Split("|")[1] == name)
{
tel = station.Split("|")[2];
}
}
}
if (tel == "")
{
return [];
}
var baseAddress = new Uri("https://mobile.12306.cn/wxxcx/wechat/bigScreen/queryTrainByStation");
var cookieContainer = new CookieContainer();
cookieContainer.Add(baseAddress, new Cookie("BIGipServerweixin_xiaochengxu", "2028077578.5670.0000"));
var handler = new HttpClientHandler() { CookieContainer = cookieContainer, UseCookies = true };
if (tel == "")
{
return [];
}
var baseAddress = new Uri("https://mobile.12306.cn/wxxcx/wechat/bigScreen/queryTrainByStation");
var cookieContainer = new CookieContainer();
cookieContainer.Add(baseAddress, new Cookie("BIGipServerweixin_xiaochengxu", "2028077578.5670.0000"));
var handler = new HttpClientHandler() { CookieContainer = cookieContainer, UseCookies = true };
HttpContent content = new FormUrlEncodedContent(new Dictionary<string, string>()
{
{"train_station_code",tel },
{"train_start_date",DateTime.Now.ToString("yyyyMMdd")}
});
client = new HttpClient(handler) { BaseAddress = baseAddress };
client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) NetType/WIFI MiniProgramEnv/Windows WindowsWechat/WMPF WindowsWechat(0x63090c11)XWEB/11581");
var response = await client.PostAsync(baseAddress, content);
var json = await response.Content.ReadAsStringAsync();
if (json.Contains("操作失败,请稍后重试")) return [];
var list = JsonDocument.Parse(json).RootElement.GetProperty("data").EnumerateArray();
if (!list.Any()) return [];
List<TrainStop> trainStops = [];
foreach (var item in list)
{
var arriveTimeStr = item.GetProperty("arrive_time").GetString();
var startTimeStr = item.GetProperty("start_time").GetString();
TimeSpan? arriveTime = null;
TimeSpan? startTime = null;
if (arriveTimeStr != "----")
HttpContent content = new FormUrlEncodedContent(new Dictionary<string, string>()
{
arriveTime = TimeSpan.Parse(arriveTimeStr);
}
if (startTimeStr != arriveTimeStr)
{"train_station_code",tel },
{"train_start_date",DateTime.Now.ToString("yyyyMMdd")}
});
client = new HttpClient(handler) { BaseAddress = baseAddress };
client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) NetType/WIFI MiniProgramEnv/Windows WindowsWechat/WMPF WindowsWechat(0x63090c11)XWEB/11581");
var response = await client.PostAsync(baseAddress, content);
var json = await response.Content.ReadAsStringAsync();
if (json.Contains("操作失败,请稍后重试")) return [];
var list = JsonDocument.Parse(json).RootElement.GetProperty("data").EnumerateArray();
if (!list.Any()) return [];
List<TrainStop> trainStops = [];
foreach (var item in list)
{
startTime = TimeSpan.Parse(startTimeStr);
var arriveTimeStr = item.GetProperty("arrive_time").GetString();
var startTimeStr = item.GetProperty("start_time").GetString();
TimeSpan? arriveTime = null;
TimeSpan? startTime = null;
if (arriveTimeStr != "----")
{
arriveTime = TimeSpan.Parse(arriveTimeStr);
}
if (startTimeStr != arriveTimeStr)
{
startTime = TimeSpan.Parse(startTimeStr);
}
trainStops.Add(new TrainStop() { Number = item.GetProperty("station_train_code").ToString(), Terminal = item.GetProperty("end_station_name").ToString(), Origin = item.GetProperty("start_station_name").ToString(), ArrivalTime = arriveTime, DepartureTime = startTime });
}
trainStops.Add(new TrainStop() { Number = item.GetProperty("station_train_code").ToString(), Terminal = item.GetProperty("end_station_name").ToString(), Origin = item.GetProperty("start_station_name").ToString(), ArrivalTime = arriveTime, DepartureTime = startTime });
return trainStops;
}
return trainStops;
catch
{
}
return null;
}
public List<PluginManifest>? GetOnlinePlugins(string url)
{
try
{
var client = new HttpClient();
var response = client.GetStringAsync(url).Result;
return JsonSerializer.Deserialize<List<PluginManifest>>(response);
}
catch
{
}
return null;
}
}
}

View File

@@ -4,9 +4,11 @@ using CRSim.Core.Enums;
using CRSim.Core.Models;
using CRSim.Core.Models.Plugin;
using CRSim.Core.Utils;
using Downloader;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.Reflection;
using Windows.System;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
@@ -14,7 +16,9 @@ namespace CRSim.Core.Services;
public class PluginService : IPluginService
{
public static readonly string PluginManifestFileName = "manifest.yml";
public static readonly string PluginManifestFileName = "manifest.yml";
private string IndexUrl => $"{_settings.ApiUri}/GetFile?fileName=plugins.json";
public static void InitializePlugins(HostBuilderContext context, IServiceCollection services,string externalPluginPath)
{
@@ -52,7 +56,7 @@ public class PluginService : IPluginService
{
Manifest = manifest,
PluginFolderPath = Path.GetFullPath(pluginDir),
RealIconPath = Path.Combine(Path.GetFullPath(pluginDir), manifest.Icon),
RealIconPath = Path.Combine(Path.GetFullPath(pluginDir), "icon.png"),
StyleInfo = manifest.Type == "ScreenStyle" ? deserializer.Deserialize<StyleInfo?>(File.ReadAllText(Path.Combine(Path.GetFullPath(pluginDir), "style.yml"))) : null,
};
if (info.IsUninstalling)
@@ -60,15 +64,19 @@ public class PluginService : IPluginService
Directory.Delete(pluginDir, true);
continue;
}
IPluginService.LoadedPluginsInternal.Add(info);
if (!info.IsEnabled)
{
info.LoadStatus = PluginLoadStatus.Disabled;
}
IPluginService.LoadedPluginsInternal.Add(info);
}
foreach (var info in IPluginService.LoadedPluginsInternal)
{
if(info.LoadStatus == PluginLoadStatus.Disabled)
{
continue;
}
var manifest = info.Manifest;
var pluginDir = info.PluginFolderPath;
try
@@ -103,5 +111,71 @@ public class PluginService : IPluginService
}
}
}
private readonly Models.Settings _settings;
private readonly INetworkService _networkService;
public PluginService(INetworkService networkService,ISettingsService settingsService)
{
_networkService = networkService;
_settings = settingsService.GetSettings();
LoadOnlinePlugins();
}
public void LoadOnlinePlugins()
{
var pluginManifests = _networkService.GetOnlinePlugins(IndexUrl);
IPluginService.OnlinePluginsInternal.Clear();
foreach (var Manifest in pluginManifests ?? [])
{
var localInfo = IPluginService.LoadedPluginsInternal.FirstOrDefault(x => x.Manifest.Id == Manifest.Id);
var info = new PluginInfo
{
Manifest = Manifest,
RealIconPath = $"{_settings.ApiUri}/GetFile?fileName=icons/{Manifest.Id}.png",
LoadStatus = localInfo?.LoadStatus ?? PluginLoadStatus.NotLoaded,
PluginFolderPath = localInfo?.PluginFolderPath ?? string.Empty,
};
IPluginService.OnlinePluginsInternal.Add(info);
}
}
public async Task InstallPluginAsync(PluginInfo plugin)
{
var id = plugin.Manifest.Id;
var packageUrl = $"{_settings.ApiUri}/GetFile?fileName=plugins/{id}{IPluginService.PluginPackageExtension}";
var tempDir = Path.Combine(AppPaths.TempPath, "Plugins", Guid.NewGuid().ToString());
Directory.CreateDirectory(tempDir);
var packagePath = Path.Combine(tempDir, $"{id}{IPluginService.PluginPackageExtension}");
DispatcherQueue dispatcherQueue = DispatcherQueue.GetForCurrentThread();
plugin.DownloadService = new DownloadService();
plugin.DownloadService.DownloadProgressChanged += (s, e) =>
{
dispatcherQueue.TryEnqueue(() =>
{
plugin.DownloadProgress = (int)(e.ProgressPercentage);
Console.WriteLine(e.ProgressPercentage);
});
};
try
{
await plugin.DownloadService.DownloadFileTaskAsync(packageUrl,packagePath);
}
catch(Exception e)
{
Console.WriteLine(e.Message);
plugin.DownloadProgress = 0;
return;
}
// 解压到插件目录
var destDir = Path.Combine(AppPaths.PluginsRootPath, id);
if (Directory.Exists(destDir))
Directory.Delete(destDir, true);
Directory.CreateDirectory(destDir);
System.IO.Compression.ZipFile.ExtractToDirectory(packagePath, destDir);
// 清理临时文件
try { Directory.Delete(tempDir, true); } catch { }
plugin.DownloadProgress = 0;
plugin.RestartRequired = true;
}
}

View File

@@ -17,6 +17,7 @@ namespace CRSim.Core.Services
{
_key.SetValue("TimeOffset", (int)_settings.TimeOffset.TotalMinutes);
_key.SetValue("SwitchPageSeconds", _settings.SwitchPageSeconds);
_key.SetValue("ApiUri", _settings.ApiUri);
_key.SetValue("MaxPages", _settings.MaxPages);
_key.SetValue("StopCheckInAdvanceDuration", (int)_settings.StopCheckInAdvanceDuration.TotalMinutes);
_key.SetValue("StopDisplayUntilDepartureDuration", (int)_settings.StopDisplayUntilDepartureDuration.TotalMinutes);
@@ -41,6 +42,7 @@ namespace CRSim.Core.Services
_settings = new Settings();
if (_key.GetValue("TimeOffset") != null) _settings.TimeOffset = TimeSpan.FromMinutes((int)_key.GetValue("TimeOffset"));
if (_key.GetValue("SwitchPageSeconds") != null) _settings.SwitchPageSeconds = (int)_key.GetValue("SwitchPageSeconds");
if (_key.GetValue("ApiUri") != null) _settings.ApiUri = (string)_key.GetValue("ApiUri");
if (_key.GetValue("MaxPages") != null) _settings.MaxPages = (int)_key.GetValue("MaxPages");
if (_key.GetValue("StopCheckInAdvanceDuration") != null) _settings.StopCheckInAdvanceDuration = TimeSpan.FromMinutes((int)_key.GetValue("StopCheckInAdvanceDuration"));
if (_key.GetValue("StopDisplayUntilDepartureDuration") != null) _settings.StopDisplayUntilDepartureDuration = TimeSpan.FromMinutes((int)_key.GetValue("StopDisplayUntilDepartureDuration"));

View File

@@ -2,13 +2,15 @@
{
public static class AppPaths
{
public static string AppDataFolder =>
public static string AppDataPath =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "CRSim");
public static string TempPath =>
Path.Combine(Path.GetTempPath(), "CRSim");
public static string ConfigFilePath =>
Path.Combine(AppDataFolder, "data.json");
Path.Combine(AppDataPath, "data.json");
public static string PluginsRootPath =>
Path.Combine(AppDataFolder, "Plugins");
Path.Combine(AppDataPath, "Plugins");
}
}

View File

@@ -9,6 +9,7 @@
<Platforms>AnyCPU;x64</Platforms>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TrimMode>partial</TrimMode>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\CRSim.Core\CRSim.Core.csproj">

View File

@@ -4,7 +4,8 @@
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<Nullable>enable</Nullable>
<UseWPF>true</UseWPF>
<TrimMode>partial</TrimMode>
<UseWPF>true</UseWPF>
<Platforms>AnyCPU;x64</Platforms>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

View File

@@ -15,7 +15,7 @@
<LangVersion>preview</LangVersion>
<TrimMode>partial</TrimMode>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<Version>2.3.101.1</Version>
<Version>2.3.102.0</Version>
</PropertyGroup>
<ItemGroup>
<Content Remove="Assets\CRSimIcon.png" />