VmResourceGuard.cs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Text.RegularExpressions;
  4. using System.Windows;
  5. using System.Windows.Media;
  6. using TeamAAS;
  7. namespace TeamAAS.Theme
  8. {
  9. /// <summary>
  10. /// 应用级主题资源守护:防御第三方控件(海康 VM 系列,经 WindowsFormsHost/ElementHost 进入视觉树)
  11. /// 对 Application.Resources 的越界写入。
  12. ///
  13. /// 背景故障(2026-09-04):
  14. /// 打开"VM 工具块"编辑视图后切换明暗主题,齿轮 Popup 布局时抛
  15. /// InvalidOperationException:"#FF797979"不是属性"Color"的有效值,且被全局异常处理器
  16. /// Handled=true 后每帧重爆、日志刷屏(10MB/5min)。
  17. ///
  18. /// 根因(日志 + 反编译确认):
  19. /// 1. 某第三方控件把应用级主题同名键(XxxColor/XxxBrush 形态)写成了字符串 "#FF797979"
  20. /// (Color.ToString() 的产物;所有 VM DLL 静态字节里均无此字面量,纯运行时生成)。
  21. /// 写入资源字典不做类型检查,因此污染当时不报错。
  22. /// 2. HandyControl 的画刷是 &lt;SolidColorBrush Color="{DynamicResource XxxColor}"/&gt; 结构;
  23. /// 画刷 Color 表达式在重新求值时把污染的字符串求进有效值(表达式值延迟类型检查)。
  24. /// 3. Border.ArrangeOverride 读取画刷 Color 时类型检查失败才抛异常。
  25. /// 4. VMControls.Winform.Release 的控件只是 ElementHost 壳,内部仍是 VMControls.WPF 的
  26. /// WPF 控件(反编译确认),所以"改用 WinForms 版避免污染"无效。
  27. ///
  28. /// 防御体系(切主题入口消毒,单道防线):
  29. /// - ThemeManager 切主题入口消毒:切换/基准捕获前 Purge 第三方注入字典与污染条目,保证求值链纯净;
  30. /// - 结构损坏 / 坏画刷 / 基准缺失时 ForceReplaceTheme 整替官方字典兜底。
  31. /// 注:常驻 Watchdog 轮询与未处理异常急救已移除——ThemeOverlay 纯值覆盖层对污染天然免疫。
  32. /// </summary>
  33. internal static class VmResourceGuard
  34. {
  35. /// <summary>颜色字面量:#RRGGBB 或 #AARRGGBB(污染值的典型形态)</summary>
  36. private static readonly Regex ColorLiteralRegex =
  37. new Regex(@"^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$", RegexOptions.Compiled);
  38. /// <summary>官方主题字典识别标记:pack Source 中含此串才允许触碰/保留</summary>
  39. private const string OfficialThemeMarker = "HandyControl";
  40. /// <summary>
  41. /// 仅消毒:移除应用级资源中"字符串形式颜色"的污染条目。
  42. /// 供切主题入口(整替前)调用——污染清干净后,画刷求值才安全。
  43. /// </summary>
  44. public static void Sanitize(string trigger)
  45. {
  46. ScanAll(trigger);
  47. }
  48. /// <summary>
  49. /// 检查主题字典中是否存在坏画刷(Color 有效值已被字符串污染、读取即爆)。
  50. /// 供切主题判断:即使字典结构标准,画刷坏了也必须强制整替。
  51. /// </summary>
  52. public static bool HasBrokenBrush()
  53. {
  54. var app = Application.Current;
  55. if (app == null) return false;
  56. bool broken = false;
  57. try
  58. {
  59. ScanDict(app.Resources, "HealthCheck", "HealthCheck", ref broken);
  60. }
  61. catch (Exception ex)
  62. {
  63. AppLogger.Error("[资源守护:HealthCheck] 扫描失败", ex, nameof(VmResourceGuard));
  64. }
  65. return broken;
  66. }
  67. private static void ScanAll(string trigger)
  68. {
  69. var app = Application.Current;
  70. if (app == null) return;
  71. try
  72. {
  73. // 第一步:移除第三方控件注入应用合并字典的非 HC 字典(含坏 BAML/字符串颜色键,
  74. // 是画刷求值链被击穿的污染源)
  75. PurgeForeignMergedDictionaries(trigger);
  76. bool brushBroken = false;
  77. int removed = ScanDict(app.Resources, "Application.Resources", trigger, ref brushBroken);
  78. if (removed > 0)
  79. {
  80. AppLogger.Warning(
  81. $"[资源守护:{trigger}] 已清除 {removed} 个污染条目(明细见上方日志)",
  82. nameof(VmResourceGuard));
  83. }
  84. }
  85. catch (Exception ex)
  86. {
  87. AppLogger.Error($"[资源守护:{trigger}] 扫描失败", ex, nameof(VmResourceGuard));
  88. }
  89. }
  90. /// <summary>
  91. /// 急救/结构修复:强制整替 HandyControl 明暗双字典(调色板 + 画刷/样式),
  92. /// 用全新画刷实例替换所有被污染写坏的旧画刷。
  93. /// 正常切主题走覆盖层重建(见 ThemeManager),本方法仅在
  94. /// 「字典结构损坏」「基准未捕获」「检测到坏画刷」时由守护/异常钩子调用。
  95. /// </summary>
  96. /// <param name="dark">目标明暗;null 时沿用 ThemeOverlay 记录的当前明暗(守护轮询路径)。</param>
  97. public static void ForceReplaceTheme(bool? dark = null)
  98. {
  99. var merged = Application.Current.Resources.MergedDictionaries;
  100. bool isDark = dark.HasValue ? dark.Value : ThemeOverlay.CurrentDark;
  101. // 先摘除覆盖层:保证整替与基准捕获读到的是官方字典真值(覆盖层查找优先级最高)
  102. ThemeOverlay.RemoveOverlay();
  103. // 消毒:移除第三方注入字典/污染条目(Watchdog/异常钩子可能不经 Sanitize 直达此处),
  104. // 保证基准捕获走的是纯净求值链
  105. Sanitize("ForceReplaceTheme");
  106. var skinUri = new Uri(isDark
  107. ? "pack://application:,,,/HandyControl;component/Themes/SkinDark.xaml"
  108. : "pack://application:,,,/HandyControl;component/Themes/SkinDefault.xaml");
  109. var themeUri = new Uri("pack://application:,,,/HandyControl;component/Themes/Theme.xaml");
  110. // 1) 调色板槽位
  111. int skinIdx = FindSlot(merged, d => d.Source != null &&
  112. (d.Source.OriginalString.Contains("SkinDefault.xaml") ||
  113. d.Source.OriginalString.Contains("SkinDark.xaml")));
  114. if (skinIdx < 0) skinIdx = 0;
  115. merged[skinIdx] = new ResourceDictionary { Source = skinUri };
  116. // 2) 画刷+样式槽位:Source 含 Theme.xaml,或旧方案残留的 Theme 实例(Source=null)
  117. int themeIdx = FindSlot(merged, d => d is HandyControl.Themes.Theme ||
  118. (d.Source != null && d.Source.OriginalString.Contains("Theme.xaml")));
  119. if (themeIdx < 0) themeIdx = merged.Count > 1 ? 1 : merged.Count;
  120. var themeDict = new ResourceDictionary { Source = themeUri };
  121. if (themeIdx >= merged.Count) merged.Add(themeDict);
  122. else merged[themeIdx] = themeDict;
  123. // 重新捕获该明暗的官方基准(此时求值链干净)并重建覆盖层(纯值画刷,免疫污染)
  124. ThemeOverlay.CaptureBase(isDark);
  125. ThemeOverlay.Apply(isDark, ThemeOverlay.CurrentAccent);
  126. }
  127. /// <summary>
  128. /// 移除应用合并字典中"非白名单"的字典(第三方控件框架级注入的字典:
  129. /// 实测含坏 BAML(GradientStop.Color 解析即爆)与字符串形式颜色键,它们挂在
  130. /// Application 求值链上会让所有 DynamicResource 画刷被击穿)。
  131. /// 白名单字典保留;第三方控件自身的资源需求由其元素级资源机制解决。
  132. /// </summary>
  133. private static void PurgeForeignMergedDictionaries(string trigger)
  134. {
  135. var merged = Application.Current.Resources.MergedDictionaries;
  136. // 先收集后移除,避免枚举中改集合
  137. List<ResourceDictionary> foreign = null;
  138. for (int i = 0; i < merged.Count; i++)
  139. {
  140. var dict = merged[i];
  141. if (IsOfficialThemeDict(dict)) continue;
  142. if (ThemeOverlay.IsOverlay(dict)) continue; // 主题覆盖层(我们自己的纯值字典)放行
  143. string describe = Describe(dict);
  144. if (string.IsNullOrEmpty(describe)) describe = "(Source=null)";
  145. AppLogger.Warning(
  146. $"[资源守护:{trigger}] 移除第三方注入的应用级字典 md[{i}] {describe}",
  147. nameof(VmResourceGuard));
  148. if (foreign == null) foreign = new List<ResourceDictionary>();
  149. foreign.Add(dict);
  150. }
  151. if (foreign != null)
  152. {
  153. foreach (var dict in foreign)
  154. {
  155. try { merged.Remove(dict); }
  156. catch { /* 无法移除时忽略(下一轮再试) */ }
  157. }
  158. }
  159. }
  160. /// <summary>是否官方主题字典(Source 含 HandyControl 标记;Source=null 不算)。</summary>
  161. private static bool IsOfficialThemeDict(ResourceDictionary dict)
  162. {
  163. try
  164. {
  165. var src = dict.Source;
  166. return src != null && src.OriginalString.Contains(OfficialThemeMarker);
  167. }
  168. catch
  169. {
  170. return false;
  171. }
  172. }
  173. /// <summary>
  174. /// 递归扫描字典树:移除污染条目(先收集后移除,避免枚举中改集合),
  175. /// 顺带用 try 读画刷 Color 检测坏画刷。返回本次移除的条目数。
  176. /// 只递归白名单字典——第三方字典含坏 BAML(展开即爆),触碰只会制造
  177. /// 第一次机会异常并中断扫描,其中的污染由 PurgeForeignMergedDictionaries 整体移除。
  178. /// </summary>
  179. private static int ScanDict(ResourceDictionary dict, string path, string trigger, ref bool brushBroken)
  180. {
  181. int removed = 0;
  182. // 1) 先扫子合并字典(仅白名单字典;第三方字典不触碰)
  183. var merged = dict.MergedDictionaries;
  184. for (int i = 0; i < merged.Count; i++)
  185. {
  186. var child = merged[i];
  187. if (!IsOfficialThemeDict(child)) continue;
  188. string childPath = path + "/md[" + i + "]" + Describe(child);
  189. ScanDict(child, childPath, trigger, ref brushBroken);
  190. }
  191. // 2) 扫描本字典条目
  192. List<object> dirtyKeys = null;
  193. foreach (var key in dict.Keys)
  194. {
  195. var keyName = key as string;
  196. if (keyName == null) continue;
  197. object value;
  198. try { value = dict[key]; }
  199. catch { continue; }
  200. var s = value as string;
  201. if (s != null)
  202. {
  203. if (IsPolluted(keyName, s))
  204. {
  205. AppLogger.Warning(
  206. $"[资源守护:{trigger}] 发现污染 {path}[\"{keyName}\"] = \"{s}\"(string,应为 Color/Brush)",
  207. nameof(VmResourceGuard));
  208. if (dirtyKeys == null) dirtyKeys = new List<object>();
  209. dirtyKeys.Add(key);
  210. }
  211. continue;
  212. }
  213. // 正常画刷:读一次 Color(effective value 已缓存,代价可忽略);
  214. // 坏画刷:Color 求值命中字符串 → 抛 InvalidOperationException
  215. var brush = value as SolidColorBrush;
  216. if (brush != null && !CheckBrush(brush))
  217. {
  218. brushBroken = true;
  219. AppLogger.Warning(
  220. $"[资源守护:{trigger}] 坏画刷 {path}[\"{keyName}\"](Color 读取抛异常,等待整替恢复)",
  221. nameof(VmResourceGuard));
  222. }
  223. }
  224. if (dirtyKeys != null)
  225. {
  226. foreach (var k in dirtyKeys)
  227. {
  228. try { dict.Remove(k); removed++; }
  229. catch { /* 只读/冻结字典无法移除,忽略 */ }
  230. }
  231. }
  232. return removed;
  233. }
  234. /// <summary>
  235. /// 污染判定(必须精确,避免误伤业务键):
  236. /// a) 值本身就是颜色字面量(#RRGGBB / #AARRGGBB)——正常主题资源不会以 string 存颜色;
  237. /// b) 主题键形态(XxxBrush/XxxColor 结尾)却存了 string 值——HC 正常值是 Color/Brush 类型。
  238. /// 业务键如 CenterTitleText(值 "TeamAAS")两条都不满足,不会误伤。
  239. /// </summary>
  240. private static bool IsPolluted(string keyName, string value)
  241. {
  242. if (ColorLiteralRegex.IsMatch(value)) return true;
  243. if ((keyName.EndsWith("Brush", StringComparison.Ordinal) ||
  244. keyName.EndsWith("Color", StringComparison.Ordinal)) &&
  245. value.Length <= 64)
  246. {
  247. return true;
  248. }
  249. return false;
  250. }
  251. /// <summary>画刷健康检查:能读出 Color 即健康。</summary>
  252. private static bool CheckBrush(SolidColorBrush brush)
  253. {
  254. try
  255. {
  256. var c = brush.Color;
  257. return true;
  258. }
  259. catch (InvalidOperationException)
  260. {
  261. return false;
  262. }
  263. }
  264. private static int FindSlot(IList<ResourceDictionary> merged, Func<ResourceDictionary, bool> match)
  265. {
  266. for (int i = 0; i < merged.Count; i++)
  267. {
  268. try { if (match(merged[i])) return i; }
  269. catch { /* Source 读取异常时跳过该槽位 */ }
  270. }
  271. return -1;
  272. }
  273. private static string Describe(ResourceDictionary dict)
  274. {
  275. try
  276. {
  277. var src = dict.Source;
  278. return src != null ? "(" + src.OriginalString + ")" : string.Empty;
  279. }
  280. catch
  281. {
  282. return string.Empty;
  283. }
  284. }
  285. }
  286. }