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 readers = QueryWmi("SELECT CurrentBrightness FROM WmiMonitorBrightness", out _); - List writers = QueryWmi("SELECT * FROM WmiMonitorBrightnessMethods", out string? failure); - - if (writers.Count == 0) - { - // 台式机 + 外接显示器时「WMI 能连上但没有亮度实例」是正常现象,不记为错误。 - // 但查询本身报错就是另一回事了,必须浮上来让用户看到。 - if (failure != null) - { - outcome.Errors.Add($"WMI 通道不可用 —— {failure}"); - } - return; - } - - for (int i = 0; i < writers.Count; i++) - { - string name = writers.Count > 1 ? $"内置屏幕 {i + 1}" : "内置屏幕"; - - try - { - int currentPercent = -1; - - if (i < readers.Count) - { - object? value = readers[i].GetType().InvokeMember( - "CurrentBrightness", BindingFlags.GetProperty, null, readers[i], null); - - if (value != null) currentPercent = Convert.ToInt32(value); - } - - int target = resolveTarget(currentPercent); - if (target < 0) - { - outcome.Skipped++; - continue; - } - - // WmiSetBrightness(Timeout, Brightness):Timeout 单位秒,0 表示立即下发。 - // 两个参数都用最朴素的整数类型(VT_I4 / VT_UI1 均可被 COM 转换), - // 避免再出现上面那种「类型不匹配被静默吞掉」的问题。 - writers[i].GetType().InvokeMember( - "WmiSetBrightness", - BindingFlags.InvokeMethod, - null, - writers[i], - new object[] { 0, (byte)target }); - - outcome.Adjusted++; - outcome.AdjustedNames.Add($"{name} → {target}%"); - } - catch (Exception ex) - { - outcome.Skipped++; - outcome.Errors.Add($"{name}:{ex.Message}"); - } - } - } - - private static void ReadWmiMonitors(List monitors) - { - List instances = QueryWmi("SELECT CurrentBrightness FROM WmiMonitorBrightness", out string? failure); - - if (failure != null) - { - monitors.Add(MonitorBrightness.Unavailable($"WMI 通道查询失败 —— {failure}")); - return; - } - - for (int i = 0; i < instances.Count; i++) - { - int current = -1; - - try - { - object? value = instances[i].GetType().InvokeMember( - "CurrentBrightness", BindingFlags.GetProperty, null, instances[i], null); - - if (value != null) current = Convert.ToInt32(value); - } - catch - { - // 个别驱动不实现该属性;保留 -1 表示「不可读」。 - } - - monitors.Add(new MonitorBrightness - { - Description = instances.Count > 1 ? $"内置屏幕 {i + 1}" : "内置屏幕", - Channel = "WMI", - IsWritable = true, - CurrentPercent = current, - }); - } - } - - /// - /// 执行一次 WMI 查询并返回结果集合中的对象列表。 - /// - /// - /// 查询失败时的真实原因;成功时为 null(「查询没报错但没有实例」属于成功)。 - /// 之所以必须区分这两者:前者是代码问题、后者是环境问题,处理方式完全不同。 - /// - private static List QueryWmi(string wql, out string? failure) - { - failure = null; - var results = new List(); - - try - { - Type? locatorType = Type.GetTypeFromProgID("WbemScripting.SWbemLocator"); - if (locatorType == null) return results; - - object? locator = Activator.CreateInstance(locatorType); - if (locator == null) return results; - - // ConnectServer(strServer, strNamespace, strUser, strPassword, - // strLocale, strAuthority, iSecurityFlags) - // - // 这里刻意只传 7 个参数、且全部给明确类型,是踩过坑之后定下来的: - // 若照 COM 文档把可选项补满 8 个参数并给 null,iSecurityFlags 会收到 VT_NULL - // 而该参数期望 VT_I4,直接抛 DISP_E_TYPEMISMATCH(类型不匹配)。 - // 后果极具迷惑性 —— 异常被下面的 catch 吞掉后表现为「WMI 查不到任何显示器」, - // 看起来像环境不支持,实际是调用姿势不对。 - // 省略末尾的 objWbemNamedValueSet 反而能让 COM 用它自己的默认值。 - object? services = locatorType.InvokeMember( - "ConnectServer", BindingFlags.InvokeMethod, null, locator, - new object[] { ".", WmiNamespace, "", "", "", "", 0 }); - - if (services == null) return results; - - object? set = services.GetType().InvokeMember( - "ExecQuery", BindingFlags.InvokeMethod, null, services, new object[] { wql }); - - if (set == null) return results; - - // 用 ItemIndex 而不是 foreach:SWbemObjectSet 的枚举要走 IEnumVARIANT, - // 在纯反射路径下取它并不可靠;ItemIndex 是明确的成员,行为可预期。 - int count = Convert.ToInt32(set.GetType().InvokeMember( - "Count", BindingFlags.GetProperty, null, set, null) ?? 0); - - for (int i = 0; i < count; i++) - { - object? item = set.GetType().InvokeMember( - "ItemIndex", BindingFlags.InvokeMethod, null, set, new object[] { i }); - - if (item != null) results.Add(item); - } - } - catch (Exception ex) - { - // 记下真实原因再返回。这里原先只是静默吞掉异常,结果让一次 - // 「COM 参数类型不匹配」伪装成了「这台机器不支持 WMI 亮度」, - // 排查代价极高 —— 所以失败必须留下证据,不能再吞。 - failure = $"{ex.GetType().Name}: {ex.Message}"; - results.Clear(); - } - - return results; - } - - private static int Clamp(int percent) => percent < 0 ? 0 : (percent > 100 ? 100 : percent); - - // ==================== P/Invoke ==================== - - private delegate bool MonitorEnumProc(IntPtr hMonitor, IntPtr hdcMonitor, ref RECT lprcMonitor, IntPtr dwData); - - [StructLayout(LayoutKind.Sequential)] - private struct RECT - { - public int Left; - public int Top; - public int Right; - public int Bottom; - } - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - private struct PHYSICAL_MONITOR - { - public IntPtr hPhysicalMonitor; - - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] - public string szPhysicalMonitorDescription; - } - - [DllImport("user32.dll")] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool EnumDisplayMonitors(IntPtr hdc, IntPtr lprcClip, MonitorEnumProc lpfnEnum, IntPtr dwData); - - [DllImport("dxva2.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool GetNumberOfPhysicalMonitorsFromHMONITOR(IntPtr hMonitor, out uint numberOfPhysicalMonitors); - - [DllImport("dxva2.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool GetPhysicalMonitorsFromHMONITOR( - IntPtr hMonitor, uint physicalMonitorArraySize, [Out] PHYSICAL_MONITOR[] physicalMonitorArray); - - [DllImport("dxva2.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool DestroyPhysicalMonitors( - uint physicalMonitorArraySize, [In] PHYSICAL_MONITOR[] physicalMonitorArray); - - [DllImport("dxva2.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool GetVCPFeatureAndVCPFeatureReply( - IntPtr hMonitor, byte vcpCode, IntPtr pvct, out uint currentValue, out uint maximumValue); - - [DllImport("dxva2.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool SetVCPFeature(IntPtr hMonitor, byte vcpCode, uint newValue); -} - -/// 一块显示器的可调亮度信息(仅用于展示与诊断)。 -internal sealed class MonitorBrightness -{ - public string Description { get; init; } = ""; - - /// 探测来源,如 DDC/CI 或 WMI。 - public string Channel { get; init; } = ""; - - public bool IsWritable { get; init; } - - /// 当前亮度百分比;-1 表示不可读。 - public int CurrentPercent { get; init; } - - /// 诊断信息:通道不可用时用它说明原因。 - public string Diagnostic { get; init; } = ""; - - public static MonitorBrightness Unavailable(string reason) => new() - { - Description = "(通道不可用)", - IsWritable = false, - CurrentPercent = -1, - Diagnostic = reason, - }; -} - -/// -/// 软件调光兜底通道:通过改写显示设备的 gamma ramp 让画面整体变暗。 -/// -/// 为什么需要它:DDC/CI 要显示器配合、WMI 只有笔记本内置屏才有。台式机接一台 -/// 没开 DDC/CI 的显示器时,前两条通道会同时落空 —— 此时若不给兜底,「调亮度」在这台机器上 -/// 就是彻底不可用。gamma 方案不依赖任何硬件配合,任何显示器都能生效。 -/// -/// -/// 它与硬件调光的区别(必须对用户诚实):硬件调光降低背光亮度,省电、对比度不变; -/// gamma 调光只是把像素值压低,背光功耗不变,黑色会变成灰黑、对比度下降。 -/// 所以它的定位是「兜底」而不是「等价替代」,只有在硬件通道完全不可用时才启用。 -/// -/// -/// 一个必须承担的责任:gamma ramp 是系统级的,改完之后即使 StarPie 退出也不会 -/// 自动恢复。所以本类在插件卸载时会把曲线写回基准值 —— 否则用户的屏幕会一直暗到重启为止。 -/// -/// -internal static class GammaController -{ - private static readonly object Gate = new(); - - /// 首次调整前捕获的原始曲线。所有调整都以它为基准,避免反复乘算累积误差。 - private static ushort[]? _baseline; - - private static int _currentPercent = 100; - - /// 当前是否处于软件调光状态(亮度低于 100%)。 - public static bool IsApplied - { - get { lock (Gate) return _currentPercent < 100; } - } - - /// 当前软件亮度百分比。 - public static int CurrentPercent - { - get { lock (Gate) return _currentPercent; } - } - - /// 应用软件调光。 - public static bool Apply(int percent) - { - lock (Gate) - { - int target = Math.Clamp(percent, 1, 100); - - _baseline ??= Capture(); - if (_baseline == null) return false; - - // 按基准曲线整体缩放。逐通道独立缩放,这样用户原有的色温设置(若通过 - // gamma 调整过)能在调光后保持相对比例,不会因为统一缩放而偏色。 - var ramp = new RAMP - { - Red = new ushort[256], - Green = new ushort[256], - Blue = new ushort[256], - }; - - double factor = target / 100.0; - - for (int i = 0; i < 256; i++) - { - ramp.Red[i] = Scale(_baseline[i], factor); - ramp.Green[i] = Scale(_baseline[256 + i], factor); - ramp.Blue[i] = Scale(_baseline[512 + i], factor); - } - - if (!Write(ramp)) return false; - - _currentPercent = target; - return true; - } - } - - /// 恢复首次调整前捕获的原始曲线。 - public static bool Restore() - { - lock (Gate) - { - if (_baseline == null) - { - _currentPercent = 100; - return true; - } - - var ramp = new RAMP - { - Red = new ushort[256], - Green = new ushort[256], - Blue = new ushort[256], - }; - - Array.Copy(_baseline, 0, ramp.Red, 0, 256); - Array.Copy(_baseline, 256, ramp.Green, 0, 256); - Array.Copy(_baseline, 512, ramp.Blue, 0, 256); - - bool ok = Write(ramp); - - // 即使写回失败也把状态标记成「未调光」:继续声称「正在软件调光」会让 - // 后续的相对调整从一个不存在的基准上算起,越调越偏。 - _currentPercent = 100; - return ok; - } - } - - private static ushort Scale(ushort baselineValue, double factor) - { - int value = (int)Math.Round(baselineValue * factor); - return (ushort)Math.Clamp(value, 0, 65535); - } - - private static ushort[]? Capture() - { - IntPtr hdc = IntPtr.Zero; - - try - { - hdc = GetDC(IntPtr.Zero); - if (hdc == IntPtr.Zero) return null; - - var ramp = new RAMP - { - Red = new ushort[256], - Green = new ushort[256], - Blue = new ushort[256], - }; - - if (!GetDeviceGammaRamp(hdc, ref ramp)) return null; - - var baseline = new ushort[768]; - Array.Copy(ramp.Red, 0, baseline, 0, 256); - Array.Copy(ramp.Green, 0, baseline, 256, 256); - Array.Copy(ramp.Blue, 0, baseline, 512, 256); - return baseline; - } - catch - { - return null; - } - finally - { - if (hdc != IntPtr.Zero) - { - try { ReleaseDC(IntPtr.Zero, hdc); } catch { } - } - } - } - - private static bool Write(RAMP ramp) - { - IntPtr hdc = IntPtr.Zero; - - try - { - hdc = GetDC(IntPtr.Zero); - if (hdc == IntPtr.Zero) return false; - - return SetDeviceGammaRamp(hdc, ref ramp); - } - catch - { - return false; - } - finally - { - if (hdc != IntPtr.Zero) - { - try { ReleaseDC(IntPtr.Zero, hdc); } catch { } - } - } - } - - [StructLayout(LayoutKind.Sequential)] - private struct RAMP - { - [MarshalAs(UnmanagedType.ByValArray, SizeConst = 256)] - public ushort[] Red; - - [MarshalAs(UnmanagedType.ByValArray, SizeConst = 256)] - public ushort[] Green; - - [MarshalAs(UnmanagedType.ByValArray, SizeConst = 256)] - public ushort[] Blue; - } - - [DllImport("user32.dll")] - private static extern IntPtr GetDC(IntPtr hWnd); - - [DllImport("user32.dll")] - private static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC); - - [DllImport("gdi32.dll")] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool GetDeviceGammaRamp(IntPtr hdc, ref RAMP lpRamp); - - [DllImport("gdi32.dll")] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool SetDeviceGammaRamp(IntPtr hdc, ref RAMP lpRamp); -} - -/// 一次亮度调整的结果汇总。 -internal sealed class BrightnessOutcome -{ - /// 成功调整的显示器数量(DDC/CI 与 WMI 合计)。 - public int Adjusted { get; set; } - - /// 检测到但未能调整的显示器数量。 - public int Skipped { get; set; } - - /// 成功调整的明细,形如「DELL U2720Q → 60%」。 - public List AdjustedNames { get; } = new(); - - /// 调整过程中的错误信息。 - public List Errors { get; } = new(); - - /// Toggle 动作的最终目标值;仅 Toggle 会填,-1 表示未涉及。 - public int TargetPercent { get; set; } = -1; - - /// 本次是否走了软件调光兜底(gamma)而不是硬件背光。 - public bool UsedSoftwareFallback { get; set; } - - /// - /// 结果是否「值得一提」。 - /// - /// 调亮 / 调暗成功属于日常操作,弹气泡纯属打扰;但「已经是最亮,什么都没做」 - /// 「这次开始改用软件调光」这类结果用户必须知道,否则会误以为插件失灵。 - /// - /// - public bool Notable { get; set; } - - public bool Success => Adjusted > 0; - - /// 拼一句给用户看的结果描述。 - public string Describe(string verb) - { - if (Adjusted == 0) - { - string detail = Errors.Count > 0 ? "(" + string.Join(";", Errors) + ")" : ""; - return $"没有可用的调光通道{detail}。硬件通道(DDC/CI、WMI)与软件 gamma 调光都没能生效。"; - } - - string suffix = Skipped > 0 ? $",另有 {Skipped} 块屏不支持" : ""; - string names = AdjustedNames.Count > 0 ? ":" + string.Join("、", AdjustedNames) : ""; - string mode = UsedSoftwareFallback - ? "\n(硬件背光不可调,已改用软件调光 —— 它只压暗画面,不降低背光功耗)" - : ""; - return $"已{verb} {Adjusted} 块屏幕{suffix}{names}{mode}"; - } -} diff --git a/plugin/samples/ScreenBrightness/ScreenBrightness.csproj b/plugin/samples/ScreenBrightness/ScreenBrightness.csproj deleted file mode 100644 index c797c8db..00000000 --- a/plugin/samples/ScreenBrightness/ScreenBrightness.csproj +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - net8.0-windows - enable - enable - 12.0 - - StarPie.Plugin.ScreenBrightness - StarPie.Plugin.ScreenBrightness - - 1.0.0 - 1.0.0.0 - 1.0.0.0 - StarPie 屏幕亮度插件 - StarPie Community - 通过 DDC/CI 与 WMI 双通道调节显示器亮度,支持外接显示器与笔记本内置屏。 - - - x64 - x64 - - - - - - false - - - - - - - - - - - - - - - - - - diff --git a/plugin/samples/ScreenBrightness/ScreenBrightnessPlugin.cs b/plugin/samples/ScreenBrightness/ScreenBrightnessPlugin.cs deleted file mode 100644 index 3e1d323f..00000000 --- a/plugin/samples/ScreenBrightness/ScreenBrightnessPlugin.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System; -using System.Collections.Generic; -using StarPie.Plugin; - -namespace StarPie.Plugin.ScreenBrightness; - -/// -/// 屏幕亮度调节插件入口。 -/// -/// 实现意图:这既是一个能用的插件,也是插件系统的**压力测试样本** —— -/// 它同时用到了 P/Invoke 原生 API、COM 互操作、以及耗时 IO 三类在进程内插件里 -/// 最容易出问题的能力。如果它能稳定跑通,说明宿主的隔离与调度做得够扎实。 -/// -/// -public sealed class ScreenBrightnessPlugin : IStarPiePlugin -{ - // 图标刻意用最朴素的 M/A/L/H/V/Z 命令构造:不追求精致,追求「一定解析得开」。 - // 插件图标一旦语法有误,会让轮盘的几何解析抛异常,收益远小于风险。 - private const string SunSvg = - "M12,8A4,4 0 0,1 12,16A4,4 0 0,1 12,8Z" + // 日面(两段半圆拼成整圆) - "M11,1H13V4H11Z" + // 上光芒 - "M11,20H13V23H11Z" + // 下光芒 - "M1,11H4V13H1Z" + // 左光芒 - "M20,11H23V13H20Z"; // 右光芒 - - private const string MoonSvg = "M12,4A8,8 0 0,0 12,20Z"; // 半月,用于标识低亮度动作 - - private readonly List _tokens = new(); - private bool _initialized; - - public void Initialize(IPluginContext context) - { - // 幂等保护:宿主在重载流程里可能重复调用 Initialize。 - if (_initialized) - { - context.Log.Warn("Initialize 被重复调用,已忽略本次调用。"); - return; - } - - _initialized = true; - - string sunIcon = context.Icons.RegisterSvg("sun", SunSvg); - string moonIcon = context.Icons.RegisterSvg("moon", MoonSvg); - - BrightnessActions.RegisterLocalization(context); - - foreach (IActionContribution contribution in BrightnessActions.CreateAll(context, sunIcon, moonIcon)) - { - _tokens.Add(context.Actions.Register(contribution)); - } - - // 把「这台机器到底能不能调亮度」在加载阶段就写进日志。 - // 用户反馈「调了没反应」时,看一眼插件日志就能分清是环境问题还是插件问题 —— - // 这是插件作者能给用户省下的最大一笔沟通成本。 - List monitors = BrightnessController.ReadAll(); - int writable = 0; - - foreach (MonitorBrightness monitor in monitors) - { - if (monitor.IsWritable) writable++; - } - - context.Log.Info($"已注册 {_tokens.Count} 个亮度动作;检测到 {monitors.Count} 块屏幕,其中 {writable} 块可调。"); - - foreach (MonitorBrightness monitor in monitors) - { - string line = $" · {monitor.Description}|{monitor.Channel}|" + - (monitor.IsWritable ? $"{monitor.CurrentPercent}%" : "不可调"); - - if (!string.IsNullOrEmpty(monitor.Diagnostic)) - { - line += $"|{monitor.Diagnostic}"; - } - - context.Log.Info(line); - } - - if (writable == 0) - { - context.Log.Warn( - "硬件通道(DDC/CI、WMI)均不可用,亮度调节会自动回退到软件调光(gamma)。" + - "若希望走硬件背光调光,请确认显示器 OSD 菜单中已开启 DDC/CI —— " + - "多数品牌默认关闭,或藏在「其他设置」里。"); - } - } - - public void Shutdown() - { - // 必须幂等:宿主在卸载、错误兜底、进程退出时都可能调用它。 - foreach (IDisposable token in _tokens) - { - try - { - token.Dispose(); - } - catch - { - // 单个 token 释放失败不应影响其余 token —— 否则会留下一半注册项, - // 下次启用时贡献点 ID 冲突,插件直接变成「加载失败」。 - } - } - - _tokens.Clear(); - - // 软件调光改的是系统级 gamma ramp ——即使 StarPie 退出也不会自动还原。 - // 不主动恢复的话,用户的屏幕会一直暗着,直到重启系统为止。 - // 这是卸载时必须承担的责任,不能指望用户自己发现。 - GammaController.Restore(); - - // 把这个标志连同 token 一起复位。若不复位,「停用后再启用」时 Initialize - // 会因为 _initialized 仍为 true 而直接返回,表现为「重新加载后一个动作都没有」。 - _initialized = false; - } -} diff --git a/plugin/samples/ScreenBrightness/plugin.json b/plugin/samples/ScreenBrightness/plugin.json deleted file mode 100644 index 5b72cb66..00000000 --- a/plugin/samples/ScreenBrightness/plugin.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "schemaVersion": 1, - - "id": "com.example.screenbrightness", - "name": "屏幕亮度调节", - "description": "用鼠标轮盘直接调屏幕亮度。外接显示器走 DDC/CI 协议,笔记本内置屏走 WMI,两条通道会同时尝试,因此内屏外屏可以一起调。", - "author": "StarPie Community", - "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.ScreenBrightness.dll", - "entryType": "StarPie.Plugin.ScreenBrightness.ScreenBrightnessPlugin", - - "capabilities": ["Process", "Ui"], - - "contributions": { - "actions": true, - "icons": true, - "i18n": true - }, - - "tags": ["屏幕", "亮度", "护眼", "显示器", "DDC/CI"] -}