From ea0be0c22037c2d3a49c277b1e2f0e1c9cafae74 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=99=BA=E5=95=86=E7=84=97=E8=92=9F=E9=95=BF?=
<104508795+ztzpro@users.noreply.github.com>
Date: Fri, 25 Sep 2026 08:57:29 +0800
Subject: [PATCH] =?UTF-8?q?refactor(plugin)=EF=BC=9A=E5=B0=86=E5=AE=98?=
=?UTF-8?q?=E6=96=B9=E6=8F=92=E4=BB=B6=E5=AD=90=E6=A8=A1=E5=9D=97=E7=A7=BB?=
=?UTF-8?q?=E5=8A=A8=E5=88=B0=20plugin=20=E7=9B=AE=E5=BD=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 将 StarPie-Official-Plugins 子模块从 external/ 移动到 plugin/
- 更新 .gitmodules,保留原有仓库地址和当前子模块版本
- 保持现有 SDK 和插件示例不变
---
.gitmodules | 6 +-
{external => plugin}/StarPie-Official-Plugins | 0
plugin/samples/FloatingBall/Actions.cs | 266 ------
plugin/samples/FloatingBall/BallController.cs | 253 -----
plugin/samples/FloatingBall/BallWindow.cs | 413 --------
.../samples/FloatingBall/FloatingBall.csproj | 79 --
.../FloatingBall/FloatingBallPlugin.cs | 176 ----
plugin/samples/FloatingBall/Preferences.cs | 130 ---
plugin/samples/FloatingBall/plugin.json | 31 -
plugin/samples/HelloAction/Actions.cs | 344 -------
plugin/samples/HelloAction/HelloAction.csproj | 75 --
.../samples/HelloAction/HelloActionPlugin.cs | 116 ---
plugin/samples/HelloAction/plugin.json | 31 -
plugin/samples/HelloAction/plugin.schema.json | 229 -----
plugin/samples/ScreenBrightness/Actions.cs | 427 ---------
.../ScreenBrightness/BrightnessController.cs | 882 ------------------
.../ScreenBrightness/ScreenBrightness.csproj | 69 --
.../ScreenBrightnessPlugin.cs | 114 ---
plugin/samples/ScreenBrightness/plugin.json | 30 -
19 files changed, 3 insertions(+), 3668 deletions(-)
rename {external => plugin}/StarPie-Official-Plugins (100%)
delete mode 100644 plugin/samples/FloatingBall/Actions.cs
delete mode 100644 plugin/samples/FloatingBall/BallController.cs
delete mode 100644 plugin/samples/FloatingBall/BallWindow.cs
delete mode 100644 plugin/samples/FloatingBall/FloatingBall.csproj
delete mode 100644 plugin/samples/FloatingBall/FloatingBallPlugin.cs
delete mode 100644 plugin/samples/FloatingBall/Preferences.cs
delete mode 100644 plugin/samples/FloatingBall/plugin.json
delete mode 100644 plugin/samples/HelloAction/Actions.cs
delete mode 100644 plugin/samples/HelloAction/HelloAction.csproj
delete mode 100644 plugin/samples/HelloAction/HelloActionPlugin.cs
delete mode 100644 plugin/samples/HelloAction/plugin.json
delete mode 100644 plugin/samples/HelloAction/plugin.schema.json
delete mode 100644 plugin/samples/ScreenBrightness/Actions.cs
delete mode 100644 plugin/samples/ScreenBrightness/BrightnessController.cs
delete mode 100644 plugin/samples/ScreenBrightness/ScreenBrightness.csproj
delete mode 100644 plugin/samples/ScreenBrightness/ScreenBrightnessPlugin.cs
delete mode 100644 plugin/samples/ScreenBrightness/plugin.json
diff --git a/.gitmodules b/.gitmodules
index ac9cf936..48782955 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,3 @@
-[submodule "external/StarPie-Official-Plugins"]
- path = external/StarPie-Official-Plugins
- url = https://github.com/Star-Pie/StarPie-Official-Plugins.git
+[submodule "plugin/StarPie-Official-Plugins"]
+ path = plugin/StarPie-Official-Plugins
+ url = https://github.com/Star-Pie/StarPie-Official-Plugins.git
\ No newline at end of file
diff --git a/external/StarPie-Official-Plugins b/plugin/StarPie-Official-Plugins
similarity index 100%
rename from external/StarPie-Official-Plugins
rename to plugin/StarPie-Official-Plugins
diff --git a/plugin/samples/FloatingBall/Actions.cs b/plugin/samples/FloatingBall/Actions.cs
deleted file mode 100644
index b2b56708..00000000
--- a/plugin/samples/FloatingBall/Actions.cs
+++ /dev/null
@@ -1,266 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Threading;
-using System.Threading.Tasks;
-using StarPie.Plugin;
-
-namespace StarPie.Plugin.FloatingBall;
-
-///
-/// 动作一:显示悬浮球(并按参数设定它的外观)。
-///
-/// 直径 / 不透明度 / 颜色同时存在于两处:这里是「这个扇区的球要长什么样」,
-/// 插件级设置页(见 )是「没有特别指定时长什么样」。
-/// 两处的键名相同、优先级固定为动作参数 > 插件级设置 > 内置默认,
-/// 于是「配两个扇区、一个常规球一个小而淡的球」和「开机恢复那颗球」都能各得其所。
-///
-///
-internal sealed class ShowBallContribution : IActionContribution
-{
- private readonly IPluginContext _context;
- private readonly BallController _ball;
- private readonly string? _iconKey;
-
- public ShowBallContribution(IPluginContext context, BallController ball, string? iconKey)
- {
- _context = context;
- _ball = ball;
- _iconKey = iconKey;
- }
-
- public ActionDescriptor Descriptor => new()
- {
- Id = "showBall",
- DisplayName = "显示悬浮球",
- DisplayNameKey = "action.show-ball.name",
- Description = "在屏幕上放一颗常驻悬浮球,点它呼出你的轮盘。拖动可挪位置,右键收起。",
- Category = "悬浮球",
- IconKey = _iconKey,
- Kind = ActionKind.Sequential,
- TimeoutSeconds = 3,
- };
-
- public IReadOnlyList Parameters => new List
- {
- new()
- {
- Key = BallPreference.DiameterKey,
- Label = "直径",
- LabelKey = "field.diameter.label",
- Type = ParameterFieldType.Number,
- DefaultValue = Defaults.DiameterDiu.ToString("0.##", CultureInfo.InvariantCulture),
- Min = Defaults.DiameterMin,
- Max = Defaults.DiameterMax,
- HelpText = "留空则用插件设置页里的默认直径。",
- },
- new()
- {
- Key = BallPreference.OpacityKey,
- Label = "不透明度",
- LabelKey = "field.opacity.label",
- Type = ParameterFieldType.Number,
- DefaultValue = Defaults.OpacityPercent.ToString("0.##", CultureInfo.InvariantCulture),
- Min = Defaults.OpacityMin,
- Max = Defaults.OpacityMax,
- HelpText = "留空则用插件设置页里的默认不透明度。",
- },
- new()
- {
- Key = BallPreference.ColorKey,
- Label = "颜色",
- LabelKey = "field.color.label",
- Type = ParameterFieldType.Color,
- DefaultValue = Defaults.Color,
- HelpText = "留空则用插件设置页里的默认颜色。",
- },
- };
-
- public string? Validate(IReadOnlyDictionary parameters)
- {
- // 只校验「扇区上确实填了」的项:留空是合法的,含义是「用插件级设置页的默认值」,
- // 把它算成越界会让刚装好、一个参数都没动的用户点哪都失败。
- // 判据是「键在不在、空不空」而不是「值是不是大于 0」:后者会把显式填的 0 当成没填。
- if (!TryReadDouble(parameters, BallPreference.DiameterKey, out double? diameter))
- {
- return _context.I18n.T("error.diameter", "直径得是个数(像素)。");
- }
- if (OutOfRange(diameter, Defaults.DiameterMin, Defaults.DiameterMax))
- {
- return string.Format(
- CultureInfo.InvariantCulture,
- _context.I18n.T("error.diameter-range", "直径要在 {0} 到 {1} 之间。"),
- Defaults.DiameterMin, Defaults.DiameterMax);
- }
-
- if (!TryReadDouble(parameters, BallPreference.OpacityKey, out double? opacity))
- {
- return _context.I18n.T("error.opacity", "不透明度得是个数(百分比)。");
- }
- if (OutOfRange(opacity, Defaults.OpacityMin, Defaults.OpacityMax))
- {
- return string.Format(
- CultureInfo.InvariantCulture,
- _context.I18n.T("error.opacity-range", "不透明度要在 {0} 到 {1} 之间。"),
- Defaults.OpacityMin, Defaults.OpacityMax);
- }
-
- return null;
- }
-
- public string Preview(IReadOnlyDictionary parameters)
- {
- // 契约要求极快:这里只做字符串拼接,不碰窗口。
- // 回落到插件级默认值读的是内存字典(PluginSettings 在实例化时就整份载入并缓存),
- // 不是磁盘 —— 否则预览会显示内置默认,而实际放出来的是用户配的另一套尺寸。
- // 钳制口径与 ExecuteAsync 的 BallPreference.FromAction 保持一致,预览就不会撒谎。
- TryReadDouble(parameters, BallPreference.DiameterKey, out double? diameter);
- TryReadDouble(parameters, BallPreference.OpacityKey, out double? opacity);
-
- double shownDiameter = diameter is double d
- ? BallPreference.Clamp(d, Defaults.DiameterMin, Defaults.DiameterMax)
- : BallPreference.Diameter(_context);
- double shownOpacity = opacity is double o
- ? BallPreference.Clamp(o, Defaults.OpacityMin, Defaults.OpacityMax)
- : BallPreference.Opacity(_context);
-
- return string.Format(
- CultureInfo.InvariantCulture,
- _context.I18n.T("preview.show-ball", "直径 {0} · 不透明 {1}%"),
- shownDiameter.ToString("0.##", CultureInfo.InvariantCulture),
- shownOpacity.ToString("0.##", CultureInfo.InvariantCulture));
- }
-
- public async Task ExecuteAsync(PluginActionInput input, CancellationToken cancellationToken)
- {
- if (cancellationToken.IsCancellationRequested)
- {
- return ActionResult.Fail(_context.I18n.T("error.cancelled", "动作已被取消。"));
- }
-
- if (!_context.Info.HasCapability(PluginCapability.Ui))
- {
- // 装机时用户可以在确认页上不勾「界面」,这属于「环境不具备」而不是插件出错:
- // 报 Fail 会让连点五次之后一个正常插件被判隔离(宿主纪律,见 AGENTS.md 的熔断一节)。
- return ActionResult.Ok(
- _context.I18n.T("notify.no-ui-capability", "清单里没声明「界面」能力,本插件画不出球窗。"),
- silent: false);
- }
-
- // 优先级:扇区上填了这个参数就用它,留空 → 插件级设置页 → 内置默认。
- // 三个值都在这里收敛,BallController 只接最终数字,不参与来源判断。
- double diameter = BallPreference.FromAction(
- input, BallPreference.DiameterKey,
- BallPreference.Diameter(_context), Defaults.DiameterMin, Defaults.DiameterMax);
- double opacity = BallPreference.FromAction(
- input, BallPreference.OpacityKey,
- BallPreference.Opacity(_context), Defaults.OpacityMin, Defaults.OpacityMax);
-
- string? actionColor = input.Parameter(BallPreference.ColorKey);
- string color = string.IsNullOrWhiteSpace(actionColor)
- ? BallPreference.Color(_context)
- : actionColor!.Trim();
-
- try
- {
- // 球是 WPF 窗口,只能在 UI 线程上建;动作线程到这里必须切一次。
- await _context.Dispatcher.InvokeAsync(() => _ball.Show(diameter, opacity, color)).ConfigureAwait(false);
- }
- catch (Exception ex)
- {
- _context.Log.Error("显示悬浮球失败", ex);
- return ActionResult.Fail(string.Format(
- CultureInfo.InvariantCulture,
- _context.I18n.T("error.show-failed", "悬浮球没能显示出来:{0}"),
- ex.Message));
- }
-
- return ActionResult.Ok(_context.I18n.T("info.shown", "悬浮球已显示,点它即可呼出轮盘。"), silent: true);
- }
-
- ///
- /// 读一个可选的数值参数。三种结局必须分得开:
- /// 没填(键不存在,或填了空白)→ 为 null,合法,含义是「用插件级默认值」;
- /// 填了但不是数 → 返回 false;填了且是数 → 返回 true 并带出值。
- ///
- /// 不要退回成「拿 0 当未填」的哨兵写法:那样显式填 0 会溜过范围校验,而 0 恰恰是最该被拦住的那个输入。
- /// 真机踩过反过来的一种:缺键被读成 0,于是刚装好的插件在用户一个参数都没填的情况下
- /// 永远报「直径要在 24 到 160 之间」。
- ///
- ///
- private static bool TryReadDouble(IReadOnlyDictionary parameters, string key, out double? value)
- {
- value = null;
- if (!parameters.TryGetValue(key, out string? raw) || string.IsNullOrWhiteSpace(raw)) return true;
-
- // 与 PluginActionInput.Double 同一条口径:不变文化。宿主写盘用的就是它。
- if (!double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed)) return false;
- value = parsed;
- return true;
- }
-
- /// 只在「真的填了值」时才算越界;null(未填)永远合法,由执行侧回落默认值。
- private static bool OutOfRange(double? value, double min, double max) =>
- value is double v && (v < min || v > max);
-}
-
-///
-/// 动作二:隐藏悬浮球。
-///
-/// 它与「右键收球」是同一条路(都走 ),差别只在要不要落盘
-/// visible=false —— 落了这个标记,下次开机就不会再自动出现。
-/// 没有这个动作的话,用户只能右键收球、然后再也找不回开机自启的开关。
-///
-///
-internal sealed class HideBallContribution : IActionContribution
-{
- private readonly IPluginContext _context;
- private readonly BallController _ball;
-
- public HideBallContribution(IPluginContext context, BallController ball)
- {
- _context = context;
- _ball = ball;
- }
-
- public ActionDescriptor Descriptor => new()
- {
- Id = "hideBall",
- DisplayName = "隐藏悬浮球",
- DisplayNameKey = "action.hide-ball.name",
- Description = "收掉当前显示的悬浮球,并记住这个选择(下次启动不再自动出现)。",
- Category = "悬浮球",
- Kind = ActionKind.Sequential,
- TimeoutSeconds = 3,
- };
-
- public IReadOnlyList Parameters => Array.Empty();
-
- public string? Validate(IReadOnlyDictionary parameters) => null;
-
- public string Preview(IReadOnlyDictionary parameters) =>
- _context.I18n.T("preview.hide-ball", "收掉悬浮球");
-
- public async Task ExecuteAsync(PluginActionInput input, CancellationToken cancellationToken)
- {
- if (cancellationToken.IsCancellationRequested)
- {
- return ActionResult.Fail(_context.I18n.T("error.cancelled", "动作已被取消。"));
- }
-
- try
- {
- await _context.Dispatcher.InvokeAsync(_ball.Hide).ConfigureAwait(false);
- }
- catch (Exception ex)
- {
- _context.Log.Error("隐藏悬浮球失败", ex);
- return ActionResult.Fail(string.Format(
- CultureInfo.InvariantCulture,
- _context.I18n.T("error.hide-failed", "悬浮球没能收起来:{0}"),
- ex.Message));
- }
-
- return ActionResult.Ok(Preview(input.Parameters), silent: true);
- }
-}
diff --git a/plugin/samples/FloatingBall/BallController.cs b/plugin/samples/FloatingBall/BallController.cs
deleted file mode 100644
index 07e33767..00000000
--- a/plugin/samples/FloatingBall/BallController.cs
+++ /dev/null
@@ -1,253 +0,0 @@
-using System;
-using System.Globalization;
-using System.Windows;
-using StarPie.Plugin;
-
-namespace StarPie.Plugin.FloatingBall;
-
-///
-/// 球的生命周期与持久化。所有方法只允许在 UI 线程上调用(构造
-/// 与读写它都必须如此),从动作线程进来时由调用方经 切过来。
-///
-/// 它同时是「插件侧唯一持有 UI 状态」的地方,所以三件事都收在这里:按当前外观参数建球、
-/// 把位置与外观写进插件私有设置、在停用时把窗口彻底关掉。
-///
-///
-internal sealed class BallController
-{
- private const string KeyVisible = "ball.visible";
- private const string KeyLeft = "ball.left";
- private const string KeyTop = "ball.top";
-
- private readonly IPluginContext _context;
-
- private BallWindow? _ball;
-
- /// 当前这个球是按哪套参数建的。用来判断「要不要重建」。
- private double _diameter;
- private double _opacity;
- private string _color = "";
-
- public BallController(IPluginContext context)
- {
- _context = context;
- }
-
- ///
- /// 按指定外观显示球。球已经在,且外观没变 → 复用实例;外观变了 → 原地重建。
- ///
- public void Show(double diameterDiu, double opacityPercent, string color)
- {
- if (_ball != null && SameLook(diameterDiu, opacityPercent, color))
- {
- if (!_ball.IsVisible) _ball.Show();
- MarkVisible();
- return;
- }
-
- // 重建而不是改属性:WPF 的窗口 Close 之后不可复用,而外观参数全部定死在构造里
- // (见 BallWindow 的类注释)。位置在这条路径上被显式带过去,用户不会看到球跳回默认点。
- int? keepLeft = null;
- int? keepTop = null;
- if (_ball != null)
- {
- BallWindow.PhysicalRect rect = _ball.ReadPhysicalRect();
- if (rect.Width > 0)
- {
- keepLeft = rect.X;
- keepTop = rect.Y;
- }
- }
-
- CloseBall();
- CreateAndShow(diameterDiu, opacityPercent, color, keepLeft, keepTop);
- }
-
- /// 隐藏球(保留位置与外观,下次 Show 回到原处)。
- public void Hide()
- {
- _context.Settings.Set(KeyVisible, "false");
- SaveSettings();
-
- if (_ball == null) return;
-
- CloseBall();
- }
-
- ///
- /// 上一轮退出时球是否处于显示状态。
- ///
- /// 单独暴露出来是给调用方一个「要不要投递恢复」的判断点:读设置是内存操作,可以在
- /// 任何线程做,而建窗只能在 UI 线程做 —— 把前者留在投递之前,就不会在无事可做时
- /// 往 UI 线程队列里塞一个握着插件闭包的操作项。
- ///
- ///
- public bool IsMarkedVisible() => _context.Settings.GetBool(KeyVisible, false);
-
- ///
- /// 按插件级设置页的外观、上次拖动到的位置把球放回来(开机预加载走这条)。
- ///
- /// 外观不再单独记「上次实际用了什么」:那会和设置页并存出两份真相,
- /// 而用户刚在设置页改大直径、重启后发现球还是上轮的小尺寸时,只能理解为「设置没生效」。
- /// 位置不同 —— 它是用户拖动出来的事实,不是任何地方声明过的值,保留下来才符合预期。
- ///
- ///
- public void Restore()
- {
- CreateAndShow(
- BallPreference.Diameter(_context),
- BallPreference.Opacity(_context),
- BallPreference.Color(_context),
- ReadInt(KeyLeft),
- ReadInt(KeyTop));
- }
-
- ///
- /// 现在屏幕上是否真有一颗球窗。
- ///
- /// 存在的意义只有一个:让调用方在投递之前就能判断有没有活要干。
- /// 一次 Post 会把插件的闭包挂进 UI 线程的队列,直到它跑完之前宿主都判不出
- /// 「插件程序集已回收」—— 一个什么也不做的投递照样算引用残留。所以投递必须可条件化。
- ///
- ///
- public bool HasWindow => _ball != null;
-
- ///
- /// 关掉球窗。
- ///
- /// 这里用 Close() 而不是 Hide():一个只是被藏起来的 WPF 窗口仍挂在
- /// Application.Current.Windows 上,那正是「插件的 AssemblyLoadContext 永远回收不掉」
- /// 的头号原因(宿主文档把它列为 WPF 插件的固有坑,且明说卸载是尽力而为)。
- ///
- ///
- public void Shutdown()
- {
- CloseBall();
- }
-
- private void CreateAndShow(
- double diameterDiu,
- double opacityPercent,
- string color,
- int? left,
- int? top)
- {
- var ball = new BallWindow(diameterDiu, opacityPercent / 100.0, color);
-
- if (left is int savedX && top is int savedY) ball.RequestPhysicalLocation(savedX, savedY);
-
- ball.WheelRequested += RequestWheel;
- ball.HideRequested += Hide;
- ball.Moved += PersistPosition;
-
- _ball = ball;
- _diameter = diameterDiu;
- _opacity = opacityPercent;
- _color = color;
-
- ball.Show();
-
- MarkVisible();
- }
-
- private void CloseBall()
- {
- BallWindow? ball = _ball;
- _ball = null;
- if (ball == null) return;
-
- ball.WheelRequested -= RequestWheel;
- ball.HideRequested -= Hide;
- ball.Moved -= PersistPosition;
-
- try
- {
- ball.Close();
- }
- catch (Exception ex)
- {
- _context.Log.Warn($"关闭悬浮球窗口时抛了异常(已忽略):{ex.Message}");
- }
- }
-
- private void RequestWheel(double physicalCenterX, double physicalCenterY)
- {
- string title = _context.I18n.T("notify.title", "悬浮球");
-
- if (!_context.Info.HasCapability(PluginCapability.Wheel))
- {
- // 清单没声明能力却被调到这里,只可能是有人改了 manifest 忘了同步。
- // 这里必须出声:用户点了球而屏幕上什么都没发生,是最容易被当成宿主 bug 报上来的现象。
- _context.Notify.Notify(title, _context.I18n.T("notify.no-capability", "本插件的清单里没有「轮盘呼出」能力,点球不会有任何反应。"));
- _context.Log.Warn("清单未声明 Wheel 能力,点球不会呼出轮盘。");
- return;
- }
-
- if (_context.Wheel.ShowWheel(physicalCenterX, physicalCenterY)) return;
-
- // 失败原因在宿主侧有好几种(正有一次真手势在进行 / 坐标不在任何显示器内 / 无界面模式),
- // 插件无法区分,所以措辞刻意写成「现在不行」而不是编一个具体理由。
- _context.Log.Warn($"轮盘呼出被宿主拒绝:球心=({physicalCenterX},{physicalCenterY})");
- _context.Notify.Notify(title, _context.I18n.T("notify.wheel-refused", "轮盘现在呼不出来,稍等一下再点。"));
- }
-
- private void PersistPosition()
- {
- if (_ball == null) return;
-
- BallWindow.PhysicalRect rect = _ball.ReadPhysicalRect();
- if (rect.Width <= 0) return;
-
- _context.Settings.Set(KeyLeft, rect.X.ToString(CultureInfo.InvariantCulture));
- _context.Settings.Set(KeyTop, rect.Y.ToString(CultureInfo.InvariantCulture));
- SaveSettings();
- }
-
- /// 记住「球此刻是显示着的」,供下次开机预加载判断要不要把球放回来。
- private void MarkVisible()
- {
- _context.Settings.Set(KeyVisible, "true");
- SaveSettings();
- }
-
- private void SaveSettings()
- {
- try
- {
- _context.Settings.Save();
- }
- catch (Exception ex)
- {
- // 设置写不进去不该让球消失:位置丢一次是可接受的,球突然没了是插件坏了。
- _context.Log.Warn($"保存悬浮球设置失败(本次仍继续显示):{ex.Message}");
- }
- }
-
- private int? ReadInt(string key)
- {
- string? raw = _context.Settings.Get(key);
- return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)
- ? value
- : null;
- }
-
- private bool SameLook(double diameterDiu, double opacityPercent, string color) =>
- Math.Abs(_diameter - diameterDiu) < 0.01
- && Math.Abs(_opacity - opacityPercent) < 0.01
- && string.Equals(_color, color, StringComparison.OrdinalIgnoreCase);
-}
-
-///
-/// 默认外观。刻意与宿主的轮盘视觉无关 —— 球只需要「在屏幕上不刺眼、在浅色深色背景上都看得见」。
-///
-internal static class Defaults
-{
- public const double DiameterDiu = 56;
- public const double OpacityPercent = 80;
- public const string Color = "#5884DE";
-
- public const double DiameterMin = 24;
- public const double DiameterMax = 160;
- public const double OpacityMin = 20;
- public const double OpacityMax = 100;
-}
diff --git a/plugin/samples/FloatingBall/BallWindow.cs b/plugin/samples/FloatingBall/BallWindow.cs
deleted file mode 100644
index 0296ac24..00000000
--- a/plugin/samples/FloatingBall/BallWindow.cs
+++ /dev/null
@@ -1,413 +0,0 @@
-using System;
-using System.Runtime.InteropServices;
-using System.Windows;
-using System.Windows.Controls;
-using System.Windows.Input;
-using System.Windows.Interop;
-using System.Windows.Media;
-using System.Windows.Media.Effects;
-using System.Windows.Shapes;
-
-namespace StarPie.Plugin.FloatingBall;
-
-///
-/// 悬浮球的球体窗口 —— 纯代码构建,不带 XAML(产物里也不该出现第二个依赖)。
-///
-/// 位置用物理像素、尺寸由「DIU 直径 × 所在显示器的 DPI」现算。这不是混了两套单位,
-/// 而是各自取最优:位置必须与「显示器怎么排布」这个物理事实一致 —— 宿主
-/// 要的就是虚拟屏幕坐标系的物理像素,
-/// 改用 WPF 的 Left/Top(DIU,且相对当前显示器)在混合 DPI 多屏下会得到
-/// 「球在副屏上看着对,点它轮盘却跑到主屏」;而直径按 DIU 声明,换到 200% 屏上球应当
-/// 跟着变大,而不是缩成一个点。落位时两个值一起交给 SetWindowPos,
-/// 与宿主 RadialWindow.PositionWindowOnTargetMonitor 同一套路子。
-///
-///
-/// 实例不可变:直径 / 颜色 / 不透明度都在构造时定死,要改就换一个新的球。
-/// 少掉三个 setter 就少掉三类「参数改了但视觉没跟上」的漂移,而插件侧没有任何机器护栏
-/// 查得见这种漂移 —— 唯一守得住它的手段是让它没有地方发生。
-///
-///
-internal sealed class BallWindow : Window
-{
- /// 按下到抬起的位移不超过这个物理像素数就算「点击」,否则算拖动。
- private const int ClickSlopPhysical = 4;
-
- /// 第一次落位时右侧留的空(物理像素):贴住屏幕右缘会压住系统托盘图标。
- private const int DefaultRightMargin = 40;
-
- private static readonly Color DefaultFill = Color.FromRgb(88, 132, 222);
-
- private readonly double _diameterDiu;
-
- private POINT _grabOffset;
- private bool _dragging;
- private bool _pressed;
- private POINT _pressCursor;
-
- /// 落位点(物理像素,窗口左上角)。两个分量都为负表示「还没定过,用默认落点」。
- private POINT _location = new(-1, -1);
-
- public BallWindow(double diameterDiu, double opacity, string fillColor)
- {
- _diameterDiu = Math.Clamp(diameterDiu, 12, 220);
-
- // 与宿主轮盘同一条纪律:收点击但绝不抢前台。
- // 一个会抢焦点的球意味着「点完球再按 Ctrl+C,复制打进的是球的窗口」。
- WindowStyle = WindowStyle.None;
- ResizeMode = ResizeMode.NoResize;
- AllowsTransparency = true;
- Background = Brushes.Transparent;
- ShowInTaskbar = false;
- ShowActivated = false;
- Focusable = false;
- Topmost = true;
- WindowStartupLocation = WindowStartupLocation.Manual;
-
- Width = _diameterDiu;
- Height = _diameterDiu;
- Content = BuildShape(opacity, fillColor);
-
- // 事件订阅而不是覆写 OnXxx:宿主工程 UseWindowsForms 与 WPF 并存,
- // 覆写会在两个同名消息类型之间撞车(CS0115,实测踩过)。
- MouseLeftButtonDown += OnLeftButtonDown;
- MouseMove += OnMouseMove;
- MouseLeftButtonUp += OnLeftButtonUp;
- MouseRightButtonUp += (_, _) => HideRequested?.Invoke();
- SourceInitialized += OnSourceInitialized;
- }
-
- /// 用户点了一下球。参数是球心在虚拟屏幕坐标系里的物理像素坐标。
- public event Action? WheelRequested;
-
- /// 用户用右键收起球。
- public event Action? HideRequested;
-
- /// 一次拖动结束(松手时触发一次,供调用方把位置落盘)。
- public event Action? Moved;
-
- ///
- /// 指定落位坐标(物理像素,虚拟屏幕坐标系)。可以在 Show() 之前调用。
- ///
- /// 刻意不做成「Show 之后再定位」:那样窗口会先在系统默认位置画一帧,用户看到的是一次跳动。
- ///
- ///
- public void RequestPhysicalLocation(int left, int top)
- {
- _location = new POINT(left, top);
- if (new WindowInteropHelper(this).Handle != nint.Zero) ApplyLocation();
- }
-
- /// 球在虚拟屏幕坐标系里的物理矩形。拿不到句柄时是 0×0,调用方据此判断「还没落位」。
- public PhysicalRect ReadPhysicalRect()
- {
- nint hwnd = new WindowInteropHelper(this).Handle;
- if (hwnd == nint.Zero || !GetWindowRect(hwnd, out RECT rect)) return default;
-
- return new PhysicalRect(rect.Left, rect.Top, rect.Right - rect.Left, rect.Bottom - rect.Top);
- }
-
- /// 球心的物理像素坐标。
- public POINT CenterPhysical
- {
- get
- {
- PhysicalRect rect = ReadPhysicalRect();
- return new POINT(rect.X + rect.Width / 2, rect.Y + rect.Height / 2);
- }
- }
-
- // ------------------------------------------------------------------ 交互
-
- private void OnLeftButtonDown(object sender, MouseButtonEventArgs e)
- {
- _pressed = true;
- _dragging = false;
-
- if (!GetCursorPos(out POINT cursor)) return;
-
- PhysicalRect rect = ReadPhysicalRect();
- _pressCursor = cursor;
- _grabOffset = new POINT(cursor.X - rect.X, cursor.Y - rect.Y);
- CaptureMouse();
- }
-
- private void OnMouseMove(object sender, MouseEventArgs e)
- {
- if (!_pressed || !HasDraggedFarEnough()) return;
- if (!GetCursorPos(out POINT cursor)) return;
-
- // 跟手用的是「光标物理坐标 - 按下时的抓取偏移」,全程不涉及 WPF 坐标:
- // 一旦拿 Mouse.GetPosition 的 DIU 增量去推物理位置,跨屏拖动就必然漂。
- RequestPhysicalLocation(cursor.X - _grabOffset.X, cursor.Y - _grabOffset.Y);
- }
-
- private void OnLeftButtonUp(object sender, MouseButtonEventArgs e)
- {
- if (!_pressed) return;
-
- _pressed = false;
- ReleaseMouseCapture();
-
- if (_dragging)
- {
- // Moved 只在松手时响一次(不在每次 MouseMove 上响):落盘的是「松手时看到的位置」,
- // 而拖动中途的每一个坐标都只是路过 —— 挂在这上面等于把一次拖拽变成几十次写盘。
- _dragging = false;
- Moved?.Invoke();
- return;
- }
-
- // 这里不判断轮盘是否真的呼出来了:ShowWheel 的 true 只代表宿主受理
- // (SDK 注释里写的就是这个语义)。球跟着改视觉状态,只会多出
- // 「球的样式和屏幕上实际有的东西不一致」这第二种现象。
- POINT center = CenterPhysical;
- WheelRequested?.Invoke(center.X, center.Y);
- }
-
- private bool HasDraggedFarEnough()
- {
- if (_dragging) return true;
- if (!GetCursorPos(out POINT cursor)) return false;
-
- if (Math.Abs(cursor.X - _pressCursor.X) <= ClickSlopPhysical
- && Math.Abs(cursor.Y - _pressCursor.Y) <= ClickSlopPhysical)
- {
- return false;
- }
-
- _dragging = true;
- return true;
- }
-
- // ------------------------------------------------------------------ 外观
-
- private static UIElement BuildShape(double opacity, string fillColor)
- {
- var grid = new Grid { Opacity = Math.Clamp(opacity, 0.08, 1.0) };
-
- grid.Children.Add(new Ellipse
- {
- Fill = ParseFill(fillColor),
-
- // 球是半透明的,没有描边就看不出边界 —— 而「哪儿算球、哪儿算球后面那个窗口」
- // 直接决定用户的下一次点击落在谁身上。
- Stroke = new SolidColorBrush(Color.FromArgb(90, 255, 255, 255)),
- StrokeThickness = 1.2,
- Margin = new Thickness(2),
- Effect = new DropShadowEffect
- {
- BlurRadius = 10,
- ShadowDepth = 0,
- Opacity = 0.35,
- Color = Colors.Black,
- },
- });
-
- return grid;
- }
-
- private static SolidColorBrush ParseFill(string fillColor)
- {
- Color parsed;
-
- try
- {
- // 只认颜色串,不认「Sc#R0.5G0.5B0.5 #RRGGBBAA」之外的形态:ConvertFromString 的返回是 object,
- // 硬转会在用户填了个怪字符串时抛出异常 —— 那由下面的 catch 接住并退回默认色。
- parsed = (Color)ColorConverter.ConvertFromString(fillColor)!;
- }
- catch (Exception)
- {
- // 用户在颜色参数里手打了半个 `#FF0`。这不该让球干脆不出来。
- parsed = DefaultFill;
- }
-
- // 颜色串里的 alpha 被丢掉:透明度归 opacity 参数独管。
- // 两处各乘一遍的结果是「设成 80% 实际得到 32%」,而用户看到的只是「颜色没生效」。
- return new SolidColorBrush(Color.FromRgb(parsed.R, parsed.G, parsed.B));
- }
-
- // ------------------------------------------------------------------ Win32
-
- private void OnSourceInitialized(object? sender, EventArgs e)
- {
- nint hwnd = new WindowInteropHelper(this).Handle;
- if (hwnd != nint.Zero)
- {
- // AllowsTransparency 会让 WPF 自己加上 WS_EX_LAYERED,但 NOACTIVATE / TOOLWINDOW
- // 没人给 —— 缺前者会抢前台,缺后者会在 Alt+Tab 列表里多出一项「球」。
- nint ex = GetWindowLongPtr(hwnd, GWL_EXSTYLE);
- SetWindowLongPtr(hwnd, GWL_EXSTYLE, ex | (nint)(WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW));
- }
-
- // 无条件定位:没被要求过落点时 ApplyLocation 会算出首次落点(右侧留白、垂直偏上),
- // 被要求过(开机恢复)时用它拿到的那个坐标。跳过这一步就等于把位置交给 WPF 的默认值,
- // 而那时用户看到的球既不在承诺的位置,也没有任何东西会再把它挪过去。
- ApplyLocation();
- }
-
- private void ApplyLocation()
- {
- nint hwnd = new WindowInteropHelper(this).Handle;
- if (hwnd == nint.Zero) return;
-
- POINT anchor = _location;
- bool firstPlacement = anchor.X < 0 && anchor.Y < 0;
-
- // 首次落位还不知道最终位置,先用「按主屏宽度估出来的球心」问一次 DPI;
- // 之后的每一次定位都已经有了确切坐标,取到的是准确值。
- if (firstPlacement)
- {
- int guessedWidth = (int)Math.Round(_diameterDiu);
- anchor = new POINT(
- GetSystemMetrics(SM_CXSCREEN) - guessedWidth - DefaultRightMargin,
- (GetSystemMetrics(SM_CYSCREEN) - guessedWidth) * 2 / 5);
- }
-
- double scale = ReadDpiScale(CenterOf(anchor));
- int width = (int)Math.Round(_diameterDiu * scale);
- int height = (int)Math.Round(_diameterDiu * scale);
-
- POINT placed = firstPlacement
- ? new POINT(GetSystemMetrics(SM_CXSCREEN) - width - DefaultRightMargin,
- (GetSystemMetrics(SM_CYSCREEN) - height) * 2 / 5)
- : anchor;
-
- (int left, int top) = ClampToVirtualScreen(placed.X, placed.Y, width, height);
- _location = new POINT(left, top);
-
- SetWindowPos(hwnd, HWND_TOPMOST, left, top, width, height, SWP_NOACTIVATE);
- }
-
- private POINT CenterOf(POINT topLeft)
- {
- int radius = (int)Math.Round(_diameterDiu / 2);
- return new POINT(topLeft.X + radius, topLeft.Y + radius);
- }
-
- ///
- /// 指定点所在显示器的 DPI 缩放系数。
- ///
- /// 按球心而不是窗口左上角取显示器:贴左边缘放置时左上角可能落到另一块屏上,
- /// 于是尺寸按副屏算、位置按主屏摆 —— 正是要避开的那类混合 DPI 偏差。
- ///
- ///
- private static double ReadDpiScale(POINT at)
- {
- try
- {
- nint monitor = MonitorFromPoint(at, MONITOR_DEFAULTTONEAREST);
- if (monitor != nint.Zero
- && GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, out uint dpiX, out uint dpiY) == 0
- && dpiX > 0 && dpiY > 0)
- {
- // 两轴取较大者:本插件把球当正方形画,混着缩放会让它变成椭圆。
- return Math.Max(dpiX, dpiY) / 96.0;
- }
- }
- catch (DllNotFoundException)
- {
- // SHCore 缺失(极老系统)时退回 100%:球只是尺寸不跟随缩放,比崩掉好。
- }
- catch (EntryPointNotFoundException)
- {
- }
-
- return 1.0;
- }
-
- private static (int Left, int Top) ClampToVirtualScreen(int left, int top, int width, int height)
- {
- int virtualLeft = GetSystemMetrics(SM_XVIRTUALSCREEN);
- int virtualTop = GetSystemMetrics(SM_YVIRTUALSCREEN);
- int virtualWidth = GetSystemMetrics(SM_CXVIRTUALSCREEN);
- int virtualHeight = GetSystemMetrics(SM_CYVIRTUALSCREEN);
-
- // 拿不到虚拟屏尺寸(异常会话)时别乱夹 —— 夹错了比夹不住更难解释。
- if (virtualWidth <= 0 || virtualHeight <= 0) return (left, top);
-
- int maxLeft = virtualLeft + Math.Max(0, virtualWidth - width);
- int maxTop = virtualTop + Math.Max(0, virtualHeight - height);
-
- return (
- Math.Min(Math.Max(left, virtualLeft), Math.Max(virtualLeft, maxLeft)),
- Math.Min(Math.Max(top, virtualTop), Math.Max(virtualTop, maxTop)));
- }
-
- private const int SM_CXSCREEN = 0;
- private const int SM_CYSCREEN = 1;
- private const int SM_XVIRTUALSCREEN = 76;
- private const int SM_YVIRTUALSCREEN = 77;
- private const int SM_CXVIRTUALSCREEN = 78;
- private const int SM_CYVIRTUALSCREEN = 79;
- private const int GWL_EXSTYLE = -20;
- private const long WS_EX_NOACTIVATE = 0x08000000;
- private const long WS_EX_TOOLWINDOW = 0x00000080;
- private const uint SWP_NOACTIVATE = 0x0010;
- private const uint MONITOR_DEFAULTTONEAREST = 2;
- private const int MDT_EFFECTIVE_DPI = 0;
- private static readonly nint HWND_TOPMOST = new(-1);
-
- [StructLayout(LayoutKind.Sequential)]
- public struct POINT
- {
- public int X;
- public int Y;
-
- public POINT(int x, int y)
- {
- X = x;
- Y = y;
- }
- }
-
- /// 物理像素矩形(虚拟屏幕坐标系)。默认值 0×0 表示「还没拿到句柄 / 未落位」。
- public readonly record struct PhysicalRect(int X, int Y, int Width, int Height);
-
- [StructLayout(LayoutKind.Sequential)]
- private struct RECT
- {
- public int Left;
- public int Top;
- public int Right;
- public int Bottom;
- }
-
- [DllImport("user32.dll")]
- private static extern bool GetCursorPos(out POINT lpPoint);
-
- [DllImport("user32.dll")]
- private static extern bool GetWindowRect(nint hWnd, out RECT lpRect);
-
- [DllImport("user32.dll")]
- private static extern int GetSystemMetrics(int nIndex);
-
- [DllImport("user32.dll")]
- private static extern nint MonitorFromPoint(POINT pt, uint dwFlags);
-
- [DllImport("SHCore.dll", SetLastError = true)]
- private static extern int GetDpiForMonitor(nint hMonitor, int dpiType, out uint dpiX, out uint dpiY);
-
- [DllImport("user32.dll", SetLastError = true)]
- private static extern bool SetWindowPos(nint hWnd, nint hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
-
- // GetWindowLongPtr / SetWindowLongPtr 只是 64 位上的名字,32 位要退回不带 Ptr 的那套。
- // 宿主只出 x64,但成对入口是宿主里的现成写法,照抄比自创省事。
- [DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")]
- private static extern nint GetWindowLongPtr64(nint hWnd, int nIndex);
-
- [DllImport("user32.dll", EntryPoint = "GetWindowLong")]
- private static extern nint GetWindowLong32(nint hWnd, int nIndex);
-
- [DllImport("user32.dll", EntryPoint = "SetWindowLongPtr")]
- private static extern nint SetWindowLongPtr64(nint hWnd, int nIndex, nint dwNewLong);
-
- [DllImport("user32.dll", EntryPoint = "SetWindowLong")]
- private static extern nint SetWindowLong32(nint hWnd, int nIndex, nint dwNewLong);
-
- private static nint GetWindowLongPtr(nint hWnd, int nIndex) =>
- IntPtr.Size == 8 ? GetWindowLongPtr64(hWnd, nIndex) : GetWindowLong32(hWnd, nIndex);
-
- private static nint SetWindowLongPtr(nint hWnd, int nIndex, nint value) =>
- IntPtr.Size == 8 ? SetWindowLongPtr64(hWnd, nIndex, value) : SetWindowLong32(hWnd, nIndex, value);
-}
diff --git a/plugin/samples/FloatingBall/FloatingBall.csproj b/plugin/samples/FloatingBall/FloatingBall.csproj
deleted file mode 100644
index eecefef0..00000000
--- a/plugin/samples/FloatingBall/FloatingBall.csproj
+++ /dev/null
@@ -1,79 +0,0 @@
-
-
-
-
-
- net8.0-windows
- enable
- enable
- 12.0
-
- StarPie.Plugin.FloatingBall
- StarPie.Plugin.FloatingBall
-
- 1.0.0
- 1.0.0.0
- 1.0.0.0
- StarPie 悬浮球插件
- StarPie Community
- 在屏幕上放一颗常驻悬浮球,点它呼出你自己的轮盘。
-
-
- x64
- x64
-
-
-
-
-
-
-
-
-
-
- false
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/plugin/samples/FloatingBall/FloatingBallPlugin.cs b/plugin/samples/FloatingBall/FloatingBallPlugin.cs
deleted file mode 100644
index 05f2121b..00000000
--- a/plugin/samples/FloatingBall/FloatingBallPlugin.cs
+++ /dev/null
@@ -1,176 +0,0 @@
-using System;
-using System.Collections.Generic;
-using StarPie.Plugin;
-
-namespace StarPie.Plugin.FloatingBall;
-
-///
-/// 悬浮球插件入口。
-///
-/// 它是 SDK 那两条常驻接缝的第一个真实用户,三半分别在三边:
-/// 球由插件自己画(),点球之后由宿主呼出
-/// 用户配置的轮盘(),
-/// 而球的默认外观由宿主渲染的设置页来填(,SDK 1.6)。
-/// 插件全程不认识轮盘的外观、扇区、命中与配置 —— 这正是把它交给宿主的意义。
-///
-///
-/// 球不是常驻进程,是常驻窗口:本插件靠「插件管理页 → 开机预加载」在启动后台加载并
-/// 把球放出来;没开预加载时球不会自己出现,需要用户先把「显示悬浮球」挂到轮盘扇区或快捷键上。
-/// 这条前提写在 的注释里,也写进 README 级别的交付说明。
-///
-///
-public sealed class FloatingBallPlugin : IStarPiePlugin
-{
- private IPluginContext? _context;
- private BallController? _ball;
-
- public void Initialize(IPluginContext context)
- {
- _context = context;
-
- RegisterLocalization(context);
-
- // 先建控制器、后注册动作:两个动作的闭包都要引用它,注册期就会读 Descriptor。
- BallController ball = new BallController(context);
- _ball = ball;
-
- string? iconKey = RegisterIcon(context);
- context.Actions.Register(new ShowBallContribution(context, ball, iconKey));
- context.Actions.Register(new HideBallContribution(context, ball));
-
- // 插件级设置页(SDK 1.6):卡片上的「设置」按钮因此出现。
- // 它声明的是默认值,不是「唯一值」—— 扇区上的动作参数仍然优先。
- RegisterSettingsPage(context);
-
- WarnIfCapabilitiesMissing(context);
-
- // 上一轮用户留着「球是显示着的」,这一轮开机就该看得见。
- // 先读开关再投递,而不是把判断整个放进闭包:一次 Post 会在 UI 线程的队列里
- // 留下一个握着插件闭包的操作项,它没跑完之前宿主判不出「程序集已回收」,
- // 停用时就会被判成「引用有残留,需要重启」。没有活要干就别排队。
- // 另一层理由:Initialize 的契约是「只注册、不做耗时操作」,
- // 而建一个 AllowsTransparency 窗口要过一遍布局与 DWM 合成。
- if (_ball.IsMarkedVisible()) context.Dispatcher.Post(() => _ball.Restore());
-
- context.Log.Info("已注册 2 个动作(显示 / 隐藏悬浮球)与 1 个插件级设置页。点球唤出轮盘由宿主 IHostWheelService 负责。");
- }
-
- public void Shutdown()
- {
- BallController? ball = _ball;
- _ball = null;
-
- if (ball != null && ball.HasWindow)
- {
- try
- {
- // 关窗必须在 UI 线程上发生,所以投递而不是就地执行 —— Shutdown 里阻塞等待
- // UI 线程是在拿「宿主正在停插件的线程」去等「正在被插件占住的线程」,没有收益只有死锁面。
- // 只在真有一颗球时才投递:一个什么也不做的操作项本身就把插件闭包
- // 握在 UI 线程的队列里,宿主的卸载探针会因此判成「引用有残留,需重启」。
- _context?.Dispatcher.Post(ball.Shutdown);
- }
- catch (Exception ex)
- {
- _context?.Log.Warn($"投递关闭悬浮球的动作失败:{ex.Message}");
- }
- }
-
- _context?.Log.Info("已停用。");
- _context = null;
- }
-
- // ------------------------------------------------------------------ 注册细节
-
- private static void RegisterLocalization(IPluginContext context)
- {
- II18nRegistry i18n = context.I18n;
-
- i18n.Register("action.show-ball.name", "显示悬浮球", "Show floating ball");
- i18n.Register("action.hide-ball.name", "隐藏悬浮球", "Hide floating ball");
-
- // 插件级设置页的标题与说明。走词条而不是只写字面文案,切英文时页面才不会剩下中文。
- i18n.Register("settings.page.title", "悬浮球", "Floating ball");
- i18n.Register("settings.page.description",
- "这里是悬浮球的默认外观:开机自动恢复的那颗球、以及参数留空的扇区都用它。"
- + "某个扇区想要另一副样子,去那条动作的参数里填;填了就覆盖这里的默认。"
- + "改完不会立刻改变屏幕上现有的球,下一次显示时生效。",
- "These are the defaults: the ball restored at startup, and any sector that leaves its own parameters empty. "
- + "To give one sector a different look, fill in that action's parameters - they take precedence. "
- + "Changes apply the next time the ball is shown, not to the one already on screen.");
-
- i18n.Register("field.diameter.label", "直径", "Diameter");
- i18n.Register("field.opacity.label", "不透明度", "Opacity");
- i18n.Register("field.color.label", "颜色", "Color");
-
- i18n.Register("preview.show-ball", "直径 {0} · 不透明 {1}%", "Diameter {0} · Opacity {1}%");
- i18n.Register("preview.hide-ball", "收掉悬浮球", "Dismiss the floating ball");
-
- i18n.Register("info.shown", "悬浮球已显示,点它即可呼出轮盘。", "Floating ball shown. Click it to bring up your wheel.");
-
- i18n.Register("notify.title", "悬浮球", "Floating ball");
- i18n.Register("notify.no-capability",
- "本插件的清单里没有「轮盘呼出」能力,点球不会有任何反应。请重新安装并在确认页勾选该能力。",
- "This plugin does not declare the 'Wheel' capability, so clicking the ball does nothing. Reinstall it and accept that capability.");
- i18n.Register("notify.wheel-refused",
- "轮盘现在呼不出来(可能正有一次鼠标手势在进行,或屏幕上没有可用的显示区域)。稍等一下再点。",
- "The wheel could not be brought up right now (a gesture may be in progress, or no display area is available). Try again in a moment.");
-
- i18n.Register("error.cancelled", "动作已被取消。", "The action was cancelled.");
- i18n.Register("notify.no-ui-capability",
- "清单里没声明「界面」能力,本插件画不出球窗。",
- "The manifest does not declare the 'Ui' capability, so this plugin cannot create its ball window.");
- i18n.Register("error.diameter", "直径得是个数(像素)。", "Diameter must be a number (pixels).");
- i18n.Register("error.diameter-range", "直径要在 {0} 到 {1} 之间。", "Diameter must be between {0} and {1}.");
- i18n.Register("error.opacity", "不透明度得是个数(百分比)。", "Opacity must be a number (percent).");
- i18n.Register("error.opacity-range", "不透明度要在 {0} 到 {1} 之间。", "Opacity must be between {0} and {1}.");
- i18n.Register("error.show-failed", "悬浮球没能显示出来:{0}", "The floating ball could not be shown: {0}");
- i18n.Register("error.hide-failed", "悬浮球没能收起来:{0}", "The floating ball could not be hidden: {0}");
- }
-
- private static void RegisterSettingsPage(IPluginContext context)
- {
- context.SettingsPage.Register(new SettingsPageDescriptor
- {
- Title = "悬浮球",
- TitleKey = "settings.page.title",
- Description = "这里是悬浮球的默认外观:开机自动恢复的那颗球、以及参数留空的扇区都用它。"
- + "某个扇区想要另一副样子,去那条动作的参数里填;填了就覆盖这里的默认。"
- + "改完不会立刻改变屏幕上现有的球,下一次显示时生效。",
- DescriptionKey = "settings.page.description",
- Fields = BallSettingsFields.Build(),
- });
- }
-
- private static string? RegisterIcon(IPluginContext context)
- {
- try
- {
- return context.Icons.RegisterSvg(
- "ball",
- "M12 3.2a8.8 8.8 0 1 0 0 17.6 8.8 8.8 0 0 0 0-17.6Zm0 2.4a6.4 6.4 0 1 1 0 12.8 6.4 6.4 0 0 1 0-12.8Z");
- }
- catch (Exception ex)
- {
- // 图标注册失败不是致命伤:动作照样能用,只是列表里没图。
- // 让它抛出去会把整个插件判成加载失败,那是用一个装饰换掉一个功能。
- context.Log.Warn($"悬浮球图标注册失败(动作仍可用,只是没有图标):{ex.Message}");
- return null;
- }
- }
-
- private static void WarnIfCapabilitiesMissing(IPluginContext context)
- {
- var missing = new List(2);
-
- if (!context.Info.HasCapability(PluginCapability.Ui)) missing.Add("Ui(画那颗球)");
- if (!context.Info.HasCapability(PluginCapability.Wheel)) missing.Add("Wheel(呼出轮盘)");
-
- if (missing.Count == 0) return;
-
- // 这里出声是必要的:两半能力缺任何一半,用户看到的现象都是同一个
- // 「球在,但点它没反应」—— 而那个现象最容易被报成宿主的 bug。
- context.Log.Warn(
- $"清单缺少必要能力:{string.Join("、", missing)}。请在 plugin.json 的 capabilities 里补齐后重新安装。");
- }
-}
diff --git a/plugin/samples/FloatingBall/Preferences.cs b/plugin/samples/FloatingBall/Preferences.cs
deleted file mode 100644
index bf03c059..00000000
--- a/plugin/samples/FloatingBall/Preferences.cs
+++ /dev/null
@@ -1,130 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using StarPie.Plugin;
-
-namespace StarPie.Plugin.FloatingBall;
-
-///
-/// 悬浮球外观的参数键与取值优先级。
-///
-/// 键名写在这里一次,插件级设置页与动作参数表都从这里取 —— 两处用的是同一批键名是刻意的
-/// (见 :设置页的值就落在插件私有配置的同一命名空间里),
-/// 但把它们写成两份字符串字面量,改一处漏一处就会让用户「改了设置没反应」,而且查不出来。
-///
-///
-/// 优先级:动作参数 > 插件级设置页 > 内置默认。
-/// 理由是这个插件的两种用法分别对应两端:挂在扇区上的参数回答「这个扇区的球该长什么样」
-/// (同一个球可以被两个扇区配成两副样子,这是悬浮球的正当用法),
-/// 设置页回答「用户没有特别指定时它长什么样」(开机预加载恢复的那颗球就没有扇区)。
-///
-///
-internal static class BallPreference
-{
- public const string DiameterKey = "diameter";
- public const string OpacityKey = "opacity";
- public const string ColorKey = "color";
-
- /// 插件级直径(已钳进合法区间)。
- public static double Diameter(IPluginContext context) =>
- Number(context, DiameterKey, Defaults.DiameterDiu, Defaults.DiameterMin, Defaults.DiameterMax);
-
- /// 插件级不透明度(已钳进合法区间)。
- public static double Opacity(IPluginContext context) =>
- Number(context, OpacityKey, Defaults.OpacityPercent, Defaults.OpacityMin, Defaults.OpacityMax);
-
- /// 插件级颜色。非法色值退回默认,而不是把异常抛给宿主。
- public static string Color(IPluginContext context)
- {
- string? raw = context.SettingsPage.GetValue(ColorKey)?.Trim();
- if (string.IsNullOrEmpty(raw)) return Defaults.Color;
-
- // 十六进制色只有 #RGB / #RRGGBB / #AARRGGBB 三种合法形状,
- // 这里用形状判断而不是抛异常试探:一个错值不该让球画不出来。
- return IsHexColor(raw!) ? raw! : Defaults.Color;
- }
-
- ///
- /// 把「动作参数」叠在「插件级设置」之上:input 里有值且能解析就用它,否则用插件级值。
- /// 两种来源的值一律钳进合法区间后再用 —— 宿主的 Min/Max 只负责把问题显示给用户,
- /// 不会拦住一个越界值落盘(见 ),兜底只能是插件自己的事。
- ///
- public static double FromAction(PluginActionInput input, string key, double pluginLevel, double min, double max)
- {
- string? raw = input.Parameter(key);
- if (string.IsNullOrWhiteSpace(raw)) return pluginLevel;
- if (!double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out double value)) return pluginLevel;
- return Clamp(value, min, max);
- }
-
- public static double Clamp(double value, double min, double max) =>
- value < min ? min : (value > max ? max : value);
-
- private static double Number(IPluginContext context, string key, double fallback, double min, double max)
- {
- string? raw = context.SettingsPage.GetValue(key);
- if (string.IsNullOrWhiteSpace(raw)) return fallback;
-
- // 不变文化解析:设置页写盘的就是不变文化字面量(宿主归一化过),
- // 逗号作小数点的区域设置上按当前文化解析会读不回自己写的值。
- return double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out double value)
- ? Clamp(value, min, max)
- : fallback;
- }
-
- private static bool IsHexColor(string value)
- {
- if (value.Length == 0 || value[0] != '#') return false;
-
- int digits = value.Length - 1;
- if (digits != 3 && digits != 6 && digits != 8) return false;
-
- for (int i = 1; i < value.Length; i++)
- {
- char c = value[i];
- bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
- if (!hex) return false;
- }
- return true;
- }
-}
-
-///
-/// 插件级设置页的字段表。与动作参数表分开写是刻意的:两处说明文字面向的问题不同 ——
-/// 设置页要交代「这是默认值、改完只影响下一次显示」,动作参数只需要说清这个扇区的球长什么样。
-///
-internal static class BallSettingsFields
-{
- public static IReadOnlyList Build() => new List
- {
- new()
- {
- Key = BallPreference.DiameterKey,
- Label = "直径",
- LabelKey = "field.diameter.label",
- Type = ParameterFieldType.Number,
- DefaultValue = Defaults.DiameterDiu.ToString("0.##", CultureInfo.InvariantCulture),
- Min = Defaults.DiameterMin,
- Max = Defaults.DiameterMax,
- HelpText = "单位是逻辑像素(跟随系统缩放)。",
- },
- new()
- {
- Key = BallPreference.OpacityKey,
- Label = "不透明度",
- LabelKey = "field.opacity.label",
- Type = ParameterFieldType.Number,
- DefaultValue = Defaults.OpacityPercent.ToString("0.##", CultureInfo.InvariantCulture),
- Min = Defaults.OpacityMin,
- Max = Defaults.OpacityMax,
- },
- new()
- {
- Key = BallPreference.ColorKey,
- Label = "颜色",
- LabelKey = "field.color.label",
- Type = ParameterFieldType.Color,
- DefaultValue = Defaults.Color,
- },
- };
-}
diff --git a/plugin/samples/FloatingBall/plugin.json b/plugin/samples/FloatingBall/plugin.json
deleted file mode 100644
index 2f1f58d4..00000000
--- a/plugin/samples/FloatingBall/plugin.json
+++ /dev/null
@@ -1,31 +0,0 @@
-{
- "$schema": "../HelloAction/plugin.schema.json",
- "schemaVersion": 1,
-
- "id": "com.example.floatingball",
- "name": "悬浮球",
- "description": "在屏幕上放一颗常驻悬浮球:点它呼出你自己的轮盘,选哪个扇区执行哪个。拖动改位置,右键收起。球由插件画,轮盘整体归宿主 —— 外观、扇区、体积、多屏 DPI 永远和你配的那一套一致。",
- "author": "StarPie Community",
- "homepage": "https://github.com/SoftBlack42/StarPie",
- "license": "MIT",
- "version": "1.0.0",
-
- "apiVersion": "1.6",
- "minHostVersion": "1.8.0",
-
- "targetFramework": "net8.0-windows",
- "platform": "win-x64",
-
- "assembly": "StarPie.Plugin.FloatingBall.dll",
- "entryType": "StarPie.Plugin.FloatingBall.FloatingBallPlugin",
-
- "capabilities": ["Ui", "Wheel"],
-
- "contributions": {
- "actions": true,
- "icons": true,
- "i18n": true
- },
-
- "tags": ["悬浮球", "轮盘", "常驻", "快捷入口", "示例"]
-}
diff --git a/plugin/samples/HelloAction/Actions.cs b/plugin/samples/HelloAction/Actions.cs
deleted file mode 100644
index 7c33e395..00000000
--- a/plugin/samples/HelloAction/Actions.cs
+++ /dev/null
@@ -1,344 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Threading;
-using System.Threading.Tasks;
-
-namespace StarPie.Plugin.HelloAction;
-
-///
-/// 动作实现的公共基类。
-///
-/// 它不是 SDK 的一部分,只是示例的组织方式。抽出来的收益是:每个动作只需要关心
-/// 「描述自己」和「干什么」,参数校验与预览有默认实现可继承。
-///
-///
-internal abstract class ContributionBase : IActionContribution
-{
- protected ContributionBase(IPluginContext context) => Context = context;
-
- protected IPluginContext Context { get; }
-
- public abstract ActionDescriptor Descriptor { get; }
-
- /// 默认无参数。有参数的动作覆写它。
- public virtual IReadOnlyList Parameters => Array.Empty();
-
- /// 默认放行。返回 null 或空串表示通过,否则返回用户可读的错误原因。
- public virtual string? Validate(IReadOnlyDictionary parameters) => null;
-
- public abstract string Preview(IReadOnlyDictionary parameters);
-
- public abstract Task ExecuteAsync(PluginActionInput input, CancellationToken cancellationToken);
-}
-
-///
-/// 动作一:打个招呼。
-/// 演示最小闭环 —— 参数表单 + 多语言 + 图标 + 通知服务。
-///
-internal sealed class GreetContribution : ContributionBase
-{
- private readonly string _iconKey;
-
- public GreetContribution(IPluginContext context, string iconKey) : base(context) => _iconKey = iconKey;
-
- public override ActionDescriptor Descriptor => new()
- {
- Id = "greet",
-
- // 两个文案字段都给了:DisplayNameKey 优先,找不到词条时回退 DisplayName。
- // 这样即使宿主语言是插件没提供的语种,也不会把 key 直接显示给用户。
- DisplayName = "打个招呼",
- DisplayNameKey = "action.greet.name",
- Description = "在托盘气泡里显示一句问候语",
-
- Category = "示例插件",
- IconKey = _iconKey,
-
- // 只弹个气泡,不碰输入与前台窗口,理论上可以并发;
- // 但它会写日志,为了示例可读性保持串行(串行是默认值,这里显式写出来是为了便于阅读)。
- Kind = ActionKind.Sequential,
- TimeoutSeconds = 3,
- };
-
- public override IReadOnlyList Parameters => new List
- {
- new()
- {
- Key = "message",
- Label = "问候语",
- LabelKey = "field.message.label",
- Type = ParameterFieldType.Text,
- DefaultValue = "你好,StarPie!",
- Required = true,
- Placeholder = "随便写点什么",
- MaxLength = 200,
- HelpText = "这段文字会显示在托盘气泡里。",
- },
- new()
- {
- Key = "showBalloon",
- Label = "显示托盘气泡",
- LabelKey = "field.showBalloon.label",
- Type = ParameterFieldType.Bool,
- DefaultValue = "true",
- },
- new()
- {
- Key = "tone",
- Label = "语气",
- LabelKey = "field.tone.label",
- Type = ParameterFieldType.Enum,
- DefaultValue = "friendly",
- Options = new List
- {
- new() { Value = "friendly", Label = "友好", LabelKey = "tone.friendly" },
- new() { Value = "formal", Label = "正式", LabelKey = "tone.formal" },
- new() { Value = "robot", Label = "机器人", LabelKey = "tone.robot" },
- },
- },
- };
-
- public override string? Validate(IReadOnlyDictionary parameters)
- {
- if (!parameters.TryGetValue("message", out string? message) || string.IsNullOrWhiteSpace(message))
- {
- return "问候语不能为空。";
- }
-
- if (parameters.TryGetValue("tone", out string? tone)
- && tone is not ("friendly" or "formal" or "robot"))
- {
- return $"无法识别的语气:{tone}。";
- }
-
- return null;
- }
-
- public override string Preview(IReadOnlyDictionary parameters)
- {
- // 契约要求这个方法必须极快(设置页滚动时会高频调用),所以只做字符串拼接
- string message = parameters.TryGetValue("message", out string? value) && !string.IsNullOrWhiteSpace(value)
- ? value
- : "你好,StarPie!";
-
- string tone = parameters.TryGetValue("tone", out string? t) ? t : "friendly";
- string toneText = Context.I18n.T($"tone.{tone}", tone);
-
- return $"{message}({toneText})";
- }
-
- public override Task ExecuteAsync(PluginActionInput input, CancellationToken cancellationToken)
- {
- string message = input.Parameter("message") ?? "你好,StarPie!";
- string tone = input.Parameter("tone") ?? "friendly";
- bool showBalloon = input.Bool("showBalloon", true);
-
- string decorated = tone switch
- {
- "formal" => $"【{message}】",
- "robot" => $"BEEP BOOP :: {message}",
- _ => message,
- };
-
- // 演示「读只读环境信息」:插件可以知道用户此刻在哪个程序里,但改不了它
- Context.Log.Info(
- $"greet 被触发:tone={tone},前台进程={input.Context.ForegroundProcessName}," +
- $"鼠标=({input.Context.CursorX},{input.Context.CursorY}),提权={input.Context.IsElevated}");
-
- if (cancellationToken.IsCancellationRequested)
- {
- return Task.FromResult(ActionResult.Fail("任务在执行前已被取消。"));
- }
-
- if (showBalloon)
- {
- Context.Notify.Notify(Context.Me.Name, decorated);
- }
-
- // Silent=false 时宿主会再帮你弹一次气泡;这里已经自己弹过了,所以静默返回
- return Task.FromResult(ActionResult.Ok());
- }
-}
-
-///
-/// 动作二:打开文件夹。
-///
-/// 它演示的是本 SDK 最重要的一条纪律:需要「启程序 / 开文件夹 / 发快捷键 / 操作剪贴板」时,
-/// 一律走 ,不要自己 Process.Start 或 P/Invoke。
-/// 宿主那几条路径已经解决了路径展开、提权降权、UIPI 放行等一堆坑。
-///
-///
-/// 它同时演示了 :先做耗时的目录校验,再切回宿主服务执行。
-///
-///
-internal sealed class OpenFolderContribution : ContributionBase
-{
- public OpenFolderContribution(IPluginContext context) : base(context) { }
-
- public override ActionDescriptor Descriptor => new()
- {
- Id = "openFolder",
- DisplayName = "打开文件夹",
- DisplayNameKey = "action.openFolder.name",
- Description = "在资源管理器里打开指定目录(支持环境变量)",
- Category = "示例插件",
- Kind = ActionKind.Sequential,
- TimeoutSeconds = 5,
- };
-
- public override IReadOnlyList Parameters => new List
- {
- new()
- {
- Key = "folder",
- Label = "文件夹路径",
- LabelKey = "field.folder.label",
- Type = ParameterFieldType.Folder,
- DefaultValue = "%USERPROFILE%",
- Required = true,
- HelpText = "可以使用 %USERPROFILE% 这类环境变量。",
- },
- };
-
- public override string? Validate(IReadOnlyDictionary parameters)
- {
- if (!parameters.TryGetValue("folder", out string? folder) || string.IsNullOrWhiteSpace(folder))
- {
- return "文件夹路径不能为空。";
- }
- return null;
- }
-
- public override string Preview(IReadOnlyDictionary parameters)
- {
- string folder = parameters.TryGetValue("folder", out string? value) ? value : "";
- return $"打开 {folder}";
- }
-
- public override Task ExecuteAsync(PluginActionInput input, CancellationToken cancellationToken)
- {
- string raw = input.Parameter("folder") ?? "";
- string expanded = Environment.ExpandEnvironmentVariables(raw);
-
- if (!System.IO.Directory.Exists(expanded))
- {
- // 失败必须给出用户能看懂、知道该做什么的原因 —— 这是契约的明确要求
- return Task.FromResult(ActionResult.Fail($"文件夹不存在:{expanded}"));
- }
-
- bool opened = Context.Host.OpenFolder(expanded);
- if (!opened)
- {
- Context.Log.Warn($"宿主拒绝打开文件夹:{expanded}");
- return Task.FromResult(ActionResult.Fail($"打开文件夹失败:{expanded}"));
- }
-
- Context.Log.Info($"已打开文件夹:{expanded}");
- return Task.FromResult(ActionResult.Ok());
- }
-}
-
-///
-/// 动作三:参数表单演示。
-///
-/// 它不做任何实际操作,只把收到的参数回报一句。存在的意义是让
-/// 的每一种控件都能被实地看一眼 ——
-/// 参考模板若只演示三四种类型,社区作者就只能靠猜来写剩下的,
-/// 而「猜出来的声明」正是插件界面出意外的根源。
-///
-///
-/// 它声明为 :只读参数、只写日志,
-/// 不碰输入、剪贴板与前台窗口,因此没必要占用唯一的动作线程。
-/// 判断依据是「会不会与前台窗口交互」,而不是「跑得快不快」。
-///
-///
-internal sealed class ParameterShowcaseContribution : ContributionBase
-{
- public ParameterShowcaseContribution(IPluginContext context) : base(context) { }
-
- public override ActionDescriptor Descriptor => new()
- {
- Id = "parameterShowcase",
- DisplayName = "参数表单演示",
- DisplayNameKey = "action.parameterShowcase.name",
- Description = "展示宿主能渲染哪些参数控件(不执行任何实际操作,可放心点测试触发)",
- Category = "示例插件",
- IconKey = null,
- Kind = ActionKind.Background,
- TimeoutSeconds = 5,
- };
-
- ///
- /// 本动作把所有参数都声明成可选:这是一张「控件长什么样」的陈列柜,
- /// 不该因为某个格子没填就拦住用户。必填语义已由 greet 那个动作演示过了。
- ///
- public override IReadOnlyList Parameters => new List
- {
- new()
- {
- Key = "note",
- Label = "多行备注",
- LabelKey = "field.note.label",
- Type = ParameterFieldType.MultilineText,
- Placeholder = "可以换行写很多字…",
- HelpText = "演示多行文本框。",
- },
- new()
- {
- Key = "level",
- Label = "强度",
- LabelKey = "field.level.label",
- Type = ParameterFieldType.Number,
- DefaultValue = "50",
- Min = 0,
- Max = 100,
- HelpText = "演示数值框,以及宿主自动补的取值区间提示。",
- },
- new()
- {
- Key = "script",
- Label = "脚本文件",
- LabelKey = "field.script.label",
- Type = ParameterFieldType.File,
- HelpText = "演示带「选择…」按钮的文件框。",
- },
- new()
- {
- Key = "hotkey",
- Label = "组合键",
- LabelKey = "field.hotkey.label",
- Type = ParameterFieldType.Hotkey,
- HelpText = "演示热键录制框:点一下控件再按键即可录制,Esc 取消。",
- },
- new()
- {
- Key = "tint",
- Label = "标记颜色",
- LabelKey = "field.tint.label",
- Type = ParameterFieldType.Color,
- DefaultValue = "#FF2563EB",
- HelpText = "演示取色器与实时色块。",
- },
- };
-
- public override string Preview(IReadOnlyDictionary parameters) => "参数表单演示";
-
- public override Task ExecuteAsync(PluginActionInput input, CancellationToken cancellationToken)
- {
- // 刻意不产生任何副作用,只回报收到了什么 ——
- // 这样用户能安全地点「测试触发」,用结果反推表单确实把值写进了配置。
- string[] keys = { "note", "level", "script", "hotkey", "tint" };
- var parts = new List();
-
- foreach (string key in keys)
- {
- string value = input.Parameter(key) ?? "";
- if (value.Length > 40) value = value.Substring(0, 40) + "…";
- parts.Add($"{key}={(value.Length == 0 ? "(未填)" : value)}");
- }
-
- Context.Log.Info("parameterShowcase 收到的参数:" + string.Join(" | ", parts));
-
- return Task.FromResult(ActionResult.Ok("收到参数:" + string.Join(",", parts), silent: false));
- }
-}
diff --git a/plugin/samples/HelloAction/HelloAction.csproj b/plugin/samples/HelloAction/HelloAction.csproj
deleted file mode 100644
index 11f7209f..00000000
--- a/plugin/samples/HelloAction/HelloAction.csproj
+++ /dev/null
@@ -1,75 +0,0 @@
-
-
-
-
-
- net8.0-windows
- enable
- enable
- 12.0
-
- StarPie.Plugin.HelloAction
- StarPie.Plugin.HelloAction
-
- 1.0.0
- 1.0.0.0
- 1.0.0.0
- StarPie HelloAction 示例插件
- StarPie Studio
- 演示 StarPie 插件 SDK 的最小可用形态:自定义动作、参数表单、图标与多语言。
-
-
- x64
- x64
-
-
-
-
-
- false
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/plugin/samples/HelloAction/HelloActionPlugin.cs b/plugin/samples/HelloAction/HelloActionPlugin.cs
deleted file mode 100644
index 266c77e8..00000000
--- a/plugin/samples/HelloAction/HelloActionPlugin.cs
+++ /dev/null
@@ -1,116 +0,0 @@
-using System;
-using System.Collections.Generic;
-
-namespace StarPie.Plugin.HelloAction;
-
-///
-/// 插件入口 —— 一个程序集里必须恰好有一个实现 的 public 具体类。
-///
-/// 三条纪律,违反任何一条都会让插件加载失败或让 StarPie 的内存无法回收:
-/// ① 构造函数必须无副作用(不做 IO、不启线程、不弹窗);
-/// ② 只做注册,不做耗时操作 —— 此刻用户正在等界面响应;
-/// ③ 必须幂等,并且释放全部订阅 token。
-///
-///
-public sealed class HelloActionPlugin : IStarPiePlugin
-{
- private IPluginContext? _context;
-
- ///
- /// 订阅凭据。
- ///
- /// 这个列表不是「礼貌性清理」,而是卸载的前提条件:宿主持有静态事件,
- /// 插件实例挂在事件链上,只要不摘掉,插件的 AssemblyLoadContext 就永远无法回收,
- /// 表现为「停用后 DLL 仍被占用、装不了新版本」。
- ///
- ///
- private readonly List _subscriptions = new();
-
- public void Initialize(IPluginContext context)
- {
- _context = context;
-
- context.Log.Info($"初始化中:宿主 {context.Info.HostVersion},语言 {context.Info.LanguageCode},便携模式 {context.Info.IsPortable}");
-
- RegisterLocalization(context);
- RegisterActions(context);
- RegisterEventSubscriptions(context);
-
- context.Log.Info("初始化完成:已注册 2 个动作、1 枚图标、若干词条。");
- }
-
- public void Shutdown()
- {
- // 幂等:宿主可能因为「兜底撤销」与「插件自觉清理」两条路径都调用一次
- foreach (IDisposable subscription in _subscriptions)
- {
- try
- {
- subscription.Dispose();
- }
- catch
- {
- }
- }
- _subscriptions.Clear();
-
- _context?.Log.Info("已停用。");
- _context = null;
- }
-
- // ------------------------------------------------------------------ 分步注册
-
- private static void RegisterLocalization(IPluginContext context)
- {
- II18nRegistry i18n = context.I18n;
-
- // key 写短键即可,宿主会自动加上 plugin.. 前缀,避免与内置词条撞车
- i18n.Register("action.greet.name", "打个招呼", "Say Hello");
- i18n.Register("action.openFolder.name", "打开文件夹", "Open Folder");
- i18n.Register("action.parameterShowcase.name", "参数表单演示", "Parameter Form Showcase");
-
- i18n.Register("field.message.label", "问候语", "Greeting");
- i18n.Register("field.showBalloon.label", "显示托盘气泡", "Show balloon");
- i18n.Register("field.tone.label", "语气", "Tone");
- i18n.Register("field.folder.label", "文件夹路径", "Folder path");
-
- // 下面这五个对应 parameterShowcase 声明的控件类型。
- // 每条都同时给了词条与字面 Label:词条命中时用译文,
- // 未命中(宿主语言没覆盖)时退回字面值,不会把 key 显示给用户。
- i18n.Register("field.note.label", "多行备注", "Notes");
- i18n.Register("field.level.label", "强度", "Level");
- i18n.Register("field.script.label", "脚本文件", "Script file");
- i18n.Register("field.hotkey.label", "组合键", "Hotkey");
- i18n.Register("field.tint.label", "标记颜色", "Tint color");
-
- i18n.Register("tone.friendly", "友好", "Friendly");
- i18n.Register("tone.formal", "正式", "Formal");
- i18n.Register("tone.robot", "机器人", "Robot");
- }
-
- private static void RegisterActions(IPluginContext context)
- {
- // 图标:注册后拿到的完整 key 直接填进 ActionDescriptor.IconKey
- string iconKey = context.Icons.RegisterSvg(
- "wave",
- "M7 11.5V5.2a1.6 1.6 0 0 1 3.2 0v5.6m0 0V4.2a1.6 1.6 0 0 1 3.2 0v6.6" +
- "m0 0V6.2a1.6 1.6 0 0 1 3.2 0v5.6c0 4.4-2.2 8.2-6.4 8.2s-6.4-2.8-6.4-6.2v-4a1.6 1.6 0 0 1 3.2 0");
-
- context.Actions.Register(new GreetContribution(context, iconKey));
- context.Actions.Register(new OpenFolderContribution(context));
- context.Actions.Register(new ParameterShowcaseContribution(context));
- }
-
- private void RegisterEventSubscriptions(IPluginContext context)
- {
- // 每一次订阅都必须留住 token,并在 Shutdown 里释放(见 _subscriptions 的注释)
- _subscriptions.Add(context.Events.OnLanguageChanged(code =>
- context.Log.Info($"收到语言切换事件:{code}")));
-
- // 轮盘事件回调保证不在鼠标钩子线程上(宿主已切到 UI 线程),但仍然必须极快、禁止 IO。
- // 这里刻意只做一次日志,演示「观察而不干涉」的正确用法。
- _subscriptions.Add(context.Events.OnWheelOpening(actionContext =>
- System.Diagnostics.Debug.WriteLine(
- $"[HelloAction] 轮盘将要呈现,前台进程 = {actionContext.ForegroundProcessName}")));
- }
-}
diff --git a/plugin/samples/HelloAction/plugin.json b/plugin/samples/HelloAction/plugin.json
deleted file mode 100644
index cd0864f2..00000000
--- a/plugin/samples/HelloAction/plugin.json
+++ /dev/null
@@ -1,31 +0,0 @@
-{
- "$schema": "./plugin.schema.json",
- "schemaVersion": 1,
-
- "id": "com.example.hello",
- "name": "Hello 示例插件",
- "description": "演示 StarPie 插件 SDK 的最小可用形态:自定义动作、参数表单、图标与多语言。",
- "author": "StarPie Studio",
- "homepage": "https://github.com/SoftBlack42/StarPie",
- "license": "MIT",
- "version": "1.0.0",
-
- "apiVersion": "1.0",
- "minHostVersion": "1.7.4",
-
- "targetFramework": "net8.0-windows",
- "platform": "win-x64",
-
- "assembly": "StarPie.Plugin.HelloAction.dll",
- "entryType": "StarPie.Plugin.HelloAction.HelloActionPlugin",
-
- "capabilities": ["Process", "Ui"],
-
- "contributions": {
- "actions": true,
- "icons": true,
- "i18n": true
- },
-
- "tags": ["示例", "入门", "Hello"]
-}
diff --git a/plugin/samples/HelloAction/plugin.schema.json b/plugin/samples/HelloAction/plugin.schema.json
deleted file mode 100644
index 68cfd3a1..00000000
--- a/plugin/samples/HelloAction/plugin.schema.json
+++ /dev/null
@@ -1,229 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-07/schema#",
- "$id": "https://github.com/SoftBlack42/StarPie/schemas/plugin.schema.json",
- "title": "StarPie 插件清单",
- "description": "plugin.json 的结构定义。宿主在识别 .dll 时会先静态读取该清单(不存在时回落到程序集元数据),字段写错会直接导致插件被拒绝并在报告里给出原因码。",
- "type": "object",
- "additionalProperties": false,
-
- "required": [
- "schemaVersion",
- "id",
- "name",
- "version",
- "apiVersion",
- "minHostVersion",
- "targetFramework",
- "platform"
- ],
-
- "properties": {
- "schemaVersion": {
- "description": "清单结构版本。当前只接受 1。",
- "type": "integer",
- "enum": [1]
- },
-
- "id": {
- "description": "插件唯一标识,必须是小写反向域名(至少含一个点),且不得使用保留前缀 starpie / winpiegestures / windows / microsoft / system / builtin。一旦发布不建议再改,因为用户配置里保存的是这个 ID。",
- "type": "string",
- "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$",
- "maxLength": 128,
- "not": {
- "pattern": "^(starpie|winpiegestures|windows|microsoft|system|builtin)(\\.|$)"
- }
- },
-
- "name": {
- "description": "展示名称,会出现在安装确认卡、插件列表与动作名称里。",
- "type": "string",
- "minLength": 1,
- "maxLength": 64
- },
-
- "description": {
- "description": "一句话说明插件做什么。",
- "type": "string",
- "maxLength": 512,
- "default": ""
- },
-
- "author": {
- "description": "作者或组织名。",
- "type": "string",
- "maxLength": 128,
- "default": ""
- },
-
- "homepage": {
- "description": "项目主页或仓库地址。",
- "type": "string",
- "maxLength": 512
- },
-
- "license": {
- "description": "SPDX 许可证标识。",
- "type": "string",
- "maxLength": 64,
- "default": "MIT"
- },
-
- "version": {
- "description": "插件自身版本,语义化版本(major.minor.patch,可带 -prerelease)。",
- "type": "string",
- "pattern": "^\\d+\\.\\d+(\\.\\d+)?(-[0-9A-Za-z.\\-]+)?$",
- "default": "1.0.0"
- },
-
- "apiVersion": {
- "description": "所依赖的插件 SDK 契约版本。主版本号必须与宿主一致,否则拒绝加载。",
- "type": "string",
- "pattern": "^\\d+\\.\\d+(\\.\\d+)?$",
- "default": "1.0"
- },
-
- "minHostVersion": {
- "description": "要求的最低 StarPie 版本。宿主为同版本号的预发布版(如宿主 1.7.4-beta.2 对 ≥1.7.4)时同样视为满足,方便插件作者在 beta 通道上开发调试。",
- "type": "string",
- "pattern": "^\\d+\\.\\d+(\\.\\d+)?(-[0-9A-Za-z.\\-]+)?$",
- "default": "0.0.0"
- },
-
- "maxHostVersion": {
- "description": "可选。允许运行的最高 StarPie 版本,用于标记「已知在更高版本上会失效」的插件。留空表示不设上限。",
- "type": "string",
- "pattern": "^\\d+\\.\\d+(\\.\\d+)?(-[0-9A-Za-z.\\-]+)?$"
- },
-
- "targetFramework": {
- "description": "编译时的目标框架。必须为 Windows 平台且不得高于宿主的 net8.0-windows10.0.19041.0;net8.0-windows 或 net8.0-windows7.0 均可,net9.0+ 会被拒绝。",
- "type": "string",
- "pattern": "^net\\d+\\.\\d+-windows(\\d+(\\.\\d+)*)?$",
- "default": "net8.0-windows"
- },
-
- "platform": {
- "description": "目标平台。当前宿主只有 x64 构建,因此只接受 win-x64。",
- "type": "string",
- "enum": ["win-x64"],
- "default": "win-x64"
- },
-
- "assembly": {
- "description": "入口程序集文件名(相对插件目录)。留空时宿主会在目录内查找唯一符合契约的 DLL。",
- "type": "string",
- "pattern": "^[^/\\\\]+\\.dll$",
- "maxLength": 260
- },
-
- "entryType": {
- "description": "实现 StarPie.Plugin.IStarPiePlugin 的入口类型全名(含命名空间)。建议显式填写:留空时宿主需要遍历类型表去找,既慢又可能有歧义。",
- "type": "string",
- "pattern": "^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*$",
- "maxLength": 512
- },
-
- "capabilities": {
- "description": "能力声明。它不会限制插件(本版不启用 CLR 沙箱),而是在安装确认卡上如实告知用户这个插件会做什么。请诚实声明:宁可多写,不要漏写。",
- "type": "array",
- "uniqueItems": true,
- "items": {
- "type": "string",
- "enum": [
- "None",
- "Process",
- "FileSystem",
- "Network",
- "Clipboard",
- "Registry",
- "GlobalHook",
- "Ui",
- "Admin",
- "WindowControl",
- "ScreenCapture",
- "InputSimulation",
- "Wheel"
- ]
- },
- "default": []
- },
-
- "contributions": {
- "description": "贡献点声明,用于生成安装前的「这个插件会往轮盘里加什么」摘要。",
- "type": "object",
- "additionalProperties": false,
- "properties": {
- "actions": {
- "description": "提供可绑定到轮盘扇区的自定义动作。",
- "type": "boolean",
- "default": false
- },
- "icons": {
- "description": "提供矢量图标包(SVG)。",
- "type": "boolean",
- "default": false
- },
- "i18n": {
- "description": "注册多语言词条。",
- "type": "boolean",
- "default": false
- },
- "styles": {
- "description": "提供轮盘渲染形态(P1,当前宿主会忽略并给出提示)。",
- "type": "boolean",
- "default": false
- },
- "presets": {
- "description": "提供预设方案或配置模板(P1,当前宿主会忽略并给出提示)。",
- "type": "boolean",
- "default": false
- }
- }
- },
-
- "dependencies": {
- "description": "对其他插件或外部运行时的依赖。首版仅做提示,不做自动解析与安装。",
- "type": "array",
- "items": {
- "type": "object",
- "additionalProperties": false,
- "required": ["id"],
- "properties": {
- "id": {
- "description": "依赖的插件 ID。",
- "type": "string",
- "minLength": 1,
- "maxLength": 128
- },
- "versionRange": {
- "description": "版本区间,如 \"1.2.0\" 或 \"*\"。",
- "type": "string",
- "maxLength": 64,
- "default": "*"
- }
- }
- },
- "default": []
- },
-
- "icon": {
- "description": "插件在列表里显示的图标文件名(PNG/ICO/SVG,相对插件目录)。",
- "type": "string",
- "maxLength": 260
- },
-
- "tags": {
- "description": "检索用标签。",
- "type": "array",
- "items": { "type": "string", "maxLength": 32 },
- "maxItems": 16,
- "default": []
- },
-
- "sha256": {
- "description": "入口程序集的 SHA256(小写十六进制)。填写后宿主会在识别阶段做完整性校验,不匹配即拒绝加载,可防止分发包在途损坏或被篡改。",
- "type": "string",
- "pattern": "^[a-f0-9]{64}$"
- }
- }
-}
diff --git a/plugin/samples/ScreenBrightness/Actions.cs b/plugin/samples/ScreenBrightness/Actions.cs
deleted file mode 100644
index aa35323e..00000000
--- a/plugin/samples/ScreenBrightness/Actions.cs
+++ /dev/null
@@ -1,427 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Threading;
-using System.Threading.Tasks;
-using StarPie.Plugin;
-
-namespace StarPie.Plugin.ScreenBrightness;
-
-///
-/// 一个亮度动作。
-///
-/// 全部动作的执行逻辑高度同构(都是「跑一次亮度操作 → 播报结果」),差别只在参数和文案。
-/// 所以这里用一个类 + 若干实例而不是给每个动作写一个子类:子类化会把同一套结果播报策略
-/// (尤其是下面 里那段关于熔断的注释)复制七遍,
-/// 将来改一处漏六处几乎是必然的。
-///
-///
-internal sealed class BrightnessContribution : IActionContribution
-{
- private readonly IPluginContext _context;
- private readonly Func _run;
- private readonly string _verb;
- private readonly bool _defaultNotifyOnSuccess;
- private readonly IReadOnlyList _parameters;
- private readonly Func, string?>? _validate;
-
- public BrightnessContribution(
- IPluginContext context,
- string id,
- string titleKey,
- string fallbackTitle,
- string description,
- string iconKey,
- Func run,
- string verb,
- bool notifyOnSuccess,
- IReadOnlyList? parameters = null,
- Func, string?>? validate = null)
- {
- _context = context;
- _run = run;
- _verb = verb;
- _defaultNotifyOnSuccess = notifyOnSuccess;
- _parameters = parameters ?? Array.Empty();
- _validate = validate;
-
- Descriptor = new ActionDescriptor
- {
- Id = id,
- DisplayNameKey = titleKey,
- DisplayName = fallbackTitle,
- Description = description,
- Category = "屏幕亮度",
- IconKey = iconKey,
-
- // 必须是后台并发:DDC/CI 单次往返可能上百毫秒,多屏叠加更久。
- // 若走 Sequential 占用动作线程,用户会明显感到「触发后轮盘卡一下」,
- // 直接违背项目的零延迟红线。
- Kind = ActionKind.Background,
- TimeoutSeconds = 20,
- };
- }
-
- public ActionDescriptor Descriptor { get; }
-
- ///
- /// 参数声明。固定在轮盘上的一档一档动作不需要参数;
- /// 「设置到指定亮度」「按步长调整」这类需要用户给数值的动作则声明出来,
- /// 由宿主渲染成主程序同款控件。
- ///
- public IReadOnlyList Parameters => _parameters;
-
- ///
- /// 自定义校验。
- ///
- /// 注意这里对多数动作返回 null —— 那是有意的:
- /// 「必填」「0~100 的范围」已经由 /
- /// / 声明过了,
- /// 宿主会据此拦下非法值,插件再写一遍只是重复。
- ///
- ///
- /// 只有声明表达不了的规则才写在这里(例如「步长为 0 等于什么都不做」)。
- ///
- ///
- public string? Validate(IReadOnlyDictionary parameters) =>
- _validate?.Invoke(parameters);
-
- ///
- /// 刻意不做悬停预览:轮盘悬停是高频操作,而读一次当前亮度要走一轮 DDC/CI 往返,
- /// 会让轮盘动画掉帧。宁可没有预览,也不能牺牲流畅度。
- ///
- public string Preview(IReadOnlyDictionary parameters) => "";
-
- public Task ExecuteAsync(PluginActionInput input, CancellationToken cancellationToken)
- {
- BrightnessOutcome outcome;
-
- try
- {
- outcome = _run(input);
- }
- catch (Exception ex)
- {
- // 只有真正的异常才算失败:P/Invoke 出错、COM 组件崩溃等。
- _context.Log.Error($"{Descriptor.Id} 执行异常", ex);
- return Task.FromResult(ActionResult.Fail($"亮度调整出错:{ex.Message}"));
- }
-
- string message = outcome.Describe(_verb);
- _context.Log.Info($"{Descriptor.Id} → {message}");
-
- if (!outcome.Success)
- {
- // 关键设计:环境不支持亮度控制**不是**插件失败,绝不能返回 Fail。
- //
- // 宿主对连续失败 5 次的动作会判定为插件缺陷并自动隔离(Quarantined)。
- // 而「显示器没开 DDC/CI」「台式机外接屏不可调」都是环境事实 ——
- // 一旦返回 Fail,用户连点几次就会把一个完全正常的插件弄成「已隔离」。
- // 所以这里返回成功但要求提示,让用户知道原因即可。
- return Task.FromResult(ActionResult.Ok(message, silent: false));
- }
-
- // notify 参数可以覆盖动作的默认播报策略:
- // 带参数的动作默认安静执行,但用户在表单里显式勾了「完成后提示我」就该照办。
- bool notify = _parameters.Count > 0
- ? input.Bool("notify", _defaultNotifyOnSuccess)
- : _defaultNotifyOnSuccess;
-
- // 平静的成功不打扰用户;但「已经是最亮」「首次启用软件调光」这类结果必须说出来。
- return Task.FromResult(notify || outcome.Notable
- ? ActionResult.Ok(message, silent: false)
- : ActionResult.Ok());
- }
-}
-
-/// 注册本插件的全部亮度动作与词条。
-internal static class BrightnessActions
-{
- public const string PluginId = "com.example.screenbrightness";
-
- ///
- /// 词条短键。
- ///
- /// SDK 契约规定插件写短键,宿主登记时自动补 plugin.<pluginId>. 前缀
- /// (见 II18nRegistry.Register 的注释)。所以这里直接返回短键即可 ——
- /// 若在这里又手写一遍插件 ID,实际存入的键会带上两层前缀,虽然仍能命中,
- /// 但会让「按前缀搜词条」这类排查变得莫名其妙。
- ///
- ///
- private static string Key(string suffix) => suffix;
-
- /// 构建全部动作。图标 key 由调用方在注册图标后传入。
- public static List CreateAll(IPluginContext context, string sunIcon, string moonIcon)
- {
- return new List
- {
- new BrightnessContribution(
- context,
- id: "up",
- titleKey: Key("up.title"),
- fallbackTitle: "亮度 +10%",
- description: "把所有可调显示器调亮一档(相对当前值,不会超过 100%)。",
- iconKey: sunIcon,
- run: _ => BrightnessController.AdjustAll(10),
- verb: "调亮",
- notifyOnSuccess: false),
-
- new BrightnessContribution(
- context,
- id: "down",
- titleKey: Key("down.title"),
- fallbackTitle: "亮度 -10%",
- description: "把所有可调显示器调暗一档(相对当前值,不会低于 0%)。",
- iconKey: sunIcon,
- run: _ => BrightnessController.AdjustAll(-10),
- verb: "调暗",
- notifyOnSuccess: false),
-
- new BrightnessContribution(
- context,
- id: "bright100",
- titleKey: Key("bright100.title"),
- fallbackTitle: "亮度 100%(最亮)",
- description: "把所有可调显示器的亮度设为最大。",
- iconKey: sunIcon,
- run: _ => BrightnessController.SetAll(100),
- verb: "设为最亮",
- notifyOnSuccess: false),
-
- new BrightnessContribution(
- context,
- id: "bright50",
- titleKey: Key("bright50.title"),
- fallbackTitle: "亮度 50%(均衡)",
- description: "把所有可调显示器的亮度设为 50%。",
- iconKey: sunIcon,
- run: _ => BrightnessController.SetAll(50),
- verb: "设为 50%",
- notifyOnSuccess: false),
-
- new BrightnessContribution(
- context,
- id: "bright25",
- titleKey: Key("bright25.title"),
- fallbackTitle: "亮度 25%(夜间护眼)",
- description: "把所有可调显示器的亮度设为 25%,适合夜间或暗环境使用。",
- iconKey: moonIcon,
- run: _ => BrightnessController.SetAll(25),
- verb: "设为 25%",
- notifyOnSuccess: false),
-
- new BrightnessContribution(
- context,
- id: "toggle_dim",
- titleKey: Key("toggle_dim.title"),
- fallbackTitle: "亮度明暗一键切换",
- description: "当前平均亮度偏亮则切到 30%,偏暗则切回 100%。适合在「看清」与「护眼」之间快速往返。",
- iconKey: moonIcon,
- run: _ => BrightnessController.Toggle(30),
- verb: "切换为",
- notifyOnSuccess: true),
-
- new BrightnessContribution(
- context,
- id: "query",
- titleKey: Key("query.title"),
- fallbackTitle: "查看当前亮度",
- description: "读取并报告所有显示器的当前亮度,用于排查「调了没反应」这类问题。",
- iconKey: sunIcon,
- run: _ =>
- {
- var outcome = new BrightnessOutcome();
- List monitors = BrightnessController.ReadAll();
-
- foreach (MonitorBrightness monitor in monitors)
- {
- if (monitor.CurrentPercent >= 0)
- {
- outcome.Adjusted++;
- outcome.AdjustedNames.Add($"{monitor.Description}({monitor.Channel}){monitor.CurrentPercent}%");
- }
- else
- {
- outcome.Skipped++;
- if (!string.IsNullOrEmpty(monitor.Diagnostic))
- {
- outcome.Errors.Add(monitor.Diagnostic);
- }
- }
- }
-
- return outcome;
- },
- verb: "读到",
- notifyOnSuccess: true),
-
- // ------------------------------------------------------------------
- // 下面两个动作带参数,用来演示「参数表单由宿主按声明渲染」。
- // 它们刻意展示了两种不同的校验分工:
- // · setlevel 一行自定义校验都不写 ——「必填」「0~100」全靠 ParameterField 声明,
- // 由宿主在保存与执行前各拦一次;
- // · stepby 额外写了一条声明表达不了的规则(步长为 0 等于什么都没做)。
- // 结论:能用声明表达的规则就不要写代码,声明会同时驱动界面与校验,不会两边不一致。
- // ------------------------------------------------------------------
-
- new BrightnessContribution(
- context,
- id: "setlevel",
- titleKey: Key("setlevel.title"),
- fallbackTitle: "设置到指定亮度…",
- description: "把显示器亮度设为你指定的百分比。适合把某个精确数值固定绑在一个手势上。",
- iconKey: sunIcon,
- run: input => BrightnessController.SetAll(ReadPercent(input, "level", 60, 0, 100)),
- verb: "设为",
- notifyOnSuccess: true,
- parameters: new List
- {
- new()
- {
- Key = "level",
- Label = "目标亮度(%)",
- LabelKey = Key("field.level.label"),
- Type = ParameterFieldType.Number,
- Required = true,
- DefaultValue = "60",
- Min = 0,
- Max = 100,
- HelpText = "0 最暗、100 最亮。实际可调范围由显示器本身决定。",
- },
- new()
- {
- Key = "notify",
- Label = "完成后提示我",
- LabelKey = Key("field.notify.label"),
- Type = ParameterFieldType.Bool,
- DefaultValue = "true",
- },
- }),
-
- new BrightnessContribution(
- context,
- id: "stepby",
- titleKey: Key("stepby.title"),
- fallbackTitle: "按步长调整亮度…",
- description: "按你指定的步长相对调整亮度:负值调暗,正值调亮。",
- iconKey: sunIcon,
- run: input => BrightnessController.AdjustAll(ReadPercent(input, "delta", 10, -100, 100)),
- verb: "调整",
- notifyOnSuccess: false,
- parameters: new List
- {
- new()
- {
- Key = "delta",
- Label = "步长(%)",
- LabelKey = Key("field.delta.label"),
- Type = ParameterFieldType.Number,
- Required = true,
- DefaultValue = "10",
- Min = -100,
- Max = 100,
- HelpText = "负值调暗、正值调亮。-100 与 100 相当于直接切到最暗/最亮。",
- },
- new()
- {
- Key = "notify",
- Label = "完成后提示我",
- LabelKey = Key("field.notify.label"),
- Type = ParameterFieldType.Bool,
- DefaultValue = "false",
- },
- },
- // 声明能表达「数值范围」,却表达不了「为 0 等于什么都没做」。
- // 这类规则正是自定义校验存在的理由 —— 不是把声明里的规则再抄一遍。
- validate: parameters =>
- parameters.TryGetValue("delta", out string? raw)
- && double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out double delta)
- && delta == 0
- ? "步长为 0 不会改变亮度,请填写非零值。"
- : null),
- };
- }
-
- ///
- /// 读取一个百分比参数并夹到合法区间。
- ///
- /// 宿主已经按声明的 Min/Max 校验过一次,这里仍夹一次,是因为参数也可能来自
- /// 被手工编辑过的 config.json。插件对自己的入参做边界保护,成本极低而收益确定。
- ///
- ///
- private static int ReadPercent(PluginActionInput input, string key, int fallback, int min, int max)
- {
- int value = input.Int(key, fallback);
- if (value < min) return min;
- if (value > max) return max;
- return value;
- }
-
- /// 注册多语言词条。
- public static void RegisterLocalization(IPluginContext context)
- {
- context.I18n.RegisterTable("zh-CN", new Dictionary
- {
- [Key("up.title")] = "亮度 +10%",
- [Key("down.title")] = "亮度 -10%",
- [Key("bright100.title")] = "亮度 100%(最亮)",
- [Key("bright50.title")] = "亮度 50%(均衡)",
- [Key("bright25.title")] = "亮度 25%(夜间护眼)",
- [Key("toggle_dim.title")] = "亮度明暗一键切换",
- [Key("query.title")] = "查看当前亮度",
- [Key("setlevel.title")] = "设置到指定亮度…",
- [Key("stepby.title")] = "按步长调整亮度…",
- [Key("field.level.label")] = "目标亮度(%)",
- [Key("field.delta.label")] = "步长(%)",
- [Key("field.notify.label")] = "完成后提示我",
- });
-
- context.I18n.RegisterTable("zh-TW", new Dictionary
- {
- [Key("up.title")] = "亮度 +10%",
- [Key("down.title")] = "亮度 -10%",
- [Key("bright100.title")] = "亮度 100%(最亮)",
- [Key("bright50.title")] = "亮度 50%(均衡)",
- [Key("bright25.title")] = "亮度 25%(夜間護眼)",
- [Key("toggle_dim.title")] = "亮度明暗一鍵切換",
- [Key("query.title")] = "查看目前亮度",
- [Key("setlevel.title")] = "設定到指定亮度…",
- [Key("stepby.title")] = "按步長調整亮度…",
- [Key("field.level.label")] = "目標亮度(%)",
- [Key("field.delta.label")] = "步長(%)",
- [Key("field.notify.label")] = "完成後提示我",
- });
-
- context.I18n.RegisterTable("en", new Dictionary
- {
- [Key("up.title")] = "Brightness +10%",
- [Key("down.title")] = "Brightness -10%",
- [Key("bright100.title")] = "Brightness 100% (Max)",
- [Key("bright50.title")] = "Brightness 50% (Balanced)",
- [Key("bright25.title")] = "Brightness 25% (Night)",
- [Key("toggle_dim.title")] = "Toggle Brightness (Dim / Bright)",
- [Key("query.title")] = "Report Current Brightness",
- [Key("setlevel.title")] = "Set Brightness To…",
- [Key("stepby.title")] = "Adjust Brightness By…",
- [Key("field.level.label")] = "Target brightness (%)",
- [Key("field.delta.label")] = "Step (%)",
- [Key("field.notify.label")] = "Notify when done",
- });
-
- context.I18n.RegisterTable("ja", new Dictionary
- {
- [Key("up.title")] = "明るさ +10%",
- [Key("down.title")] = "明るさ -10%",
- [Key("bright100.title")] = "明るさ 100%(最大)",
- [Key("bright50.title")] = "明るさ 50%(標準)",
- [Key("bright25.title")] = "明るさ 25%(夜間)",
- [Key("toggle_dim.title")] = "明るさのワンタッチ切替",
- [Key("query.title")] = "現在の明るさを表示",
- [Key("setlevel.title")] = "指定の明るさに設定…",
- [Key("stepby.title")] = "ステップで明るさを調整…",
- [Key("field.level.label")] = "目標の明るさ(%)",
- [Key("field.delta.label")] = "ステップ(%)",
- [Key("field.notify.label")] = "完了時に通知",
- });
- }
-}
diff --git a/plugin/samples/ScreenBrightness/BrightnessController.cs b/plugin/samples/ScreenBrightness/BrightnessController.cs
deleted file mode 100644
index 72655fa4..00000000
--- a/plugin/samples/ScreenBrightness/BrightnessController.cs
+++ /dev/null
@@ -1,882 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Reflection;
-using System.Runtime.InteropServices;
-
-namespace StarPie.Plugin.ScreenBrightness;
-
-///
-/// 显示器亮度控制的内部实现。
-///
-/// 宿主只封装了「模拟输入」这一类通用能力(IHostActionInvoker),亮度不在其中,
-/// 所以这个插件必须自己 P/Invoke。这正是插件系统期望的分工:通用且高风险的能力由宿主
-/// 统一提供,领域专用的实现由插件自己负责。
-///
-///
-/// Windows 上调亮度有两条互不相通的路径,覆盖的硬件完全不同,缺一不可:
-///
-///
-/// - DDC/CI(dxva2.dll)—— 通过视频线缆走 MCCS 协议直接与显示器通讯。
-/// 绝大多数外接显示器支持(前提是显示器 OSD 里开启了 DDC/CI)。
-/// - WMI(root\wmi)—— 由显卡驱动暴露,绝大多数笔记本内置屏走这条路;
-/// 台式机的独立显示器通常不支持。
-///
-///
-/// 两条路径都要尝试而不是「前者失败才用后者」:笔记本外接显示器是很常见的场景,
-/// 此时内置屏只能走 WMI、外接屏只能走 DDC/CI,只做一条就会漏掉一半屏幕。
-///
-///
-/// 一个容易踩的坑:DDC/CI 的物理显示器句柄只在
-/// GetPhysicalMonitorsFromHMONITOR 与 DestroyPhysicalMonitors 之间有效。
-/// 因此「先枚举读一遍、过一会儿再拿句柄去写」是行不通的 —— 句柄已经随会话销毁了。
-/// 本类的做法是把「读当前值 → 算出目标值 → 写入」全部压在同一个会话生命周期内完成。
-///
-///
-internal static class BrightnessController
-{
- /// DDC/CI 的 VCP 代码:0x10 = Luminance(亮度)。MCCS 标准里的固定值。
- private const byte VcpLuminance = 0x10;
-
- ///
- /// 串行化所有亮度调整。DDC/CI 单次往返可能耗时上百毫秒,用户连点轮盘时多个后台任务
- /// 并发下发会因时序交错让亮度乱跳;串行执行虽然慢一点,但每次的最终效果都是确定的。
- ///
- private static readonly object Gate = new();
-
- // ==================== 读取(仅供展示,不含可用句柄) ====================
-
- /// 读取所有可调亮度的显示器,用于「当前亮度」这类查询展示。
- public static List ReadAll()
- {
- lock (Gate)
- {
- var monitors = new List();
-
- try
- {
- ReadDdcMonitors(monitors);
- }
- catch (Exception ex)
- {
- monitors.Add(MonitorBrightness.Unavailable($"DDC/CI 通道探测失败:{ex.Message}"));
- }
-
- try
- {
- ReadWmiMonitors(monitors);
- }
- catch (Exception ex)
- {
- monitors.Add(MonitorBrightness.Unavailable($"WMI 通道探测失败:{ex.Message}"));
- }
-
- // 软件调光是一条「虚拟屏幕」:它作用于整个桌面而不是某一块物理屏,
- // 所以只在真的处于调光状态时才报告,否则会在查询结果里多出一行噪音。
- if (GammaController.IsApplied)
- {
- monitors.Add(new MonitorBrightness
- {
- Description = "整个桌面",
- Channel = "软件调光",
- IsWritable = true,
- CurrentPercent = GammaController.CurrentPercent,
- });
- }
-
- return monitors;
- }
- }
-
- /// 读出所有屏的平均亮度百分比;没有可读屏时返回 -1。
- public static int ReadAveragePercent()
- {
- List monitors = ReadAll();
-
- int sum = 0;
- int readable = 0;
-
- foreach (MonitorBrightness monitor in monitors)
- {
- if (monitor.CurrentPercent >= 0)
- {
- sum += monitor.CurrentPercent;
- readable++;
- }
- }
-
- return readable > 0 ? sum / readable : -1;
- }
-
- // ==================== 写入 ====================
-
- /// 把所有显示器的亮度设为同一个百分比。
- public static BrightnessOutcome SetAll(int percent)
- {
- int target = Clamp(percent);
-
- lock (Gate)
- {
- var outcome = new BrightnessOutcome();
-
- // DDC/CI 不需要先读当前值,传入的回调直接返回目标值。
- // 但句柄的读/写仍必须在同一个会话内 —— 见类注释里的坑。
- ApplyViaDdc(outcome, _ => target);
- ApplyViaWmi(outcome, _ => target);
-
- if (outcome.Adjusted > 0)
- {
- DropSoftwareFallback();
- return outcome;
- }
-
- ApplySoftwareFallback(outcome, target);
- return outcome;
- }
- }
-
- /// 把所有显示器的亮度相对调整 个百分点。
- public static BrightnessOutcome AdjustAll(int deltaPercent)
- {
- lock (Gate)
- {
- var outcome = new BrightnessOutcome();
-
- // 相对调整必须先读当前值:每块屏的起点可能不同,不能拿某一个值去套所有屏。
- ApplyViaDdc(outcome, current => current < 0 ? -1 : Clamp(current + deltaPercent));
- ApplyViaWmi(outcome, current => current < 0 ? -1 : Clamp(current + deltaPercent));
-
- if (outcome.Adjusted > 0)
- {
- DropSoftwareFallback();
- return outcome;
- }
-
- // 软件模式下没有「硬件当前值」可读,只能以自己记住的上一次目标为基准。
- ApplySoftwareFallback(outcome, Clamp(GammaController.CurrentPercent + deltaPercent));
- return outcome;
- }
- }
-
- /// 在 与 100% 之间切换(省电 / 护眼快切)。
- public static BrightnessOutcome Toggle(int dimPercent)
- {
- int dim = Clamp(dimPercent);
-
- // 先读一次平均值来决定往哪边切,再整体设为目标值。
- // 多屏亮度不一致时「当前是不是暗的」本就是模糊判断,取平均值最符合直觉。
- int average = ReadAveragePercent();
- int target = average < 0 || average < (dim + 100) / 2 ? 100 : dim;
-
- BrightnessOutcome outcome = SetAll(target);
- outcome.TargetPercent = target;
- return outcome;
- }
-
- // ==================== DDC/CI ====================
-
- ///
- /// 在同一个 DDC/CI 会话内完成「读 → 算 → 写」。
- ///
- ///
- /// 由当前亮度百分比算出目标百分比;返回 -1 表示这块屏本轮跳过。
- ///
- private static void ApplyViaDdc(BrightnessOutcome outcome, Func resolveTarget)
- {
- using DdcSession session = DdcSession.Open();
-
- for (int i = 0; i < session.Handles.Count; i++)
- {
- IntPtr handle = session.Handles[i];
- string name = i < session.Names.Count && !string.IsNullOrWhiteSpace(session.Names[i])
- ? session.Names[i]
- : $"显示器 {i + 1}";
-
- try
- {
- // 读回显器自报的量程。DDC/CI 的亮度范围并非固定 0-100,
- // 直接塞 0-100 会在部分显示器上退化成「只有最亮和最暗两档」。
- if (!GetVCPFeatureAndVCPFeatureReply(handle, VcpLuminance, IntPtr.Zero, out uint current, out uint max) || max == 0)
- {
- // 探到了物理显示器,但它不接受亮度控制。常见原因:
- // 显示器 OSD 里关掉了 DDC/CI、走 HDMI 转接芯片、或这是笔记本内置屏。
- // 这不记为错误 —— 内置屏还有 WMI 通道在后面兜着。
- outcome.Skipped++;
- continue;
- }
-
- int currentPercent = (int)Math.Round(current * 100.0 / max);
- int target = resolveTarget(currentPercent);
-
- if (target < 0)
- {
- outcome.Skipped++;
- continue;
- }
-
- uint raw = (uint)Math.Round(target * max / 100.0);
-
- if (SetVCPFeature(handle, VcpLuminance, raw))
- {
- outcome.Adjusted++;
- outcome.AdjustedNames.Add($"{name} → {target}%");
- }
- else
- {
- outcome.Skipped++;
- outcome.Errors.Add($"{name}:写入被拒绝");
- }
- }
- catch (Exception ex)
- {
- outcome.Skipped++;
- outcome.Errors.Add($"{name}:{ex.Message}");
- }
- }
- }
-
- private static void ReadDdcMonitors(List monitors)
- {
- using DdcSession session = DdcSession.Open();
-
- for (int i = 0; i < session.Handles.Count; i++)
- {
- IntPtr handle = session.Handles[i];
- string name = i < session.Names.Count && !string.IsNullOrWhiteSpace(session.Names[i])
- ? session.Names[i]
- : $"显示器 {i + 1}";
-
- bool supported = GetVCPFeatureAndVCPFeatureReply(handle, VcpLuminance, IntPtr.Zero, out uint current, out uint max);
-
- monitors.Add(new MonitorBrightness
- {
- Description = name,
- Channel = "DDC/CI",
- IsWritable = supported && max > 0,
- CurrentPercent = supported && max > 0 ? (int)Math.Round(current * 100.0 / max) : -1,
- });
- }
- }
-
- ///
- /// 一次「枚举物理显示器 → 用完销毁句柄」的会话。
- ///
- /// 必须成对调用 GetPhysicalMonitorsFromHMONITOR / DestroyPhysicalMonitors:
- /// 后者会释放每个物理显示器句柄,漏掉就会在长期运行中稳定泄漏驱动侧句柄。
- ///
- ///
- private sealed class DdcSession : IDisposable
- {
- private readonly List _arrays = new();
-
- public List Handles { get; } = new();
-
- public List Names { get; } = new();
-
- public static DdcSession Open()
- {
- var session = new DdcSession();
-
- // 委托必须在 EnumDisplayMonitors 返回前保持存活:若被 GC 回收,
- // 非托管侧回调到已释放的 thunk 会直接崩掉宿主进程。
- MonitorEnumProc callback = (IntPtr hMonitor, IntPtr hdc, ref RECT rect, IntPtr data) =>
- {
- if (GetNumberOfPhysicalMonitorsFromHMONITOR(hMonitor, out uint count) && count > 0)
- {
- var array = new PHYSICAL_MONITOR[count];
-
- if (GetPhysicalMonitorsFromHMONITOR(hMonitor, count, array))
- {
- session._arrays.Add(array);
-
- foreach (PHYSICAL_MONITOR physical in array)
- {
- session.Handles.Add(physical.hPhysicalMonitor);
- session.Names.Add(physical.szPhysicalMonitorDescription ?? "");
- }
- }
- }
-
- return true;
- };
-
- try
- {
- EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero, callback, IntPtr.Zero);
- }
- finally
- {
- GC.KeepAlive(callback);
- }
-
- return session;
- }
-
- public void Dispose()
- {
- foreach (PHYSICAL_MONITOR[] array in _arrays)
- {
- try
- {
- DestroyPhysicalMonitors((uint)array.Length, array);
- }
- catch
- {
- // 释放失败没有可做的补救动作,也不该影响调用方的主流程。
- }
- }
-
- _arrays.Clear();
- Handles.Clear();
- Names.Clear();
- }
- }
-
- // ==================== WMI ====================
-
- ///
- /// 通过 COM 的 WbemScripting.SWbemLocator 访问 root\wmi。
- ///
- /// 为什么不用 System.Management:它是独立的 NuGet 包,引入后插件目录里就得多带
- /// 一个程序集,破坏「插件只需引用 SDK」这一前提。WbemScripting 是 Windows 自带的
- /// COM 组件(wbemdisp.dll),纯反射即可调用,零额外依赖。
- ///
- ///
- /// 代价是调用点全是反射、没有编译期检查 —— 所以所有失败路径都必须显式吞掉并返回空集合,
- /// 由上层汇总成用户能看懂的信息,而不是让异常穿透到宿主的动作线程。
- ///
- ///
- private const string WmiNamespace = @"root\wmi";
-
- /// 硬件两条通道都没成功时,退到 gamma 软件调光。
- private static void ApplySoftwareFallback(BrightnessOutcome outcome, int target)
- {
- bool wasApplied = GammaController.IsApplied;
-
- if (target >= 100 && !wasApplied)
- {
- // 本来就是最亮、也从没启动过软件调光 —— 没有可做的事。
- // 但仍要如实告知:否则用户点「亮度 +10%」看到毫无反应,只会以为插件坏了。
- outcome.Adjusted++;
- outcome.Notable = true;
- outcome.AdjustedNames.Add("已是最亮,无需调整");
- return;
- }
-
- if (GammaController.Apply(target))
- {
- outcome.Adjusted++;
- outcome.UsedSoftwareFallback = true;
- outcome.AdjustedNames.Add($"软件调光 → {target}%");
-
- // 只在「首次启用软件调光」时提示:用户需要知道此刻生效的是软件方案而非背光。
- // 之后再调就静默,否则连点几次会被气泡烦到。
- if (!wasApplied) outcome.Notable = true;
- }
- else
- {
- outcome.Skipped++;
- outcome.Errors.Add("软件调光失败:显卡驱动拒绝了 gamma 曲线写入");
- }
- }
-
- ///
- /// 硬件通道生效后,把此前的软件调光撤掉。
- ///
- /// 不撤的话会变成「硬件亮度 + gamma 双重压低」,屏幕比用户预期暗得多,
- /// 而且界面上没有任何提示 —— 这类「静默叠加」是最难排查的一类问题。
- ///
- ///
- private static void DropSoftwareFallback()
- {
- if (GammaController.IsApplied)
- {
- GammaController.Restore();
- }
- }
-
- private static void ApplyViaWmi(BrightnessOutcome outcome, Func resolveTarget)
- {
- List