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

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,276 @@
1
+ //! Linux notification probe: watch `org.freedesktop.Notifications`.
2
+ //!
3
+ //! `Notify` is a method call to the desktop notification daemon, so a normal
4
+ //! D-Bus client cannot observe it. The probe turns its connection into a bus
5
+ //! monitor with `org.freedesktop.DBus.Monitoring.BecomeMonitor` (the same
6
+ //! mechanism `dbus-monitor` uses) and filters for `Notify` calls.
7
+ //!
8
+ //! `notifications watch` streams one JSON snapshot per notification, which is
9
+ //! what the Node service consumes. `notifications` samples a short window and
10
+ //! returns whatever arrived in it (there is no portable history API).
11
+
12
+ use std::collections::HashMap;
13
+ use std::io::Write;
14
+ use std::path::PathBuf;
15
+ use std::sync::mpsc::{channel, Receiver};
16
+ use std::time::{Duration, Instant};
17
+
18
+ use zbus::blocking::{Connection, MessageIterator};
19
+ use zbus::message::Message;
20
+ use zbus::zvariant::OwnedValue;
21
+
22
+ use super::{snapshot_json, NotificationItem, Snapshot, SnapshotResult};
23
+ use crate::cli::CliResult;
24
+ use crate::util;
25
+
26
+ pub const SOURCE: &str = "linux-dbus-notifications";
27
+ pub const LIVE_ONLY: bool = true;
28
+
29
+ const SNAPSHOT_WINDOW_MS: u64 = 1200;
30
+ const MAX_ICON_BYTES: u64 = 512 * 1024;
31
+ const NOTIFY_RULE: &str =
32
+ "type='method_call',interface='org.freedesktop.Notifications',member='Notify'";
33
+
34
+ /// D-Bus `Notify` signature: susssasa{sv}i.
35
+ type NotifyBody = (
36
+ String,
37
+ u32,
38
+ String,
39
+ String,
40
+ String,
41
+ Vec<String>,
42
+ HashMap<String, OwnedValue>,
43
+ i32,
44
+ );
45
+
46
+ fn monitor() -> Result<Receiver<Message>, String> {
47
+ let connection =
48
+ Connection::session().map_err(|error| format!("D-Bus session unavailable: {error}"))?;
49
+ connection
50
+ .call_method(
51
+ Some("org.freedesktop.DBus"),
52
+ "/org/freedesktop/DBus",
53
+ Some("org.freedesktop.DBus.Monitoring"),
54
+ "BecomeMonitor",
55
+ &(&[NOTIFY_RULE] as &[&str], 0u32),
56
+ )
57
+ .map_err(|error| format!("could not monitor org.freedesktop.Notifications: {error}"))?;
58
+
59
+ let (sender, receiver) = channel();
60
+ std::thread::spawn(move || {
61
+ let iterator = MessageIterator::from(connection);
62
+ for message in iterator {
63
+ let Ok(message) = message else {
64
+ break;
65
+ };
66
+ if sender.send(message).is_err() {
67
+ break;
68
+ }
69
+ }
70
+ });
71
+ Ok(receiver)
72
+ }
73
+
74
+ fn read_icon_source(source: &str) -> Option<String> {
75
+ let source = source.trim();
76
+ if source.is_empty() {
77
+ return None;
78
+ }
79
+ let path: PathBuf = if source.starts_with("file://") {
80
+ util::file_url_to_path(source)?
81
+ } else {
82
+ PathBuf::from(source)
83
+ };
84
+ if !path.is_absolute() {
85
+ // A themed icon name (for example "firefox") is not a file path.
86
+ return None;
87
+ }
88
+ util::file_image_data_url(&path, MAX_ICON_BYTES)
89
+ }
90
+
91
+ fn hint_string(hints: &HashMap<String, OwnedValue>, key: &str) -> Option<String> {
92
+ let value = hints.get(key)?.try_clone().ok()?;
93
+ String::try_from(value).ok()
94
+ }
95
+
96
+ fn hint_icon(hints: &HashMap<String, OwnedValue>) -> Option<String> {
97
+ for key in ["image-path", "image_path", "imagePath"] {
98
+ if let Some(source) = hint_string(hints, key) {
99
+ if let Some(icon) = read_icon_source(&source) {
100
+ return Some(icon);
101
+ }
102
+ }
103
+ }
104
+ None
105
+ }
106
+
107
+ fn item_from_message(message: &Message, counter: u64) -> Option<NotificationItem> {
108
+ let header = message.header();
109
+ if header.member().map(|member| member.as_str()) != Some("Notify") {
110
+ return None;
111
+ }
112
+ let body: NotifyBody = message.body().deserialize().ok()?;
113
+ let (app_name, _replaces_id, app_icon, summary, body_text, _actions, hints, _timeout) = body;
114
+ let app_name = app_name.trim();
115
+ let app_name = if app_name.is_empty() {
116
+ "通知".to_string()
117
+ } else {
118
+ app_name.to_string()
119
+ };
120
+ let title = if summary.trim().is_empty() {
121
+ app_name.clone()
122
+ } else {
123
+ summary.trim().to_string()
124
+ };
125
+ let body_text = body_text.trim().to_string();
126
+ let avatar = read_icon_source(&app_icon).or_else(|| hint_icon(&hints));
127
+ let has_image = avatar.is_some()
128
+ || hints.contains_key("image-data")
129
+ || hints.contains_key("image_data")
130
+ || hints.contains_key("image-path")
131
+ || hints.contains_key("image_path");
132
+ let received_at = util::now_ms();
133
+ Some(NotificationItem {
134
+ id: format!("{received_at}-{counter}"),
135
+ app_key: app_name.clone(),
136
+ app_name,
137
+ title: super::truncate(&title, 160),
138
+ body: super::truncate(&body_text, 260),
139
+ avatar,
140
+ has_image,
141
+ received_at,
142
+ is_group: false,
143
+ })
144
+ }
145
+
146
+ fn push_item(items: &mut Vec<NotificationItem>, item: NotificationItem, limit: usize) {
147
+ items.insert(0, item);
148
+ items.truncate(limit);
149
+ }
150
+
151
+ /// Sample live notifications for a short window.
152
+ pub fn read(limit: usize) -> SnapshotResult {
153
+ let receiver = monitor()?;
154
+ let deadline = Instant::now() + Duration::from_millis(SNAPSHOT_WINDOW_MS);
155
+ let mut items = Vec::new();
156
+ let mut counter = 0_u64;
157
+ loop {
158
+ let remaining = deadline.saturating_duration_since(Instant::now());
159
+ if remaining.is_zero() {
160
+ break;
161
+ }
162
+ match receiver.recv_timeout(remaining) {
163
+ Ok(message) => {
164
+ if let Some(item) = item_from_message(&message, counter) {
165
+ counter += 1;
166
+ push_item(&mut items, item, limit);
167
+ }
168
+ }
169
+ Err(_) => break,
170
+ }
171
+ }
172
+ Ok(Snapshot {
173
+ source: SOURCE,
174
+ live_only: LIVE_ONLY,
175
+ notifications: items,
176
+ updated_at: util::now_ms(),
177
+ })
178
+ }
179
+
180
+ fn print_json(value: &serde_json::Value) {
181
+ match serde_json::to_string(value) {
182
+ Ok(line) => {
183
+ println!("{line}");
184
+ let _ = std::io::stdout().flush();
185
+ }
186
+ Err(error) => {
187
+ eprintln!("could not encode notification snapshot: {error}");
188
+ }
189
+ }
190
+ }
191
+
192
+ fn print_snapshot(snapshot: &Snapshot, limit: usize) {
193
+ print_json(&snapshot_json(snapshot, limit, util::now_ms()));
194
+ }
195
+
196
+ /// Print the notification-shaped error and exit; the Node service reads the
197
+ /// error from the stream instead of the generic CLI error envelope.
198
+ fn fail(error: &str, limit: usize) -> ! {
199
+ print_json(&super::error_json(
200
+ error,
201
+ SOURCE,
202
+ LIVE_ONLY,
203
+ limit,
204
+ util::now_ms(),
205
+ ));
206
+ std::process::exit(1);
207
+ }
208
+
209
+ /// Stream snapshots until the monitor connection closes.
210
+ pub fn watch(limit: usize) -> CliResult {
211
+ let receiver = match monitor() {
212
+ Ok(receiver) => receiver,
213
+ Err(error) => fail(&error, limit),
214
+ };
215
+ let mut items: Vec<NotificationItem> = Vec::new();
216
+ let mut counter = 0_u64;
217
+ print_snapshot(
218
+ &Snapshot {
219
+ source: SOURCE,
220
+ live_only: LIVE_ONLY,
221
+ notifications: Vec::new(),
222
+ updated_at: util::now_ms(),
223
+ },
224
+ limit,
225
+ );
226
+ for message in receiver {
227
+ if let Some(item) = item_from_message(&message, counter) {
228
+ counter += 1;
229
+ push_item(&mut items, item, limit);
230
+ print_snapshot(
231
+ &Snapshot {
232
+ source: SOURCE,
233
+ live_only: LIVE_ONLY,
234
+ notifications: items.clone(),
235
+ updated_at: util::now_ms(),
236
+ },
237
+ limit,
238
+ );
239
+ }
240
+ }
241
+ fail("notification monitor ended (D-Bus session closed)", limit)
242
+ }
243
+
244
+ #[cfg(test)]
245
+ mod tests {
246
+ use super::*;
247
+
248
+ #[test]
249
+ fn ignores_themed_icon_names_and_missing_files() {
250
+ assert!(read_icon_source("firefox").is_none());
251
+ assert!(read_icon_source("/nonexistent/dsh-probe/icon.png").is_none());
252
+ }
253
+
254
+ #[test]
255
+ fn keeps_only_the_most_recent_items() {
256
+ let mut items = Vec::new();
257
+ for index in 0..3 {
258
+ push_item(
259
+ &mut items,
260
+ NotificationItem {
261
+ id: index.to_string(),
262
+ ..Default::default()
263
+ },
264
+ 2,
265
+ );
266
+ }
267
+ assert_eq!(items.len(), 2);
268
+ assert_eq!(items[0].id, "2");
269
+ }
270
+
271
+ #[test]
272
+ fn notification_rule_targets_notify_calls() {
273
+ assert!(NOTIFY_RULE.contains("member='Notify'"));
274
+ assert!(NOTIFY_RULE.contains("org.freedesktop.Notifications"));
275
+ }
276
+ }
@@ -0,0 +1,382 @@
1
+ //! macOS notification probe: read the Notification Center database.
2
+ //!
3
+ //! Source only: this module is type-checked with `--features macos-check` but has
4
+ //! not been run on macOS. Notification Center stores a binary property list per
5
+ //! record; it is parsed in-process (no `plutil`/AppleScript fallback).
6
+ //!
7
+ //! Reading the database requires Full Disk Access for the DSH host. When access
8
+ //! is denied the probe returns a clear error instead of partial/fake data.
9
+
10
+ use std::path::PathBuf;
11
+
12
+ use plist::{Dictionary, Value};
13
+ use rusqlite::{types::ValueRef, Connection, OpenFlags};
14
+
15
+ use super::{truncate, NotificationItem, Snapshot, SnapshotResult};
16
+ use crate::cli::{CliError, CliResult};
17
+ use crate::util;
18
+
19
+ pub const SOURCE: &str = "macos-notification-center";
20
+ pub const LIVE_ONLY: bool = false;
21
+
22
+ pub fn watch(_limit: usize) -> CliResult {
23
+ Err(CliError::usage(
24
+ "notifications watch is available on Linux only".to_string(),
25
+ ))
26
+ }
27
+
28
+ const APP_LABELS: &[(&str, &str)] = &[
29
+ ("com.apple.Music", "音乐"),
30
+ ("com.apple.iTunes", "音乐"),
31
+ ("com.spotify.client", "Spotify"),
32
+ ("com.tencent.xinWeChat", "微信"),
33
+ ("com.tencent.qq", "QQ"),
34
+ ("com.microsoft.teams2", "Microsoft Teams"),
35
+ ("com.microsoft.Outlook", "Outlook"),
36
+ ("com.google.Chrome", "Google Chrome"),
37
+ ("com.apple.Safari", "Safari"),
38
+ ("com.apple.mail", "邮件"),
39
+ ("com.apple.reminders", "提醒事项"),
40
+ ("com.apple.iCal", "日历"),
41
+ ];
42
+
43
+ fn database_path() -> Option<PathBuf> {
44
+ util::home_dir().map(|home| {
45
+ home.join("Library")
46
+ .join("Group Containers")
47
+ .join("group.com.apple.usernoted")
48
+ .join("db2")
49
+ .join("db")
50
+ })
51
+ }
52
+
53
+ fn quote(identifier: &str) -> String {
54
+ format!("\"{}\"", identifier.replace('"', "\"\""))
55
+ }
56
+
57
+ fn value_string(value: ValueRef<'_>) -> Option<String> {
58
+ match value {
59
+ ValueRef::Text(bytes) => Some(String::from_utf8_lossy(bytes).into_owned()),
60
+ ValueRef::Integer(number) => Some(number.to_string()),
61
+ ValueRef::Real(number) => Some(number.to_string()),
62
+ _ => None,
63
+ }
64
+ }
65
+
66
+ fn value_bytes(value: ValueRef<'_>) -> Option<Vec<u8>> {
67
+ match value {
68
+ ValueRef::Blob(bytes) => Some(bytes.to_vec()),
69
+ ValueRef::Text(bytes) => Some(bytes.to_vec()),
70
+ _ => None,
71
+ }
72
+ }
73
+
74
+ fn tables(connection: &Connection) -> Vec<String> {
75
+ let Ok(mut statement) =
76
+ connection.prepare("select name from sqlite_master where type = 'table'")
77
+ else {
78
+ return Vec::new();
79
+ };
80
+ let Ok(rows) = statement.query_map([], |row| row.get::<_, String>(0)) else {
81
+ return Vec::new();
82
+ };
83
+ rows.flatten().collect()
84
+ }
85
+
86
+ fn columns(connection: &Connection, table: &str) -> Vec<String> {
87
+ let sql = format!("pragma table_info({})", quote(table));
88
+ let Ok(mut statement) = connection.prepare(&sql) else {
89
+ return Vec::new();
90
+ };
91
+ let Ok(rows) = statement.query_map([], |row| row.get::<_, String>(1)) else {
92
+ return Vec::new();
93
+ };
94
+ rows.flatten().collect()
95
+ }
96
+
97
+ fn pick_column<'a>(columns: &'a [String], predicate: impl Fn(&str) -> bool) -> Option<&'a str> {
98
+ columns
99
+ .iter()
100
+ .find(|name| predicate(&name.to_lowercase()))
101
+ .map(String::as_str)
102
+ }
103
+
104
+ fn app_name(app_key: &str, app_table_names: &std::collections::HashMap<String, String>) -> String {
105
+ if let Some(name) = app_table_names.get(app_key) {
106
+ return name.clone();
107
+ }
108
+ if let Some((_, label)) = APP_LABELS.iter().find(|(key, _)| *key == app_key) {
109
+ return (*label).to_string();
110
+ }
111
+ app_key
112
+ .split('.')
113
+ .filter(|part| !part.is_empty())
114
+ .next_back()
115
+ .unwrap_or("macOS")
116
+ .to_string()
117
+ }
118
+
119
+ fn pick_string(dictionary: &Dictionary, keys: &[&str]) -> String {
120
+ for key in keys {
121
+ let Some(value) = dictionary.get(key) else {
122
+ continue;
123
+ };
124
+ if let Some(text) = value.as_string() {
125
+ if !text.trim().is_empty() {
126
+ return text.trim().to_string();
127
+ }
128
+ }
129
+ if let Some(inner) = value.as_dictionary() {
130
+ for inner_key in ["string", "stringValue"] {
131
+ if let Some(text) = inner.get(inner_key).and_then(Value::as_string) {
132
+ if !text.trim().is_empty() {
133
+ return text.trim().to_string();
134
+ }
135
+ }
136
+ }
137
+ }
138
+ }
139
+ String::new()
140
+ }
141
+
142
+ fn parse_payload(payload: &[u8]) -> Option<(String, String, String)> {
143
+ let value = Value::from_reader(std::io::Cursor::new(payload)).ok()?;
144
+ let root = value.as_dictionary()?;
145
+ let request = root
146
+ .get("req")
147
+ .and_then(Value::as_dictionary)
148
+ .unwrap_or(root);
149
+ Some((
150
+ pick_string(request, &["titl", "title"]),
151
+ pick_string(request, &["subt", "subtitle"]),
152
+ pick_string(request, &["body", "mess"]),
153
+ ))
154
+ }
155
+
156
+ fn apple_time_to_ms(value: ValueRef<'_>) -> Option<i64> {
157
+ let number = match value {
158
+ ValueRef::Integer(number) => number as f64,
159
+ ValueRef::Real(number) => number,
160
+ ValueRef::Text(bytes) => String::from_utf8_lossy(bytes).trim().parse::<f64>().ok()?,
161
+ _ => return None,
162
+ };
163
+ if !number.is_finite() || number <= 0.0 {
164
+ return None;
165
+ }
166
+ let seconds = if number > 10_000_000_000.0 {
167
+ number / 1000.0
168
+ } else {
169
+ number
170
+ };
171
+ Some(((seconds + 978_307_200.0) * 1000.0).round() as i64)
172
+ }
173
+
174
+ pub fn read(limit: usize) -> SnapshotResult {
175
+ let source = database_path().ok_or_else(|| "home directory not found".to_string())?;
176
+ if !source.is_file() {
177
+ return Err("Notification Center database not found".to_string());
178
+ }
179
+ let temp = util::TempDir::create("dsh-probe-nc")?;
180
+ let target = temp.path().join("db");
181
+ util::copy_sqlite_database(&source, &target)?;
182
+ let connection = Connection::open_with_flags(&target, OpenFlags::SQLITE_OPEN_READ_ONLY)
183
+ .map_err(|error| {
184
+ format!(
185
+ "Notification Center is not readable (grant Full Disk Access to the DSH host): {error}"
186
+ )
187
+ })?;
188
+
189
+ let table_names = tables(&connection);
190
+ let record_table = table_names
191
+ .iter()
192
+ .find(|name| name.eq_ignore_ascii_case("record"))
193
+ .or_else(|| {
194
+ table_names
195
+ .iter()
196
+ .find(|name| name.to_lowercase().contains("record"))
197
+ })
198
+ .ok_or_else(|| "Notification Center record table not found".to_string())?
199
+ .clone();
200
+ let record_columns = columns(&connection, &record_table);
201
+ let data_column = pick_column(&record_columns, |name| {
202
+ name == "data" || name == "plist" || name == "record"
203
+ })
204
+ .or_else(|| pick_column(&record_columns, |name| name.contains("data")))
205
+ .ok_or_else(|| "Notification Center payload column not found".to_string())?
206
+ .to_string();
207
+ let app_column = pick_column(&record_columns, |name| {
208
+ (name.contains("app") && (name.contains("id") || name.contains("bundle")))
209
+ || name.contains("bundle")
210
+ })
211
+ .map(str::to_string);
212
+ let date_column = pick_column(&record_columns, |name| {
213
+ name.contains("deliver") || name.contains("date") || name.contains("time")
214
+ })
215
+ .map(str::to_string);
216
+
217
+ let mut select = format!("{} as payload", quote(&data_column));
218
+ select.push_str(&format!(
219
+ ", {} as appId",
220
+ app_column
221
+ .as_deref()
222
+ .map(quote)
223
+ .unwrap_or_else(|| "''".to_string())
224
+ ));
225
+ select.push_str(&format!(
226
+ ", {} as deliveredAt",
227
+ date_column
228
+ .as_deref()
229
+ .map(quote)
230
+ .unwrap_or_else(|| "0".to_string())
231
+ ));
232
+ let order = date_column
233
+ .as_deref()
234
+ .map(quote)
235
+ .unwrap_or_else(|| "rowid".to_string());
236
+ let sql = format!(
237
+ "select {select} from {} order by {order} desc limit ?1",
238
+ quote(&record_table)
239
+ );
240
+ let fetch = (limit.saturating_mul(3)).max(24) as i64;
241
+ let mut statement = connection
242
+ .prepare(&sql)
243
+ .map_err(|error| format!("notification query failed: {error}"))?;
244
+ let rows = statement
245
+ .query_map([fetch], |row| {
246
+ Ok((
247
+ value_bytes(row.get_ref(0)?).unwrap_or_default(),
248
+ value_string(row.get_ref(1)?).unwrap_or_default(),
249
+ apple_time_to_ms(row.get_ref(2)?),
250
+ ))
251
+ })
252
+ .map_err(|error| format!("notification query failed: {error}"))?;
253
+
254
+ let mut app_table_names = std::collections::HashMap::new();
255
+ if let Some(app_table) = table_names
256
+ .iter()
257
+ .find(|name| name.eq_ignore_ascii_case("app"))
258
+ {
259
+ let app_columns = columns(&connection, app_table);
260
+ let id_column = pick_column(&app_columns, |name| {
261
+ name.contains("identifier") || name.contains("bundle")
262
+ });
263
+ let name_column = pick_column(&app_columns, |name| {
264
+ name.contains("name") || name.contains("display")
265
+ });
266
+ if let (Some(id_column), Some(name_column)) = (id_column, name_column) {
267
+ let sql = format!(
268
+ "select {}, {} from {}",
269
+ quote(id_column),
270
+ quote(name_column),
271
+ quote(app_table)
272
+ );
273
+ if let Ok(mut app_statement) = connection.prepare(&sql) {
274
+ if let Ok(app_rows) = app_statement.query_map([], |row| {
275
+ Ok((
276
+ value_string(row.get_ref(0)?).unwrap_or_default(),
277
+ value_string(row.get_ref(1)?).unwrap_or_default(),
278
+ ))
279
+ }) {
280
+ for (key, name) in app_rows.flatten() {
281
+ if !key.is_empty() && !name.is_empty() {
282
+ app_table_names.insert(key, name);
283
+ }
284
+ }
285
+ }
286
+ }
287
+ }
288
+ }
289
+
290
+ let now = util::now_ms();
291
+ let mut notifications = Vec::new();
292
+ for (payload, app_id, delivered_at) in rows.flatten() {
293
+ if payload.is_empty() {
294
+ continue;
295
+ }
296
+ let Some((title, subtitle, body)) = parse_payload(&payload) else {
297
+ continue;
298
+ };
299
+ if title.is_empty() && body.is_empty() && subtitle.is_empty() {
300
+ continue;
301
+ }
302
+ let app_key = if app_id.trim().is_empty() {
303
+ "macOS".to_string()
304
+ } else {
305
+ app_id.trim().to_string()
306
+ };
307
+ let app_name = app_name(&app_key, &app_table_names);
308
+ let title = if title.is_empty() {
309
+ app_name.clone()
310
+ } else {
311
+ title
312
+ };
313
+ let body = [subtitle, body]
314
+ .into_iter()
315
+ .filter(|part| !part.is_empty())
316
+ .collect::<Vec<_>>()
317
+ .join(" ");
318
+ let received_at = delivered_at.unwrap_or(now);
319
+ notifications.push(NotificationItem {
320
+ id: format!("{received_at}-{}", notifications.len()),
321
+ app_key,
322
+ app_name,
323
+ title: truncate(&title, 160),
324
+ body: truncate(&body, 260),
325
+ avatar: None,
326
+ has_image: false,
327
+ received_at,
328
+ is_group: false,
329
+ });
330
+ if notifications.len() >= limit {
331
+ break;
332
+ }
333
+ }
334
+
335
+ Ok(Snapshot {
336
+ source: SOURCE,
337
+ live_only: LIVE_ONLY,
338
+ notifications,
339
+ updated_at: now,
340
+ })
341
+ }
342
+
343
+ #[cfg(test)]
344
+ mod tests {
345
+ use super::*;
346
+
347
+ #[test]
348
+ fn converts_apple_epoch_times() {
349
+ // 2024-01-01T00:00:00Z is 726_537_600 seconds after the 2001 epoch.
350
+ assert_eq!(
351
+ apple_time_to_ms(ValueRef::Real(726_537_600.0)),
352
+ Some(1_704_067_200_000)
353
+ );
354
+ // Millisecond values are detected and normalized.
355
+ assert_eq!(
356
+ apple_time_to_ms(ValueRef::Integer(1_704_067_200_000)),
357
+ Some(1_704_067_200_000)
358
+ );
359
+ }
360
+
361
+ #[test]
362
+ fn picks_nested_plist_strings() {
363
+ let plist = r#"<?xml version="1.0" encoding="UTF-8"?>
364
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
365
+ <plist version="1.0"><dict><key>req</key><dict>
366
+ <key>titl</key><dict><key>string</key><string>Hello</string></dict>
367
+ <key>body</key><string>World</string>
368
+ </dict></dict></plist>"#;
369
+ let value = Value::from_reader(std::io::Cursor::new(plist.as_bytes())).unwrap();
370
+ let root = value.as_dictionary().unwrap();
371
+ let request = root.get("req").and_then(Value::as_dictionary).unwrap();
372
+ assert_eq!(pick_string(request, &["titl"]), "Hello");
373
+ assert_eq!(pick_string(request, &["body"]), "World");
374
+ }
375
+
376
+ #[test]
377
+ fn derives_app_names() {
378
+ let names = std::collections::HashMap::new();
379
+ assert_eq!(app_name("com.apple.Music", &names), "音乐");
380
+ assert_eq!(app_name("com.example.SomeApp", &names), "SomeApp");
381
+ }
382
+ }