@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,360 @@
1
+ //! Small shared helpers: time, numbers, image sniffing/data URLs, commands.
2
+ //!
3
+ //! Some helpers are only used by the Linux/macOS probes; the Windows build still
4
+ //! compiles this module with them present.
5
+ #![allow(dead_code)]
6
+
7
+ use std::path::{Path, PathBuf};
8
+ use std::process::{Command, Stdio};
9
+ use std::sync::atomic::{AtomicU64, Ordering};
10
+ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
11
+
12
+ use base64::Engine as _;
13
+
14
+ /// Current UNIX time in milliseconds.
15
+ pub fn now_ms() -> i64 {
16
+ SystemTime::now()
17
+ .duration_since(UNIX_EPOCH)
18
+ .map(|duration| duration.as_millis() as i64)
19
+ .unwrap_or(0)
20
+ }
21
+
22
+ /// Convert a WinRT `TimeSpan` (100 ns ticks) to seconds.
23
+ pub fn ticks_to_seconds(ticks: i64) -> f64 {
24
+ ticks as f64 / 10_000_000.0
25
+ }
26
+
27
+ /// Convert a WinRT `DateTime` (100 ns ticks since 1601-01-01 UTC) to UNIX ms.
28
+ pub fn winrt_time_to_unix_ms(universal_time: i64) -> i64 {
29
+ universal_time / 10_000 - 11_644_473_600_000
30
+ }
31
+
32
+ /// Round to one decimal place, the precision used by the original probes.
33
+ pub fn round1(value: f64) -> f64 {
34
+ if !value.is_finite() {
35
+ return 0.0;
36
+ }
37
+ (value * 10.0).round() / 10.0
38
+ }
39
+
40
+ /// Clamp a value into `[low, high]`, ignoring NaN by returning `low`.
41
+ pub fn clamp(value: f64, low: f64, high: f64) -> f64 {
42
+ if value.is_nan() {
43
+ low
44
+ } else {
45
+ value.max(low).min(high)
46
+ }
47
+ }
48
+
49
+ /// Detect an image MIME type from the magic bytes we can render in a browser.
50
+ pub fn sniff_image_mime(bytes: &[u8]) -> Option<&'static str> {
51
+ if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
52
+ return Some("image/png");
53
+ }
54
+ if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
55
+ return Some("image/jpeg");
56
+ }
57
+ if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
58
+ return Some("image/gif");
59
+ }
60
+ if bytes.len() > 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
61
+ return Some("image/webp");
62
+ }
63
+ if bytes.starts_with(b"BM") {
64
+ return Some("image/bmp");
65
+ }
66
+ if bytes.len() > 12
67
+ && &bytes[4..8] == b"ftyp"
68
+ && (&bytes[8..12] == b"avif" || &bytes[8..12] == b"avis")
69
+ {
70
+ return Some("image/avif");
71
+ }
72
+ None
73
+ }
74
+
75
+ /// Build a `data:` URL for image bytes, or `None` when the type is unknown.
76
+ pub fn image_data_url(bytes: &[u8]) -> Option<String> {
77
+ let mime = sniff_image_mime(bytes)?;
78
+ Some(data_url(mime, bytes))
79
+ }
80
+
81
+ /// Build a `data:<mime>;base64,` URL.
82
+ pub fn data_url(mime: &str, bytes: &[u8]) -> String {
83
+ let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
84
+ format!("data:{mime};base64,{encoded}")
85
+ }
86
+
87
+ /// Read a local image file into a data URL, capped by `max_bytes`.
88
+ pub fn file_image_data_url(path: &Path, max_bytes: u64) -> Option<String> {
89
+ let metadata = std::fs::metadata(path).ok()?;
90
+ if !metadata.is_file() || metadata.len() == 0 || metadata.len() > max_bytes {
91
+ return None;
92
+ }
93
+ let bytes = std::fs::read(path).ok()?;
94
+ image_data_url(&bytes)
95
+ }
96
+
97
+ /// Decode a `file://` URL into a path (percent-decoding `%20` style escapes).
98
+ pub fn file_url_to_path(url: &str) -> Option<PathBuf> {
99
+ let raw = url.strip_prefix("file://")?;
100
+ // file:///home/x -> /home/x, file://localhost/home/x -> /home/x
101
+ let raw = raw.strip_prefix("localhost").unwrap_or(raw);
102
+ Some(PathBuf::from(percent_decode(raw)))
103
+ }
104
+
105
+ /// Minimal percent-decoder for file URLs; returns the input when malformed.
106
+ pub fn percent_decode(text: &str) -> String {
107
+ let bytes = text.as_bytes();
108
+ let mut out = Vec::with_capacity(bytes.len());
109
+ let mut index = 0;
110
+ while index < bytes.len() {
111
+ if bytes[index] == b'%' && index + 2 < bytes.len() {
112
+ let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).ok();
113
+ if let Some(value) = hex.and_then(|hex| u8::from_str_radix(hex, 16).ok()) {
114
+ out.push(value);
115
+ index += 3;
116
+ continue;
117
+ }
118
+ }
119
+ out.push(bytes[index]);
120
+ index += 1;
121
+ }
122
+ String::from_utf8_lossy(&out).into_owned()
123
+ }
124
+
125
+ /// The current user's home directory, without pulling in a `dirs` dependency.
126
+ pub fn home_dir() -> Option<PathBuf> {
127
+ std::env::var_os("HOME")
128
+ .or_else(|| std::env::var_os("USERPROFILE"))
129
+ .map(PathBuf::from)
130
+ .filter(|path| !path.as_os_str().is_empty())
131
+ }
132
+
133
+ /// A temporary directory that removes itself (and its contents) on drop.
134
+ pub struct TempDir {
135
+ path: PathBuf,
136
+ }
137
+
138
+ impl TempDir {
139
+ pub fn create(prefix: &str) -> Result<Self, String> {
140
+ static COUNTER: AtomicU64 = AtomicU64::new(0);
141
+ // The DSH sandbox can deny writes to the system temp directory for
142
+ // binaries living under the plugin workspace. The probe therefore
143
+ // accepts a host-provided directory (`DSH_RC_TMPDIR`) and falls back to
144
+ // the working directory / executable directory, both of which are
145
+ // writable for an installed plugin.
146
+ let mut bases: Vec<PathBuf> = Vec::new();
147
+ if let Some(configured) = std::env::var_os("DSH_RC_TMPDIR") {
148
+ if !configured.is_empty() {
149
+ bases.push(PathBuf::from(configured));
150
+ }
151
+ }
152
+ bases.push(std::env::temp_dir());
153
+ if let Ok(current) = std::env::current_dir() {
154
+ bases.push(current);
155
+ }
156
+ if let Ok(executable) = std::env::current_exe() {
157
+ if let Some(directory) = executable.parent() {
158
+ bases.push(directory.to_path_buf());
159
+ }
160
+ }
161
+
162
+ let mut last_error = String::new();
163
+ for base in bases {
164
+ let _ = std::fs::create_dir_all(&base);
165
+ for attempt in 0..8 {
166
+ let unique = format!(
167
+ "{prefix}-{}-{}-{}-{attempt}",
168
+ std::process::id(),
169
+ now_ms(),
170
+ COUNTER.fetch_add(1, Ordering::Relaxed)
171
+ );
172
+ let path = base.join(unique);
173
+ match std::fs::create_dir(&path) {
174
+ Ok(()) => return Ok(Self { path }),
175
+ Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
176
+ Err(error) => {
177
+ last_error = format!(
178
+ "could not create temporary directory {}: {error}",
179
+ path.display()
180
+ );
181
+ break;
182
+ }
183
+ }
184
+ }
185
+ }
186
+ Err(if last_error.is_empty() {
187
+ "could not create a unique temporary directory".to_string()
188
+ } else {
189
+ last_error
190
+ })
191
+ }
192
+
193
+ pub fn path(&self) -> &Path {
194
+ &self.path
195
+ }
196
+ }
197
+
198
+ impl Drop for TempDir {
199
+ fn drop(&mut self) {
200
+ let _ = std::fs::remove_dir_all(&self.path);
201
+ }
202
+ }
203
+
204
+ /// Copy `source` into `target`, including the `-wal`/`-shm` sidecars SQLite may
205
+ /// keep open. A missing sidecar is not an error: the main database is still
206
+ /// readable.
207
+ pub fn copy_sqlite_database(source: &Path, target: &Path) -> Result<(), String> {
208
+ std::fs::copy(source, target)
209
+ .map_err(|error| format!("could not copy {}: {error}", source.display()))?;
210
+ let source_text = source.to_string_lossy();
211
+ let target_text = target.to_string_lossy();
212
+ for suffix in ["-wal", "-shm"] {
213
+ let extra = format!("{source_text}{suffix}");
214
+ if Path::new(&extra).is_file() {
215
+ let _ = std::fs::copy(&extra, format!("{target_text}{suffix}"));
216
+ }
217
+ }
218
+ Ok(())
219
+ }
220
+
221
+ /// Look up an executable on `PATH` without spawning a shell.
222
+ pub fn find_executable(name: &str) -> Option<PathBuf> {
223
+ let path = std::env::var_os("PATH")?;
224
+ std::env::split_paths(&path)
225
+ .map(|directory| directory.join(name))
226
+ .find(|candidate| candidate.is_file())
227
+ }
228
+
229
+ /// Run a command and return trimmed stdout, or a readable error.
230
+ pub fn command_output(program: &str, args: &[&str]) -> Result<String, String> {
231
+ let output = Command::new(program)
232
+ .args(args)
233
+ .output()
234
+ .map_err(|error| format!("{program}: {error}"))?;
235
+ if !output.status.success() {
236
+ let stderr = String::from_utf8_lossy(&output.stderr);
237
+ let message = stderr.trim();
238
+ return Err(if message.is_empty() {
239
+ format!("{program} exited with {}", output.status)
240
+ } else {
241
+ format!("{program}: {message}")
242
+ });
243
+ }
244
+ Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
245
+ }
246
+
247
+ /// Run a command that already includes its resolved path.
248
+ pub fn run_path_command(program: &Path, args: &[&str]) -> Result<String, String> {
249
+ let program = program.to_string_lossy();
250
+ command_output(&program, args)
251
+ }
252
+
253
+ /// Run a command with a hard timeout. Used by the macOS accessibility reader:
254
+ /// UI scripting can block for seconds when an application is busy, and a
255
+ /// display-only progress read must never stall the probe.
256
+ pub fn command_output_timeout(
257
+ program: &str,
258
+ args: &[&str],
259
+ timeout: Duration,
260
+ ) -> Result<String, String> {
261
+ let mut child = Command::new(program)
262
+ .args(args)
263
+ .stdout(Stdio::piped())
264
+ .stderr(Stdio::piped())
265
+ .spawn()
266
+ .map_err(|error| format!("{program}: {error}"))?;
267
+ let started = Instant::now();
268
+ loop {
269
+ match child.try_wait() {
270
+ Ok(Some(status)) => {
271
+ let output = child
272
+ .wait_with_output()
273
+ .map_err(|error| format!("{program}: {error}"))?;
274
+ if !status.success() {
275
+ let stderr = String::from_utf8_lossy(&output.stderr);
276
+ let message = stderr.trim();
277
+ return Err(if message.is_empty() {
278
+ format!("{program} exited with {status}")
279
+ } else {
280
+ format!("{program}: {message}")
281
+ });
282
+ }
283
+ return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string());
284
+ }
285
+ Ok(None) => {
286
+ if started.elapsed() >= timeout {
287
+ let _ = child.kill();
288
+ let _ = child.wait();
289
+ return Err(format!(
290
+ "{program} timed out after {} ms",
291
+ timeout.as_millis()
292
+ ));
293
+ }
294
+ std::thread::sleep(Duration::from_millis(20));
295
+ }
296
+ Err(error) => return Err(format!("{program}: {error}")),
297
+ }
298
+ }
299
+ }
300
+
301
+ #[cfg(test)]
302
+ mod tests {
303
+ use super::*;
304
+
305
+ #[test]
306
+ fn converts_winrt_time() {
307
+ // 2024-01-01T00:00:00Z
308
+ let ticks = 133_485_408_000_000_000_i64;
309
+ assert_eq!(winrt_time_to_unix_ms(ticks), 1_704_067_200_000);
310
+ }
311
+
312
+ #[test]
313
+ fn converts_ticks() {
314
+ assert!((ticks_to_seconds(15_000_000) - 1.5).abs() < f64::EPSILON);
315
+ }
316
+
317
+ #[test]
318
+ fn sniffs_common_images() {
319
+ assert_eq!(
320
+ sniff_image_mime(&[0xFF, 0xD8, 0xFF, 0xE0]),
321
+ Some("image/jpeg")
322
+ );
323
+ assert_eq!(
324
+ sniff_image_mime(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]),
325
+ Some("image/png")
326
+ );
327
+ assert_eq!(sniff_image_mime(b"not an image"), None);
328
+ }
329
+
330
+ #[test]
331
+ fn makes_data_url() {
332
+ assert_eq!(
333
+ data_url("image/png", &[1, 2, 3]),
334
+ "data:image/png;base64,AQID"
335
+ );
336
+ }
337
+
338
+ #[test]
339
+ fn decodes_file_urls() {
340
+ assert_eq!(percent_decode("a%20b%2Fc"), "a b/c");
341
+ assert_eq!(
342
+ file_url_to_path("file:///home/user/a%20b.png"),
343
+ Some(PathBuf::from("/home/user/a b.png"))
344
+ );
345
+ }
346
+
347
+ #[test]
348
+ fn creates_and_removes_temp_dir() {
349
+ let dir = TempDir::create("dsh-probe-util-test").expect("create temp dir");
350
+ std::fs::write(dir.path().join("probe.txt"), b"ok").expect("write temp file");
351
+ assert!(dir.path().is_dir());
352
+ }
353
+
354
+ #[test]
355
+ fn clamps_and_rounds() {
356
+ assert_eq!(clamp(f64::NAN, 0.0, 1.0), 0.0);
357
+ assert_eq!(clamp(2.0, 0.0, 1.0), 1.0);
358
+ assert_eq!(round1(1.259), 1.3);
359
+ }
360
+ }
@@ -0,0 +1,195 @@
1
+ //! Linux volume probe.
2
+ //!
3
+ //! Preference order matches the JavaScript adapter: PipeWire (`wpctl`), then
4
+ //! PulseAudio (`pactl`), then ALSA (`amixer`). Each backend is an external
5
+ //! command with a strict output parser; a failure falls through to the next.
6
+
7
+ use std::path::PathBuf;
8
+
9
+ use super::Reading;
10
+ use crate::util;
11
+
12
+ pub const BACKEND: &str = "wpctl";
13
+
14
+ fn wpctl() -> Option<PathBuf> {
15
+ util::find_executable("wpctl")
16
+ }
17
+
18
+ fn pactl() -> Option<PathBuf> {
19
+ util::find_executable("pactl")
20
+ }
21
+
22
+ fn amixer() -> Option<PathBuf> {
23
+ util::find_executable("amixer")
24
+ }
25
+
26
+ fn parse_decimal_after(text: &str, marker: &str) -> Option<f64> {
27
+ let rest = text.split_once(marker)?.1;
28
+ let number: String = rest
29
+ .trim_start()
30
+ .chars()
31
+ .take_while(|character| character.is_ascii_digit() || *character == '.')
32
+ .collect();
33
+ number.parse::<f64>().ok()
34
+ }
35
+
36
+ fn parse_percent(text: &str) -> Option<f64> {
37
+ // "Volume: front-left: 32768 / 50% / -18.06 dB" -> 50
38
+ let mut digits = String::new();
39
+ for character in text.chars() {
40
+ if character.is_ascii_digit() {
41
+ digits.push(character);
42
+ } else if character == '%' {
43
+ if let Ok(value) = digits.parse::<f64>() {
44
+ return Some(value / 100.0);
45
+ }
46
+ digits.clear();
47
+ } else {
48
+ digits.clear();
49
+ }
50
+ }
51
+ None
52
+ }
53
+
54
+ fn read_wpctl(path: &PathBuf) -> Result<Reading, String> {
55
+ let text = util::run_path_command(path, &["get-volume", "@DEFAULT_AUDIO_SINK@"])?;
56
+ let volume = parse_decimal_after(&text, "Volume:").unwrap_or(0.5);
57
+ Ok(Reading {
58
+ volume: util::clamp(volume, 0.0, 1.0),
59
+ muted: text.to_ascii_uppercase().contains("[MUTED]"),
60
+ backend: "wpctl".to_string(),
61
+ })
62
+ }
63
+
64
+ fn read_pactl(path: &PathBuf) -> Result<Reading, String> {
65
+ let volume_text = util::run_path_command(path, &["get-sink-volume", "@DEFAULT_SINK@"])?;
66
+ let mute_text = util::run_path_command(path, &["get-sink-mute", "@DEFAULT_SINK@"])?;
67
+ let volume = parse_percent(&volume_text).unwrap_or(0.5);
68
+ Ok(Reading {
69
+ volume: util::clamp(volume, 0.0, 1.0),
70
+ muted: mute_text.to_ascii_lowercase().contains("yes"),
71
+ backend: "pactl".to_string(),
72
+ })
73
+ }
74
+
75
+ fn read_amixer(path: &PathBuf) -> Result<Reading, String> {
76
+ let text = util::run_path_command(path, &["get", "Master"])?;
77
+ let volume = parse_percent(&text).unwrap_or(0.5);
78
+ Ok(Reading {
79
+ volume: util::clamp(volume, 0.0, 1.0),
80
+ muted: text.contains("[off]"),
81
+ backend: "amixer".to_string(),
82
+ })
83
+ }
84
+
85
+ pub fn read() -> Result<Reading, String> {
86
+ let mut last_error = String::new();
87
+ if let Some(path) = wpctl() {
88
+ match read_wpctl(&path) {
89
+ Ok(reading) => return Ok(reading),
90
+ Err(error) => last_error = error,
91
+ }
92
+ }
93
+ if let Some(path) = pactl() {
94
+ match read_pactl(&path) {
95
+ Ok(reading) => return Ok(reading),
96
+ Err(error) => last_error = error,
97
+ }
98
+ }
99
+ if let Some(path) = amixer() {
100
+ match read_amixer(&path) {
101
+ Ok(reading) => return Ok(reading),
102
+ Err(error) => last_error = error,
103
+ }
104
+ }
105
+ if last_error.is_empty() {
106
+ Err("no supported audio backend (wpctl, pactl, amixer)".to_string())
107
+ } else {
108
+ Err(last_error)
109
+ }
110
+ }
111
+
112
+ pub fn set(value: f64) -> Result<Reading, String> {
113
+ let value = util::clamp(value, 0.0, 1.0);
114
+ let mut last_error = String::new();
115
+ if let Some(path) = wpctl() {
116
+ let text = format!("{value:.2}");
117
+ match util::run_path_command(&path, &["set-volume", "@DEFAULT_AUDIO_SINK@", &text]) {
118
+ Ok(_) => return read_wpctl(&path),
119
+ Err(error) => last_error = error,
120
+ }
121
+ }
122
+ if let Some(path) = pactl() {
123
+ let text = format!("{}%", (value * 100.0).round() as i64);
124
+ match util::run_path_command(&path, &["set-sink-volume", "@DEFAULT_SINK@", &text]) {
125
+ Ok(_) => return read_pactl(&path),
126
+ Err(error) => last_error = error,
127
+ }
128
+ }
129
+ if let Some(path) = amixer() {
130
+ let text = format!("{}%", (value * 100.0).round() as i64);
131
+ match util::run_path_command(&path, &["set", "Master", &text]) {
132
+ Ok(_) => return read_amixer(&path),
133
+ Err(error) => last_error = error,
134
+ }
135
+ }
136
+ if last_error.is_empty() {
137
+ Err("no supported audio backend (wpctl, pactl, amixer)".to_string())
138
+ } else {
139
+ Err(last_error)
140
+ }
141
+ }
142
+
143
+ pub fn toggle() -> Result<Reading, String> {
144
+ let mut last_error = String::new();
145
+ if let Some(path) = wpctl() {
146
+ match util::run_path_command(&path, &["set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"]) {
147
+ Ok(_) => return read_wpctl(&path),
148
+ Err(error) => last_error = error,
149
+ }
150
+ }
151
+ if let Some(path) = pactl() {
152
+ match util::run_path_command(&path, &["set-sink-mute", "@DEFAULT_SINK@", "toggle"]) {
153
+ Ok(_) => return read_pactl(&path),
154
+ Err(error) => last_error = error,
155
+ }
156
+ }
157
+ if let Some(path) = amixer() {
158
+ match util::run_path_command(&path, &["set", "Master", "toggle"]) {
159
+ Ok(_) => return read_amixer(&path),
160
+ Err(error) => last_error = error,
161
+ }
162
+ }
163
+ if last_error.is_empty() {
164
+ Err("no supported audio backend (wpctl, pactl, amixer)".to_string())
165
+ } else {
166
+ Err(last_error)
167
+ }
168
+ }
169
+
170
+ #[cfg(test)]
171
+ mod tests {
172
+ use super::*;
173
+
174
+ #[test]
175
+ fn parses_wpctl_volume() {
176
+ assert_eq!(parse_decimal_after("Volume: 0.45", "Volume:"), Some(0.45));
177
+ assert_eq!(
178
+ parse_decimal_after("Volume: 1.00 [MUTED]", "Volume:"),
179
+ Some(1.0)
180
+ );
181
+ }
182
+
183
+ #[test]
184
+ fn parses_pactl_percent() {
185
+ assert_eq!(
186
+ parse_percent("Volume: front-left: 32768 / 50% / -18.06 dB"),
187
+ Some(0.5)
188
+ );
189
+ assert_eq!(
190
+ parse_percent("Volume: front-left: 65536 / 100% / 0.00 dB"),
191
+ Some(1.0)
192
+ );
193
+ assert_eq!(parse_percent("no percent"), None);
194
+ }
195
+ }
@@ -0,0 +1,54 @@
1
+ //! macOS volume probe: `osascript` on the system output volume.
2
+ //! Source only; not run in this repository's CI.
3
+
4
+ use super::Reading;
5
+ use crate::util;
6
+
7
+ pub const BACKEND: &str = "osascript";
8
+
9
+ fn volume_percent() -> Result<f64, String> {
10
+ let text = util::command_output(
11
+ "/usr/bin/osascript",
12
+ &["-e", "output volume of (get volume settings)"],
13
+ )?;
14
+ text.parse::<f64>()
15
+ .map_err(|error| format!("osascript returned {text:?}: {error}"))
16
+ }
17
+
18
+ fn muted() -> Result<bool, String> {
19
+ let text = util::command_output(
20
+ "/usr/bin/osascript",
21
+ &["-e", "output muted of (get volume settings)"],
22
+ )?;
23
+ Ok(text.eq_ignore_ascii_case("true"))
24
+ }
25
+
26
+ pub fn read() -> Result<Reading, String> {
27
+ Ok(Reading {
28
+ volume: util::clamp(volume_percent()? / 100.0, 0.0, 1.0),
29
+ muted: muted().unwrap_or(false),
30
+ backend: BACKEND.to_string(),
31
+ })
32
+ }
33
+
34
+ pub fn set(value: f64) -> Result<Reading, String> {
35
+ let level = (util::clamp(value, 0.0, 1.0) * 100.0).round() as i64;
36
+ util::command_output(
37
+ "/usr/bin/osascript",
38
+ &["-e", &format!("set volume output volume {level}")],
39
+ )?;
40
+ read()
41
+ }
42
+
43
+ pub fn toggle() -> Result<Reading, String> {
44
+ let next = if muted().unwrap_or(false) {
45
+ "false"
46
+ } else {
47
+ "true"
48
+ };
49
+ util::command_output(
50
+ "/usr/bin/osascript",
51
+ &["-e", &format!("set volume output muted {next}")],
52
+ )?;
53
+ read()
54
+ }