@ohmykreee/dsh-remote-control 0.0.0-stage → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (373) hide show
  1. package/AGENTS.md +556 -0
  2. package/INSTALLATION.md +310 -0
  3. package/LICENSE +21 -0
  4. package/README.md +195 -2
  5. package/client.js +3 -0
  6. package/components.json +20 -0
  7. package/cordis.patch.yml +3 -0
  8. package/docs/README.en.md +196 -0
  9. package/docs/screenshots/en/overview.png +0 -0
  10. package/docs/screenshots/en/question.png +0 -0
  11. package/docs/screenshots/zh/overview.png +0 -0
  12. package/docs/screenshots/zh/question.png +0 -0
  13. package/icon.svg +24 -0
  14. package/index.js +2 -0
  15. package/lib/account.js +1 -0
  16. package/lib/app-mapping.js +1 -0
  17. package/lib/i18n.js +1 -0
  18. package/lib/media-enrich.js +1 -0
  19. package/lib/notifications.js +1 -0
  20. package/lib/performance.js +1 -0
  21. package/lib/platform/index.js +1 -0
  22. package/lib/probe-adapter.js +1 -0
  23. package/lib/probe-daemon.js +1 -0
  24. package/lib/probe.js +1 -0
  25. package/lib/qr.js +1 -0
  26. package/lib/runtime.js +1 -0
  27. package/lib/service.js +1 -0
  28. package/lib/sessions.js +1 -0
  29. package/lib/settings.js +1 -0
  30. package/lib/types.js +1 -0
  31. package/lib/util.js +1 -0
  32. package/lib/weather.js +1 -0
  33. package/lib/ws.js +1 -0
  34. package/locale/en.json +6 -0
  35. package/locale/zh.json +6 -0
  36. package/package.json +118 -4
  37. package/scripts/browser-shot.mjs +217 -0
  38. package/scripts/build-dsh.mjs +228 -0
  39. package/scripts/build-rust.mjs +319 -0
  40. package/scripts/build-web.mjs +17 -0
  41. package/scripts/check-npm-package.mjs +322 -0
  42. package/scripts/mock-preview.py +574 -0
  43. package/scripts/screenshot-compose.py +89 -0
  44. package/scripts/screenshot-device.sh +75 -0
  45. package/src/dsh/client/api.ts +68 -0
  46. package/src/dsh/client/i18n.ts +573 -0
  47. package/src/dsh/client/icons.ts +56 -0
  48. package/src/dsh/client/index.ts +95 -0
  49. package/src/dsh/client/logic.ts +310 -0
  50. package/src/dsh/client/panel.ts +483 -0
  51. package/src/dsh/client/sections/apps.ts +40 -0
  52. package/src/dsh/client/sections/balance.ts +38 -0
  53. package/src/dsh/client/sections/connection.ts +97 -0
  54. package/src/dsh/client/sections/notifications.ts +43 -0
  55. package/src/dsh/client/sections/pomodoro.ts +184 -0
  56. package/src/dsh/client/sections/preferences.ts +138 -0
  57. package/src/dsh/client/sections/system.ts +120 -0
  58. package/src/dsh/client/sections/weather.ts +276 -0
  59. package/src/dsh/client/styles.ts +1119 -0
  60. package/src/dsh/client/types.ts +366 -0
  61. package/src/dsh/client/ui.ts +142 -0
  62. package/src/dsh/index.ts +108 -0
  63. package/src/dsh/lib/account.ts +520 -0
  64. package/src/dsh/lib/app-mapping.ts +366 -0
  65. package/src/dsh/lib/i18n.ts +82 -0
  66. package/src/dsh/lib/media-enrich.ts +202 -0
  67. package/src/dsh/lib/notifications.ts +322 -0
  68. package/src/dsh/lib/performance.ts +279 -0
  69. package/src/dsh/lib/platform/index.ts +43 -0
  70. package/src/dsh/lib/probe-adapter.ts +275 -0
  71. package/src/dsh/lib/probe-daemon.ts +359 -0
  72. package/src/dsh/lib/probe.ts +395 -0
  73. package/src/dsh/lib/qr.ts +42 -0
  74. package/src/dsh/lib/runtime.ts +126 -0
  75. package/src/dsh/lib/service.ts +1645 -0
  76. package/src/dsh/lib/sessions.ts +907 -0
  77. package/src/dsh/lib/settings.ts +468 -0
  78. package/src/dsh/lib/types.ts +231 -0
  79. package/src/dsh/lib/util.ts +115 -0
  80. package/src/dsh/lib/weather.ts +532 -0
  81. package/src/dsh/lib/ws.ts +328 -0
  82. package/src/dsh/types/qrcode-generator.d.ts +16 -0
  83. package/src/rust/Cargo.lock +1431 -0
  84. package/src/rust/Cargo.toml +68 -0
  85. package/src/rust/src/cli.rs +233 -0
  86. package/src/rust/src/daemon.rs +140 -0
  87. package/src/rust/src/location/linux.rs +118 -0
  88. package/src/rust/src/location/macos.rs +26 -0
  89. package/src/rust/src/location/mod.rs +170 -0
  90. package/src/rust/src/location/windows.rs +205 -0
  91. package/src/rust/src/main.rs +42 -0
  92. package/src/rust/src/media/catalog.rs +454 -0
  93. package/src/rust/src/media/linux.rs +350 -0
  94. package/src/rust/src/media/macos/accessibility.rs +59 -0
  95. package/src/rust/src/media/macos.rs +366 -0
  96. package/src/rust/src/media/mod.rs +298 -0
  97. package/src/rust/src/media/vendor/apple_music.rs +90 -0
  98. package/src/rust/src/media/vendor/common.rs +186 -0
  99. package/src/rust/src/media/vendor/kugou.rs +52 -0
  100. package/src/rust/src/media/vendor/kuwo.rs +49 -0
  101. package/src/rust/src/media/vendor/mod.rs +75 -0
  102. package/src/rust/src/media/vendor/netease.rs +1715 -0
  103. package/src/rust/src/media/vendor/qishui.rs +36 -0
  104. package/src/rust/src/media/vendor/qqmusic.rs +33 -0
  105. package/src/rust/src/media/vendor/spotify.rs +46 -0
  106. package/src/rust/src/media/vendor/wesing.rs +39 -0
  107. package/src/rust/src/media/vendor/youtube_music.rs +49 -0
  108. package/src/rust/src/media/windows/accessibility.rs +85 -0
  109. package/src/rust/src/media/windows/audio.rs +116 -0
  110. package/src/rust/src/media/windows/private.rs +291 -0
  111. package/src/rust/src/media/windows.rs +775 -0
  112. package/src/rust/src/notifications/linux.rs +276 -0
  113. package/src/rust/src/notifications/macos.rs +382 -0
  114. package/src/rust/src/notifications/mod.rs +244 -0
  115. package/src/rust/src/notifications/windows.rs +565 -0
  116. package/src/rust/src/perf/common.rs +109 -0
  117. package/src/rust/src/perf/linux.rs +282 -0
  118. package/src/rust/src/perf/macos.rs +28 -0
  119. package/src/rust/src/perf/mod.rs +157 -0
  120. package/src/rust/src/perf/windows.rs +981 -0
  121. package/src/rust/src/util.rs +360 -0
  122. package/src/rust/src/volume/linux.rs +195 -0
  123. package/src/rust/src/volume/macos.rs +54 -0
  124. package/src/rust/src/volume/mod.rs +116 -0
  125. package/src/rust/src/volume/windows.rs +75 -0
  126. package/src/rust/tests/cli.rs +79 -0
  127. package/src/rust/tests/daemon.rs +107 -0
  128. package/src/svelte/App.svelte +385 -0
  129. package/src/svelte/index.html +18 -0
  130. package/src/svelte/lib/billing.svelte.ts +228 -0
  131. package/src/svelte/lib/components/BalanceCard.svelte +173 -0
  132. package/src/svelte/lib/components/ClockCard.svelte +183 -0
  133. package/src/svelte/lib/components/CountdownCard.svelte +261 -0
  134. package/src/svelte/lib/components/CpuMemoryCard.svelte +238 -0
  135. package/src/svelte/lib/components/EmptyState.svelte +34 -0
  136. package/src/svelte/lib/components/GpuCard.svelte +245 -0
  137. package/src/svelte/lib/components/HomePage.svelte +34 -0
  138. package/src/svelte/lib/components/MusicControls.svelte +256 -0
  139. package/src/svelte/lib/components/MusicCover.svelte +88 -0
  140. package/src/svelte/lib/components/MusicPage.svelte +116 -0
  141. package/src/svelte/lib/components/NotificationsCard.svelte +109 -0
  142. package/src/svelte/lib/components/Panel.svelte +22 -0
  143. package/src/svelte/lib/components/PerformancePage.svelte +55 -0
  144. package/src/svelte/lib/components/PomodoroCard.svelte +250 -0
  145. package/src/svelte/lib/components/QuestionSheet.svelte +354 -0
  146. package/src/svelte/lib/components/RadialGauge.svelte +99 -0
  147. package/src/svelte/lib/components/Rail.svelte +217 -0
  148. package/src/svelte/lib/components/SectionHeader.svelte +40 -0
  149. package/src/svelte/lib/components/SeekBar.svelte +421 -0
  150. package/src/svelte/lib/components/SessionList.svelte +364 -0
  151. package/src/svelte/lib/components/SessionStats.svelte +98 -0
  152. package/src/svelte/lib/components/SessionsPage.svelte +61 -0
  153. package/src/svelte/lib/components/SpendCard.svelte +105 -0
  154. package/src/svelte/lib/components/ThermalCard.svelte +249 -0
  155. package/src/svelte/lib/components/Toast.svelte +32 -0
  156. package/src/svelte/lib/components/WeatherIcon.svelte +40 -0
  157. package/src/svelte/lib/components/WeatherPanel.svelte +420 -0
  158. package/src/svelte/lib/components/ui/alert/alert-action.svelte +20 -0
  159. package/src/svelte/lib/components/ui/alert/alert-description.svelte +23 -0
  160. package/src/svelte/lib/components/ui/alert/alert-title.svelte +23 -0
  161. package/src/svelte/lib/components/ui/alert/alert.svelte +43 -0
  162. package/src/svelte/lib/components/ui/alert/index.ts +17 -0
  163. package/src/svelte/lib/components/ui/avatar/avatar-badge.svelte +26 -0
  164. package/src/svelte/lib/components/ui/avatar/avatar-fallback.svelte +20 -0
  165. package/src/svelte/lib/components/ui/avatar/avatar-group-count.svelte +23 -0
  166. package/src/svelte/lib/components/ui/avatar/avatar-group.svelte +23 -0
  167. package/src/svelte/lib/components/ui/avatar/avatar-image.svelte +17 -0
  168. package/src/svelte/lib/components/ui/avatar/avatar.svelte +26 -0
  169. package/src/svelte/lib/components/ui/avatar/index.ts +22 -0
  170. package/src/svelte/lib/components/ui/badge/badge.svelte +49 -0
  171. package/src/svelte/lib/components/ui/badge/index.ts +2 -0
  172. package/src/svelte/lib/components/ui/button/button.svelte +82 -0
  173. package/src/svelte/lib/components/ui/button/index.ts +17 -0
  174. package/src/svelte/lib/components/ui/card/card-action.svelte +23 -0
  175. package/src/svelte/lib/components/ui/card/card-content.svelte +20 -0
  176. package/src/svelte/lib/components/ui/card/card-description.svelte +20 -0
  177. package/src/svelte/lib/components/ui/card/card-footer.svelte +20 -0
  178. package/src/svelte/lib/components/ui/card/card-header.svelte +23 -0
  179. package/src/svelte/lib/components/ui/card/card-title.svelte +20 -0
  180. package/src/svelte/lib/components/ui/card/card.svelte +22 -0
  181. package/src/svelte/lib/components/ui/card/index.ts +25 -0
  182. package/src/svelte/lib/components/ui/dialog/dialog-close.svelte +11 -0
  183. package/src/svelte/lib/components/ui/dialog/dialog-content.svelte +48 -0
  184. package/src/svelte/lib/components/ui/dialog/dialog-description.svelte +17 -0
  185. package/src/svelte/lib/components/ui/dialog/dialog-footer.svelte +32 -0
  186. package/src/svelte/lib/components/ui/dialog/dialog-header.svelte +20 -0
  187. package/src/svelte/lib/components/ui/dialog/dialog-overlay.svelte +17 -0
  188. package/src/svelte/lib/components/ui/dialog/dialog-portal.svelte +7 -0
  189. package/src/svelte/lib/components/ui/dialog/dialog-title.svelte +17 -0
  190. package/src/svelte/lib/components/ui/dialog/dialog-trigger.svelte +11 -0
  191. package/src/svelte/lib/components/ui/dialog/dialog.svelte +7 -0
  192. package/src/svelte/lib/components/ui/dialog/index.ts +34 -0
  193. package/src/svelte/lib/components/ui/empty/empty-content.svelte +23 -0
  194. package/src/svelte/lib/components/ui/empty/empty-description.svelte +23 -0
  195. package/src/svelte/lib/components/ui/empty/empty-header.svelte +20 -0
  196. package/src/svelte/lib/components/ui/empty/empty-media.svelte +41 -0
  197. package/src/svelte/lib/components/ui/empty/empty-title.svelte +20 -0
  198. package/src/svelte/lib/components/ui/empty/empty.svelte +23 -0
  199. package/src/svelte/lib/components/ui/empty/index.ts +22 -0
  200. package/src/svelte/lib/components/ui/input/index.ts +7 -0
  201. package/src/svelte/lib/components/ui/input/input.svelte +48 -0
  202. package/src/svelte/lib/components/ui/progress/index.ts +7 -0
  203. package/src/svelte/lib/components/ui/progress/progress.svelte +27 -0
  204. package/src/svelte/lib/components/ui/scroll-area/index.ts +10 -0
  205. package/src/svelte/lib/components/ui/scroll-area/scroll-area-scrollbar.svelte +30 -0
  206. package/src/svelte/lib/components/ui/scroll-area/scroll-area.svelte +43 -0
  207. package/src/svelte/lib/components/ui/separator/index.ts +7 -0
  208. package/src/svelte/lib/components/ui/separator/separator.svelte +21 -0
  209. package/src/svelte/lib/components/ui/sheet/index.ts +34 -0
  210. package/src/svelte/lib/components/ui/sheet/sheet-close.svelte +7 -0
  211. package/src/svelte/lib/components/ui/sheet/sheet-content.svelte +55 -0
  212. package/src/svelte/lib/components/ui/sheet/sheet-description.svelte +17 -0
  213. package/src/svelte/lib/components/ui/sheet/sheet-footer.svelte +20 -0
  214. package/src/svelte/lib/components/ui/sheet/sheet-header.svelte +20 -0
  215. package/src/svelte/lib/components/ui/sheet/sheet-overlay.svelte +20 -0
  216. package/src/svelte/lib/components/ui/sheet/sheet-portal.svelte +7 -0
  217. package/src/svelte/lib/components/ui/sheet/sheet-title.svelte +17 -0
  218. package/src/svelte/lib/components/ui/sheet/sheet-trigger.svelte +7 -0
  219. package/src/svelte/lib/components/ui/sheet/sheet.svelte +7 -0
  220. package/src/svelte/lib/components/ui/skeleton/index.ts +7 -0
  221. package/src/svelte/lib/components/ui/skeleton/skeleton.svelte +17 -0
  222. package/src/svelte/lib/components/ui/slider/index.ts +7 -0
  223. package/src/svelte/lib/components/ui/slider/slider.svelte +52 -0
  224. package/src/svelte/lib/components/ui/tooltip/index.ts +19 -0
  225. package/src/svelte/lib/components/ui/tooltip/tooltip-content.svelte +51 -0
  226. package/src/svelte/lib/components/ui/tooltip/tooltip-portal.svelte +7 -0
  227. package/src/svelte/lib/components/ui/tooltip/tooltip-provider.svelte +7 -0
  228. package/src/svelte/lib/components/ui/tooltip/tooltip-trigger.svelte +7 -0
  229. package/src/svelte/lib/components/ui/tooltip/tooltip.svelte +7 -0
  230. package/src/svelte/lib/countdown.svelte.ts +252 -0
  231. package/src/svelte/lib/format.ts +199 -0
  232. package/src/svelte/lib/i18n.svelte.ts +583 -0
  233. package/src/svelte/lib/media.ts +32 -0
  234. package/src/svelte/lib/pages.ts +18 -0
  235. package/src/svelte/lib/pomodoro.svelte.ts +223 -0
  236. package/src/svelte/lib/session-tree.ts +80 -0
  237. package/src/svelte/lib/state.svelte.ts +359 -0
  238. package/src/svelte/lib/timer-alarm.svelte.ts +319 -0
  239. package/src/svelte/lib/types.ts +306 -0
  240. package/src/svelte/lib/utils.ts +8 -0
  241. package/src/svelte/lib/weather.ts +84 -0
  242. package/src/svelte/lib/ws.ts +104 -0
  243. package/src/svelte/main.ts +13 -0
  244. package/src/svelte/styles/global.css +315 -0
  245. package/src/svelte/vite-env.d.ts +12 -0
  246. package/static/fonts/LICENSE.txt +287 -0
  247. package/static/icon.svg +24 -0
  248. package/static/manifest.webmanifest +18 -0
  249. package/svelte.config.js +5 -0
  250. package/tsconfig.dsh-client.json +18 -0
  251. package/tsconfig.dsh.json +21 -0
  252. package/tsconfig.json +20 -0
  253. package/vite.config.js +31 -0
  254. package/web/assets/index-DLdSfnJ3.css +1 -0
  255. package/web/assets/index-YNE8X0Uy.js +6 -0
  256. package/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  257. package/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  258. package/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  259. package/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  260. package/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  261. package/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  262. package/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  263. package/web/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  264. package/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  265. package/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  266. package/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  267. package/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  268. package/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  269. package/web/assets/noto-sans-sc-100-wght-normal-DluiXYzi.woff2 +0 -0
  270. package/web/assets/noto-sans-sc-101-wght-normal-CPkZHnw0.woff2 +0 -0
  271. package/web/assets/noto-sans-sc-102-wght-normal-gzIulUH6.woff2 +0 -0
  272. package/web/assets/noto-sans-sc-103-wght-normal-CJfSBxFo.woff2 +0 -0
  273. package/web/assets/noto-sans-sc-104-wght-normal-B3QMglgE.woff2 +0 -0
  274. package/web/assets/noto-sans-sc-105-wght-normal-xXh_OMfk.woff2 +0 -0
  275. package/web/assets/noto-sans-sc-106-wght-normal-B9n3jMjP.woff2 +0 -0
  276. package/web/assets/noto-sans-sc-107-wght-normal-D7fvVprN.woff2 +0 -0
  277. package/web/assets/noto-sans-sc-108-wght-normal-J0_LhxVo.woff2 +0 -0
  278. package/web/assets/noto-sans-sc-109-wght-normal-Dsp-De_0.woff2 +0 -0
  279. package/web/assets/noto-sans-sc-110-wght-normal-DE_6NUrb.woff2 +0 -0
  280. package/web/assets/noto-sans-sc-111-wght-normal-C4S-TlTn.woff2 +0 -0
  281. package/web/assets/noto-sans-sc-112-wght-normal-BcFuP3w6.woff2 +0 -0
  282. package/web/assets/noto-sans-sc-113-wght-normal-Dk932kmj.woff2 +0 -0
  283. package/web/assets/noto-sans-sc-114-wght-normal-CO_5Xo-i.woff2 +0 -0
  284. package/web/assets/noto-sans-sc-115-wght-normal-BWy07H14.woff2 +0 -0
  285. package/web/assets/noto-sans-sc-116-wght-normal-B4HVAfHo.woff2 +0 -0
  286. package/web/assets/noto-sans-sc-117-wght-normal-siZzFqgK.woff2 +0 -0
  287. package/web/assets/noto-sans-sc-118-wght-normal-xhBB7f6V.woff2 +0 -0
  288. package/web/assets/noto-sans-sc-119-wght-normal-Ck_g2FGz.woff2 +0 -0
  289. package/web/assets/noto-sans-sc-21-wght-normal-D0x9-yBt.woff2 +0 -0
  290. package/web/assets/noto-sans-sc-22-wght-normal-BBUVTShg.woff2 +0 -0
  291. package/web/assets/noto-sans-sc-23-wght-normal-Du1oYWkE.woff2 +0 -0
  292. package/web/assets/noto-sans-sc-24-wght-normal-CmWjJ0gt.woff2 +0 -0
  293. package/web/assets/noto-sans-sc-25-wght-normal-BFHiqilg.woff2 +0 -0
  294. package/web/assets/noto-sans-sc-26-wght-normal-C0sF2Z2f.woff2 +0 -0
  295. package/web/assets/noto-sans-sc-27-wght-normal-COFdrquJ.woff2 +0 -0
  296. package/web/assets/noto-sans-sc-28-wght-normal-DMgdNu1o.woff2 +0 -0
  297. package/web/assets/noto-sans-sc-29-wght-normal-Cmq8em30.woff2 +0 -0
  298. package/web/assets/noto-sans-sc-30-wght-normal-CZWqZ3yU.woff2 +0 -0
  299. package/web/assets/noto-sans-sc-31-wght-normal-Wfi_lutW.woff2 +0 -0
  300. package/web/assets/noto-sans-sc-32-wght-normal-BRJQ4PY5.woff2 +0 -0
  301. package/web/assets/noto-sans-sc-33-wght-normal-CUVQXNAJ.woff2 +0 -0
  302. package/web/assets/noto-sans-sc-34-wght-normal-Bspba0wC.woff2 +0 -0
  303. package/web/assets/noto-sans-sc-35-wght-normal-K8fX_YPG.woff2 +0 -0
  304. package/web/assets/noto-sans-sc-36-wght-normal-CkITyjoT.woff2 +0 -0
  305. package/web/assets/noto-sans-sc-37-wght-normal-WCZ2zfEe.woff2 +0 -0
  306. package/web/assets/noto-sans-sc-38-wght-normal-DeM7CUce.woff2 +0 -0
  307. package/web/assets/noto-sans-sc-39-wght-normal-BMZNLWQU.woff2 +0 -0
  308. package/web/assets/noto-sans-sc-4-wght-normal-AR5bdDjz.woff2 +0 -0
  309. package/web/assets/noto-sans-sc-40-wght-normal-BQ5xpS5j.woff2 +0 -0
  310. package/web/assets/noto-sans-sc-41-wght-normal-Hthm7FFi.woff2 +0 -0
  311. package/web/assets/noto-sans-sc-42-wght-normal-eaS2gKds.woff2 +0 -0
  312. package/web/assets/noto-sans-sc-43-wght-normal-o-T53XXV.woff2 +0 -0
  313. package/web/assets/noto-sans-sc-44-wght-normal-BJH5J4Ez.woff2 +0 -0
  314. package/web/assets/noto-sans-sc-45-wght-normal-BKuxSCOJ.woff2 +0 -0
  315. package/web/assets/noto-sans-sc-46-wght-normal-BR_5Mu6e.woff2 +0 -0
  316. package/web/assets/noto-sans-sc-47-wght-normal-CyY7gKNX.woff2 +0 -0
  317. package/web/assets/noto-sans-sc-48-wght-normal-BNMfTR6k.woff2 +0 -0
  318. package/web/assets/noto-sans-sc-49-wght-normal-DX-0y7wg.woff2 +0 -0
  319. package/web/assets/noto-sans-sc-5-wght-normal-CfWugllB.woff2 +0 -0
  320. package/web/assets/noto-sans-sc-50-wght-normal-DAOGW-Dr.woff2 +0 -0
  321. package/web/assets/noto-sans-sc-51-wght-normal-BlFLOjcP.woff2 +0 -0
  322. package/web/assets/noto-sans-sc-52-wght-normal-OZjrybnw.woff2 +0 -0
  323. package/web/assets/noto-sans-sc-53-wght-normal-CwyWNT6x.woff2 +0 -0
  324. package/web/assets/noto-sans-sc-54-wght-normal-DM0nRe18.woff2 +0 -0
  325. package/web/assets/noto-sans-sc-55-wght-normal-DBK2wUOR.woff2 +0 -0
  326. package/web/assets/noto-sans-sc-56-wght-normal-C-VKK1LS.woff2 +0 -0
  327. package/web/assets/noto-sans-sc-57-wght-normal-Cdbfh5kq.woff2 +0 -0
  328. package/web/assets/noto-sans-sc-58-wght-normal-CflMKARZ.woff2 +0 -0
  329. package/web/assets/noto-sans-sc-59-wght-normal-BS4Kwzxx.woff2 +0 -0
  330. package/web/assets/noto-sans-sc-6-wght-normal-ChnZIQfQ.woff2 +0 -0
  331. package/web/assets/noto-sans-sc-60-wght-normal-B7QOYsXJ.woff2 +0 -0
  332. package/web/assets/noto-sans-sc-61-wght-normal-C8JjQS7w.woff2 +0 -0
  333. package/web/assets/noto-sans-sc-62-wght-normal-bS4wq1Gg.woff2 +0 -0
  334. package/web/assets/noto-sans-sc-63-wght-normal-BJr8P-OB.woff2 +0 -0
  335. package/web/assets/noto-sans-sc-64-wght-normal-DVB11t7j.woff2 +0 -0
  336. package/web/assets/noto-sans-sc-65-wght-normal-DMPCXBvK.woff2 +0 -0
  337. package/web/assets/noto-sans-sc-66-wght-normal-BobaG10V.woff2 +0 -0
  338. package/web/assets/noto-sans-sc-67-wght-normal-Dzk7WhCR.woff2 +0 -0
  339. package/web/assets/noto-sans-sc-68-wght-normal-DGbCD9xu.woff2 +0 -0
  340. package/web/assets/noto-sans-sc-69-wght-normal-Ctrv1EqA.woff2 +0 -0
  341. package/web/assets/noto-sans-sc-70-wght-normal-RgTO941D.woff2 +0 -0
  342. package/web/assets/noto-sans-sc-71-wght-normal-D9sG-CWv.woff2 +0 -0
  343. package/web/assets/noto-sans-sc-72-wght-normal-C4oWy3ul.woff2 +0 -0
  344. package/web/assets/noto-sans-sc-73-wght-normal-B1ZoOTgM.woff2 +0 -0
  345. package/web/assets/noto-sans-sc-74-wght-normal-F7dH_rZJ.woff2 +0 -0
  346. package/web/assets/noto-sans-sc-75-wght-normal-Cp2wp4si.woff2 +0 -0
  347. package/web/assets/noto-sans-sc-76-wght-normal-B494D7xS.woff2 +0 -0
  348. package/web/assets/noto-sans-sc-77-wght-normal-B3MVY9Ax.woff2 +0 -0
  349. package/web/assets/noto-sans-sc-78-wght-normal-5jX7iNMC.woff2 +0 -0
  350. package/web/assets/noto-sans-sc-79-wght-normal-BIoZy8fr.woff2 +0 -0
  351. package/web/assets/noto-sans-sc-80-wght-normal-Bje1Fxy1.woff2 +0 -0
  352. package/web/assets/noto-sans-sc-81-wght-normal-B2NN7Qi3.woff2 +0 -0
  353. package/web/assets/noto-sans-sc-82-wght-normal-CUggNZi4.woff2 +0 -0
  354. package/web/assets/noto-sans-sc-83-wght-normal-99rD8YO-.woff2 +0 -0
  355. package/web/assets/noto-sans-sc-84-wght-normal-zMBmUocm.woff2 +0 -0
  356. package/web/assets/noto-sans-sc-85-wght-normal-CjI3qdxG.woff2 +0 -0
  357. package/web/assets/noto-sans-sc-86-wght-normal-BHY8TVEH.woff2 +0 -0
  358. package/web/assets/noto-sans-sc-87-wght-normal-DioZSHnU.woff2 +0 -0
  359. package/web/assets/noto-sans-sc-88-wght-normal-BXYxwf2t.woff2 +0 -0
  360. package/web/assets/noto-sans-sc-89-wght-normal-ClMFrFaL.woff2 +0 -0
  361. package/web/assets/noto-sans-sc-90-wght-normal-BnOlPreP.woff2 +0 -0
  362. package/web/assets/noto-sans-sc-91-wght-normal-DTr9RQcS.woff2 +0 -0
  363. package/web/assets/noto-sans-sc-97-wght-normal-9u6Aiav-.woff2 +0 -0
  364. package/web/assets/noto-sans-sc-98-wght-normal-BR-H2C0V.woff2 +0 -0
  365. package/web/assets/noto-sans-sc-99-wght-normal-BI6XPoxp.woff2 +0 -0
  366. package/web/assets/noto-sans-sc-cyrillic-wght-normal-BGmsQlw-.woff2 +0 -0
  367. package/web/assets/noto-sans-sc-latin-ext-wght-normal-DuJTpTg_.woff2 +0 -0
  368. package/web/assets/noto-sans-sc-latin-wght-normal-DDW8mKAR.woff2 +0 -0
  369. package/web/assets/noto-sans-sc-vietnamese-wght-normal-Bi3ATJ76.woff2 +0 -0
  370. package/web/fonts/LICENSE.txt +287 -0
  371. package/web/icon.svg +24 -0
  372. package/web/index.html +19 -0
  373. package/web/manifest.webmanifest +18 -0
@@ -0,0 +1,565 @@
1
+ //! Windows notification probe: read the notification center SQLite database.
2
+ //!
3
+ //! The live database is copied (with its WAL/SHM sidecars) to a temporary
4
+ //! directory before opening read-only. The copy is only refreshed when the
5
+ //! database, WAL or SHM metadata changes; an unchanged poll reuses the parsed
6
+ //! snapshot, so the normal 2.5 s poll performs `stat` calls instead of rewriting
7
+ //! several megabytes of database pages. Toast payloads are normalized from their
8
+ //! XML, including the app-logo/hero image when it points at a local file.
9
+
10
+ use std::cell::RefCell;
11
+ use std::path::{Path, PathBuf};
12
+ use std::time::SystemTime;
13
+
14
+ use rusqlite::{types::ValueRef, Connection, OpenFlags};
15
+
16
+ use super::{truncate, NotificationItem, Snapshot, SnapshotResult};
17
+ use crate::cli::{CliError, CliResult};
18
+ use crate::util;
19
+
20
+ pub const SOURCE: &str = "windows-notification-database";
21
+ pub const LIVE_ONLY: bool = false;
22
+
23
+ pub fn watch(_limit: usize) -> CliResult {
24
+ Err(CliError::usage(
25
+ "notifications watch is available on Linux only".to_string(),
26
+ ))
27
+ }
28
+
29
+ const MAX_AVATAR_BYTES: u64 = 768 * 1024;
30
+ const MAX_BODY_CHARS: usize = 260;
31
+ const MAX_TITLE_CHARS: usize = 160;
32
+ /// Mirrors `notifications::clamp_limit`; a request can never ask for more.
33
+ const CACHE_LIMIT: usize = 20;
34
+
35
+ #[derive(Clone, PartialEq, Eq)]
36
+ struct FileStamp {
37
+ len: u64,
38
+ modified: Option<SystemTime>,
39
+ }
40
+
41
+ #[derive(Clone, PartialEq, Eq)]
42
+ struct DatabaseSignature {
43
+ database: FileStamp,
44
+ wal: FileStamp,
45
+ shm: FileStamp,
46
+ }
47
+
48
+ struct CachedNotification {
49
+ item: NotificationItem,
50
+ expires_at: Option<i64>,
51
+ }
52
+
53
+ struct CachedSnapshot {
54
+ signature: DatabaseSignature,
55
+ items: Vec<CachedNotification>,
56
+ }
57
+
58
+ thread_local! {
59
+ /// The daemon handles notification requests on one thread. Keep the last
60
+ /// parsed snapshot and copy the OS database again only when its metadata
61
+ /// changes; the common case (a poll every 2.5 s with no new toast) becomes
62
+ /// three `stat` calls instead of rewriting a multi-megabyte database.
63
+ static CACHE: RefCell<Option<CachedSnapshot>> = const { RefCell::new(None) };
64
+ }
65
+
66
+ fn database_path() -> PathBuf {
67
+ let local = std::env::var_os("LOCALAPPDATA")
68
+ .map(PathBuf::from)
69
+ .or_else(|| util::home_dir().map(|home| home.join("AppData").join("Local")))
70
+ .unwrap_or_default();
71
+ local
72
+ .join("Microsoft")
73
+ .join("Windows")
74
+ .join("Notifications")
75
+ .join("wpndatabase.db")
76
+ }
77
+
78
+ fn clip_chars(text: &str, max_chars: usize) -> String {
79
+ text.chars().take(max_chars).collect()
80
+ }
81
+
82
+ fn value_string(value: ValueRef<'_>) -> Option<String> {
83
+ match value {
84
+ ValueRef::Text(bytes) => Some(String::from_utf8_lossy(bytes).into_owned()),
85
+ ValueRef::Integer(number) => Some(number.to_string()),
86
+ ValueRef::Real(number) => Some(number.to_string()),
87
+ _ => None,
88
+ }
89
+ }
90
+
91
+ fn value_i64(value: ValueRef<'_>) -> Option<i64> {
92
+ match value {
93
+ ValueRef::Integer(number) => Some(number),
94
+ ValueRef::Real(number) => Some(number as i64),
95
+ ValueRef::Text(bytes) => String::from_utf8_lossy(bytes).trim().parse().ok(),
96
+ _ => None,
97
+ }
98
+ }
99
+
100
+ fn value_bytes(value: ValueRef<'_>) -> Option<Vec<u8>> {
101
+ match value {
102
+ ValueRef::Blob(bytes) => Some(bytes.to_vec()),
103
+ ValueRef::Text(bytes) => Some(bytes.to_vec()),
104
+ _ => None,
105
+ }
106
+ }
107
+
108
+ struct Row {
109
+ id: String,
110
+ handler_id: Option<i64>,
111
+ payload: Vec<u8>,
112
+ arrival: Option<i64>,
113
+ expiry: Option<i64>,
114
+ primary_id: Option<String>,
115
+ }
116
+
117
+ fn read_rows(connection: &Connection, limit: usize) -> Result<Vec<Row>, String> {
118
+ let fetch = (limit.saturating_mul(4)).max(40) as i64;
119
+ let mut statement = connection
120
+ .prepare(
121
+ "select n.Id, n.HandlerId, n.Payload, n.Type,
122
+ n.ArrivalTime, n.ExpiryTime, h.PrimaryId, h.HandlerType
123
+ from Notification n
124
+ left join NotificationHandler h on h.RecordId = n.HandlerId
125
+ where n.Type = 'toast'
126
+ order by cast(n.ArrivalTime as integer) desc
127
+ limit ?1",
128
+ )
129
+ .map_err(|error| format!("notification query failed: {error}"))?;
130
+ let rows = statement
131
+ .query_map([fetch], |row| {
132
+ Ok(Row {
133
+ id: value_string(row.get_ref(0)?).unwrap_or_default(),
134
+ handler_id: value_i64(row.get_ref(1)?),
135
+ payload: value_bytes(row.get_ref(2)?).unwrap_or_default(),
136
+ arrival: value_i64(row.get_ref(4)?),
137
+ expiry: value_i64(row.get_ref(5)?),
138
+ primary_id: value_string(row.get_ref(6)?),
139
+ })
140
+ })
141
+ .map_err(|error| format!("notification query failed: {error}"))?;
142
+ Ok(rows.flatten().collect())
143
+ }
144
+
145
+ fn sidecar_path(source: &Path, suffix: &str) -> PathBuf {
146
+ let mut path = source.as_os_str().to_os_string();
147
+ path.push(suffix);
148
+ PathBuf::from(path)
149
+ }
150
+
151
+ fn file_stamp(path: &Path) -> FileStamp {
152
+ match std::fs::metadata(path) {
153
+ Ok(metadata) => FileStamp {
154
+ len: metadata.len(),
155
+ modified: metadata.modified().ok(),
156
+ },
157
+ Err(_) => FileStamp {
158
+ len: 0,
159
+ modified: None,
160
+ },
161
+ }
162
+ }
163
+
164
+ fn database_signature(source: &Path) -> DatabaseSignature {
165
+ DatabaseSignature {
166
+ database: file_stamp(source),
167
+ wal: file_stamp(&sidecar_path(source, "-wal")),
168
+ shm: file_stamp(&sidecar_path(source, "-shm")),
169
+ }
170
+ }
171
+
172
+ fn is_fresh(notification: &CachedNotification, now: i64) -> bool {
173
+ let received_at = notification.item.received_at;
174
+ if received_at > now + 60_000 || now - received_at > 7 * 24 * 60 * 60 * 1000 {
175
+ return false;
176
+ }
177
+ notification.expires_at.map_or(true, |expiry| expiry >= now)
178
+ }
179
+
180
+ fn snapshot_from_items(items: &[CachedNotification], limit: usize, now: i64) -> Snapshot {
181
+ Snapshot {
182
+ source: SOURCE,
183
+ live_only: LIVE_ONLY,
184
+ notifications: items
185
+ .iter()
186
+ .filter(|item| is_fresh(item, now))
187
+ .take(limit.min(CACHE_LIMIT))
188
+ .map(|item| item.item.clone())
189
+ .collect(),
190
+ updated_at: now,
191
+ }
192
+ }
193
+
194
+ pub fn read(limit: usize) -> SnapshotResult {
195
+ let source = database_path();
196
+ if !source.is_file() {
197
+ return Err("Windows notification database not found".to_string());
198
+ }
199
+ let signature = database_signature(&source);
200
+ let now = util::now_ms();
201
+
202
+ // `stat` is cheap; only rebuild the snapshot when the database, WAL or SHM
203
+ // changed. Expiry is applied on every read, so a cached snapshot still
204
+ // drops notifications whose expiry passed without a database write.
205
+ if let Some(snapshot) = CACHE.with(|cache| {
206
+ let cache = cache.borrow();
207
+ let cached = cache.as_ref()?;
208
+ if cached.signature != signature {
209
+ return None;
210
+ }
211
+ Some(snapshot_from_items(&cached.items, limit, now))
212
+ }) {
213
+ return Ok(snapshot);
214
+ }
215
+
216
+ let temp = util::TempDir::create("dsh-probe-wpn")?;
217
+ let target = temp.path().join("wpndatabase.db");
218
+ util::copy_sqlite_database(&source, &target)?;
219
+ let connection = Connection::open_with_flags(&target, OpenFlags::SQLITE_OPEN_READ_ONLY)
220
+ .map_err(|error| format!("could not open the notification database: {error}"))?;
221
+ let rows = read_rows(&connection, CACHE_LIMIT)?;
222
+
223
+ let mut items = Vec::new();
224
+ for row in rows {
225
+ let Some(received_at) = row.arrival.and_then(filetime_to_unix_ms) else {
226
+ continue;
227
+ };
228
+ if received_at > now + 60_000 || now - received_at > 7 * 24 * 60 * 60 * 1000 {
229
+ continue;
230
+ }
231
+ let expires_at = row.expiry.and_then(filetime_to_unix_ms);
232
+ if expires_at.is_some_and(|expiry| expiry < now) {
233
+ continue;
234
+ }
235
+ let payload = String::from_utf8_lossy(&row.payload);
236
+ let parsed = parse_toast_xml(&payload);
237
+ if parsed.texts.is_empty() && parsed.image.is_none() {
238
+ continue;
239
+ }
240
+ let app_key = clip_chars(
241
+ row.primary_id
242
+ .as_deref()
243
+ .filter(|value| !value.trim().is_empty())
244
+ .unwrap_or(&format!("handler-{}", row.handler_id.unwrap_or_default())),
245
+ 240,
246
+ );
247
+ // The probe only forwards the raw application identity from the OS; the
248
+ // plugin keeps the display-name mapping (and user renames) on the Node
249
+ // side so Windows app ids never need a hard-coded table here.
250
+ let app_name = raw_app_name(&app_key);
251
+ let title = parsed
252
+ .texts
253
+ .first()
254
+ .cloned()
255
+ .unwrap_or_else(|| app_name.clone());
256
+ let mut body = parsed
257
+ .texts
258
+ .iter()
259
+ .skip(1)
260
+ .cloned()
261
+ .collect::<Vec<_>>()
262
+ .join(" ");
263
+ if body == title {
264
+ body.clear();
265
+ }
266
+ items.push(CachedNotification {
267
+ item: NotificationItem {
268
+ id: if row.id.is_empty() {
269
+ format!("{received_at}-{}", items.len())
270
+ } else {
271
+ row.id
272
+ },
273
+ app_key,
274
+ app_name,
275
+ title: truncate(&title, MAX_TITLE_CHARS),
276
+ body: truncate(&body, MAX_BODY_CHARS),
277
+ avatar: parsed.image.as_deref().and_then(avatar_data_url),
278
+ has_image: parsed.has_inline_image,
279
+ received_at,
280
+ is_group: is_group_title(&title),
281
+ },
282
+ expires_at,
283
+ });
284
+ if items.len() >= CACHE_LIMIT {
285
+ break;
286
+ }
287
+ }
288
+
289
+ let result = snapshot_from_items(&items, limit, now);
290
+ CACHE.with(|cache| {
291
+ *cache.borrow_mut() = Some(CachedSnapshot { signature, items });
292
+ });
293
+ Ok(result)
294
+ }
295
+
296
+ fn is_group_title(title: &str) -> bool {
297
+ let lower = title.to_lowercase();
298
+ lower.contains("群")
299
+ || lower.contains("group")
300
+ || lower.contains("频道")
301
+ || lower.contains("channel")
302
+ }
303
+
304
+ /// The probe deliberately reports the raw OS application id; the plugin maps it
305
+ /// to a display name (with user overrides) on the Node side.
306
+ fn raw_app_name(primary_id: &str) -> String {
307
+ if primary_id.trim().is_empty() {
308
+ "Windows".to_string()
309
+ } else {
310
+ clip_chars(primary_id.trim(), 120)
311
+ }
312
+ }
313
+
314
+ fn filetime_to_unix_ms(filetime: i64) -> Option<i64> {
315
+ if filetime <= 0 {
316
+ return None;
317
+ }
318
+ Some(filetime / 10_000 - 11_644_473_600_000)
319
+ }
320
+
321
+ fn avatar_data_url(source: &str) -> Option<String> {
322
+ let source = source.trim();
323
+ if source.is_empty() {
324
+ return None;
325
+ }
326
+ let path = if let Some(path) = source.strip_prefix("file://") {
327
+ PathBuf::from(util::percent_decode(path.trim_start_matches("localhost")))
328
+ } else if source.starts_with("ms-appdata://") || source.starts_with("ms-appx://") {
329
+ return None;
330
+ } else {
331
+ PathBuf::from(source)
332
+ };
333
+ if !path.is_absolute() {
334
+ return None;
335
+ }
336
+ util::file_image_data_url(&path, MAX_AVATAR_BYTES)
337
+ }
338
+
339
+ #[derive(Debug, Default, PartialEq)]
340
+ pub struct ToastContent {
341
+ pub texts: Vec<String>,
342
+ pub image: Option<String>,
343
+ pub launch: String,
344
+ pub has_inline_image: bool,
345
+ }
346
+
347
+ fn next_tag(lower: &str, from: usize, tag: &str) -> Option<usize> {
348
+ lower[from..]
349
+ .find(&format!("<{tag}"))
350
+ .map(|index| index + from)
351
+ }
352
+
353
+ /// Decode the XML entities that appear in toast payloads.
354
+ pub fn decode_xml(text: &str) -> String {
355
+ let mut output = String::with_capacity(text.len());
356
+ let mut rest = text;
357
+ while let Some(index) = rest.find('&') {
358
+ output.push_str(&rest[..index]);
359
+ let Some(end) = rest[index..].find(';') else {
360
+ output.push_str(&rest[index..]);
361
+ return output;
362
+ };
363
+ let entity = &rest[index + 1..index + end];
364
+ let decoded = match entity {
365
+ "amp" => Some('&'),
366
+ "lt" => Some('<'),
367
+ "gt" => Some('>'),
368
+ "quot" => Some('"'),
369
+ "apos" => Some('\''),
370
+ "nbsp" => Some(' '),
371
+ _ => entity
372
+ .strip_prefix("#x")
373
+ .or_else(|| entity.strip_prefix("#X"))
374
+ .and_then(|hex| u32::from_str_radix(hex, 16).ok())
375
+ .or_else(|| {
376
+ entity
377
+ .strip_prefix('#')
378
+ .and_then(|digits| digits.parse::<u32>().ok())
379
+ })
380
+ .and_then(char::from_u32),
381
+ };
382
+ match decoded {
383
+ Some(character) => output.push(character),
384
+ None => {
385
+ output.push('&');
386
+ output.push_str(entity);
387
+ output.push(';');
388
+ }
389
+ }
390
+ rest = &rest[index + end + 1..];
391
+ }
392
+ output.push_str(rest);
393
+ output
394
+ }
395
+
396
+ fn collapse_whitespace(text: &str) -> String {
397
+ text.split_whitespace().collect::<Vec<_>>().join(" ")
398
+ }
399
+
400
+ fn attribute(attributes: &str, name: &str) -> Option<String> {
401
+ let lower = attributes.to_ascii_lowercase();
402
+ let bytes = lower.as_bytes();
403
+ let needle = name.as_bytes();
404
+ let mut index = 0;
405
+ while index + needle.len() <= bytes.len() {
406
+ if &bytes[index..index + needle.len()] == needle {
407
+ let boundary_before = index == 0
408
+ || !(bytes[index - 1].is_ascii_alphanumeric() || bytes[index - 1] == b'_');
409
+ if boundary_before {
410
+ let mut cursor = index + needle.len();
411
+ while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
412
+ cursor += 1;
413
+ }
414
+ if cursor < bytes.len() && bytes[cursor] == b'=' {
415
+ cursor += 1;
416
+ while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
417
+ cursor += 1;
418
+ }
419
+ if cursor < bytes.len() && (bytes[cursor] == b'"' || bytes[cursor] == b'\'') {
420
+ let quote = bytes[cursor];
421
+ let start = cursor + 1;
422
+ if let Some(offset) = bytes[start..].iter().position(|byte| *byte == quote)
423
+ {
424
+ let value = &attributes[start..start + offset];
425
+ return Some(decode_xml(value).trim().to_string());
426
+ }
427
+ }
428
+ }
429
+ }
430
+ }
431
+ index += 1;
432
+ }
433
+ None
434
+ }
435
+
436
+ pub fn parse_toast_xml(xml: &str) -> ToastContent {
437
+ let lower = xml.to_ascii_lowercase();
438
+ let mut content = ToastContent::default();
439
+
440
+ let mut cursor = 0;
441
+ while let Some(start) = next_tag(&lower, cursor, "text") {
442
+ let Some(open_end) = lower[start..].find('>').map(|index| index + start) else {
443
+ break;
444
+ };
445
+ let Some(close_start) = next_tag(&lower, open_end + 1, "/text") else {
446
+ break;
447
+ };
448
+ let text = collapse_whitespace(&decode_xml(&xml[open_end + 1..close_start]));
449
+ if !text.is_empty() {
450
+ content.texts.push(text);
451
+ }
452
+ cursor = close_start + 1;
453
+ }
454
+
455
+ let mut images: Vec<(String, String)> = Vec::new();
456
+ let mut cursor = 0;
457
+ while let Some(start) = next_tag(&lower, cursor, "image") {
458
+ let Some(open_end) = lower[start..].find('>').map(|index| index + start) else {
459
+ break;
460
+ };
461
+ let attributes = &xml[start + 6..open_end];
462
+ if let Some(source) = attribute(attributes, "src").filter(|value| !value.is_empty()) {
463
+ let placement = attribute(attributes, "placement")
464
+ .map(|value| value.to_ascii_lowercase())
465
+ .unwrap_or_else(|| "inline".to_string());
466
+ images.push((source, placement));
467
+ }
468
+ cursor = open_end + 1;
469
+ }
470
+ content.image = images
471
+ .iter()
472
+ .find(|(_, placement)| placement == "applogooverride")
473
+ .or_else(|| images.iter().find(|(_, placement)| placement == "hero"))
474
+ .or_else(|| images.first())
475
+ .map(|(source, _)| source.clone());
476
+ content.has_inline_image = images
477
+ .iter()
478
+ .any(|(_, placement)| placement != "applogooverride");
479
+ content.launch = attribute(xml, "launch").unwrap_or_default();
480
+ content
481
+ }
482
+
483
+ #[cfg(test)]
484
+ mod tests {
485
+ use super::*;
486
+
487
+ #[test]
488
+ fn parses_toast_texts_and_entity_codes() {
489
+ let xml = r#"<toast launch="app://open"><visual><binding template="ToastGeneric">
490
+ <text>Hello &amp; welcome</text><text> spaced
491
+ body </text></binding></visual></toast>"#;
492
+ let parsed = parse_toast_xml(xml);
493
+ assert_eq!(parsed.texts, vec!["Hello & welcome", "spaced body"]);
494
+ assert_eq!(parsed.launch, "app://open");
495
+ assert!(parsed.image.is_none());
496
+ }
497
+
498
+ #[test]
499
+ fn prefers_application_logo_and_detects_inline_images() {
500
+ let xml = r#"<toast><visual><binding>
501
+ <image src="C:\hero.png" placement="hero"/>
502
+ <image src="C:\logo.png" placement="applogooverride"/>
503
+ <text>Title</text></binding></visual></toast>"#;
504
+ let parsed = parse_toast_xml(xml);
505
+ assert_eq!(parsed.image.as_deref(), Some("C:\\logo.png"));
506
+ assert!(parsed.has_inline_image);
507
+ }
508
+
509
+ #[test]
510
+ fn decodes_numeric_entities() {
511
+ assert_eq!(decode_xml("A&#65;&#x42;"), "AAB");
512
+ assert_eq!(decode_xml("unknown &bogus; kept"), "unknown &bogus; kept");
513
+ }
514
+
515
+ #[test]
516
+ fn converts_filetime() {
517
+ assert_eq!(
518
+ filetime_to_unix_ms(133_485_408_000_000_000),
519
+ Some(1_704_067_200_000)
520
+ );
521
+ assert_eq!(filetime_to_unix_ms(0), None);
522
+ }
523
+
524
+ #[test]
525
+ fn forwards_raw_application_ids() {
526
+ assert_eq!(raw_app_name("QQ"), "QQ");
527
+ assert_eq!(
528
+ raw_app_name("Microsoft.WindowsTerminal_8wekyb3d8bbwe!App"),
529
+ "Microsoft.WindowsTerminal_8wekyb3d8bbwe!App"
530
+ );
531
+ assert_eq!(
532
+ raw_app_name("C:\\Tools\\player.exe"),
533
+ "C:\\Tools\\player.exe"
534
+ );
535
+ assert_eq!(raw_app_name(" "), "Windows");
536
+ }
537
+
538
+ #[test]
539
+ fn detects_group_titles() {
540
+ assert!(is_group_title("项目群"));
541
+ assert!(is_group_title("Team channel"));
542
+ assert!(!is_group_title("Alice"));
543
+ }
544
+
545
+ #[test]
546
+ fn cached_snapshot_drops_expired_and_old_items() {
547
+ let now = 1_000_000_000_i64;
548
+ let make = |received_at: i64, expires_at: Option<i64>| CachedNotification {
549
+ item: NotificationItem {
550
+ received_at,
551
+ ..Default::default()
552
+ },
553
+ expires_at,
554
+ };
555
+ let items = vec![
556
+ make(now, Some(now + 1_000)),
557
+ make(now, Some(now - 1_000)),
558
+ make(now - 8 * 24 * 60 * 60 * 1000, None),
559
+ make(now + 120_000, None),
560
+ ];
561
+ let snapshot = snapshot_from_items(&items, 8, now);
562
+ assert_eq!(snapshot.notifications.len(), 1);
563
+ assert_eq!(snapshot.updated_at, now);
564
+ }
565
+ }
@@ -0,0 +1,109 @@
1
+ //! Shared CPU/memory sampling via sysinfo.
2
+ //!
3
+ //! The sampler is kept in thread-local state so a long-lived `daemon` process
4
+ //! can reuse the previous CPU counters instead of sleeping for a fresh window on
5
+ //! every request. A one-shot invocation still pays the initial
6
+ //! `MINIMUM_CPU_UPDATE_INTERVAL` sample (there is no previous baseline), which
7
+ //! preserves the existing CLI behaviour exactly.
8
+
9
+ use std::cell::RefCell;
10
+ use std::time::Instant;
11
+
12
+ use sysinfo::{System, MINIMUM_CPU_UPDATE_INTERVAL};
13
+
14
+ use super::{Cpu, Memory};
15
+ use crate::util;
16
+
17
+ struct Sampler {
18
+ system: System,
19
+ last_refresh: Option<Instant>,
20
+ last_usage: f64,
21
+ }
22
+
23
+ thread_local! {
24
+ /// Each thread gets its own sysinfo baseline. The daemon handles requests on
25
+ /// one thread; tests and one-shot CLI runs simply take the first-sample path.
26
+ static SAMPLER: RefCell<Option<Sampler>> = const { RefCell::new(None) };
27
+ }
28
+
29
+ pub fn sample_system() -> (Cpu, Memory) {
30
+ SAMPLER.with(|slot| {
31
+ let mut slot = slot.borrow_mut();
32
+ let sampler = slot.get_or_insert_with(|| Sampler {
33
+ system: System::new(),
34
+ last_refresh: None,
35
+ last_usage: 0.0,
36
+ });
37
+
38
+ match sampler.last_refresh {
39
+ None => {
40
+ sampler.system.refresh_cpu_all();
41
+ // The first refresh has no previous counters to diff against.
42
+ std::thread::sleep(MINIMUM_CPU_UPDATE_INTERVAL);
43
+ sampler.system.refresh_cpu_usage();
44
+ sampler.last_usage = util::round1(util::clamp(
45
+ sampler.system.global_cpu_usage() as f64,
46
+ 0.0,
47
+ 100.0,
48
+ ));
49
+ }
50
+ Some(previous) => {
51
+ let elapsed = previous.elapsed();
52
+ if elapsed >= MINIMUM_CPU_UPDATE_INTERVAL {
53
+ sampler.system.refresh_cpu_usage();
54
+ sampler.last_usage = util::round1(util::clamp(
55
+ sampler.system.global_cpu_usage() as f64,
56
+ 0.0,
57
+ 100.0,
58
+ ));
59
+ }
60
+ // Repeated requests inside the minimum window reuse the last
61
+ // valid reading instead of sleeping. Callers poll at >= 1.5 s,
62
+ // so this only affects diagnostic bursts.
63
+ }
64
+ }
65
+ sampler.system.refresh_memory();
66
+ sampler.last_refresh = Some(Instant::now());
67
+
68
+ let system = &sampler.system;
69
+ let cpus = system.cpus();
70
+ let model = cpus
71
+ .first()
72
+ .map(|cpu| cpu.brand().trim().to_string())
73
+ .filter(|model| !model.is_empty())
74
+ .unwrap_or_else(|| "CPU".to_string());
75
+ let cores = cpus.len().max(1);
76
+ let base_mhz = cpus
77
+ .first()
78
+ .map(|cpu| cpu.frequency() as f64)
79
+ .filter(|frequency| *frequency > 0.0);
80
+ let usage = sampler.last_usage;
81
+
82
+ let total = system.total_memory();
83
+ let used = system.used_memory();
84
+ let free = system.free_memory();
85
+ let percent = if total > 0 {
86
+ util::round1(used as f64 / total as f64 * 100.0)
87
+ } else {
88
+ 0.0
89
+ };
90
+
91
+ (
92
+ Cpu {
93
+ model,
94
+ cores,
95
+ physical_cores: system.physical_core_count().filter(|count| *count > 0),
96
+ usage,
97
+ speed_mhz: None,
98
+ base_mhz,
99
+ uptime: System::uptime(),
100
+ },
101
+ Memory {
102
+ total,
103
+ used,
104
+ free,
105
+ percent,
106
+ },
107
+ )
108
+ })
109
+ }