@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,115 @@
1
+ /** Shared, dependency-free helpers for the Host half. */
2
+
3
+ import type { JsonRecord } from './types.js';
4
+ import { isRecord } from './types.js';
5
+
6
+ export const VERSION = '1.0.3';
7
+ export const PANEL_ID = 'remote-control';
8
+ export const ROUTE_PREFIX = '/dsh-remote-control';
9
+ export const INFO_PATH = `${ROUTE_PREFIX}/api/info`;
10
+ export const QR_PATH = `${ROUTE_PREFIX}/qr.svg`;
11
+ export const SETTINGS_PATH = `${ROUTE_PREFIX}/api/settings`;
12
+ export const APP_NAMES_PATH = `${ROUTE_PREFIX}/api/apps`;
13
+ export const LOCATION_PATH = `${ROUTE_PREFIX}/api/location`;
14
+ export const GEOCODE_PATH = `${ROUTE_PREFIX}/api/geocode`;
15
+ export const THEME_PATH = `${ROUTE_PREFIX}/api/theme`;
16
+ export const LOCALE_PATH = `${ROUTE_PREFIX}/api/locale`;
17
+ export const TOKEN_ROTATE_PATH = `${ROUTE_PREFIX}/api/token/rotate`;
18
+ export const DISCONNECT_PATH = `${ROUTE_PREFIX}/api/disconnect`;
19
+
20
+ export function clamp(value: number, min: number, max: number): number {
21
+ return Math.min(max, Math.max(min, value));
22
+ }
23
+
24
+ export function finiteNumber(value: unknown, fallback?: null): number | null;
25
+ export function finiteNumber(value: unknown, fallback: number): number;
26
+ export function finiteNumber(value: unknown, fallback: number | null = null): number | null {
27
+ if (value === null || value === undefined) return fallback;
28
+ if (typeof value === 'string' && value.trim() === '') return fallback;
29
+ const n = typeof value === 'number' ? value : Number(value);
30
+ return Number.isFinite(n) ? n : fallback;
31
+ }
32
+
33
+ export function safeString(value: unknown, max = 500): string {
34
+ if (typeof value !== 'string') return '';
35
+ return value.length > max ? value.slice(0, max) : value;
36
+ }
37
+
38
+ export function decodeXml(value: unknown): string {
39
+ return String(value)
40
+ .replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)))
41
+ .replace(/&#([0-9]+);/g, (_, dec: string) => String.fromCodePoint(parseInt(dec, 10)))
42
+ .replace(/&lt;/g, '<')
43
+ .replace(/&gt;/g, '>')
44
+ .replace(/&quot;/g, '"')
45
+ .replace(/&apos;/g, "'")
46
+ .replace(/&amp;/g, '&');
47
+ }
48
+
49
+ export function escapeHtml(value: unknown): string {
50
+ return String(value)
51
+ .replace(/&/g, '&amp;')
52
+ .replace(/</g, '&lt;')
53
+ .replace(/>/g, '&gt;')
54
+ .replace(/"/g, '&quot;')
55
+ .replace(/'/g, '&#39;');
56
+ }
57
+
58
+ export function basename(filePath: unknown): string {
59
+ if (typeof filePath !== 'string' || filePath.length === 0) return '';
60
+ const normalized = filePath.replace(/[\\/]+$/, '');
61
+ const parts = normalized.split(/[\\/]/);
62
+ return parts[parts.length - 1] || normalized;
63
+ }
64
+
65
+ export function isObject(value: unknown): value is JsonRecord {
66
+ return isRecord(value);
67
+ }
68
+
69
+ export function sniffImageMediaType(bytes: Uint8Array | null | undefined): string | null {
70
+ if (!bytes || bytes.length < 12) return null;
71
+ if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image/png';
72
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg';
73
+ const ascii = Buffer.from(bytes.buffer, bytes.byteOffset, Math.min(bytes.length, 16)).toString('ascii');
74
+ if (ascii.startsWith('GIF8')) return 'image/gif';
75
+ if (ascii.startsWith('RIFF') && ascii.slice(8, 12) === 'WEBP') return 'image/webp';
76
+ if (ascii.slice(4, 12).includes('ftyp')) return 'image/avif';
77
+ return null;
78
+ }
79
+
80
+ export function startOfLocalDay(date: Date = new Date()): Date {
81
+ const d = new Date(date);
82
+ d.setHours(0, 0, 0, 0);
83
+ return d;
84
+ }
85
+
86
+ /** Monday 00:00 in the host's local timezone. */
87
+ export function startOfLocalWeek(date: Date = new Date()): Date {
88
+ const d = startOfLocalDay(date);
89
+ const day = d.getDay(); // Sunday = 0
90
+ const delta = day === 0 ? 6 : day - 1;
91
+ d.setDate(d.getDate() - delta);
92
+ return d;
93
+ }
94
+
95
+ export function localTimezoneOffsetSeconds(date: Date = new Date()): number {
96
+ return -date.getTimezoneOffset() * 60;
97
+ }
98
+
99
+ export function formatRelativeTime(timestamp: number, now: number = Date.now()): string {
100
+ if (!Number.isFinite(timestamp) || timestamp <= 0) return '';
101
+ const seconds = Math.max(0, Math.round((now - timestamp) / 1000));
102
+ if (seconds < 60) return '刚刚';
103
+ if (seconds < 3600) return `${Math.floor(seconds / 60)} 分钟前`;
104
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)} 小时前`;
105
+ return `${Math.floor(seconds / 86400)} 天前`;
106
+ }
107
+
108
+ export function serializeError(error: unknown): string {
109
+ if (error instanceof Error) return error.message;
110
+ try {
111
+ return String(error);
112
+ } catch {
113
+ return 'unknown error';
114
+ }
115
+ }
@@ -0,0 +1,532 @@
1
+ /** Weather and coarse current-location resolution, with no API key. */
2
+
3
+ import { finiteNumber, safeString, serializeError } from './util.js';
4
+ import { probeInfo, runProbe } from './probe.js';
5
+ import { isRecord, type JsonRecord } from './types.js';
6
+
7
+ const WEATHER_TTL_MS = 10 * 60 * 1000;
8
+ const WEATHER_ERROR_TTL_MS = 45 * 1000;
9
+ const LOCATION_TTL_MS = 6 * 60 * 60 * 1000;
10
+ const LOCATION_ERROR_TTL_MS = 5 * 60 * 1000;
11
+ const GEOCODE_TTL_MS = 10 * 60 * 1000;
12
+ const GEOCODE_CACHE_LIMIT = 64;
13
+
14
+ /** Open-Meteo needs the administrative suffix for some bare Chinese names. */
15
+ const HAN_CITY_NAME = /^[\u4e00-\u9fff]{2,}$/;
16
+
17
+ interface TimezoneFallback {
18
+ name: string;
19
+ latitude: number;
20
+ longitude: number;
21
+ }
22
+
23
+ const TIMEZONE_FALLBACK: Record<string, TimezoneFallback> = {
24
+ 'Asia/Shanghai': { name: '上海', latitude: 31.2304, longitude: 121.4737 },
25
+ 'Asia/Chongqing': { name: '重庆', latitude: 29.563, longitude: 106.5516 },
26
+ 'Asia/Harbin': { name: '哈尔滨', latitude: 45.8038, longitude: 126.535 },
27
+ 'Asia/Hong_Kong': { name: '香港', latitude: 22.3193, longitude: 114.1694 },
28
+ 'Asia/Macau': { name: '澳门', latitude: 22.1987, longitude: 113.5439 },
29
+ 'Asia/Taipei': { name: '台北', latitude: 25.033, longitude: 121.5654 },
30
+ 'Asia/Tokyo': { name: '东京', latitude: 35.6762, longitude: 139.6503 },
31
+ 'Asia/Seoul': { name: '首尔', latitude: 37.5665, longitude: 126.978 },
32
+ 'Asia/Singapore': { name: '新加坡', latitude: 1.3521, longitude: 103.8198 },
33
+ 'America/Los_Angeles': { name: '洛杉矶', latitude: 34.0522, longitude: -118.2437 },
34
+ 'America/New_York': { name: '纽约', latitude: 40.7128, longitude: -74.006 },
35
+ 'Europe/London': { name: '伦敦', latitude: 51.5072, longitude: -0.1276 },
36
+ 'Europe/Paris': { name: '巴黎', latitude: 48.8566, longitude: 2.3522 },
37
+ };
38
+
39
+ /** A coordinate plus its label, as echoed in the weather payload. */
40
+ export interface WeatherLocation {
41
+ name: string;
42
+ latitude: number;
43
+ longitude: number;
44
+ source: string;
45
+ }
46
+
47
+ /** Location chosen by `resolveLocation`, which also settles the timezone. */
48
+ export interface ResolvedLocation extends WeatherLocation {
49
+ timezone: string;
50
+ accuracy?: number | null;
51
+ error?: string;
52
+ }
53
+
54
+ /** Explicit coordinates requested by the settings page. */
55
+ export interface WeatherQuery {
56
+ latitude?: number | null;
57
+ longitude?: number | null;
58
+ name?: string;
59
+ }
60
+
61
+ /** One city returned by Open-Meteo's geocoding API. */
62
+ export interface GeocodePlace {
63
+ id: number;
64
+ name: string;
65
+ latitude: number;
66
+ longitude: number;
67
+ country: string;
68
+ admin1: string;
69
+ timezone: string;
70
+ }
71
+
72
+ /** The `current` block of the open-meteo response after normalisation. */
73
+ export interface WeatherCurrent {
74
+ time: string | null;
75
+ temperature: number | null;
76
+ feelsLike: number | null;
77
+ humidity: number | null;
78
+ precipitation: number;
79
+ code: number;
80
+ isDay: boolean;
81
+ windSpeed: number | null;
82
+ }
83
+
84
+ /** Today's summary, taken from the first element of each daily series. */
85
+ export interface WeatherDaily {
86
+ code: number;
87
+ high: number | null;
88
+ low: number | null;
89
+ sunrise: string | null;
90
+ sunset: string | null;
91
+ tomorrowHigh: number | null;
92
+ tomorrowLow: number | null;
93
+ }
94
+
95
+ /** One entry of the short-range hourly strip. */
96
+ export interface WeatherHourly {
97
+ time: string | null;
98
+ temperature: number | null;
99
+ code: number | null;
100
+ precipitationProbability: number | null;
101
+ }
102
+
103
+ /** One entry of the seven-day forecast. */
104
+ export interface WeatherDailyForecast {
105
+ date: string | null;
106
+ code: number;
107
+ high: number | null;
108
+ low: number | null;
109
+ sunrise: string | null;
110
+ sunset: string | null;
111
+ }
112
+
113
+ /**
114
+ * Browser-facing weather snapshot. `current`/`daily` are null when the upstream
115
+ * request failed, in which case `error` is set and the strips are empty.
116
+ */
117
+ export interface WeatherPayload {
118
+ ok: boolean;
119
+ location: WeatherLocation;
120
+ current: WeatherCurrent | null;
121
+ daily: WeatherDaily | null;
122
+ hourly: WeatherHourly[];
123
+ dailyForecast: WeatherDailyForecast[];
124
+ timezone?: string | null;
125
+ error?: string;
126
+ cached?: boolean;
127
+ /** Added by the service layer after `getWeather` returns. */
128
+ forecastMode?: 'daily' | 'hourly';
129
+ updatedAt: number;
130
+ }
131
+
132
+ export interface WeatherService {
133
+ getWeather(input?: WeatherQuery): Promise<WeatherPayload>;
134
+ resolveLocation(): Promise<ResolvedLocation>;
135
+ /** Forward geocoding for the settings page's city search. */
136
+ searchCities(query: string, limit?: number): Promise<GeocodePlace[]>;
137
+ /** Drop cached coordinates so a permission change is picked up immediately. */
138
+ resetLocation(): void;
139
+ }
140
+
141
+ interface WeatherCacheEntry {
142
+ at: number;
143
+ value: WeatherPayload;
144
+ error?: boolean;
145
+ }
146
+
147
+ interface ProbeLocationSuccess {
148
+ ok: true;
149
+ latitude: number;
150
+ longitude: number;
151
+ accuracy: number | null;
152
+ source: string;
153
+ }
154
+
155
+ interface ProbeLocationFailure {
156
+ ok: false;
157
+ error: string;
158
+ }
159
+
160
+ type ProbeLocationCache = ProbeLocationSuccess | ProbeLocationFailure;
161
+
162
+ /** Index-safe timezone lookup: an unknown zone has no fallback. */
163
+ function timezoneFallback(timezone: string): TimezoneFallback | undefined {
164
+ return TIMEZONE_FALLBACK[timezone];
165
+ }
166
+
167
+ function stringOrNull(value: unknown): string | null {
168
+ return typeof value === 'string' ? value : null;
169
+ }
170
+
171
+ async function fetchJson(url: string, timeoutMs = 9000): Promise<unknown> {
172
+ const controller = new AbortController();
173
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
174
+ try {
175
+ const response = await fetch(url, {
176
+ signal: controller.signal,
177
+ headers: {
178
+ accept: 'application/json',
179
+ 'user-agent': 'dsh-remote-control/1.0 (+local smart display)',
180
+ },
181
+ });
182
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
183
+ return await response.json();
184
+ } finally {
185
+ clearTimeout(timer);
186
+ }
187
+ }
188
+
189
+ /** One Open-Meteo geocoding request, normalised to lowercase-safe places. */
190
+ async function geocodePlaces(name: string, count: number): Promise<GeocodePlace[]> {
191
+ const url = new URL('https://geocoding-api.open-meteo.com/v1/search');
192
+ url.searchParams.set('name', name);
193
+ url.searchParams.set('count', String(count));
194
+ url.searchParams.set('language', 'zh');
195
+ url.searchParams.set('format', 'json');
196
+ const raw = await fetchJson(url.toString(), 8000);
197
+ const payload = isRecord(raw) ? raw : null;
198
+ const results: unknown[] = payload && Array.isArray(payload.results) ? payload.results : [];
199
+ const places: GeocodePlace[] = [];
200
+ const seen = new Set<string>();
201
+ for (const item of results) {
202
+ if (!isRecord(item)) continue;
203
+ const label = safeString(item.name, 120).trim();
204
+ const latitude = finiteNumber(item.latitude);
205
+ const longitude = finiteNumber(item.longitude);
206
+ if (!label || latitude === null || longitude === null) continue;
207
+ const id = finiteNumber(item.id, 0);
208
+ const dedupe = `${id || label}|${latitude.toFixed(4)}|${longitude.toFixed(4)}`;
209
+ if (seen.has(dedupe)) continue;
210
+ seen.add(dedupe);
211
+ places.push({
212
+ id,
213
+ name: label,
214
+ latitude,
215
+ longitude,
216
+ country: safeString(item.country, 80).trim(),
217
+ admin1: safeString(item.admin1, 80).trim(),
218
+ timezone: safeString(item.timezone, 80).trim(),
219
+ });
220
+ }
221
+ return places;
222
+ }
223
+
224
+ let probeLocationCache: ProbeLocationCache | null = null;
225
+ let probeLocationAt = 0;
226
+
227
+ /** Forget the cached OS-location result (called after a permission request). */
228
+ export function resetProbeLocationCache(): void {
229
+ probeLocationCache = null;
230
+ probeLocationAt = 0;
231
+ }
232
+
233
+ /** Ask the OS location API (via the Rust probe) before falling back to IP. */
234
+ async function readProbeLocation(): Promise<ProbeLocationSuccess | null> {
235
+ const now = Date.now();
236
+ if (probeLocationCache !== null && now - probeLocationAt < 30 * 60 * 1000) {
237
+ return probeLocationCache.ok ? probeLocationCache : null;
238
+ }
239
+ if (!probeInfo().available) {
240
+ // Do not cache a missing binary for 30 minutes: a later build should be
241
+ // picked up on the next weather refresh.
242
+ return null;
243
+ }
244
+ try {
245
+ const raw = await runProbe(['location'], { timeout: 12_000 });
246
+ const result = isRecord(raw) ? raw : null;
247
+ const latitude = finiteNumber(result?.latitude, null);
248
+ const longitude = finiteNumber(result?.longitude, null);
249
+ if (result?.ok && latitude !== null && longitude !== null) {
250
+ probeLocationCache = {
251
+ ok: true,
252
+ latitude,
253
+ longitude,
254
+ accuracy: finiteNumber(result.accuracy, null),
255
+ source: safeString(result.source, 80) || 'system',
256
+ };
257
+ probeLocationAt = now;
258
+ return probeLocationCache;
259
+ }
260
+ const reason = result && typeof result.error === 'string' && result.error
261
+ ? result.error
262
+ : 'system location unavailable';
263
+ probeLocationCache = { ok: false, error: reason };
264
+ probeLocationAt = now;
265
+ return null;
266
+ } catch (error) {
267
+ probeLocationCache = { ok: false, error: serializeError(error) };
268
+ probeLocationAt = now;
269
+ return null;
270
+ }
271
+ }
272
+
273
+ async function reverseGeocode(latitude: number, longitude: number): Promise<string> {
274
+ try {
275
+ const url = new URL('https://api.bigdatacloud.net/data/reverse-geocode-client');
276
+ url.searchParams.set('latitude', String(latitude));
277
+ url.searchParams.set('longitude', String(longitude));
278
+ url.searchParams.set('localityLanguage', 'zh');
279
+ const payload = await fetchJson(url.toString(), 6000);
280
+ if (!isRecord(payload)) return '';
281
+ return safeString(payload.city || payload.locality || payload.principalSubdivision, 80);
282
+ } catch {
283
+ return '';
284
+ }
285
+ }
286
+
287
+ export function createWeatherService(): WeatherService {
288
+ let locationCache: ResolvedLocation | null = null;
289
+ let locationCacheAt = 0;
290
+ const weatherCache = new Map<string, WeatherCacheEntry>();
291
+ const geocodeCache = new Map<string, { at: number; value: GeocodePlace[] }>();
292
+
293
+ function resetLocation(): void {
294
+ resetProbeLocationCache();
295
+ locationCache = null;
296
+ locationCacheAt = 0;
297
+ }
298
+
299
+ /**
300
+ * Look up cities through Open-Meteo's geocoding API (the same provider as the
301
+ * forecast). Results are cached briefly because the settings page searches on
302
+ * every debounced keystroke. An empty query never reaches the network.
303
+ */
304
+ async function searchCities(query: string, limit: number = 8): Promise<GeocodePlace[]> {
305
+ const name = safeString(query, 80).trim();
306
+ if (!name) return [];
307
+ const count = Math.max(1, Math.min(20, Math.trunc(limit) || 8));
308
+ const key = `${name.toLowerCase()}|${count}`;
309
+ const now = Date.now();
310
+ const cached = geocodeCache.get(key);
311
+ if (cached && now - cached.at < GEOCODE_TTL_MS) return cached.value;
312
+
313
+ let places = await geocodePlaces(name, count);
314
+ // The Chinese index is incomplete for bare prefecture names: "厦门" returns
315
+ // nothing while "厦门市" resolves. Retry once with the suffix before
316
+ // reporting no match.
317
+ if (places.length === 0 && HAN_CITY_NAME.test(name)) {
318
+ places = await geocodePlaces(`${name}市`, count);
319
+ }
320
+
321
+ geocodeCache.set(key, { at: now, value: places });
322
+ if (geocodeCache.size > GEOCODE_CACHE_LIMIT) {
323
+ const oldest = geocodeCache.keys().next().value;
324
+ if (oldest !== undefined) geocodeCache.delete(oldest);
325
+ }
326
+ return places;
327
+ }
328
+
329
+ async function resolveLocation(): Promise<ResolvedLocation> {
330
+ const now = Date.now();
331
+ if (locationCache && now - locationCacheAt < LOCATION_TTL_MS) return locationCache;
332
+ if (locationCache?.error && now - locationCacheAt < LOCATION_ERROR_TTL_MS) return locationCache;
333
+
334
+ // Automatic mode asks the OS location API first (Windows Geolocation,
335
+ // Linux GeoClue2), then IP geolocation, then the local timezone.
336
+ const nativeLocation = await readProbeLocation();
337
+ if (nativeLocation) {
338
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
339
+ const name = (await reverseGeocode(nativeLocation.latitude, nativeLocation.longitude))
340
+ || timezoneFallback(timezone)?.name
341
+ || '当前位置';
342
+ locationCache = {
343
+ name,
344
+ latitude: nativeLocation.latitude,
345
+ longitude: nativeLocation.longitude,
346
+ timezone,
347
+ source: nativeLocation.source,
348
+ accuracy: nativeLocation.accuracy,
349
+ };
350
+ locationCacheAt = now;
351
+ return locationCache;
352
+ }
353
+
354
+ try {
355
+ const payload = await fetchJson('https://ipwho.is/', 6500);
356
+ const record = isRecord(payload) ? payload : null;
357
+ const latitude = finiteNumber(record?.latitude);
358
+ const longitude = finiteNumber(record?.longitude);
359
+ if (record && record.success !== false && latitude !== null && longitude !== null) {
360
+ const timezoneRecord = isRecord(record.timezone) ? record.timezone : null;
361
+ const timezone = safeString(timezoneRecord?.id, 80);
362
+ const fallback = timezoneFallback(timezone);
363
+ const name = safeString(record.city || record.region || fallback?.name || timezone, 80);
364
+ locationCache = {
365
+ name: name || '当前位置',
366
+ latitude,
367
+ longitude,
368
+ timezone,
369
+ source: 'ip',
370
+ };
371
+ locationCacheAt = now;
372
+ return locationCache;
373
+ }
374
+ throw new Error('IP location response was incomplete');
375
+ } catch (error) {
376
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
377
+ const fallback = timezoneFallback(timezone);
378
+ locationCache = fallback
379
+ ? { ...fallback, timezone, source: 'timezone', error: serializeError(error) }
380
+ : {
381
+ name: timezone || '当前位置',
382
+ latitude: 31.2304,
383
+ longitude: 121.4737,
384
+ timezone,
385
+ source: 'fallback',
386
+ error: serializeError(error),
387
+ };
388
+ locationCacheAt = now;
389
+ return locationCache;
390
+ }
391
+ }
392
+
393
+ async function getWeather(input: WeatherQuery = {}): Promise<WeatherPayload> {
394
+ const requestedLat = finiteNumber(input.latitude);
395
+ const requestedLon = finiteNumber(input.longitude);
396
+ const hasCustom = requestedLat !== null && requestedLon !== null;
397
+ const location: WeatherLocation = hasCustom
398
+ ? {
399
+ name: safeString(input.name, 80) || '自定义位置',
400
+ latitude: Math.max(-90, Math.min(90, requestedLat)),
401
+ longitude: Math.max(-180, Math.min(180, requestedLon)),
402
+ source: 'manual',
403
+ }
404
+ : await resolveLocation();
405
+
406
+ const key = `${location.latitude.toFixed(3)},${location.longitude.toFixed(3)}`;
407
+ const now = Date.now();
408
+ const cached = weatherCache.get(key);
409
+ if (cached && now - cached.at < (cached.error ? WEATHER_ERROR_TTL_MS : WEATHER_TTL_MS)) {
410
+ return { ...cached.value, cached: true };
411
+ }
412
+
413
+ const url = new URL('https://api.open-meteo.com/v1/forecast');
414
+ url.searchParams.set('latitude', String(location.latitude));
415
+ url.searchParams.set('longitude', String(location.longitude));
416
+ url.searchParams.set('current', [
417
+ 'temperature_2m',
418
+ 'relative_humidity_2m',
419
+ 'apparent_temperature',
420
+ 'is_day',
421
+ 'precipitation',
422
+ 'weather_code',
423
+ 'wind_speed_10m',
424
+ ].join(','));
425
+ url.searchParams.set('daily', [
426
+ 'weather_code',
427
+ 'temperature_2m_max',
428
+ 'temperature_2m_min',
429
+ 'sunrise',
430
+ 'sunset',
431
+ ].join(','));
432
+ url.searchParams.set('hourly', ['temperature_2m', 'weather_code', 'precipitation_probability'].join(','));
433
+ url.searchParams.set('timezone', 'auto');
434
+ url.searchParams.set('forecast_days', '7');
435
+
436
+ try {
437
+ const raw = await fetchJson(url.toString(), 10000);
438
+ const payload = isRecord(raw) ? raw : null;
439
+ const current: JsonRecord = payload && isRecord(payload.current) ? payload.current : {};
440
+ const daily: JsonRecord = payload && isRecord(payload.daily) ? payload.daily : {};
441
+ const hourly: JsonRecord = payload && isRecord(payload.hourly) ? payload.hourly : {};
442
+ const dailyTimes: unknown[] = Array.isArray(daily.time) ? daily.time : [];
443
+ const dailyCodes: unknown[] = Array.isArray(daily.weather_code) ? daily.weather_code : [];
444
+ const dailyHighs: unknown[] = Array.isArray(daily.temperature_2m_max) ? daily.temperature_2m_max : [];
445
+ const dailyLows: unknown[] = Array.isArray(daily.temperature_2m_min) ? daily.temperature_2m_min : [];
446
+ const dailySunrises: unknown[] = Array.isArray(daily.sunrise) ? daily.sunrise : [];
447
+ const dailySunsets: unknown[] = Array.isArray(daily.sunset) ? daily.sunset : [];
448
+ const hourlyTimes: unknown[] = Array.isArray(hourly.time) ? hourly.time : [];
449
+ const hourlyTemperatures: unknown[] = Array.isArray(hourly.temperature_2m) ? hourly.temperature_2m : [];
450
+ const hourlyCodes: unknown[] = Array.isArray(hourly.weather_code) ? hourly.weather_code : [];
451
+ const hourlyPrecipitation: unknown[] = Array.isArray(hourly.precipitation_probability)
452
+ ? hourly.precipitation_probability
453
+ : [];
454
+ const currentTime = typeof current.time === 'string' ? current.time : '';
455
+ let startIndex = 0;
456
+ if (currentTime) {
457
+ const index = hourlyTimes.findIndex(
458
+ (time) => typeof time === 'string' && time >= currentTime.slice(0, 13) + ':00',
459
+ );
460
+ if (index >= 0) startIndex = index;
461
+ }
462
+ const upcoming: WeatherHourly[] = hourlyTimes.slice(startIndex, startIndex + 8).map((time, index) => ({
463
+ time: stringOrNull(time),
464
+ temperature: finiteNumber(hourlyTemperatures[startIndex + index]),
465
+ code: finiteNumber(hourlyCodes[startIndex + index]),
466
+ precipitationProbability: finiteNumber(hourlyPrecipitation[startIndex + index]),
467
+ }));
468
+
469
+ const value: WeatherPayload = {
470
+ ok: true,
471
+ location: {
472
+ name: location.name,
473
+ latitude: location.latitude,
474
+ longitude: location.longitude,
475
+ source: location.source,
476
+ },
477
+ current: {
478
+ time: stringOrNull(current.time),
479
+ temperature: finiteNumber(current.temperature_2m),
480
+ feelsLike: finiteNumber(current.apparent_temperature),
481
+ humidity: finiteNumber(current.relative_humidity_2m),
482
+ precipitation: finiteNumber(current.precipitation, 0) ?? 0,
483
+ code: finiteNumber(current.weather_code, 0) ?? 0,
484
+ isDay: current.is_day === 1 || current.is_day === true,
485
+ windSpeed: finiteNumber(current.wind_speed_10m),
486
+ },
487
+ daily: {
488
+ code: finiteNumber(dailyCodes[0], 0) ?? 0,
489
+ high: finiteNumber(dailyHighs[0]),
490
+ low: finiteNumber(dailyLows[0]),
491
+ sunrise: stringOrNull(dailySunrises[0]),
492
+ sunset: stringOrNull(dailySunsets[0]),
493
+ tomorrowHigh: finiteNumber(dailyHighs[1]),
494
+ tomorrowLow: finiteNumber(dailyLows[1]),
495
+ },
496
+ dailyForecast: dailyTimes.slice(0, 7).map((date, index) => ({
497
+ date: stringOrNull(date),
498
+ code: finiteNumber(dailyCodes[index], 0) ?? 0,
499
+ high: finiteNumber(dailyHighs[index]),
500
+ low: finiteNumber(dailyLows[index]),
501
+ sunrise: stringOrNull(dailySunrises[index]),
502
+ sunset: stringOrNull(dailySunsets[index]),
503
+ })),
504
+ hourly: upcoming,
505
+ timezone: stringOrNull(payload?.timezone),
506
+ updatedAt: now,
507
+ };
508
+ weatherCache.set(key, { at: now, value });
509
+ return value;
510
+ } catch (error) {
511
+ const fallback: WeatherPayload = {
512
+ ok: false,
513
+ location: {
514
+ name: location.name,
515
+ latitude: location.latitude,
516
+ longitude: location.longitude,
517
+ source: location.source,
518
+ },
519
+ current: null,
520
+ daily: null,
521
+ hourly: [],
522
+ dailyForecast: [],
523
+ error: serializeError(error),
524
+ updatedAt: now,
525
+ };
526
+ weatherCache.set(key, { at: now, value: fallback, error: true });
527
+ return fallback;
528
+ }
529
+ }
530
+
531
+ return { getWeather, resolveLocation, searchCities, resetLocation };
532
+ }