@ohmykreee/dsh-remote-control 0.0.0-stage → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +556 -0
- package/INSTALLATION.md +310 -0
- package/LICENSE +21 -0
- package/README.md +195 -2
- package/client.js +3 -0
- package/components.json +20 -0
- package/cordis.patch.yml +3 -0
- package/docs/README.en.md +196 -0
- package/docs/screenshots/en/overview.png +0 -0
- package/docs/screenshots/en/question.png +0 -0
- package/docs/screenshots/zh/overview.png +0 -0
- package/docs/screenshots/zh/question.png +0 -0
- package/icon.svg +24 -0
- package/index.js +2 -0
- package/lib/account.js +1 -0
- package/lib/app-mapping.js +1 -0
- package/lib/i18n.js +1 -0
- package/lib/media-enrich.js +1 -0
- package/lib/notifications.js +1 -0
- package/lib/performance.js +1 -0
- package/lib/platform/index.js +1 -0
- package/lib/probe-adapter.js +1 -0
- package/lib/probe-daemon.js +1 -0
- package/lib/probe.js +1 -0
- package/lib/qr.js +1 -0
- package/lib/runtime.js +1 -0
- package/lib/service.js +1 -0
- package/lib/sessions.js +1 -0
- package/lib/settings.js +1 -0
- package/lib/types.js +1 -0
- package/lib/util.js +1 -0
- package/lib/weather.js +1 -0
- package/lib/ws.js +1 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +118 -4
- package/scripts/browser-shot.mjs +217 -0
- package/scripts/build-dsh.mjs +228 -0
- package/scripts/build-rust.mjs +319 -0
- package/scripts/build-web.mjs +17 -0
- package/scripts/check-npm-package.mjs +322 -0
- package/scripts/mock-preview.py +574 -0
- package/scripts/screenshot-compose.py +89 -0
- package/scripts/screenshot-device.sh +75 -0
- package/src/dsh/client/api.ts +68 -0
- package/src/dsh/client/i18n.ts +573 -0
- package/src/dsh/client/icons.ts +56 -0
- package/src/dsh/client/index.ts +95 -0
- package/src/dsh/client/logic.ts +310 -0
- package/src/dsh/client/panel.ts +483 -0
- package/src/dsh/client/sections/apps.ts +40 -0
- package/src/dsh/client/sections/balance.ts +38 -0
- package/src/dsh/client/sections/connection.ts +97 -0
- package/src/dsh/client/sections/notifications.ts +43 -0
- package/src/dsh/client/sections/pomodoro.ts +184 -0
- package/src/dsh/client/sections/preferences.ts +138 -0
- package/src/dsh/client/sections/system.ts +120 -0
- package/src/dsh/client/sections/weather.ts +276 -0
- package/src/dsh/client/styles.ts +1119 -0
- package/src/dsh/client/types.ts +366 -0
- package/src/dsh/client/ui.ts +142 -0
- package/src/dsh/index.ts +108 -0
- package/src/dsh/lib/account.ts +520 -0
- package/src/dsh/lib/app-mapping.ts +366 -0
- package/src/dsh/lib/i18n.ts +82 -0
- package/src/dsh/lib/media-enrich.ts +202 -0
- package/src/dsh/lib/notifications.ts +322 -0
- package/src/dsh/lib/performance.ts +279 -0
- package/src/dsh/lib/platform/index.ts +43 -0
- package/src/dsh/lib/probe-adapter.ts +275 -0
- package/src/dsh/lib/probe-daemon.ts +359 -0
- package/src/dsh/lib/probe.ts +395 -0
- package/src/dsh/lib/qr.ts +42 -0
- package/src/dsh/lib/runtime.ts +126 -0
- package/src/dsh/lib/service.ts +1645 -0
- package/src/dsh/lib/sessions.ts +907 -0
- package/src/dsh/lib/settings.ts +468 -0
- package/src/dsh/lib/types.ts +231 -0
- package/src/dsh/lib/util.ts +115 -0
- package/src/dsh/lib/weather.ts +532 -0
- package/src/dsh/lib/ws.ts +328 -0
- package/src/dsh/types/qrcode-generator.d.ts +16 -0
- package/src/rust/Cargo.lock +1431 -0
- package/src/rust/Cargo.toml +68 -0
- package/src/rust/src/cli.rs +233 -0
- package/src/rust/src/daemon.rs +140 -0
- package/src/rust/src/location/linux.rs +118 -0
- package/src/rust/src/location/macos.rs +26 -0
- package/src/rust/src/location/mod.rs +170 -0
- package/src/rust/src/location/windows.rs +205 -0
- package/src/rust/src/main.rs +42 -0
- package/src/rust/src/media/catalog.rs +454 -0
- package/src/rust/src/media/linux.rs +350 -0
- package/src/rust/src/media/macos/accessibility.rs +59 -0
- package/src/rust/src/media/macos.rs +366 -0
- package/src/rust/src/media/mod.rs +298 -0
- package/src/rust/src/media/vendor/apple_music.rs +90 -0
- package/src/rust/src/media/vendor/common.rs +186 -0
- package/src/rust/src/media/vendor/kugou.rs +52 -0
- package/src/rust/src/media/vendor/kuwo.rs +49 -0
- package/src/rust/src/media/vendor/mod.rs +75 -0
- package/src/rust/src/media/vendor/netease.rs +1715 -0
- package/src/rust/src/media/vendor/qishui.rs +36 -0
- package/src/rust/src/media/vendor/qqmusic.rs +33 -0
- package/src/rust/src/media/vendor/spotify.rs +46 -0
- package/src/rust/src/media/vendor/wesing.rs +39 -0
- package/src/rust/src/media/vendor/youtube_music.rs +49 -0
- package/src/rust/src/media/windows/accessibility.rs +85 -0
- package/src/rust/src/media/windows/audio.rs +116 -0
- package/src/rust/src/media/windows/private.rs +291 -0
- package/src/rust/src/media/windows.rs +775 -0
- package/src/rust/src/notifications/linux.rs +276 -0
- package/src/rust/src/notifications/macos.rs +382 -0
- package/src/rust/src/notifications/mod.rs +244 -0
- package/src/rust/src/notifications/windows.rs +565 -0
- package/src/rust/src/perf/common.rs +109 -0
- package/src/rust/src/perf/linux.rs +282 -0
- package/src/rust/src/perf/macos.rs +28 -0
- package/src/rust/src/perf/mod.rs +157 -0
- package/src/rust/src/perf/windows.rs +981 -0
- package/src/rust/src/util.rs +360 -0
- package/src/rust/src/volume/linux.rs +195 -0
- package/src/rust/src/volume/macos.rs +54 -0
- package/src/rust/src/volume/mod.rs +116 -0
- package/src/rust/src/volume/windows.rs +75 -0
- package/src/rust/tests/cli.rs +79 -0
- package/src/rust/tests/daemon.rs +107 -0
- package/src/svelte/App.svelte +385 -0
- package/src/svelte/index.html +18 -0
- package/src/svelte/lib/billing.svelte.ts +228 -0
- package/src/svelte/lib/components/BalanceCard.svelte +173 -0
- package/src/svelte/lib/components/ClockCard.svelte +183 -0
- package/src/svelte/lib/components/CountdownCard.svelte +261 -0
- package/src/svelte/lib/components/CpuMemoryCard.svelte +238 -0
- package/src/svelte/lib/components/EmptyState.svelte +34 -0
- package/src/svelte/lib/components/GpuCard.svelte +245 -0
- package/src/svelte/lib/components/HomePage.svelte +34 -0
- package/src/svelte/lib/components/MusicControls.svelte +256 -0
- package/src/svelte/lib/components/MusicCover.svelte +88 -0
- package/src/svelte/lib/components/MusicPage.svelte +116 -0
- package/src/svelte/lib/components/NotificationsCard.svelte +109 -0
- package/src/svelte/lib/components/Panel.svelte +22 -0
- package/src/svelte/lib/components/PerformancePage.svelte +55 -0
- package/src/svelte/lib/components/PomodoroCard.svelte +250 -0
- package/src/svelte/lib/components/QuestionSheet.svelte +354 -0
- package/src/svelte/lib/components/RadialGauge.svelte +99 -0
- package/src/svelte/lib/components/Rail.svelte +217 -0
- package/src/svelte/lib/components/SectionHeader.svelte +40 -0
- package/src/svelte/lib/components/SeekBar.svelte +421 -0
- package/src/svelte/lib/components/SessionList.svelte +364 -0
- package/src/svelte/lib/components/SessionStats.svelte +98 -0
- package/src/svelte/lib/components/SessionsPage.svelte +61 -0
- package/src/svelte/lib/components/SpendCard.svelte +105 -0
- package/src/svelte/lib/components/ThermalCard.svelte +249 -0
- package/src/svelte/lib/components/Toast.svelte +32 -0
- package/src/svelte/lib/components/WeatherIcon.svelte +40 -0
- package/src/svelte/lib/components/WeatherPanel.svelte +420 -0
- package/src/svelte/lib/components/ui/alert/alert-action.svelte +20 -0
- package/src/svelte/lib/components/ui/alert/alert-description.svelte +23 -0
- package/src/svelte/lib/components/ui/alert/alert-title.svelte +23 -0
- package/src/svelte/lib/components/ui/alert/alert.svelte +43 -0
- package/src/svelte/lib/components/ui/alert/index.ts +17 -0
- package/src/svelte/lib/components/ui/avatar/avatar-badge.svelte +26 -0
- package/src/svelte/lib/components/ui/avatar/avatar-fallback.svelte +20 -0
- package/src/svelte/lib/components/ui/avatar/avatar-group-count.svelte +23 -0
- package/src/svelte/lib/components/ui/avatar/avatar-group.svelte +23 -0
- package/src/svelte/lib/components/ui/avatar/avatar-image.svelte +17 -0
- package/src/svelte/lib/components/ui/avatar/avatar.svelte +26 -0
- package/src/svelte/lib/components/ui/avatar/index.ts +22 -0
- package/src/svelte/lib/components/ui/badge/badge.svelte +49 -0
- package/src/svelte/lib/components/ui/badge/index.ts +2 -0
- package/src/svelte/lib/components/ui/button/button.svelte +82 -0
- package/src/svelte/lib/components/ui/button/index.ts +17 -0
- package/src/svelte/lib/components/ui/card/card-action.svelte +23 -0
- package/src/svelte/lib/components/ui/card/card-content.svelte +20 -0
- package/src/svelte/lib/components/ui/card/card-description.svelte +20 -0
- package/src/svelte/lib/components/ui/card/card-footer.svelte +20 -0
- package/src/svelte/lib/components/ui/card/card-header.svelte +23 -0
- package/src/svelte/lib/components/ui/card/card-title.svelte +20 -0
- package/src/svelte/lib/components/ui/card/card.svelte +22 -0
- package/src/svelte/lib/components/ui/card/index.ts +25 -0
- package/src/svelte/lib/components/ui/dialog/dialog-close.svelte +11 -0
- package/src/svelte/lib/components/ui/dialog/dialog-content.svelte +48 -0
- package/src/svelte/lib/components/ui/dialog/dialog-description.svelte +17 -0
- package/src/svelte/lib/components/ui/dialog/dialog-footer.svelte +32 -0
- package/src/svelte/lib/components/ui/dialog/dialog-header.svelte +20 -0
- package/src/svelte/lib/components/ui/dialog/dialog-overlay.svelte +17 -0
- package/src/svelte/lib/components/ui/dialog/dialog-portal.svelte +7 -0
- package/src/svelte/lib/components/ui/dialog/dialog-title.svelte +17 -0
- package/src/svelte/lib/components/ui/dialog/dialog-trigger.svelte +11 -0
- package/src/svelte/lib/components/ui/dialog/dialog.svelte +7 -0
- package/src/svelte/lib/components/ui/dialog/index.ts +34 -0
- package/src/svelte/lib/components/ui/empty/empty-content.svelte +23 -0
- package/src/svelte/lib/components/ui/empty/empty-description.svelte +23 -0
- package/src/svelte/lib/components/ui/empty/empty-header.svelte +20 -0
- package/src/svelte/lib/components/ui/empty/empty-media.svelte +41 -0
- package/src/svelte/lib/components/ui/empty/empty-title.svelte +20 -0
- package/src/svelte/lib/components/ui/empty/empty.svelte +23 -0
- package/src/svelte/lib/components/ui/empty/index.ts +22 -0
- package/src/svelte/lib/components/ui/input/index.ts +7 -0
- package/src/svelte/lib/components/ui/input/input.svelte +48 -0
- package/src/svelte/lib/components/ui/progress/index.ts +7 -0
- package/src/svelte/lib/components/ui/progress/progress.svelte +27 -0
- package/src/svelte/lib/components/ui/scroll-area/index.ts +10 -0
- package/src/svelte/lib/components/ui/scroll-area/scroll-area-scrollbar.svelte +30 -0
- package/src/svelte/lib/components/ui/scroll-area/scroll-area.svelte +43 -0
- package/src/svelte/lib/components/ui/separator/index.ts +7 -0
- package/src/svelte/lib/components/ui/separator/separator.svelte +21 -0
- package/src/svelte/lib/components/ui/sheet/index.ts +34 -0
- package/src/svelte/lib/components/ui/sheet/sheet-close.svelte +7 -0
- package/src/svelte/lib/components/ui/sheet/sheet-content.svelte +55 -0
- package/src/svelte/lib/components/ui/sheet/sheet-description.svelte +17 -0
- package/src/svelte/lib/components/ui/sheet/sheet-footer.svelte +20 -0
- package/src/svelte/lib/components/ui/sheet/sheet-header.svelte +20 -0
- package/src/svelte/lib/components/ui/sheet/sheet-overlay.svelte +20 -0
- package/src/svelte/lib/components/ui/sheet/sheet-portal.svelte +7 -0
- package/src/svelte/lib/components/ui/sheet/sheet-title.svelte +17 -0
- package/src/svelte/lib/components/ui/sheet/sheet-trigger.svelte +7 -0
- package/src/svelte/lib/components/ui/sheet/sheet.svelte +7 -0
- package/src/svelte/lib/components/ui/skeleton/index.ts +7 -0
- package/src/svelte/lib/components/ui/skeleton/skeleton.svelte +17 -0
- package/src/svelte/lib/components/ui/slider/index.ts +7 -0
- package/src/svelte/lib/components/ui/slider/slider.svelte +52 -0
- package/src/svelte/lib/components/ui/tooltip/index.ts +19 -0
- package/src/svelte/lib/components/ui/tooltip/tooltip-content.svelte +51 -0
- package/src/svelte/lib/components/ui/tooltip/tooltip-portal.svelte +7 -0
- package/src/svelte/lib/components/ui/tooltip/tooltip-provider.svelte +7 -0
- package/src/svelte/lib/components/ui/tooltip/tooltip-trigger.svelte +7 -0
- package/src/svelte/lib/components/ui/tooltip/tooltip.svelte +7 -0
- package/src/svelte/lib/countdown.svelte.ts +252 -0
- package/src/svelte/lib/format.ts +199 -0
- package/src/svelte/lib/i18n.svelte.ts +583 -0
- package/src/svelte/lib/media.ts +32 -0
- package/src/svelte/lib/pages.ts +18 -0
- package/src/svelte/lib/pomodoro.svelte.ts +223 -0
- package/src/svelte/lib/session-tree.ts +80 -0
- package/src/svelte/lib/state.svelte.ts +359 -0
- package/src/svelte/lib/timer-alarm.svelte.ts +319 -0
- package/src/svelte/lib/types.ts +306 -0
- package/src/svelte/lib/utils.ts +8 -0
- package/src/svelte/lib/weather.ts +84 -0
- package/src/svelte/lib/ws.ts +104 -0
- package/src/svelte/main.ts +13 -0
- package/src/svelte/styles/global.css +315 -0
- package/src/svelte/vite-env.d.ts +12 -0
- package/static/fonts/LICENSE.txt +287 -0
- package/static/icon.svg +24 -0
- package/static/manifest.webmanifest +18 -0
- package/svelte.config.js +5 -0
- package/tsconfig.dsh-client.json +18 -0
- package/tsconfig.dsh.json +21 -0
- package/tsconfig.json +20 -0
- package/vite.config.js +31 -0
- package/web/assets/index-DLdSfnJ3.css +1 -0
- package/web/assets/index-YNE8X0Uy.js +6 -0
- package/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/web/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
- package/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/web/assets/noto-sans-sc-100-wght-normal-DluiXYzi.woff2 +0 -0
- package/web/assets/noto-sans-sc-101-wght-normal-CPkZHnw0.woff2 +0 -0
- package/web/assets/noto-sans-sc-102-wght-normal-gzIulUH6.woff2 +0 -0
- package/web/assets/noto-sans-sc-103-wght-normal-CJfSBxFo.woff2 +0 -0
- package/web/assets/noto-sans-sc-104-wght-normal-B3QMglgE.woff2 +0 -0
- package/web/assets/noto-sans-sc-105-wght-normal-xXh_OMfk.woff2 +0 -0
- package/web/assets/noto-sans-sc-106-wght-normal-B9n3jMjP.woff2 +0 -0
- package/web/assets/noto-sans-sc-107-wght-normal-D7fvVprN.woff2 +0 -0
- package/web/assets/noto-sans-sc-108-wght-normal-J0_LhxVo.woff2 +0 -0
- package/web/assets/noto-sans-sc-109-wght-normal-Dsp-De_0.woff2 +0 -0
- package/web/assets/noto-sans-sc-110-wght-normal-DE_6NUrb.woff2 +0 -0
- package/web/assets/noto-sans-sc-111-wght-normal-C4S-TlTn.woff2 +0 -0
- package/web/assets/noto-sans-sc-112-wght-normal-BcFuP3w6.woff2 +0 -0
- package/web/assets/noto-sans-sc-113-wght-normal-Dk932kmj.woff2 +0 -0
- package/web/assets/noto-sans-sc-114-wght-normal-CO_5Xo-i.woff2 +0 -0
- package/web/assets/noto-sans-sc-115-wght-normal-BWy07H14.woff2 +0 -0
- package/web/assets/noto-sans-sc-116-wght-normal-B4HVAfHo.woff2 +0 -0
- package/web/assets/noto-sans-sc-117-wght-normal-siZzFqgK.woff2 +0 -0
- package/web/assets/noto-sans-sc-118-wght-normal-xhBB7f6V.woff2 +0 -0
- package/web/assets/noto-sans-sc-119-wght-normal-Ck_g2FGz.woff2 +0 -0
- package/web/assets/noto-sans-sc-21-wght-normal-D0x9-yBt.woff2 +0 -0
- package/web/assets/noto-sans-sc-22-wght-normal-BBUVTShg.woff2 +0 -0
- package/web/assets/noto-sans-sc-23-wght-normal-Du1oYWkE.woff2 +0 -0
- package/web/assets/noto-sans-sc-24-wght-normal-CmWjJ0gt.woff2 +0 -0
- package/web/assets/noto-sans-sc-25-wght-normal-BFHiqilg.woff2 +0 -0
- package/web/assets/noto-sans-sc-26-wght-normal-C0sF2Z2f.woff2 +0 -0
- package/web/assets/noto-sans-sc-27-wght-normal-COFdrquJ.woff2 +0 -0
- package/web/assets/noto-sans-sc-28-wght-normal-DMgdNu1o.woff2 +0 -0
- package/web/assets/noto-sans-sc-29-wght-normal-Cmq8em30.woff2 +0 -0
- package/web/assets/noto-sans-sc-30-wght-normal-CZWqZ3yU.woff2 +0 -0
- package/web/assets/noto-sans-sc-31-wght-normal-Wfi_lutW.woff2 +0 -0
- package/web/assets/noto-sans-sc-32-wght-normal-BRJQ4PY5.woff2 +0 -0
- package/web/assets/noto-sans-sc-33-wght-normal-CUVQXNAJ.woff2 +0 -0
- package/web/assets/noto-sans-sc-34-wght-normal-Bspba0wC.woff2 +0 -0
- package/web/assets/noto-sans-sc-35-wght-normal-K8fX_YPG.woff2 +0 -0
- package/web/assets/noto-sans-sc-36-wght-normal-CkITyjoT.woff2 +0 -0
- package/web/assets/noto-sans-sc-37-wght-normal-WCZ2zfEe.woff2 +0 -0
- package/web/assets/noto-sans-sc-38-wght-normal-DeM7CUce.woff2 +0 -0
- package/web/assets/noto-sans-sc-39-wght-normal-BMZNLWQU.woff2 +0 -0
- package/web/assets/noto-sans-sc-4-wght-normal-AR5bdDjz.woff2 +0 -0
- package/web/assets/noto-sans-sc-40-wght-normal-BQ5xpS5j.woff2 +0 -0
- package/web/assets/noto-sans-sc-41-wght-normal-Hthm7FFi.woff2 +0 -0
- package/web/assets/noto-sans-sc-42-wght-normal-eaS2gKds.woff2 +0 -0
- package/web/assets/noto-sans-sc-43-wght-normal-o-T53XXV.woff2 +0 -0
- package/web/assets/noto-sans-sc-44-wght-normal-BJH5J4Ez.woff2 +0 -0
- package/web/assets/noto-sans-sc-45-wght-normal-BKuxSCOJ.woff2 +0 -0
- package/web/assets/noto-sans-sc-46-wght-normal-BR_5Mu6e.woff2 +0 -0
- package/web/assets/noto-sans-sc-47-wght-normal-CyY7gKNX.woff2 +0 -0
- package/web/assets/noto-sans-sc-48-wght-normal-BNMfTR6k.woff2 +0 -0
- package/web/assets/noto-sans-sc-49-wght-normal-DX-0y7wg.woff2 +0 -0
- package/web/assets/noto-sans-sc-5-wght-normal-CfWugllB.woff2 +0 -0
- package/web/assets/noto-sans-sc-50-wght-normal-DAOGW-Dr.woff2 +0 -0
- package/web/assets/noto-sans-sc-51-wght-normal-BlFLOjcP.woff2 +0 -0
- package/web/assets/noto-sans-sc-52-wght-normal-OZjrybnw.woff2 +0 -0
- package/web/assets/noto-sans-sc-53-wght-normal-CwyWNT6x.woff2 +0 -0
- package/web/assets/noto-sans-sc-54-wght-normal-DM0nRe18.woff2 +0 -0
- package/web/assets/noto-sans-sc-55-wght-normal-DBK2wUOR.woff2 +0 -0
- package/web/assets/noto-sans-sc-56-wght-normal-C-VKK1LS.woff2 +0 -0
- package/web/assets/noto-sans-sc-57-wght-normal-Cdbfh5kq.woff2 +0 -0
- package/web/assets/noto-sans-sc-58-wght-normal-CflMKARZ.woff2 +0 -0
- package/web/assets/noto-sans-sc-59-wght-normal-BS4Kwzxx.woff2 +0 -0
- package/web/assets/noto-sans-sc-6-wght-normal-ChnZIQfQ.woff2 +0 -0
- package/web/assets/noto-sans-sc-60-wght-normal-B7QOYsXJ.woff2 +0 -0
- package/web/assets/noto-sans-sc-61-wght-normal-C8JjQS7w.woff2 +0 -0
- package/web/assets/noto-sans-sc-62-wght-normal-bS4wq1Gg.woff2 +0 -0
- package/web/assets/noto-sans-sc-63-wght-normal-BJr8P-OB.woff2 +0 -0
- package/web/assets/noto-sans-sc-64-wght-normal-DVB11t7j.woff2 +0 -0
- package/web/assets/noto-sans-sc-65-wght-normal-DMPCXBvK.woff2 +0 -0
- package/web/assets/noto-sans-sc-66-wght-normal-BobaG10V.woff2 +0 -0
- package/web/assets/noto-sans-sc-67-wght-normal-Dzk7WhCR.woff2 +0 -0
- package/web/assets/noto-sans-sc-68-wght-normal-DGbCD9xu.woff2 +0 -0
- package/web/assets/noto-sans-sc-69-wght-normal-Ctrv1EqA.woff2 +0 -0
- package/web/assets/noto-sans-sc-70-wght-normal-RgTO941D.woff2 +0 -0
- package/web/assets/noto-sans-sc-71-wght-normal-D9sG-CWv.woff2 +0 -0
- package/web/assets/noto-sans-sc-72-wght-normal-C4oWy3ul.woff2 +0 -0
- package/web/assets/noto-sans-sc-73-wght-normal-B1ZoOTgM.woff2 +0 -0
- package/web/assets/noto-sans-sc-74-wght-normal-F7dH_rZJ.woff2 +0 -0
- package/web/assets/noto-sans-sc-75-wght-normal-Cp2wp4si.woff2 +0 -0
- package/web/assets/noto-sans-sc-76-wght-normal-B494D7xS.woff2 +0 -0
- package/web/assets/noto-sans-sc-77-wght-normal-B3MVY9Ax.woff2 +0 -0
- package/web/assets/noto-sans-sc-78-wght-normal-5jX7iNMC.woff2 +0 -0
- package/web/assets/noto-sans-sc-79-wght-normal-BIoZy8fr.woff2 +0 -0
- package/web/assets/noto-sans-sc-80-wght-normal-Bje1Fxy1.woff2 +0 -0
- package/web/assets/noto-sans-sc-81-wght-normal-B2NN7Qi3.woff2 +0 -0
- package/web/assets/noto-sans-sc-82-wght-normal-CUggNZi4.woff2 +0 -0
- package/web/assets/noto-sans-sc-83-wght-normal-99rD8YO-.woff2 +0 -0
- package/web/assets/noto-sans-sc-84-wght-normal-zMBmUocm.woff2 +0 -0
- package/web/assets/noto-sans-sc-85-wght-normal-CjI3qdxG.woff2 +0 -0
- package/web/assets/noto-sans-sc-86-wght-normal-BHY8TVEH.woff2 +0 -0
- package/web/assets/noto-sans-sc-87-wght-normal-DioZSHnU.woff2 +0 -0
- package/web/assets/noto-sans-sc-88-wght-normal-BXYxwf2t.woff2 +0 -0
- package/web/assets/noto-sans-sc-89-wght-normal-ClMFrFaL.woff2 +0 -0
- package/web/assets/noto-sans-sc-90-wght-normal-BnOlPreP.woff2 +0 -0
- package/web/assets/noto-sans-sc-91-wght-normal-DTr9RQcS.woff2 +0 -0
- package/web/assets/noto-sans-sc-97-wght-normal-9u6Aiav-.woff2 +0 -0
- package/web/assets/noto-sans-sc-98-wght-normal-BR-H2C0V.woff2 +0 -0
- package/web/assets/noto-sans-sc-99-wght-normal-BI6XPoxp.woff2 +0 -0
- package/web/assets/noto-sans-sc-cyrillic-wght-normal-BGmsQlw-.woff2 +0 -0
- package/web/assets/noto-sans-sc-latin-ext-wght-normal-DuJTpTg_.woff2 +0 -0
- package/web/assets/noto-sans-sc-latin-wght-normal-DDW8mKAR.woff2 +0 -0
- package/web/assets/noto-sans-sc-vietnamese-wght-normal-Bi3ATJ76.woff2 +0 -0
- package/web/fonts/LICENSE.txt +287 -0
- package/web/icon.svg +24 -0
- package/web/index.html +19 -0
- package/web/manifest.webmanifest +18 -0
|
@@ -0,0 +1,1645 @@
|
|
|
1
|
+
/** LAN HTTP/WebSocket service, pairing, settings, and live device snapshots. */
|
|
2
|
+
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
6
|
+
import type { IncomingMessage, Server as HttpServer, ServerResponse } from 'node:http';
|
|
7
|
+
import { createServer as createHttpsServer } from 'node:https';
|
|
8
|
+
import type { Server as HttpsServer } from 'node:https';
|
|
9
|
+
import { networkInterfaces } from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { createAccountService } from './account.js';
|
|
13
|
+
import { createAppMapping } from './app-mapping.js';
|
|
14
|
+
import type { MappedApp } from './app-mapping.js';
|
|
15
|
+
import { hostMessage } from './i18n.js';
|
|
16
|
+
import { createPerformanceService } from './performance.js';
|
|
17
|
+
import { createPlatformAdapters } from './platform/index.js';
|
|
18
|
+
import { probeInfo, runProbe } from './probe.js';
|
|
19
|
+
import { qrSvg } from './qr.js';
|
|
20
|
+
import { readNodeCapability, readProbeCapability, readProfileName } from './runtime.js';
|
|
21
|
+
import type { NodeCapability, ProbeCapability } from './runtime.js';
|
|
22
|
+
import { collectSessions, createPendingTracker, parsePendingAnswer } from './sessions.js';
|
|
23
|
+
import type { PendingAnswer } from './sessions.js';
|
|
24
|
+
import { createSettingsStore, normalizeDshLocale, resolveLocale } from './settings.js';
|
|
25
|
+
import type { PluginLocale } from './settings.js';
|
|
26
|
+
import {
|
|
27
|
+
APP_NAMES_PATH,
|
|
28
|
+
INFO_PATH,
|
|
29
|
+
LOCATION_PATH,
|
|
30
|
+
LOCALE_PATH,
|
|
31
|
+
QR_PATH,
|
|
32
|
+
ROUTE_PREFIX,
|
|
33
|
+
SETTINGS_PATH,
|
|
34
|
+
THEME_PATH,
|
|
35
|
+
TOKEN_ROTATE_PATH,
|
|
36
|
+
VERSION,
|
|
37
|
+
finiteNumber,
|
|
38
|
+
safeString,
|
|
39
|
+
serializeError,
|
|
40
|
+
} from './util.js';
|
|
41
|
+
import { createWeatherService } from './weather.js';
|
|
42
|
+
import { createWebSocketHub } from './ws.js';
|
|
43
|
+
import type {
|
|
44
|
+
DshContext,
|
|
45
|
+
JsonRecord,
|
|
46
|
+
NotificationItem,
|
|
47
|
+
NotificationPayload,
|
|
48
|
+
WebSocketAuthorizeDecision,
|
|
49
|
+
WebSocketClient,
|
|
50
|
+
WebSocketClientInfo,
|
|
51
|
+
WebSocketHub,
|
|
52
|
+
} from './types.js';
|
|
53
|
+
import { isRecord } from './types.js';
|
|
54
|
+
|
|
55
|
+
const WEB_DIR = fileURLToPath(new URL('../web/', import.meta.url));
|
|
56
|
+
const DEFAULT_PORT = 38765;
|
|
57
|
+
const COOKIE_NAME = 'dsh_rc';
|
|
58
|
+
const BODY_LIMIT = 64 * 1024;
|
|
59
|
+
const POMODORO_SOUND_PATH = '/api/pomodoro/sound';
|
|
60
|
+
|
|
61
|
+
const MIME: Record<string, string> = {
|
|
62
|
+
'.html': 'text/html; charset=utf-8',
|
|
63
|
+
'.css': 'text/css; charset=utf-8',
|
|
64
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
65
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
66
|
+
'.json': 'application/json; charset=utf-8',
|
|
67
|
+
'.map': 'application/json; charset=utf-8',
|
|
68
|
+
'.svg': 'image/svg+xml; charset=utf-8',
|
|
69
|
+
'.woff2': 'font/woff2',
|
|
70
|
+
'.woff': 'font/woff',
|
|
71
|
+
'.ttf': 'font/ttf',
|
|
72
|
+
'.otf': 'font/otf',
|
|
73
|
+
'.png': 'image/png',
|
|
74
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
75
|
+
'.mp3': 'audio/mpeg',
|
|
76
|
+
'.m4a': 'audio/mp4',
|
|
77
|
+
'.aac': 'audio/aac',
|
|
78
|
+
'.wav': 'audio/wav',
|
|
79
|
+
'.ogg': 'audio/ogg',
|
|
80
|
+
'.oga': 'audio/ogg',
|
|
81
|
+
'.flac': 'audio/flac',
|
|
82
|
+
'.webm': 'audio/webm',
|
|
83
|
+
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const SAFE_FILE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
87
|
+
|
|
88
|
+
/** Sibling services are typed by derivation so no port order is assumed. */
|
|
89
|
+
type SettingsStore = ReturnType<typeof createSettingsStore>;
|
|
90
|
+
type SettingsValue = ReturnType<SettingsStore['get']>;
|
|
91
|
+
type AccountService = ReturnType<typeof createAccountService>;
|
|
92
|
+
type WeatherService = ReturnType<typeof createWeatherService>;
|
|
93
|
+
type PerformanceService = ReturnType<typeof createPerformanceService>;
|
|
94
|
+
type PlatformAdapters = ReturnType<typeof createPlatformAdapters>;
|
|
95
|
+
type AppMapping = ReturnType<typeof createAppMapping>;
|
|
96
|
+
type SessionList = ReturnType<typeof collectSessions>;
|
|
97
|
+
type WeatherSnapshot = Awaited<ReturnType<WeatherService['getWeather']>>;
|
|
98
|
+
type AccountSnapshot = Awaited<ReturnType<AccountService['getAccount']>>;
|
|
99
|
+
type PerformanceSnapshot = Awaited<ReturnType<PerformanceService['sample']>>;
|
|
100
|
+
type MediaSnapshot = Awaited<ReturnType<PlatformAdapters['media']['getState']>>;
|
|
101
|
+
|
|
102
|
+
/** Response headers accepted by the JSON and text helpers. */
|
|
103
|
+
type ResponseHeaders = Record<string, string | number>;
|
|
104
|
+
|
|
105
|
+
/** Parsed IPv4 network of one interface. */
|
|
106
|
+
interface CidrInfo {
|
|
107
|
+
network: number;
|
|
108
|
+
mask: number;
|
|
109
|
+
prefix: number;
|
|
110
|
+
cidr: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One LAN interface as exposed to the settings panel. */
|
|
114
|
+
export interface InterfaceEntry {
|
|
115
|
+
name: string;
|
|
116
|
+
address: string;
|
|
117
|
+
netmask: string;
|
|
118
|
+
cidr: string | null;
|
|
119
|
+
selected: boolean;
|
|
120
|
+
url?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** One advertised console URL for a selected interface. */
|
|
124
|
+
export interface RemoteUrlEntry {
|
|
125
|
+
name: string;
|
|
126
|
+
address: string;
|
|
127
|
+
url?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Untrusted input forwarded to the weather service (`{}` = auto location). */
|
|
131
|
+
type WeatherInput = {
|
|
132
|
+
latitude?: number | null;
|
|
133
|
+
longitude?: number | null;
|
|
134
|
+
name?: string;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Browser-facing settings snapshot pushed over the socket. */
|
|
138
|
+
export interface RemoteSettingsPayload {
|
|
139
|
+
theme: string;
|
|
140
|
+
/** Resolved console language (Chinese or English). */
|
|
141
|
+
locale: PluginLocale;
|
|
142
|
+
temperatureUnit: string;
|
|
143
|
+
notificationLimit: number;
|
|
144
|
+
/** Whether the phone may show and answer pending session questions. */
|
|
145
|
+
pushQuestions: boolean;
|
|
146
|
+
navPosition: string;
|
|
147
|
+
balance: {
|
|
148
|
+
spendWindow: string;
|
|
149
|
+
lowThreshold: number | null;
|
|
150
|
+
showPeakValley: boolean;
|
|
151
|
+
};
|
|
152
|
+
pomodoro: {
|
|
153
|
+
enabled: boolean;
|
|
154
|
+
focusMinutes: number;
|
|
155
|
+
breakMinutes: number;
|
|
156
|
+
soundPath: string;
|
|
157
|
+
loopAlarm: boolean;
|
|
158
|
+
};
|
|
159
|
+
countdown: {
|
|
160
|
+
enabled: boolean;
|
|
161
|
+
presets: number[];
|
|
162
|
+
};
|
|
163
|
+
pageOrder: string[];
|
|
164
|
+
pageEnabled: Record<string, boolean>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Filtered notification view built from a raw probe payload. */
|
|
168
|
+
interface NotificationViewPayload {
|
|
169
|
+
available: boolean;
|
|
170
|
+
reason: string | null;
|
|
171
|
+
liveOnly: boolean;
|
|
172
|
+
notifications: NotificationItem[];
|
|
173
|
+
apps: MappedApp[];
|
|
174
|
+
enabledApps: string[];
|
|
175
|
+
updatedAt: number;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Account view after the low-balance decoration is applied. */
|
|
179
|
+
type AccountView = AccountSnapshot & { lowBalance: boolean; lowThreshold: number | null };
|
|
180
|
+
|
|
181
|
+
/** Degraded account payload used when the account service throws. */
|
|
182
|
+
interface AccountFallback {
|
|
183
|
+
available: false;
|
|
184
|
+
signedIn: false;
|
|
185
|
+
error: string;
|
|
186
|
+
lowBalance: false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Degraded weather payload used when the weather service rejects. */
|
|
190
|
+
interface WeatherFallback {
|
|
191
|
+
ok: false;
|
|
192
|
+
error: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Degraded performance payload used when sampling throws. */
|
|
196
|
+
interface PerformanceFallback {
|
|
197
|
+
ok: false;
|
|
198
|
+
error: string;
|
|
199
|
+
cpu: { usage: null; model: string; cores: null };
|
|
200
|
+
memory: { total: null; used: null; percent: null };
|
|
201
|
+
gpu: { ok: false; error: string };
|
|
202
|
+
temperatures: unknown[];
|
|
203
|
+
sampledAt: number;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Degraded media payload used when the adapter throws. */
|
|
207
|
+
interface MediaFallback {
|
|
208
|
+
ok: false;
|
|
209
|
+
media: { ok: false; available: false; media: null; error: string };
|
|
210
|
+
volume: { ok: false; volume: number; muted: boolean; error: string };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Notification payload used when the probe snapshot is unusable. */
|
|
214
|
+
interface NotificationFallbackPayload {
|
|
215
|
+
available: false;
|
|
216
|
+
reason: string;
|
|
217
|
+
notifications: NotificationItem[];
|
|
218
|
+
apps: MappedApp[];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Everything the sidebar/phone needs about the running console. */
|
|
222
|
+
export interface InfoPayload {
|
|
223
|
+
version: string;
|
|
224
|
+
platform: NodeJS.Platform;
|
|
225
|
+
platformLabel: string;
|
|
226
|
+
running: boolean;
|
|
227
|
+
port: number | null;
|
|
228
|
+
protocol: 'http' | 'https';
|
|
229
|
+
startedAt: number;
|
|
230
|
+
error: string | null;
|
|
231
|
+
tls: { configured: boolean; active: boolean; error: string | null };
|
|
232
|
+
tokenRequired: boolean;
|
|
233
|
+
primaryUrl: string;
|
|
234
|
+
urls: RemoteUrlEntry[];
|
|
235
|
+
usesLoopbackOnly: boolean;
|
|
236
|
+
qrPath: string;
|
|
237
|
+
routes: { info: string; settings: string; remote: string };
|
|
238
|
+
clients: WebSocketClientInfo[];
|
|
239
|
+
capabilities: { notifications: unknown; media: unknown; volume: unknown };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Aggregate snapshot sent to a phone when it (re)connects. */
|
|
243
|
+
interface OverviewPayload {
|
|
244
|
+
sessions: SessionList;
|
|
245
|
+
sessionsError: string | null;
|
|
246
|
+
weather: WeatherSnapshot | WeatherFallback;
|
|
247
|
+
notifications: NotificationViewPayload | NotificationFallbackPayload;
|
|
248
|
+
account: AccountView | AccountFallback;
|
|
249
|
+
performance: PerformanceSnapshot | PerformanceFallback | null;
|
|
250
|
+
settings: RemoteSettingsPayload;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Full settings-panel payload (settings + diagnostics). */
|
|
254
|
+
export interface SettingsPayload {
|
|
255
|
+
ok: boolean;
|
|
256
|
+
settings: ReturnType<SettingsStore['public']>;
|
|
257
|
+
interfaces: InterfaceEntry[];
|
|
258
|
+
apps: MappedApp[];
|
|
259
|
+
clients: WebSocketClientInfo[];
|
|
260
|
+
info: InfoPayload;
|
|
261
|
+
capabilities: {
|
|
262
|
+
notifications: unknown;
|
|
263
|
+
media: unknown;
|
|
264
|
+
volume: unknown;
|
|
265
|
+
weather: { ok: unknown; updatedAt: unknown };
|
|
266
|
+
probe: ProbeCapability;
|
|
267
|
+
node: NodeCapability;
|
|
268
|
+
};
|
|
269
|
+
diagnostics: {
|
|
270
|
+
platform: NodeJS.Platform;
|
|
271
|
+
platformLabel: string;
|
|
272
|
+
version: string;
|
|
273
|
+
running: boolean;
|
|
274
|
+
protocol: 'http' | 'https';
|
|
275
|
+
clientCount: number;
|
|
276
|
+
notificationApps: number;
|
|
277
|
+
profile: string;
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Options accepted by {@link createRemoteControlService}. */
|
|
282
|
+
export interface RemoteControlOptions {
|
|
283
|
+
log?: (message: string) => void;
|
|
284
|
+
locale?: string;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The Host service surface consumed by `src/dsh/index.ts`. */
|
|
288
|
+
export interface RemoteControlService {
|
|
289
|
+
start(): Promise<InfoPayload>;
|
|
290
|
+
stop(): Promise<void>;
|
|
291
|
+
restart(): Promise<InfoPayload>;
|
|
292
|
+
getInfo(): InfoPayload;
|
|
293
|
+
handleInfo(req: IncomingMessage, res: ServerResponse): Promise<void>;
|
|
294
|
+
handleQr(req: IncomingMessage, res: ServerResponse): Promise<void>;
|
|
295
|
+
handleSettingsGet(req: IncomingMessage, res: ServerResponse): Promise<void>;
|
|
296
|
+
handleSettingsPost(req: IncomingMessage, res: ServerResponse): Promise<void>;
|
|
297
|
+
handleAppNames(req: IncomingMessage, res: ServerResponse): Promise<void>;
|
|
298
|
+
handleLocation(req: IncomingMessage, res: ServerResponse): Promise<void>;
|
|
299
|
+
handleGeocode(req: IncomingMessage, res: ServerResponse): Promise<void>;
|
|
300
|
+
handleTheme(req: IncomingMessage, res: ServerResponse): Promise<void>;
|
|
301
|
+
handleLocale(req: IncomingMessage, res: ServerResponse): Promise<void>;
|
|
302
|
+
handleTokenRotate(req: IncomingMessage, res: ServerResponse): Promise<void>;
|
|
303
|
+
handleDisconnect(req: IncomingMessage, res: ServerResponse): Promise<void>;
|
|
304
|
+
settingsStore: SettingsStore;
|
|
305
|
+
applySettings(patch: JsonRecord): Promise<SettingsValue>;
|
|
306
|
+
dispose(): void;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function safeRelativeFile(value: string): string | null {
|
|
310
|
+
let decoded: string;
|
|
311
|
+
try {
|
|
312
|
+
decoded = decodeURIComponent(value);
|
|
313
|
+
} catch {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
if (!decoded || decoded.includes('\0') || decoded.includes('\\')) return null;
|
|
317
|
+
const segments = decoded.split('/');
|
|
318
|
+
if (!segments.every((segment) => segment && segment !== '.' && segment !== '..' && SAFE_FILE_SEGMENT.test(segment))) {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
return decoded;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
function cookieValue(header: string | undefined, name: string): string | null {
|
|
326
|
+
if (!header) return null;
|
|
327
|
+
for (const part of String(header).split(';')) {
|
|
328
|
+
const index = part.indexOf('=');
|
|
329
|
+
if (index < 0) continue;
|
|
330
|
+
if (part.slice(0, index).trim() === name) return part.slice(index + 1).trim();
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function ipv4ToInt(ip: string): number | null {
|
|
336
|
+
const parts = String(ip).split('.');
|
|
337
|
+
if (parts.length !== 4) return null;
|
|
338
|
+
let value = 0;
|
|
339
|
+
for (const part of parts) {
|
|
340
|
+
const octet = Number(part);
|
|
341
|
+
if (!Number.isInteger(octet) || octet < 0 || octet > 255) return null;
|
|
342
|
+
value = (value << 8) | octet;
|
|
343
|
+
}
|
|
344
|
+
return value >>> 0;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function intToIpv4(value: number): string {
|
|
348
|
+
return [24, 16, 8, 0].map((shift) => (value >>> shift) & 255).join('.');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function prefixFromNetmask(netmask: string): number | null {
|
|
352
|
+
const mask = ipv4ToInt(netmask);
|
|
353
|
+
if (mask === null) return null;
|
|
354
|
+
let prefix = 0;
|
|
355
|
+
for (let bit = 31; bit >= 0; bit -= 1) {
|
|
356
|
+
if (((mask >>> bit) & 1) === 1) prefix += 1;
|
|
357
|
+
else break;
|
|
358
|
+
}
|
|
359
|
+
return prefix;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function cidrFor(address: string, netmask: string): CidrInfo | null {
|
|
363
|
+
const ip = ipv4ToInt(address);
|
|
364
|
+
const mask = ipv4ToInt(netmask);
|
|
365
|
+
if (ip === null || mask === null) return null;
|
|
366
|
+
const prefix = prefixFromNetmask(netmask);
|
|
367
|
+
if (prefix === null) return null;
|
|
368
|
+
return { network: ip & mask, mask, prefix, cidr: `${intToIpv4(ip & mask)}/${prefix}` };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function normalizeIp(value: string | undefined): string {
|
|
372
|
+
const ip = String(value || '');
|
|
373
|
+
return ip.startsWith('::ffff:') ? ip.slice(7) : ip;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function isLoopback(ip: string): boolean {
|
|
377
|
+
return ip === '::1' || ip === '127.0.0.1' || ip.startsWith('127.');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function parsePort(value: string | number): number {
|
|
381
|
+
const n = Number.parseInt(String(value), 10);
|
|
382
|
+
return Number.isInteger(n) && n >= 0 && n <= 65535 ? n : DEFAULT_PORT;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function jsonResponse(res: ServerResponse, status: number, value: unknown, extraHeaders: ResponseHeaders = {}): void {
|
|
386
|
+
const body = JSON.stringify(value);
|
|
387
|
+
res.writeHead(status, {
|
|
388
|
+
'content-type': 'application/json; charset=utf-8',
|
|
389
|
+
'cache-control': 'no-store',
|
|
390
|
+
'content-length': Buffer.byteLength(body),
|
|
391
|
+
...extraHeaders,
|
|
392
|
+
});
|
|
393
|
+
res.end(body);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function textResponse(res: ServerResponse, status: number, body: string, extraHeaders: ResponseHeaders = {}): void {
|
|
397
|
+
res.writeHead(status, {
|
|
398
|
+
'content-type': 'text/html; charset=utf-8',
|
|
399
|
+
'cache-control': 'no-store',
|
|
400
|
+
...extraHeaders,
|
|
401
|
+
});
|
|
402
|
+
res.end(body);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function unauthorizedPage(locale: PluginLocale): string {
|
|
406
|
+
const title = hostMessage(locale, 'pairingTitle');
|
|
407
|
+
const body = hostMessage(locale, 'pairingBody');
|
|
408
|
+
return `<!doctype html><html lang="${locale === 'zh' ? 'zh-CN' : 'en'}"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title>
|
|
409
|
+
<style>html,body{height:100%;margin:0;background:#05070d;color:#f4f7ff;font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Noto Sans SC",sans-serif}main{height:100%;display:grid;place-items:center;padding:32px;box-sizing:border-box}.card{max-width:460px;padding:32px;border:1px solid #1b2436;border-radius:28px;background:#0c111c;box-shadow:0 30px 80px #0008}h1{font-size:24px;margin:0 0 12px}p{color:#93a0b8;margin:0 0 10px}.dot{display:inline-block;width:9px;height:9px;border-radius:50%;background:#4d6bff;margin-right:8px}</style>
|
|
410
|
+
<main><div class="card"><h1><span class="dot"></span>${title}</h1><p>${body}</p></main></html>`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function readBody(req: IncomingMessage, limit: number = BODY_LIMIT): Promise<JsonRecord> {
|
|
414
|
+
return new Promise((resolve, reject) => {
|
|
415
|
+
let size = 0;
|
|
416
|
+
const chunks: Buffer[] = [];
|
|
417
|
+
req.on('data', (chunk: Buffer) => {
|
|
418
|
+
size += chunk.length;
|
|
419
|
+
if (size > limit) {
|
|
420
|
+
reject(new Error('Request body is too large'));
|
|
421
|
+
req.destroy();
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
chunks.push(chunk);
|
|
425
|
+
});
|
|
426
|
+
req.on('end', () => {
|
|
427
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
428
|
+
if (!text) return resolve({});
|
|
429
|
+
try {
|
|
430
|
+
resolve(JSON.parse(text));
|
|
431
|
+
} catch {
|
|
432
|
+
reject(new Error('Invalid JSON'));
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
req.on('error', reject);
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function createRemoteControlService(ctx: DshContext, options: RemoteControlOptions = {}): RemoteControlService {
|
|
440
|
+
const log: (message: string) => void = options.log || (() => {});
|
|
441
|
+
const locale = options.locale || 'zh';
|
|
442
|
+
const settingsStore: SettingsStore = createSettingsStore();
|
|
443
|
+
const appMapping: AppMapping = createAppMapping(settingsStore.dir);
|
|
444
|
+
let hub: WebSocketHub | null = null;
|
|
445
|
+
const pending = createPendingTracker(ctx, {
|
|
446
|
+
onChange: () => {
|
|
447
|
+
void pushSessions(true);
|
|
448
|
+
},
|
|
449
|
+
getLocale: effectiveLocale,
|
|
450
|
+
});
|
|
451
|
+
const weather: WeatherService = createWeatherService();
|
|
452
|
+
const performance: PerformanceService = createPerformanceService();
|
|
453
|
+
const adapters: PlatformAdapters = createPlatformAdapters({ limit: 8 });
|
|
454
|
+
const account: AccountService = createAccountService(ctx, { locale, getSettings: settingsStore.get });
|
|
455
|
+
const preferredPort = parsePort(process.env.DSH_REMOTE_CONTROL_PORT || DEFAULT_PORT);
|
|
456
|
+
const startedAt = Date.now();
|
|
457
|
+
|
|
458
|
+
let server: HttpServer | HttpsServer | null = null;
|
|
459
|
+
let port: number | null = null;
|
|
460
|
+
let running = false;
|
|
461
|
+
let protocol: 'http' | 'https' = 'http';
|
|
462
|
+
let tlsActive = false;
|
|
463
|
+
let tlsError: string | null = null;
|
|
464
|
+
let error: string | null = null;
|
|
465
|
+
let urls: RemoteUrlEntry[] = [];
|
|
466
|
+
let primaryUrl = '';
|
|
467
|
+
let disposed = false;
|
|
468
|
+
let lastWeather: WeatherSnapshot | null = null;
|
|
469
|
+
let loops: NodeJS.Timeout[] | null = null;
|
|
470
|
+
const lastSnapshots = new Map<string, string>();
|
|
471
|
+
let notificationsBusy = false;
|
|
472
|
+
let mediaBusy = false;
|
|
473
|
+
let accountBusy = false;
|
|
474
|
+
let weatherBusy = false;
|
|
475
|
+
let performanceBusy = false;
|
|
476
|
+
let performanceCache: PerformanceSnapshot | null = null;
|
|
477
|
+
let performanceAt = 0;
|
|
478
|
+
|
|
479
|
+
function settings(): SettingsValue {
|
|
480
|
+
return settingsStore.get();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function effectiveTheme(): string {
|
|
484
|
+
const value = settings();
|
|
485
|
+
return value.theme === 'dsh' ? value.dshTheme : value.theme;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Console language after the plugin setting and the synced DSH locale. */
|
|
489
|
+
function effectiveLocale(): PluginLocale {
|
|
490
|
+
const value = settings();
|
|
491
|
+
return resolveLocale(value.language, value.dshLocale);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function interfaceEntries(withUrls: boolean = false): InterfaceEntry[] {
|
|
495
|
+
const selected = settings().allowedInterfaces;
|
|
496
|
+
const entries: Array<InterfaceEntry & { score: number }> = [];
|
|
497
|
+
for (const [name, addresses] of Object.entries(networkInterfaces())) {
|
|
498
|
+
for (const address of addresses ?? []) {
|
|
499
|
+
if (address.family !== 'IPv4' || address.internal) continue;
|
|
500
|
+
const cidr = cidrFor(address.address, address.netmask);
|
|
501
|
+
const isSelected = selected === null || selected.includes(name);
|
|
502
|
+
let score = 5;
|
|
503
|
+
if (address.address.startsWith('192.168.')) score = 0;
|
|
504
|
+
else if (address.address.startsWith('10.')) score = 1;
|
|
505
|
+
else if (/^172\.(1[6-9]|2\d|3[01])\./.test(address.address)) score = 2;
|
|
506
|
+
if (/wi-?fi|wlan|wireless|ethernet|en0|eth0/i.test(name)) score -= 0.5;
|
|
507
|
+
if (/vethernet|virtual|vmware|vbox|tailscale|zerotier|wsl|docker|hyper-v/i.test(name)) score += 4;
|
|
508
|
+
entries.push({
|
|
509
|
+
name,
|
|
510
|
+
address: address.address,
|
|
511
|
+
netmask: address.netmask,
|
|
512
|
+
cidr: cidr?.cidr || null,
|
|
513
|
+
selected: isSelected,
|
|
514
|
+
score,
|
|
515
|
+
...(withUrls && port ? { url: `${protocol}://${address.address}:${port}/` } : {}),
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
entries.sort((a, b) => {
|
|
520
|
+
if (a.selected !== b.selected) return a.selected ? -1 : 1;
|
|
521
|
+
return a.score - b.score || a.name.localeCompare(b.name) || a.address.localeCompare(b.address);
|
|
522
|
+
});
|
|
523
|
+
return entries.map(({ score, ...entry }) => entry);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function selectedNetworks(): CidrInfo[] {
|
|
527
|
+
return interfaceEntries()
|
|
528
|
+
.filter((entry) => entry.selected)
|
|
529
|
+
.map((entry) => cidrFor(entry.address, entry.netmask))
|
|
530
|
+
.filter((entry): entry is CidrInfo => entry !== null);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function ipAllowed(remoteAddress: string | undefined): boolean {
|
|
534
|
+
const ip = normalizeIp(remoteAddress);
|
|
535
|
+
if (!ip) return false;
|
|
536
|
+
if (isLoopback(ip)) return true;
|
|
537
|
+
const value = ipv4ToInt(ip);
|
|
538
|
+
if (value === null) return false;
|
|
539
|
+
return selectedNetworks().some((network) => (value & network.mask) === (network.network & network.mask));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function recomputeUrls(): void {
|
|
543
|
+
if (!port) {
|
|
544
|
+
urls = [];
|
|
545
|
+
primaryUrl = '';
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
urls = interfaceEntries(true)
|
|
549
|
+
.filter((entry) => entry.selected)
|
|
550
|
+
.map((entry) => ({ name: entry.name, address: entry.address, url: entry.url }));
|
|
551
|
+
primaryUrl = urls[0]?.url || `${protocol}://127.0.0.1:${port}/`;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function remoteTokenQuery(): string {
|
|
555
|
+
const value = settings();
|
|
556
|
+
return value.tokenEnabled ? `?token=${encodeURIComponent(value.token)}` : '';
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function remoteUrl(): string {
|
|
560
|
+
if (!primaryUrl) return '';
|
|
561
|
+
return `${primaryUrl}${remoteTokenQuery()}`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function getInfo(): InfoPayload {
|
|
565
|
+
const value = settings();
|
|
566
|
+
const mediaStatus = adapters.media?.status?.() || {};
|
|
567
|
+
const notificationStatus = adapters.notifications?.status?.() || {};
|
|
568
|
+
return {
|
|
569
|
+
version: VERSION,
|
|
570
|
+
platform: adapters.platform,
|
|
571
|
+
platformLabel: adapters.label,
|
|
572
|
+
running,
|
|
573
|
+
port,
|
|
574
|
+
protocol,
|
|
575
|
+
startedAt,
|
|
576
|
+
error,
|
|
577
|
+
tls: {
|
|
578
|
+
configured: value.tls.enabled,
|
|
579
|
+
active: tlsActive,
|
|
580
|
+
error: tlsError,
|
|
581
|
+
},
|
|
582
|
+
tokenRequired: value.tokenEnabled,
|
|
583
|
+
primaryUrl,
|
|
584
|
+
urls,
|
|
585
|
+
usesLoopbackOnly: running && urls.length === 0,
|
|
586
|
+
qrPath: `${QR_PATH}?v=${startedAt}${value.tokenEnabled ? `&token=${encodeURIComponent(value.token)}` : ''}`,
|
|
587
|
+
routes: {
|
|
588
|
+
info: INFO_PATH,
|
|
589
|
+
settings: SETTINGS_PATH,
|
|
590
|
+
remote: remoteUrl(),
|
|
591
|
+
},
|
|
592
|
+
clients: hub?.info?.() ?? [],
|
|
593
|
+
capabilities: {
|
|
594
|
+
notifications: notificationStatus,
|
|
595
|
+
media: mediaStatus.media ?? {},
|
|
596
|
+
volume: mediaStatus.volume ?? {},
|
|
597
|
+
},
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function remoteSettings(): RemoteSettingsPayload {
|
|
602
|
+
return {
|
|
603
|
+
theme: effectiveTheme(),
|
|
604
|
+
locale: effectiveLocale(),
|
|
605
|
+
temperatureUnit: settings().temperatureUnit,
|
|
606
|
+
notificationLimit: settings().notifications.limit,
|
|
607
|
+
pushQuestions: settings().notifications.pushQuestions,
|
|
608
|
+
navPosition: settings().navPosition,
|
|
609
|
+
balance: { ...settings().balance },
|
|
610
|
+
pomodoro: { ...settings().pomodoro },
|
|
611
|
+
countdown: {
|
|
612
|
+
enabled: settings().countdown.enabled,
|
|
613
|
+
presets: [...settings().countdown.presets],
|
|
614
|
+
},
|
|
615
|
+
pageOrder: [...settings().pageOrder],
|
|
616
|
+
pageEnabled: { ...settings().pageEnabled },
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function isAuthorized(req: IncomingMessage, url: URL): boolean {
|
|
621
|
+
if (!ipAllowed(req.socket.remoteAddress)) return false;
|
|
622
|
+
const value = settings();
|
|
623
|
+
if (!value.tokenEnabled) return true;
|
|
624
|
+
return url.searchParams.get('token') === value.token || cookieValue(req.headers.cookie, COOKIE_NAME) === value.token;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function authHeaders(url: URL): ResponseHeaders {
|
|
628
|
+
const value = settings();
|
|
629
|
+
if (!value.tokenEnabled || url.searchParams.get('token') !== value.token) return {};
|
|
630
|
+
return {
|
|
631
|
+
'set-cookie': `${COOKIE_NAME}=${value.token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000${tlsActive ? '; Secure' : ''}`,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function serveStatic(
|
|
636
|
+
req: IncomingMessage,
|
|
637
|
+
res: ServerResponse,
|
|
638
|
+
pathname: string,
|
|
639
|
+
extraHeaders: ResponseHeaders = {},
|
|
640
|
+
): Promise<boolean> {
|
|
641
|
+
const map: Record<string, string> = {
|
|
642
|
+
'/': 'index.html',
|
|
643
|
+
'/index.html': 'index.html',
|
|
644
|
+
'/manifest.webmanifest': 'manifest.webmanifest',
|
|
645
|
+
'/icon.svg': 'icon.svg',
|
|
646
|
+
};
|
|
647
|
+
let file: string | undefined = map[pathname];
|
|
648
|
+
if (!file && pathname.startsWith('/assets/')) {
|
|
649
|
+
const relative = safeRelativeFile(pathname.slice('/assets/'.length));
|
|
650
|
+
if (relative) file = `assets/${relative}`;
|
|
651
|
+
}
|
|
652
|
+
if (!file && pathname.startsWith('/fonts/')) {
|
|
653
|
+
const relative = safeRelativeFile(pathname.slice('/fonts/'.length));
|
|
654
|
+
if (relative) file = `fonts/${relative}`;
|
|
655
|
+
}
|
|
656
|
+
if (!file) return false;
|
|
657
|
+
const absolute = path.resolve(WEB_DIR, file);
|
|
658
|
+
const webRoot = path.resolve(WEB_DIR);
|
|
659
|
+
if (!absolute.startsWith(`${webRoot}${path.sep}`)) return false;
|
|
660
|
+
try {
|
|
661
|
+
const body = await fs.readFile(absolute);
|
|
662
|
+
const fontAsset = /\.(woff2|woff|ttf|otf)$/i.test(file);
|
|
663
|
+
res.writeHead(200, {
|
|
664
|
+
'content-type': MIME[path.extname(file)] || 'application/octet-stream',
|
|
665
|
+
'cache-control': fontAsset ? 'public, max-age=31536000, immutable' : 'no-store',
|
|
666
|
+
'content-length': body.length,
|
|
667
|
+
'x-content-type-options': 'nosniff',
|
|
668
|
+
'referrer-policy': 'no-referrer',
|
|
669
|
+
'content-security-policy':
|
|
670
|
+
"default-src 'self'; img-src 'self' data: blob: https: http:; media-src 'self' data: blob: https: http:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ws: wss: https:; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
|
671
|
+
...extraHeaders,
|
|
672
|
+
});
|
|
673
|
+
res.end(req.method === 'HEAD' ? undefined : body);
|
|
674
|
+
return true;
|
|
675
|
+
} catch (readError) {
|
|
676
|
+
const code = isRecord(readError) ? readError.code : undefined;
|
|
677
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
678
|
+
textResponse(res, 404, 'Asset not found');
|
|
679
|
+
} else {
|
|
680
|
+
textResponse(res, 500, `Asset unavailable: ${serializeError(readError)}`);
|
|
681
|
+
}
|
|
682
|
+
return true;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Stream the configured timer alert sound to an authorized console tab.
|
|
688
|
+
*
|
|
689
|
+
* Both the pomodoro and the countdown use this file. The console never learns
|
|
690
|
+
* host filesystem paths; it only asks this route for the currently configured
|
|
691
|
+
* file. Remote URLs are configured on the phone side and played directly, so
|
|
692
|
+
* this route is only for local files.
|
|
693
|
+
*/
|
|
694
|
+
async function servePomodoroSound(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
695
|
+
const configured = settings().pomodoro.soundPath.trim();
|
|
696
|
+
if (!configured) {
|
|
697
|
+
jsonResponse(res, 404, { ok: false, error: 'no pomodoro sound configured' });
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
if (/^(https?:)?\/\//i.test(configured) || /^(data|blob):/i.test(configured)) {
|
|
701
|
+
jsonResponse(res, 404, { ok: false, error: 'remote sound urls are played directly by the console' });
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
const absolute = path.resolve(configured);
|
|
705
|
+
try {
|
|
706
|
+
const stat = await fs.stat(absolute);
|
|
707
|
+
if (!stat.isFile()) {
|
|
708
|
+
jsonResponse(res, 404, { ok: false, error: 'pomodoro sound is not a file' });
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (stat.size > 64 * 1024 * 1024) {
|
|
712
|
+
jsonResponse(res, 413, { ok: false, error: 'pomodoro sound is too large' });
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
const body = await fs.readFile(absolute);
|
|
716
|
+
res.writeHead(200, {
|
|
717
|
+
'content-type': MIME[path.extname(absolute).toLowerCase()] || 'application/octet-stream',
|
|
718
|
+
'content-length': body.length,
|
|
719
|
+
'cache-control': 'no-store',
|
|
720
|
+
'x-content-type-options': 'nosniff',
|
|
721
|
+
'referrer-policy': 'no-referrer',
|
|
722
|
+
});
|
|
723
|
+
res.end(req.method === 'HEAD' ? undefined : body);
|
|
724
|
+
} catch (errorValue) {
|
|
725
|
+
const code = isRecord(errorValue) ? errorValue.code : undefined;
|
|
726
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
727
|
+
jsonResponse(res, 404, { ok: false, error: 'pomodoro sound file not found' });
|
|
728
|
+
} else {
|
|
729
|
+
jsonResponse(res, 500, { ok: false, error: serializeError(errorValue) });
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function sameOrigin(req: IncomingMessage): boolean { const origin = req.headers.origin;
|
|
735
|
+
if (!origin) return true;
|
|
736
|
+
const host = req.headers.host;
|
|
737
|
+
return origin === `http://${host}` || origin === `https://${host}`;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function handleRemote(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
741
|
+
try {
|
|
742
|
+
if (!ipAllowed(req.socket.remoteAddress)) {
|
|
743
|
+
textResponse(res, 403, 'This device is not allowed to access the console.');
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
const url = new URL(req.url || '/', `${protocol}://${req.headers.host || '127.0.0.1'}`);
|
|
747
|
+
const pathname = url.pathname;
|
|
748
|
+
const authorized = isAuthorized(req, url);
|
|
749
|
+
|
|
750
|
+
if (pathname === '/' && !authorized) {
|
|
751
|
+
textResponse(res, 401, unauthorizedPage(effectiveLocale()), {
|
|
752
|
+
'x-content-type-options': 'nosniff',
|
|
753
|
+
'referrer-policy': 'no-referrer',
|
|
754
|
+
});
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
const staticPath = pathname === '/'
|
|
758
|
+
|| pathname === '/index.html'
|
|
759
|
+
|| pathname === '/manifest.webmanifest'
|
|
760
|
+
|| pathname === '/icon.svg'
|
|
761
|
+
|| pathname.startsWith('/assets/')
|
|
762
|
+
|| pathname.startsWith('/fonts/');
|
|
763
|
+
if (staticPath && !authorized) {
|
|
764
|
+
textResponse(res, 401, 'Pairing required');
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (pathname === POMODORO_SOUND_PATH) {
|
|
768
|
+
if (!authorized) {
|
|
769
|
+
textResponse(res, 401, 'Pairing required');
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
773
|
+
textResponse(res, 405, 'Method not allowed');
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
await servePomodoroSound(req, res);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
780
|
+
if (await serveStatic(req, res, pathname, authHeaders(url))) return;
|
|
781
|
+
}
|
|
782
|
+
if (pathname === '/api/health') {
|
|
783
|
+
jsonResponse(res, 200, { ok: true, version: VERSION, now: Date.now() });
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if (pathname === '/favicon.ico') {
|
|
787
|
+
res.writeHead(204);
|
|
788
|
+
res.end();
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
jsonResponse(res, 404, { ok: false, error: 'not found' });
|
|
792
|
+
} catch (errorValue) {
|
|
793
|
+
log(`request failed: ${serializeError(errorValue)}`);
|
|
794
|
+
if (!res.headersSent) jsonResponse(res, 500, { ok: false, error: serializeError(errorValue) });
|
|
795
|
+
else res.end();
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function notificationView(raw: NotificationPayload): NotificationViewPayload {
|
|
800
|
+
const value = settings();
|
|
801
|
+
const enabled = new Set<string>(value.notifications.enabledApps);
|
|
802
|
+
const apps = appMapping.list();
|
|
803
|
+
const list = (Array.isArray(raw?.notifications) ? raw.notifications : [])
|
|
804
|
+
.filter((item) => enabled.size === 0 || enabled.has(item.appKey ?? ''))
|
|
805
|
+
.slice(0, value.notifications.limit)
|
|
806
|
+
.map((item) => {
|
|
807
|
+
const appName = appMapping.displayName(item.appKey ?? '');
|
|
808
|
+
// The probe falls back to the raw app id when a toast has no text.
|
|
809
|
+
const title = item.title && item.title !== item.appKey ? item.title : appName;
|
|
810
|
+
return { ...item, appName, title };
|
|
811
|
+
});
|
|
812
|
+
return {
|
|
813
|
+
available: raw?.available !== false,
|
|
814
|
+
reason: raw?.reason || null,
|
|
815
|
+
liveOnly: Boolean(raw?.liveOnly),
|
|
816
|
+
notifications: list,
|
|
817
|
+
apps,
|
|
818
|
+
enabledApps: [...value.notifications.enabledApps],
|
|
819
|
+
updatedAt: raw?.updatedAt || Date.now(),
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
async function getNotificationsView(): Promise<NotificationViewPayload> {
|
|
824
|
+
let raw: NotificationPayload;
|
|
825
|
+
try {
|
|
826
|
+
raw = await adapters.notifications.getNotifications();
|
|
827
|
+
} catch (errorValue) {
|
|
828
|
+
raw = { available: false, reason: serializeError(errorValue), notifications: [], apps: [] };
|
|
829
|
+
}
|
|
830
|
+
if (Array.isArray(raw.apps)) {
|
|
831
|
+
appMapping.record(
|
|
832
|
+
raw.apps.map((app) => ({ key: app.key, count: app.count })),
|
|
833
|
+
'notification',
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
return notificationView(raw);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
async function getWeatherView(): Promise<WeatherSnapshot> {
|
|
840
|
+
const value = settings();
|
|
841
|
+
let input: WeatherInput = {};
|
|
842
|
+
if (value.weather.mode === 'manual') {
|
|
843
|
+
if (value.weather.latitude !== null && value.weather.longitude !== null) {
|
|
844
|
+
input = {
|
|
845
|
+
latitude: value.weather.latitude,
|
|
846
|
+
longitude: value.weather.longitude,
|
|
847
|
+
name: value.weather.name,
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
const result: WeatherSnapshot = await weather.getWeather(input);
|
|
852
|
+
result.forecastMode = value.weather.forecastMode === 'daily' ? 'daily' : 'hourly';
|
|
853
|
+
lastWeather = result;
|
|
854
|
+
return result;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function decorateAccount(value: AccountSnapshot): AccountView {
|
|
858
|
+
const configured: unknown = settings().balance.lowThreshold;
|
|
859
|
+
const threshold = configured === null || configured === '' ? null : Number(configured);
|
|
860
|
+
if (
|
|
861
|
+
value?.signedIn &&
|
|
862
|
+
isRecord(value.balance) &&
|
|
863
|
+
Number.isFinite(Number(value.balance.balance)) &&
|
|
864
|
+
threshold !== null &&
|
|
865
|
+
Number.isFinite(threshold) &&
|
|
866
|
+
threshold >= 0
|
|
867
|
+
) {
|
|
868
|
+
return {
|
|
869
|
+
...value,
|
|
870
|
+
lowBalance: Number(value.balance.balance) < threshold,
|
|
871
|
+
lowThreshold: threshold,
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
return { ...value, lowBalance: false, lowThreshold: null };
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
async function getAccountView(): Promise<AccountView | AccountFallback> {
|
|
878
|
+
try {
|
|
879
|
+
return decorateAccount(await account.getAccount());
|
|
880
|
+
} catch (errorValue) {
|
|
881
|
+
return {
|
|
882
|
+
available: false,
|
|
883
|
+
signedIn: false,
|
|
884
|
+
error: serializeError(errorValue),
|
|
885
|
+
lowBalance: false,
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
async function getPerformanceView(force: boolean = false): Promise<PerformanceSnapshot | PerformanceFallback> {
|
|
891
|
+
const now = Date.now();
|
|
892
|
+
if (!force && performanceCache && now - performanceAt < 1600) return performanceCache;
|
|
893
|
+
try {
|
|
894
|
+
performanceCache = await performance.sample(force);
|
|
895
|
+
performanceAt = now;
|
|
896
|
+
return performanceCache;
|
|
897
|
+
} catch (errorValue) {
|
|
898
|
+
return {
|
|
899
|
+
ok: false,
|
|
900
|
+
error: serializeError(errorValue),
|
|
901
|
+
cpu: { usage: null, model: '', cores: null },
|
|
902
|
+
memory: { total: null, used: null, percent: null },
|
|
903
|
+
gpu: { ok: false, error: serializeError(errorValue) },
|
|
904
|
+
temperatures: [],
|
|
905
|
+
sampledAt: now,
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** Session list sent to the phone, with question details only when enabled. */
|
|
911
|
+
function consoleSessions(): SessionList {
|
|
912
|
+
const sessions = collectSessions(ctx, pending, effectiveLocale);
|
|
913
|
+
if (settings().notifications.pushQuestions) return sessions;
|
|
914
|
+
return sessions.map((session) => session.pending
|
|
915
|
+
? { ...session, pending: { ...session.pending, questions: [], answerable: false } }
|
|
916
|
+
: session);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
async function buildOverview(): Promise<OverviewPayload> {
|
|
920
|
+
const enabled = settings().pageEnabled;
|
|
921
|
+
const [weatherResult, notificationsResult, accountResult, performanceResult] = await Promise.allSettled([
|
|
922
|
+
enabled.home ? getWeatherView() : Promise.resolve(null),
|
|
923
|
+
enabled.home ? getNotificationsView() : Promise.resolve(null),
|
|
924
|
+
enabled.sessions ? getAccountView() : Promise.resolve(null),
|
|
925
|
+
enabled.performance ? getPerformanceView() : Promise.resolve(null),
|
|
926
|
+
]);
|
|
927
|
+
let sessions: SessionList = [];
|
|
928
|
+
let sessionsError: string | null = null;
|
|
929
|
+
if (enabled.sessions) {
|
|
930
|
+
try {
|
|
931
|
+
sessions = consoleSessions();
|
|
932
|
+
} catch (errorValue) {
|
|
933
|
+
sessionsError = serializeError(errorValue);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return {
|
|
937
|
+
sessions,
|
|
938
|
+
sessionsError,
|
|
939
|
+
weather:
|
|
940
|
+
weatherResult.status === 'fulfilled' && weatherResult.value
|
|
941
|
+
? weatherResult.value
|
|
942
|
+
: { ok: false, error: serializeError('reason' in weatherResult ? weatherResult.reason : undefined) },
|
|
943
|
+
notifications:
|
|
944
|
+
notificationsResult.status === 'fulfilled' && notificationsResult.value
|
|
945
|
+
? notificationsResult.value
|
|
946
|
+
: {
|
|
947
|
+
available: false,
|
|
948
|
+
reason: serializeError('reason' in notificationsResult ? notificationsResult.reason : undefined),
|
|
949
|
+
notifications: [],
|
|
950
|
+
apps: [],
|
|
951
|
+
},
|
|
952
|
+
account:
|
|
953
|
+
accountResult.status === 'fulfilled' && accountResult.value
|
|
954
|
+
? accountResult.value
|
|
955
|
+
: {
|
|
956
|
+
available: false,
|
|
957
|
+
signedIn: false,
|
|
958
|
+
error: serializeError('reason' in accountResult ? accountResult.reason : undefined),
|
|
959
|
+
lowBalance: false,
|
|
960
|
+
},
|
|
961
|
+
performance:
|
|
962
|
+
performanceResult.status === 'fulfilled' && performanceResult.value
|
|
963
|
+
? performanceResult.value
|
|
964
|
+
: null,
|
|
965
|
+
settings: remoteSettings(),
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
async function buildMedia(): Promise<MediaSnapshot | MediaFallback> {
|
|
970
|
+
try {
|
|
971
|
+
const value = await adapters.media.getState();
|
|
972
|
+
const media = value.media?.media ?? null;
|
|
973
|
+
if (media?.appId) {
|
|
974
|
+
appMapping.record([{ key: media.appId }], 'music');
|
|
975
|
+
media.appName = appMapping.displayName(media.appId);
|
|
976
|
+
}
|
|
977
|
+
return value;
|
|
978
|
+
} catch (errorValue) {
|
|
979
|
+
return {
|
|
980
|
+
ok: false,
|
|
981
|
+
media: { ok: false, available: false, media: null, error: serializeError(errorValue) },
|
|
982
|
+
volume: { ok: false, volume: 0.5, muted: false, error: serializeError(errorValue) },
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function push(type: string, data: unknown, force: boolean = false): void {
|
|
988
|
+
if (!hub || hub.size() === 0) return;
|
|
989
|
+
let serialized: string;
|
|
990
|
+
try {
|
|
991
|
+
serialized = JSON.stringify(data);
|
|
992
|
+
} catch {
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
if (!force && lastSnapshots.get(type) === serialized) return;
|
|
996
|
+
lastSnapshots.set(type, serialized);
|
|
997
|
+
hub.broadcast({ type, data });
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
async function pushSessions(force: boolean = false): Promise<void> {
|
|
1001
|
+
if (!hub || hub.size() === 0 || !settings().pageEnabled.sessions) return;
|
|
1002
|
+
let sessions: SessionList = [];
|
|
1003
|
+
try {
|
|
1004
|
+
sessions = consoleSessions();
|
|
1005
|
+
} catch {
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
push('sessions', sessions, force);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
async function pushNotifications(force: boolean = false): Promise<void> {
|
|
1012
|
+
if (!hub || hub.size() === 0 || notificationsBusy || !settings().pageEnabled.home) return;
|
|
1013
|
+
notificationsBusy = true;
|
|
1014
|
+
try {
|
|
1015
|
+
const view = await getNotificationsView();
|
|
1016
|
+
push('notifications', view, force);
|
|
1017
|
+
} finally {
|
|
1018
|
+
notificationsBusy = false;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
async function pushAccount(force: boolean = false): Promise<void> {
|
|
1023
|
+
if (!hub || hub.size() === 0 || accountBusy || !settings().pageEnabled.sessions) return;
|
|
1024
|
+
accountBusy = true;
|
|
1025
|
+
try {
|
|
1026
|
+
const value = await getAccountView();
|
|
1027
|
+
push('account', value, force);
|
|
1028
|
+
} finally {
|
|
1029
|
+
accountBusy = false;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
async function pushWeather(force: boolean = false): Promise<void> {
|
|
1034
|
+
if (!hub || hub.size() === 0 || weatherBusy || !settings().pageEnabled.home) return;
|
|
1035
|
+
weatherBusy = true;
|
|
1036
|
+
try {
|
|
1037
|
+
const value = await getWeatherView();
|
|
1038
|
+
push('weather', value, force);
|
|
1039
|
+
} finally {
|
|
1040
|
+
weatherBusy = false;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
async function pushMedia(force: boolean = false): Promise<void> {
|
|
1045
|
+
if (!hub || hub.size() === 0 || mediaBusy || !settings().pageEnabled.music) return;
|
|
1046
|
+
mediaBusy = true;
|
|
1047
|
+
try {
|
|
1048
|
+
const value = await buildMedia();
|
|
1049
|
+
push('media', value, force);
|
|
1050
|
+
} finally {
|
|
1051
|
+
mediaBusy = false;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
async function pushPerformance(force: boolean = false): Promise<void> {
|
|
1056
|
+
if (!hub || hub.size() === 0 || performanceBusy) return;
|
|
1057
|
+
if (!settings().pageEnabled.performance) return;
|
|
1058
|
+
performanceBusy = true;
|
|
1059
|
+
try {
|
|
1060
|
+
const value = await getPerformanceView(force);
|
|
1061
|
+
push('performance', value, force);
|
|
1062
|
+
} finally {
|
|
1063
|
+
performanceBusy = false;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function pushSettings(force: boolean = false): void {
|
|
1068
|
+
push('settings', remoteSettings(), force);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function pushClients(): void {
|
|
1072
|
+
if (!hub) return;
|
|
1073
|
+
push('clients', hub.info(), true);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function startLoops(): void {
|
|
1077
|
+
if (loops) return;
|
|
1078
|
+
loops = [
|
|
1079
|
+
setInterval(() => {
|
|
1080
|
+
if (!hub || hub.size() === 0) {
|
|
1081
|
+
stopLoops();
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
if (!settings().pageEnabled.sessions) return;
|
|
1085
|
+
void pushSessions();
|
|
1086
|
+
}, 1500),
|
|
1087
|
+
setInterval(() => {
|
|
1088
|
+
if (!hub || hub.size() === 0) return;
|
|
1089
|
+
if (!settings().pageEnabled.home) return;
|
|
1090
|
+
void pushNotifications();
|
|
1091
|
+
}, 2500),
|
|
1092
|
+
setInterval(() => {
|
|
1093
|
+
if (!hub || hub.size() === 0) return;
|
|
1094
|
+
if (!settings().pageEnabled.music) return;
|
|
1095
|
+
void pushMedia();
|
|
1096
|
+
}, 2200),
|
|
1097
|
+
setInterval(() => {
|
|
1098
|
+
if (!hub || hub.size() === 0) return;
|
|
1099
|
+
if (!settings().pageEnabled.sessions) return;
|
|
1100
|
+
void pushAccount();
|
|
1101
|
+
}, 30_000),
|
|
1102
|
+
setInterval(() => {
|
|
1103
|
+
if (!hub || hub.size() === 0) return;
|
|
1104
|
+
if (!settings().pageEnabled.home) return;
|
|
1105
|
+
void pushWeather();
|
|
1106
|
+
}, 5 * 60_000),
|
|
1107
|
+
setInterval(() => {
|
|
1108
|
+
if (!hub || hub.size() === 0) return;
|
|
1109
|
+
if (!settings().pageEnabled.performance) return;
|
|
1110
|
+
void pushPerformance();
|
|
1111
|
+
}, 2500),
|
|
1112
|
+
];
|
|
1113
|
+
for (const timer of loops) timer.unref?.();
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function stopLoops(): void {
|
|
1117
|
+
if (!loops) return;
|
|
1118
|
+
for (const timer of loops) clearInterval(timer);
|
|
1119
|
+
loops = null;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
async function sendInitial(client: WebSocketClient): Promise<void> {
|
|
1123
|
+
try {
|
|
1124
|
+
client.send({ type: 'welcome', settings: remoteSettings() });
|
|
1125
|
+
const enabled = settings().pageEnabled;
|
|
1126
|
+
const [overview, media, performanceView] = await Promise.all([
|
|
1127
|
+
buildOverview(),
|
|
1128
|
+
enabled.music ? buildMedia() : Promise.resolve(null),
|
|
1129
|
+
enabled.performance ? getPerformanceView() : Promise.resolve(null),
|
|
1130
|
+
]);
|
|
1131
|
+
client.send({ type: 'overview', data: overview });
|
|
1132
|
+
lastSnapshots.set('overview', JSON.stringify(overview));
|
|
1133
|
+
if (media) {
|
|
1134
|
+
client.send({ type: 'media', data: media });
|
|
1135
|
+
lastSnapshots.set('media', JSON.stringify(media));
|
|
1136
|
+
}
|
|
1137
|
+
if (performanceView) {
|
|
1138
|
+
client.send({ type: 'performance', data: performanceView });
|
|
1139
|
+
lastSnapshots.set('performance', JSON.stringify(performanceView));
|
|
1140
|
+
}
|
|
1141
|
+
client.send({ type: 'clients', data: hub?.info?.() ?? [] });
|
|
1142
|
+
} catch (errorValue) {
|
|
1143
|
+
client.send({ type: 'error', error: serializeError(errorValue) });
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
async function handleSocketMessage(client: WebSocketClient, message: unknown): Promise<void> {
|
|
1148
|
+
if (!isRecord(message)) return;
|
|
1149
|
+
if (message.type === 'action') {
|
|
1150
|
+
const action = safeString(message.action, 40).toLowerCase();
|
|
1151
|
+
if (!['playpause', 'next', 'previous', 'seek', 'like', 'volume', 'mute'].includes(action)) {
|
|
1152
|
+
client.send({ type: 'action-result', ok: false, action, error: 'Unsupported action' });
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
try {
|
|
1156
|
+
const value =
|
|
1157
|
+
action === 'like'
|
|
1158
|
+
? message.value === true
|
|
1159
|
+
? true
|
|
1160
|
+
: message.value === false
|
|
1161
|
+
? false
|
|
1162
|
+
: null
|
|
1163
|
+
: finiteNumber(message.value, null);
|
|
1164
|
+
await adapters.media.control(action, value);
|
|
1165
|
+
client.send({ type: 'action-result', ok: true, action });
|
|
1166
|
+
await pushMedia(true);
|
|
1167
|
+
} catch (errorValue) {
|
|
1168
|
+
client.send({ type: 'action-result', ok: false, action, error: serializeError(errorValue) });
|
|
1169
|
+
}
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
if (message.type === 'refresh') {
|
|
1173
|
+
await sendInitial(client);
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
if (message.type === 'settings') {
|
|
1177
|
+
client.send({ type: 'settings', data: remoteSettings() });
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
if (message.type === 'answer-question') {
|
|
1181
|
+
const sessionId = safeString(message.sessionId, 200);
|
|
1182
|
+
const requestId = safeString(message.requestId, 200);
|
|
1183
|
+
if (!settings().notifications.pushQuestions) {
|
|
1184
|
+
client.send({ type: 'answer-result', ok: false, sessionId, requestId, error: hostMessage(effectiveLocale(), 'answerPushOff') });
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
const answer: PendingAnswer | null = parsePendingAnswer(message);
|
|
1188
|
+
if (!sessionId || !requestId || !answer) {
|
|
1189
|
+
client.send({ type: 'answer-result', ok: false, sessionId, requestId, error: hostMessage(effectiveLocale(), 'answerInvalid') });
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
const result = await pending.respond(sessionId, requestId, answer);
|
|
1193
|
+
client.send({ type: 'answer-result', ok: result.ok, sessionId, requestId, error: result.error });
|
|
1194
|
+
if (result.ok) await pushSessions(true);
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
if (message.type === 'ping') {
|
|
1198
|
+
client.send({ type: 'pong' });
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function authorizeSocket(req: IncomingMessage, url: URL): WebSocketAuthorizeDecision {
|
|
1203
|
+
if (!ipAllowed(req.socket.remoteAddress)) return { ok: false, status: 403 };
|
|
1204
|
+
const origin = req.headers.origin;
|
|
1205
|
+
if (origin) {
|
|
1206
|
+
const host = req.headers.host;
|
|
1207
|
+
if (origin !== `http://${host}` && origin !== `https://${host}`) return { ok: false, status: 403 };
|
|
1208
|
+
}
|
|
1209
|
+
const value = settings();
|
|
1210
|
+
if (!value.tokenEnabled) return { ok: true };
|
|
1211
|
+
const cookie = cookieValue(req.headers.cookie, COOKIE_NAME);
|
|
1212
|
+
const query = url.searchParams.get('token');
|
|
1213
|
+
if (cookie === value.token || query === value.token) return { ok: true };
|
|
1214
|
+
return { ok: false, status: 401 };
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function attachHub(instance: HttpServer | HttpsServer): WebSocketHub {
|
|
1218
|
+
return createWebSocketHub({
|
|
1219
|
+
server: instance,
|
|
1220
|
+
path: '/ws',
|
|
1221
|
+
authorize: authorizeSocket,
|
|
1222
|
+
onConnection(client: WebSocketClient): void {
|
|
1223
|
+
startLoops();
|
|
1224
|
+
pushClients();
|
|
1225
|
+
void sendInitial(client);
|
|
1226
|
+
},
|
|
1227
|
+
onMessage(client: WebSocketClient, message: unknown): void {
|
|
1228
|
+
void handleSocketMessage(client, message);
|
|
1229
|
+
},
|
|
1230
|
+
onClose(): void {
|
|
1231
|
+
if (!hub || hub.size() === 0) stopLoops();
|
|
1232
|
+
pushClients();
|
|
1233
|
+
},
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
async function listen(instance: HttpServer | HttpsServer, selectedPort: number): Promise<void> {
|
|
1238
|
+
await new Promise<void>((resolve, reject) => {
|
|
1239
|
+
const onError = (listenError: unknown): void => {
|
|
1240
|
+
instance.off('listening', onListening);
|
|
1241
|
+
reject(listenError);
|
|
1242
|
+
};
|
|
1243
|
+
const onListening = (): void => {
|
|
1244
|
+
instance.off('error', onError);
|
|
1245
|
+
resolve();
|
|
1246
|
+
};
|
|
1247
|
+
instance.once('error', onError);
|
|
1248
|
+
instance.once('listening', onListening);
|
|
1249
|
+
instance.listen(selectedPort, '0.0.0.0');
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
async function createInstance(): Promise<HttpServer | HttpsServer> {
|
|
1254
|
+
const value = settings();
|
|
1255
|
+
let tlsOptions: { cert: Buffer; key: Buffer } | null = null;
|
|
1256
|
+
tlsError = null;
|
|
1257
|
+
tlsActive = false;
|
|
1258
|
+
if (value.tls.enabled) {
|
|
1259
|
+
try {
|
|
1260
|
+
if (!value.tls.certPath || !value.tls.keyPath) throw new Error('Certificate and key paths are required');
|
|
1261
|
+
const cert = await fs.readFile(path.resolve(value.tls.certPath));
|
|
1262
|
+
const key = await fs.readFile(path.resolve(value.tls.keyPath));
|
|
1263
|
+
tlsOptions = { cert, key };
|
|
1264
|
+
tlsActive = true;
|
|
1265
|
+
} catch (errorValue) {
|
|
1266
|
+
tlsError = serializeError(errorValue);
|
|
1267
|
+
tlsOptions = null;
|
|
1268
|
+
tlsActive = false;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
protocol = tlsActive ? 'https' : 'http';
|
|
1272
|
+
const handler = (req: IncomingMessage, res: ServerResponse): void => {
|
|
1273
|
+
void handleRemote(req, res);
|
|
1274
|
+
};
|
|
1275
|
+
const instance = tlsOptions ? createHttpsServer(tlsOptions, handler) : createHttpServer(handler);
|
|
1276
|
+
instance.keepAliveTimeout = 30_000;
|
|
1277
|
+
instance.headersTimeout = 35_000;
|
|
1278
|
+
return instance;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
async function start(): Promise<InfoPayload> {
|
|
1282
|
+
if (server || running || disposed) return getInfo();
|
|
1283
|
+
try {
|
|
1284
|
+
const instance = await createInstance();
|
|
1285
|
+
try {
|
|
1286
|
+
await listen(instance, preferredPort);
|
|
1287
|
+
} catch (listenError) {
|
|
1288
|
+
const code = isRecord(listenError) ? listenError.code : undefined;
|
|
1289
|
+
if (code !== 'EADDRINUSE' || preferredPort === 0) throw listenError;
|
|
1290
|
+
await listen(instance, 0);
|
|
1291
|
+
}
|
|
1292
|
+
server = instance;
|
|
1293
|
+
const address = server.address();
|
|
1294
|
+
port = typeof address === 'object' && address ? address.port : preferredPort;
|
|
1295
|
+
running = true;
|
|
1296
|
+
error = null;
|
|
1297
|
+
recomputeUrls();
|
|
1298
|
+
hub = attachHub(server);
|
|
1299
|
+
if (disposed) {
|
|
1300
|
+
stopLoops();
|
|
1301
|
+
hub.dispose();
|
|
1302
|
+
hub = null;
|
|
1303
|
+
await stopServer();
|
|
1304
|
+
return getInfo();
|
|
1305
|
+
}
|
|
1306
|
+
log(`listening on ${primaryUrl} (${urls.length} network${urls.length === 1 ? '' : 's'})`);
|
|
1307
|
+
} catch (errorValue) {
|
|
1308
|
+
error = serializeError(errorValue);
|
|
1309
|
+
running = false;
|
|
1310
|
+
server = null;
|
|
1311
|
+
hub = null;
|
|
1312
|
+
log(`failed to start: ${error}`);
|
|
1313
|
+
}
|
|
1314
|
+
return getInfo();
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
async function stopServer(): Promise<void> {
|
|
1318
|
+
stopLoops();
|
|
1319
|
+
const currentHub = hub;
|
|
1320
|
+
hub = null;
|
|
1321
|
+
currentHub?.dispose?.();
|
|
1322
|
+
const instance = server;
|
|
1323
|
+
server = null;
|
|
1324
|
+
running = false;
|
|
1325
|
+
port = null;
|
|
1326
|
+
urls = [];
|
|
1327
|
+
primaryUrl = '';
|
|
1328
|
+
if (!instance) return;
|
|
1329
|
+
try {
|
|
1330
|
+
instance.closeAllConnections?.();
|
|
1331
|
+
await new Promise<void>((resolve) => instance.close(() => resolve()));
|
|
1332
|
+
} catch {
|
|
1333
|
+
/* ignore */
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
async function restartServer(): Promise<InfoPayload> {
|
|
1338
|
+
await stopServer();
|
|
1339
|
+
return start();
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
async function getSettingsPayload(): Promise<SettingsPayload> {
|
|
1343
|
+
const [notifications, probe] = await Promise.all([getNotificationsView(), readProbeCapability()]);
|
|
1344
|
+
const mediaStatus = adapters.media?.status?.() || {};
|
|
1345
|
+
return {
|
|
1346
|
+
ok: true,
|
|
1347
|
+
settings: settingsStore.public(),
|
|
1348
|
+
interfaces: interfaceEntries(false),
|
|
1349
|
+
apps: appMapping.list(),
|
|
1350
|
+
clients: hub?.info?.() ?? [],
|
|
1351
|
+
info: getInfo(),
|
|
1352
|
+
capabilities: {
|
|
1353
|
+
notifications: adapters.notifications?.status?.() || {},
|
|
1354
|
+
media: mediaStatus.media || {},
|
|
1355
|
+
volume: mediaStatus.volume || {},
|
|
1356
|
+
weather: {
|
|
1357
|
+
ok: lastWeather?.ok ?? null,
|
|
1358
|
+
updatedAt: lastWeather?.updatedAt ?? null,
|
|
1359
|
+
},
|
|
1360
|
+
probe,
|
|
1361
|
+
node: readNodeCapability(),
|
|
1362
|
+
},
|
|
1363
|
+
diagnostics: {
|
|
1364
|
+
platform: adapters.platform,
|
|
1365
|
+
platformLabel: adapters.label,
|
|
1366
|
+
version: VERSION,
|
|
1367
|
+
running,
|
|
1368
|
+
protocol,
|
|
1369
|
+
clientCount: hub?.size?.() ?? 0,
|
|
1370
|
+
notificationApps: notifications.apps.length,
|
|
1371
|
+
profile: readProfileName(),
|
|
1372
|
+
},
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
async function applySettings(patch: JsonRecord): Promise<SettingsValue> {
|
|
1377
|
+
const before = settingsStore.get();
|
|
1378
|
+
const sanitized: JsonRecord = patch && typeof patch === 'object' ? { ...patch } : {};
|
|
1379
|
+
delete sanitized.token;
|
|
1380
|
+
delete sanitized.tokenRotatedAt;
|
|
1381
|
+
const next = settingsStore.update(sanitized);
|
|
1382
|
+
const tlsChanged = JSON.stringify(before.tls) !== JSON.stringify(next.tls);
|
|
1383
|
+
const authChanged = before.tokenEnabled !== next.tokenEnabled;
|
|
1384
|
+
const interfacesChanged = JSON.stringify(before.allowedInterfaces) !== JSON.stringify(next.allowedInterfaces);
|
|
1385
|
+
if (authChanged || interfacesChanged) {
|
|
1386
|
+
hub?.closeAll('settings changed');
|
|
1387
|
+
}
|
|
1388
|
+
if (tlsChanged) {
|
|
1389
|
+
await restartServer();
|
|
1390
|
+
} else {
|
|
1391
|
+
recomputeUrls();
|
|
1392
|
+
}
|
|
1393
|
+
pushSettings(true);
|
|
1394
|
+
if (next.pageEnabled.home) {
|
|
1395
|
+
if (JSON.stringify(before.notifications) !== JSON.stringify(next.notifications) || !before.pageEnabled.home) void pushNotifications(true);
|
|
1396
|
+
if (JSON.stringify(before.weather) !== JSON.stringify(next.weather) || !before.pageEnabled.home) void pushWeather(true);
|
|
1397
|
+
}
|
|
1398
|
+
if (next.pageEnabled.sessions) {
|
|
1399
|
+
if (JSON.stringify(before.balance) !== JSON.stringify(next.balance) || !before.pageEnabled.sessions) {
|
|
1400
|
+
void pushAccount(true);
|
|
1401
|
+
void pushSessions(true);
|
|
1402
|
+
}
|
|
1403
|
+
if (JSON.stringify(before.notifications) !== JSON.stringify(next.notifications)) void pushSessions(true);
|
|
1404
|
+
}
|
|
1405
|
+
if (next.pageEnabled.music && !before.pageEnabled.music) void pushMedia(true);
|
|
1406
|
+
if (next.pageEnabled.performance && !before.pageEnabled.performance) void pushPerformance(true);
|
|
1407
|
+
return next;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
async function handleInfo(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
1411
|
+
jsonResponse(res, 200, getInfo());
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
async function handleSettingsGet(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
1415
|
+
jsonResponse(res, 200, await getSettingsPayload());
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
async function handleSettingsPost(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
1419
|
+
if (!sameOrigin(req)) {
|
|
1420
|
+
jsonResponse(res, 403, { ok: false, error: 'origin rejected' });
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
try {
|
|
1424
|
+
const body = await readBody(req);
|
|
1425
|
+
await applySettings(body);
|
|
1426
|
+
jsonResponse(res, 200, await getSettingsPayload());
|
|
1427
|
+
} catch (errorValue) {
|
|
1428
|
+
jsonResponse(res, 400, { ok: false, error: serializeError(errorValue) });
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* Rename one observed application (the "app mapping" settings tab). An empty
|
|
1434
|
+
* name clears the override and returns to the built-in/default name.
|
|
1435
|
+
*/
|
|
1436
|
+
async function handleAppNames(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
1437
|
+
if (!sameOrigin(req)) {
|
|
1438
|
+
jsonResponse(res, 403, { ok: false, error: 'origin rejected' });
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
try {
|
|
1442
|
+
const body = await readBody(req);
|
|
1443
|
+
const key = safeString(body?.key, 240).trim();
|
|
1444
|
+
const name = typeof body?.name === 'string' ? body.name : null;
|
|
1445
|
+
if (!key) {
|
|
1446
|
+
jsonResponse(res, 400, { ok: false, error: 'application key is required' });
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
const updated = appMapping.rename(key, name);
|
|
1450
|
+
if (!updated) {
|
|
1451
|
+
jsonResponse(res, 404, { ok: false, error: 'application has not been recorded yet' });
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
void pushNotifications(true);
|
|
1455
|
+
void pushMedia(true);
|
|
1456
|
+
jsonResponse(res, 200, await getSettingsPayload());
|
|
1457
|
+
} catch (errorValue) {
|
|
1458
|
+
jsonResponse(res, 400, { ok: false, error: serializeError(errorValue) });
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/**
|
|
1463
|
+
* Ask the OS for location permission. On Windows this opens the privacy
|
|
1464
|
+
* settings page when access is denied; a successful grant drops the cached
|
|
1465
|
+
* weather coordinates so the next snapshot uses the real position.
|
|
1466
|
+
*/
|
|
1467
|
+
async function handleLocation(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
1468
|
+
if (!sameOrigin(req)) {
|
|
1469
|
+
jsonResponse(res, 403, { ok: false, error: 'origin rejected' });
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
try {
|
|
1473
|
+
const body = await readBody(req);
|
|
1474
|
+
const probe = probeInfo();
|
|
1475
|
+
if (!probe.available) {
|
|
1476
|
+
jsonResponse(res, 503, { ok: false, error: probe.error || 'native probe unavailable' });
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
const args = ['location', 'request'];
|
|
1480
|
+
if (body?.openSettings !== false) args.push('--open-settings');
|
|
1481
|
+
const raw = await runProbe(args, { timeout: 20_000 });
|
|
1482
|
+
const result = isRecord(raw) ? raw : {};
|
|
1483
|
+
const allowed = result.allowed === true || result.access === 'allowed';
|
|
1484
|
+
if (allowed) {
|
|
1485
|
+
weather.resetLocation();
|
|
1486
|
+
void pushWeather(true);
|
|
1487
|
+
}
|
|
1488
|
+
jsonResponse(res, 200, {
|
|
1489
|
+
ok: true,
|
|
1490
|
+
allowed,
|
|
1491
|
+
access: safeString(result.access, 40) || 'unknown',
|
|
1492
|
+
lowIntegrity: result.lowIntegrity === true,
|
|
1493
|
+
openedSettings: result.openedSettings === true,
|
|
1494
|
+
reason: safeString(result.reason, 60) || null,
|
|
1495
|
+
sampledAt: Date.now(),
|
|
1496
|
+
});
|
|
1497
|
+
} catch (errorValue) {
|
|
1498
|
+
jsonResponse(res, 502, { ok: false, error: serializeError(errorValue) });
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/**
|
|
1503
|
+
* Forward geocoding for the weather settings city picker. The Host proxies
|
|
1504
|
+
* Open-Meteo (the same provider as the forecast API) so the sidebar page does
|
|
1505
|
+
* not depend on the remote service's CORS headers and can share its timeout
|
|
1506
|
+
* and result cache.
|
|
1507
|
+
*/
|
|
1508
|
+
async function handleGeocode(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
1509
|
+
if (req.method !== 'GET') {
|
|
1510
|
+
jsonResponse(res, 405, { ok: false, error: 'method not allowed' });
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
1514
|
+
const query = safeString(url.searchParams.get('q') || '', 120).trim();
|
|
1515
|
+
if (!query) {
|
|
1516
|
+
jsonResponse(res, 400, { ok: false, error: 'query is required' });
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
try {
|
|
1520
|
+
const results = await weather.searchCities(query);
|
|
1521
|
+
jsonResponse(res, 200, { ok: true, query, results, updatedAt: Date.now() });
|
|
1522
|
+
} catch (errorValue) {
|
|
1523
|
+
log(`geocode "${query}" failed: ${serializeError(errorValue)}`);
|
|
1524
|
+
jsonResponse(res, 502, { ok: false, error: serializeError(errorValue) });
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
async function handleTheme(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
1529
|
+
if (!sameOrigin(req)) {
|
|
1530
|
+
jsonResponse(res, 403, { ok: false, error: 'origin rejected' });
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
try {
|
|
1534
|
+
const body = await readBody(req);
|
|
1535
|
+
const theme = body?.theme === 'light' ? 'light' : body?.theme === 'dark' ? 'dark' : null;
|
|
1536
|
+
if (!theme) {
|
|
1537
|
+
jsonResponse(res, 400, { ok: false, error: 'invalid theme' });
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
settingsStore.setDshTheme(theme);
|
|
1541
|
+
pushSettings(true);
|
|
1542
|
+
jsonResponse(res, 200, { ok: true, settings: settingsStore.public() });
|
|
1543
|
+
} catch (errorValue) {
|
|
1544
|
+
jsonResponse(res, 400, { ok: false, error: serializeError(errorValue) });
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
async function handleLocale(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
1549
|
+
if (!sameOrigin(req)) {
|
|
1550
|
+
jsonResponse(res, 403, { ok: false, error: 'origin rejected' });
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
try {
|
|
1554
|
+
const body = await readBody(req);
|
|
1555
|
+
const requested = safeString(body?.locale, 35);
|
|
1556
|
+
const locale = normalizeDshLocale(requested);
|
|
1557
|
+
if (!locale) {
|
|
1558
|
+
jsonResponse(res, 400, { ok: false, error: 'invalid locale' });
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
settingsStore.setDshLocale(locale);
|
|
1562
|
+
pushSettings(true);
|
|
1563
|
+
jsonResponse(res, 200, {
|
|
1564
|
+
ok: true,
|
|
1565
|
+
dshLocale: locale,
|
|
1566
|
+
locale: effectiveLocale(),
|
|
1567
|
+
settings: settingsStore.public(),
|
|
1568
|
+
});
|
|
1569
|
+
} catch (errorValue) {
|
|
1570
|
+
jsonResponse(res, 400, { ok: false, error: serializeError(errorValue) });
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
async function handleTokenRotate(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
1575
|
+
if (!sameOrigin(req)) {
|
|
1576
|
+
jsonResponse(res, 403, { ok: false, error: 'origin rejected' });
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
settingsStore.rotateToken();
|
|
1580
|
+
hub?.closeAll('token rotated');
|
|
1581
|
+
pushSettings(true);
|
|
1582
|
+
jsonResponse(res, 200, await getSettingsPayload());
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
async function handleDisconnect(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
1586
|
+
if (!sameOrigin(req)) {
|
|
1587
|
+
jsonResponse(res, 403, { ok: false, error: 'origin rejected' });
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
try {
|
|
1591
|
+
const body = await readBody(req);
|
|
1592
|
+
const id = safeString(body?.id, 80);
|
|
1593
|
+
if (id) hub?.close(id, 'disconnected by host');
|
|
1594
|
+
jsonResponse(res, 200, { ok: true, clients: hub?.info?.() ?? [] });
|
|
1595
|
+
} catch (errorValue) {
|
|
1596
|
+
jsonResponse(res, 400, { ok: false, error: serializeError(errorValue) });
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
async function handleQr(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
1601
|
+
if (!primaryUrl) {
|
|
1602
|
+
textResponse(res, 503, 'Remote control server is not running');
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
const svg = qrSvg(remoteUrl(), { cellSize: 8, margin: 4, label: 'Remote control pairing QR code' });
|
|
1606
|
+
res.writeHead(200, {
|
|
1607
|
+
'content-type': 'image/svg+xml; charset=utf-8',
|
|
1608
|
+
'cache-control': 'no-store',
|
|
1609
|
+
'content-length': Buffer.byteLength(svg),
|
|
1610
|
+
'x-content-type-options': 'nosniff',
|
|
1611
|
+
});
|
|
1612
|
+
res.end(req.method === 'HEAD' ? undefined : svg);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
async function handleDisconnectRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
1616
|
+
return handleDisconnect(req, res);
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
return {
|
|
1620
|
+
start,
|
|
1621
|
+
stop: stopServer,
|
|
1622
|
+
restart: restartServer,
|
|
1623
|
+
getInfo,
|
|
1624
|
+
handleInfo,
|
|
1625
|
+
handleQr,
|
|
1626
|
+
handleSettingsGet,
|
|
1627
|
+
handleSettingsPost,
|
|
1628
|
+
handleAppNames,
|
|
1629
|
+
handleLocation,
|
|
1630
|
+
handleGeocode,
|
|
1631
|
+
handleTheme,
|
|
1632
|
+
handleLocale,
|
|
1633
|
+
handleTokenRotate,
|
|
1634
|
+
handleDisconnect: handleDisconnectRequest,
|
|
1635
|
+
settingsStore,
|
|
1636
|
+
applySettings,
|
|
1637
|
+
dispose() {
|
|
1638
|
+
disposed = true;
|
|
1639
|
+
pending.dispose();
|
|
1640
|
+
adapters.dispose();
|
|
1641
|
+
appMapping.dispose();
|
|
1642
|
+
void stopServer();
|
|
1643
|
+
},
|
|
1644
|
+
};
|
|
1645
|
+
}
|