[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"project-94297":3},{"id":4,"name":5,"fullName":6,"owner":7,"repo":5,"description":8,"homepage":8,"htmlUrl":8,"language":9,"languages":8,"totalLinesOfCode":8,"stars":10,"forks":11,"watchers":12,"openIssues":13,"contributorsCount":14,"subscribersCount":14,"size":14,"stars1d":14,"stars7d":14,"stars30d":15,"stars90d":14,"forks30d":14,"starsTrendScore":14,"compositeScore":16,"rankGlobal":8,"rankLanguage":8,"license":17,"archived":18,"fork":18,"defaultBranch":19,"hasWiki":20,"hasPages":18,"topics":21,"createdAt":8,"pushedAt":8,"updatedAt":22,"readmeContent":23,"aiSummary":24,"trendingCount":14,"starSnapshotCount":14,"syncStatus":25,"lastSyncTime":26,"discoverSource":27},94297,"Desko","typewriter03\u002FDesko","typewriter03",null,"Python",148,7,102,1,0,46,44.31,"Other",false,"master",true,[],"2026-08-25 04:01:21","\u003Cdiv align=\"center\">\n\n# ⌁ DESKO\n\n**Turn a spare phone into an always-on desk dashboard.**\n\nMusic with karaoke lyrics · live PC vitals · git status · a clock that actually looks good ·\na Pomodoro timer, pushed from your PC to any old phone on your WiFi.\n\n[![Python](https:\u002F\u002Fimg.shields.io\u002Fbadge\u002Fpython-3.11%2B-3776AB?style=flat-square&logo=python&logoColor=white)](https:\u002F\u002Fwww.python.org\u002F)\n[![Windows](https:\u002F\u002Fimg.shields.io\u002Fbadge\u002FWindows-full-0078D4?style=flat-square&logo=windows&logoColor=white)](#platform-support)\n[![macOS](https:\u002F\u002Fimg.shields.io\u002Fbadge\u002FmacOS%20%2F%20Linux-partial-999?style=flat-square&logo=apple&logoColor=white)](#platform-support)\n[![Frontend](https:\u002F\u002Fimg.shields.io\u002Fbadge\u002Ffrontend-vanilla%20JS-F7DF1E?style=flat-square&logo=javascript&logoColor=black)](#how-it-works)\n[![No build step](https:\u002F\u002Fimg.shields.io\u002Fbadge\u002Fbuild%20step-none-82f59b?style=flat-square)](#how-it-works)\n[![License](https:\u002F\u002Fimg.shields.io\u002Fbadge\u002Flicense-Noncommercial-blue?style=flat-square)](LICENSE)\n\n\u003Cbr>\n\n\u003Cimg src=\"docs\u002Fimages\u002Fhero-music.jpg\" width=\"880\" alt=\"An old phone on a desk running Desko, showing the Music scene with time-synced lyrics\">\n\n\u003C\u002Fdiv>\n\n---\n\n## What it is\n\nThat old phone in your drawer has a perfectly good screen. Desko gives it a job.\n\nA **single lightweight Python process** runs on your PC, collects things worth looking at,\nand pushes them over a WebSocket to **one browser tab** on the phone sitting on your desk\nstand. No app to install, no account, no cloud, no build tooling. You open a URL and it\njust runs, forever.\n\nThe display cycles through five scenes on a timer. Swipe to take manual control, tap the\npadlock to pin one.\n\n```\n   ┌──────────────────────────────────────────────────────────────────────┐\n   │ ⛶  DESKO OS 0.8.5   │ ● LINKED  OMEN-PC  192.168.0.7 │ 21:04  🔋87% ⚡🔒│\n   ├──────────────────────────────────────────────────────────────────────┤\n   │  ┌────────────┐   MEDIA.TRACK                            PLAYING     │\n   │  │            │   EAGLES · HOTEL CALIFORNIA                          │\n   │  │  ▓▓ album  │   ┌─ LYRICS ─────────────────────────────────────┐   │\n   │  │  ▓▓  art   │   │      on a dark desert highway                │   │\n   │  │            │   │  ▌ cool wind in my hair                      │   │  ← active line\n   │  └────────────┘   │      warm smell of colitas                   │   │     glows + scrolls\n   │  ◉ YT MUSIC       │      rising up through the air               │   │\n   │                   └──────────────────────────────────────────────┘   │\n   │  0:44 ▬▬▬▬▬▬░░░░░░░░░░░░░░░░░░ -4:15    ⏮   ⏸   ⏭   🔊              │\n   └──────────────────────────────────────────────────────────────────────┘\n        ambient background = the album art, blurred, filling the whole screen\n```\n\n\u003Cdiv align=\"center\">\n  \u003Cimg src=\"docs\u002Fimages\u002Fdesk-setup.jpg\" width=\"620\" alt=\"Desko running on a phone propped in front of a monitor and keyboard\">\n  \u003Cbr>\n  \u003Csub>An old realme 3 earning its keep. Double-tap the clock to open the launcher.\u003C\u002Fsub>\n\u003C\u002Fdiv>\n\nEverything degrades gracefully. No media playing, no LibreHardwareMonitor, no VS Code, and\nthose widgets just hide. Nothing crashes, nothing shows a dead `-`.\n\n## The five scenes\n\n| | Scene | What's on it |\n|:--:|---|---|\n| 🕐 | **Idle** | Big IST clock plus London secondary, date, live weather, phone battery, link latency, PC uptime |\n| 🎵 | **Music** | Album art as an ambient blurred backdrop, **synced karaoke lyrics**, transport and volume control of the *PC*, per-track colour theming pulled from the cover |\n| 📊 | **Stats** | CPU\u002FGPU load and temperature, RAM, network up\u002Fdown, 60-second sparklines, session timer, game name when a configured process is running |\n| 💻 | **Dev** | Workspace, branch, ahead\u002Fbehind, changed files, cursor position, **last commit**, **today's commits and lines**. Live from VS Code, and from git alone once the editor is closed |\n| 🍅 | **Focus** | Pomodoro countdown ring, work\u002Fbreak auto-flow, cycle counter, adjustable lengths. Server-side, so it's identical on every device and survives reloads |\n\n\u003Ctable>\n  \u003Ctr>\n    \u003Ctd width=\"33%\" align=\"center\">\n      \u003Cimg src=\"docs\u002Fimages\u002Fscene-music.jpg\" alt=\"Music scene showing album art and synced lyrics\">\n      \u003Cbr>\u003Csub>\u003Cb>Music\u003C\u002Fb>\u003Cbr>ambient album art, synced lyrics\u003C\u002Fsub>\n    \u003C\u002Ftd>\n    \u003Ctd width=\"33%\" align=\"center\">\n      \u003Cimg src=\"docs\u002Fimages\u002Fscene-stats.jpg\" alt=\"Stats scene showing CPU, GPU, RAM and sparklines\">\n      \u003Cbr>\u003Csub>\u003Cb>Stats\u003C\u002Fb>\u003Cbr>load, temps, 60s sparklines\u003C\u002Fsub>\n    \u003C\u002Ftd>\n    \u003Ctd width=\"33%\" align=\"center\">\n      \u003Cimg src=\"docs\u002Fimages\u002Fscene-idle.jpg\" alt=\"Idle scene showing the clock and weather\">\n      \u003Cbr>\u003Csub>\u003Cb>Idle\u003C\u002Fb>\u003Cbr>clock, weather, link and uptime\u003C\u002Fsub>\n    \u003C\u002Ftd>\n  \u003C\u002Ftr>\n\u003C\u002Ftable>\n\n\u003Cdiv align=\"center\">\n\n**Rotation order** &nbsp;·&nbsp; `idle → music → stats → dev → focus` &nbsp;·&nbsp; wrapping, `rotate_sec` each\n\n\u003C\u002Fdiv>\n\n---\n\n## Quick start\n\n```powershell\ngit clone https:\u002F\u002Fgithub.com\u002Ftypewriter03\u002FDesko.git\ncd Desko\npython -m pip install -r requirements.txt\npython -m pip install -r requirements-windows.txt   # Windows only: media, temps, volume\npython run.py\n```\n\nThe terminal prints two URLs and a scannable QR code. Open one on your phone's browser.\nSame WiFi is the only requirement.\n\n> [!TIP]\n> **On Windows, double-click [`desko.bat`](#launchers-windows) instead.** It keeps a window\n> open showing the URL and QR, and closing the window stops the server. Running it twice just\n> tells you it's already up.\n\n### Which URL to save\n\n| URL | Use it when |\n|---|---|\n| **`http:\u002F\u002Fdesko.local:7777`** ⭐ | **Save this one.** An mDNS name that keeps working when the router hands your PC a different IP. Works on iOS, macOS, Windows, Linux and **Android 12+**. Always type the full `http:\u002F\u002F` so Chrome doesn't force HTTPS. |\n| `http:\u002F\u002F\u003Ccurrent-ip>:7777` | Also in the QR. Changes whenever the DHCP lease moves. On **Android 10\u002F11** there's no `.local` resolver, so you need this one. Make it permanent with a **DHCP reservation** in your router for the PC's MAC, which Desko prints under the QR. |\n\n### Try it with nothing running\n\n```powershell\npython run.py --demo        # or:  desko.bat --demo\n```\n\nDemo mode fabricates media, lyrics, stats, git and weather, then cycles Music, Stats, Dev and\nIdle every 20 s. Works on any OS with zero setup, no platform deps needed. (Focus, the process\nlist and the volume slider aren't fabricated yet, so they stay empty here. See\n[Extending Desko](#wire-it-into-demo-mode).)\n\n---\n\n## Platform support\n\nThe server is cross-platform Python. Only some *collectors* are Windows-bound, and every one\nof them is import-guarded. Here is what actually happens on a machine with no Windows modules\navailable, measured rather than assumed by booting the server with `winsdk`, `wmi`,\n`pythoncom`, `pycaw` and `comtypes` blocked at import:\n\n| Feature | Windows | macOS \u002F Linux | Why |\n|---|:--:|:--:|---|\n| Server, WebSocket, scene carousel | ✅ | ✅ | pure Python |\n| Idle: clock, date, weather, battery | ✅ | ✅ | Open-Meteo plus the phone's own Battery API |\n| Focus: Pomodoro | ✅ | ✅ | server-side timer, no OS calls |\n| Stats: CPU %, RAM, network, sparklines | ✅ | ✅ | `psutil` is cross-platform |\n| Stats: CPU\u002FGPU **temperature**, GPU load | ✅ | ❌ | reads LibreHardwareMonitor over WMI |\n| **Dev: branch, changes, commit, today** | ✅ | ✅ | plain `git` subprocesses |\n| **Dev: live editor state** | ✅ | ✅ | the VS Code extension is just JS plus HTTP |\n| Music: now playing, album art, lyrics | ✅ | ❌ | Windows GSMTC media session |\n| Music: PC volume slider | ✅ | ❌ | `pycaw` \u002F Core Audio |\n| `http:\u002F\u002Fdesko.local` (mDNS) | ✅ | ✅ | `zeroconf` |\n| `--demo` mode | ✅ | ✅ | fabricated data |\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>What that looks like in practice\u003C\u002Fb>\u003C\u002Fsummary>\n\nBooting on a non-Windows machine gives you a working dashboard with **Idle, Stats, Dev and\nFocus**. The Music scene stays empty and the temperature widgets hide. Verbatim output of the\ndegradation test:\n\n```\nsections after 6s with all Windows modules blocked:\n  sys      {'cpuPercent': 21.8, 'ramPercent': 82.2, 'netDownKbs': 2869.5,\n            'cpuTempC': None, 'gpuTempC': None, 'gpuPercent': None}\n  media    ABSENT\n  volume   ABSENT\n  weather  {'tempC': 29.5, 'city': 'Bengaluru'}\n  dev      {'source': 'git', 'branch': 'master', 'dirty': 16}\n  focus    {'running': False}\n```\n\n> [!NOTE]\n> That test ran on Windows with the platform modules hidden. It proves the import guards and\n> the degradation paths, **not** that Desko has been run on a real Mac. Nothing in the code is\n> known to be Windows-path-dependent, but treat macOS as untested rather than supported.\n\n**What macOS parity would take**, if you want it:\n\n- **Now playing.** A `nowplaying-cli` style shim, or AppleScript against Music and Spotify.\n  Apple's private `MediaRemote` framework has been progressively locked down for third-party\n  processes, so the AppleScript route is the durable one.\n- **Volume.** `osascript -e 'set volume output volume N'`, as a small `volume.py` sibling.\n- **Temps.** `powermetrics` needs root; `iStats` and `smc` are the usual third-party route.\n\nThe Dev scene needs nothing. `desko\u002Fcollectors\u002Fgit.py` shells out to `git` and the VS Code\nextension is plain JavaScript, so both already work anywhere.\n\n\u003C\u002Fdetails>\n\n---\n\n## Phone setup\n\n\u003Cdetails open>\n\u003Csummary>\u003Cb>Three steps, once\u003C\u002Fb>\u003C\u002Fsummary>\n\n1. **Open the URL** (or scan the QR with the phone camera).\n2. **Add to Home Screen** via Chrome menu → *Add to home screen*. You get a fullscreen,\n   chromeless app with a proper icon. Launch it from there, not from the browser.\n3. **Keep the screen on.** Desko holds the display awake itself with a hidden always-playing\n   video, the one trick that works over plain HTTP. For a permanently-mounted display, also\n   enable Android's *Stay awake*:\n   - Settings → About phone → tap **Build number** 7 times to unlock Developer options\n   - Settings → System → Developer options → enable **Stay awake**\n   - Keep it plugged in.\n\n\u003C\u002Fdetails>\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>Optional: enable the real Wake Lock API (cheaper on the battery)\u003C\u002Fb>\u003C\u002Fsummary>\n\nDesko's keep-awake falls back to a looping hidden video because the proper\n[Screen Wake Lock API](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FScreen_Wake_Lock_API)\nrequires a *secure context*, and `http:\u002F\u002F` on a LAN isn't one. Playing a video forever costs a\ndecoder instance and real battery.\n\nYou can grant the exception, once, on the phone:\n\n1. Open `chrome:\u002F\u002Fflags\u002F#unsafely-treat-insecure-origin-as-secure`\n2. Set **Enabled**, and enter `http:\u002F\u002Fdesko.local:7777` in the box\n3. Relaunch Chrome\n\nThe bundled keep-awake shim feature-detects the native API and switches to it automatically,\nwith no code change. The entry is a full origin including the port, and Chrome sometimes\nclears flags across major updates, so re-check it there if the screen starts sleeping again.\n\n\u003C\u002Fdetails>\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>If the display freezes until you touch it\u003C\u002Fb>\u003C\u002Fsummary>\n\nAndroid suspends timers and can drop a WiFi TCP connection without closing it. Desko detects\nboth. An unanswered ping for 15 s forces a reconnect, and a gap of over 2 s in the render tick\nre-probes the link, so it recovers on its own within seconds. If it still stalls:\n\n- Settings → Battery → **App battery management** → allow Chrome to run in the background.\n  If you launched Desko from the home screen, that's a **separate** app entry, so set it there too.\n- Turn off any **WiFi power-saving** toggle in the WiFi advanced settings.\n- Enable the Wake Lock flag above, so keep-awake stops relying on the video fallback.\n\n\u003C\u002Fdetails>\n\n## Controls\n\n| Gesture | Does |\n|---|---|\n| **Swipe** ← → | Previous \u002F next scene (takes manual control) |\n| **Double-tap** | Open the Desko home screen |\n| **Tap a lyric line** | Seek the PC's playback to that moment (synced lyrics only) |\n| 🔒 **Padlock** (top right) | Freeze the current scene so nothing auto-switches. Open = AUTO, closed = LOCKED |\n| ⚡ **Bolt** (top right) | [Performance mode](#performance-mode), which flattens the theme for weak GPUs |\n| ⛶ **Corners** (top left) | Fullscreen |\n\n---\n\n## Performance mode\n\nOld phones have old GPUs. The Realme 3 this was built for chokes on full-resolution backdrop\nblur, layered glows, and half a dozen infinite animations all compositing at once.\n\nTap the **⚡ bolt** in the system bar to strip all of it: no blur, no glows, no looping\nanimation, no scanline overlay, no edge-fade mask, opaque panels instead of translucent ones.\nSame green-on-dark identity, same layout, none of the fill-rate cost.\n\n- The setting sticks per device (`localStorage`), and applies **before first paint**, so\n  there's no flash of the expensive theme on reload.\n- Auto-enables by default if your phone has OS-level *Reduce motion* switched on. An explicit\n  choice always wins over that.\n- Force it from a URL with **`?perf=1`** or **`?perf=0`**. Useful because `localStorage` is\n  per-origin, so `desko.local` and the raw IP keep separate settings.\n- Measure it: **`?fps=1`** puts a live frame-time readout in the corner. Watch the `max`\n  number while lyrics scroll, because that's where the difference shows, not in the average.\n\n---\n\n## Configuration\n\nTwo ways, both fine:\n\n- **In the browser.** Open **`http:\u002F\u002Fdesko.local:7777\u002Fconfig`**, a settings page for the\n  weather city, game list, Pomodoro lengths, rotation speed and port. It tells you which\n  changes apply live and which need a restart.\n- **By hand.** Edit **`config.json`** (created on first run from `config.example.json`) and\n  restart.\n\n```jsonc\n{\n  \"host\": \"0.0.0.0\",                  \u002F\u002F \"127.0.0.1\" to keep it on this machine only\n  \"port\": 7777,\n  \"mdns_name\": \"desko\",               \u002F\u002F stable URL name -> http:\u002F\u002Fdesko.local:7777\n  \"rotate_sec\": 60,                   \u002F\u002F seconds each scene holds in the carousel\n  \"weather_city\": \"\",                 \u002F\u002F e.g. \"Pune, IN\"; empty = auto-detect by IP\n  \"weather_lat\": null,                \u002F\u002F set both to skip geocoding entirely\n  \"weather_lon\": null,\n  \"game_processes\": [\"cs2.exe\"],      \u002F\u002F lowercase .exe names -> Stats scene header\n  \"focus_work_min\": 25,               \u002F\u002F Pomodoro defaults\n  \"focus_break_min\": 5,\n  \"override_timeout_sec\": 300,        \u002F\u002F how long a manual swipe holds before auto resumes\n  \"vscode_stale_sec\": 45,             \u002F\u002F no editor heartbeat for this long -> git takes over\n  \"git_repo_path\": \"\",                \u002F\u002F Dev fallback repo; empty = follow VS Code's workspace\n  \"lhm_enabled\": true,                \u002F\u002F LibreHardwareMonitor for temps + GPU\n  \"poll\": {\n    \"media_sec\": 0.3,                 \u002F\u002F playhead freshness (karaoke sync depends on it)\n    \"sysstats_sec\": 1.0,\n    \"temps_sec\": 3.0,\n    \"weather_sec\": 1800,\n    \"volume_sec\": 0.5,\n    \"git_sec\": 10                     \u002F\u002F only polls while VS Code is NOT reporting\n  }\n}\n```\n\n> [!NOTE]\n> Any key you leave out is filled from the defaults in `run.py`, so an old `config.json` keeps\n> working after an update. You don't have to re-add new keys by hand.\n\n---\n\n## Per-scene setup\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>🎵 Music: now playing, lyrics, volume\u003C\u002Fb> &nbsp;(Windows)\u003C\u002Fsummary>\n\nInstall `requirements-windows.txt` for `winsdk`. Then just play audio in any app or browser\ntab **on the PC**. Windows' media session API sees Chrome, Edge, Brave, Spotify, and most\ndesktop players. Nothing to configure per-app.\n\n**Lyrics** resolve through a chain, first hit wins:\n\n1. `cache\u002Flyrics\u002Fmanual\u002F\u003Cslug>.lrc`, a file you dropped in yourself\n2. **LRCLIB** exact match, retried across cleaned-up query variants\n3. **LRCLIB** search, looser matching, still synced\n4. **lyrics.ovh**, an independent catalogue, plain text only\n\nStep 2's cleanup matters more than it sounds. Windows reports whatever the browser tab is\ncalled, so `Channa Mereya (Official Video) [Lyrics]` by `Arijit Singh - Topic` used to be a\nguaranteed miss. It now resolves. Results are cached including \"no lyrics\", so instrumentals\naren't re-fetched every run, but a network failure is never cached as a miss.\n\n**To force lyrics for a specific song**, drop an `.lrc` at\n`cache\u002Flyrics\u002Fmanual\u002F\u003Cartist>-\u003Ctitle>.lrc`, lowercase, with non-alphanumerics collapsed to `-`\n(`arijit-singh-channa-mereya.lrc`). Bare `\u003Ctitle>.lrc` also matches, and `.txt` works for\nunsynced text. Manual files are read *before* the cache, so edits apply on the next track\nchange with no restart.\n\n**Volume.** The slider drives the PC's system master volume via `pycaw`, tracks changes you\nmake on the PC, and the speaker button mutes. Without `pycaw` the control just hides.\n\n\u003C\u002Fdetails>\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>📊 Stats: temperatures and GPU load\u003C\u002Fb> &nbsp;(Windows, one-time install)\u003C\u002Fsummary>\n\nCPU, RAM and network need nothing but `psutil`. **Temps and GPU %** need\nLibreHardwareMonitor, which this script installs for you:\n\n```powershell\npwsh -ExecutionPolicy Bypass -File scripts\u002Fsetup-lhm.ps1\n```\n\nIt puts LHM (portable) under `%LOCALAPPDATA%\\Desko\\lhm` and registers an elevated scheduled\ntask `Desko-LibreHardwareMonitor` so it starts at login with the admin rights the sensors\nneed. Desko also **relaunches LHM itself** whenever temps go offline by triggering that task,\nso there's no UAC prompt, and a closed or crashed LHM is back within about 20 s.\n\nRe-running the script is safe. If LHM is already there it skips the download and just repairs\nthe task. Without any of it, the temperature widgets simply hide.\n\n\u003C\u002Fdetails>\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>💻 Dev: VS Code and git\u003C\u002Fb> &nbsp;(any OS)\u003C\u002Fsummary>\n\nThe Dev scene has **two independent data sources**, so it keeps working whether or not your\neditor is open.\n\n**1. The VS Code extension** (fast path). Copy it in:\n\n```powershell\nCopy-Item -Recurse vscode-extension \"$env:USERPROFILE\\.vscode\\extensions\\desko-status-0.1.0\"\n```\n\n```bash\n# macOS \u002F Linux\ncp -r vscode-extension ~\u002F.vscode\u002Fextensions\u002Fdesko-status-0.1.0\n```\n\nThen reload the window (`Ctrl\u002FCmd+Shift+P` → *Developer: Reload Window*). It reports the\nworkspace, branch, ahead\u002Fbehind, the real changed-file list, the current file, **cursor\nposition and line ending**, and the **last commit**.\n\nIt's **event-driven**, not polled. It subscribes to the git extension's `state.onDidChange`\nplus editor focus, selection and save events, so a branch switch reaches the phone in well\nunder a second. Identical payloads are dropped rather than re-sent, and a 15 s heartbeat\nexists purely to prove the editor is alive. It also picks the repo that owns the file you're\nactually looking at, so multi-root workspaces resolve correctly.\n\n**2. The git collector** (the floor). When the extension stops reporting for\n`vscode_stale_sec` (default 45 s), `desko\u002Fcollectors\u002Fgit.py` takes the section over and reads\nthe repo directly. The chip in the panel header changes from `ACTIVE` to `GIT`, and the\neditor-only fields blank out rather than showing what happened to be open when you quit.\n\nIt follows whatever workspace VS Code last reported, **so it needs no configuration**. Open a\nfolder once and Desko remembers which repo to watch. Set `git_repo_path` to pin it somewhere\nelse. It also computes **today's commits and lines changed**, which rides along under either\nsource.\n\nIf neither source has data, auto-rotation skips the Dev scene entirely rather than parking\n60 s on an empty screen. A manual swipe still lands on it.\n\n\u003C\u002Fdetails>\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>🍅 Focus: Pomodoro\u003C\u002Fb> &nbsp;(any OS)\u003C\u002Fsummary>\n\nNo setup at all. Open the Focus scene and press play. Work and break phases auto-flow, the\nlengths are adjustable from the scene itself or `\u002Fconfig`, and the timer lives on the\n**server**, so every connected device shows the same countdown and a page reload doesn't\nreset it.\n\n\u003C\u002Fdetails>\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>🌤️ Weather\u003C\u002Fb> &nbsp;(any OS)\u003C\u002Fsummary>\n\nLeave `weather_city` empty to auto-detect by IP, or set it explicitly (`\"Bengaluru, IN\"`,\n`\"Berlin, DE\"`). Powered by Open-Meteo, free, no API key. Offline, the widget hides rather\nthan showing stale numbers.\n\n\u003C\u002Fdetails>\n\n---\n\n## Launchers (Windows)\n\nFour helpers ship in the repo root. All of them are optional, since `python run.py` is always\nequivalent.\n\n| File | Double-click behaviour | Use it when |\n|---|---|---|\n| **`desko.bat`** | Opens a console showing the URL and QR and streams the log. Closing the window (or `Ctrl+C`) stops the server. | **Normal use.** Right-click → *Send to* → *Desktop (create shortcut)* to keep it one click away. |\n| **`desko-hidden.vbs`** | Starts with **no window at all** via `pythonw`; output is redirected to `desko.log`. | You want it running invisibly at all times, for example from `shell:startup`. |\n| **`desko-stop.cmd`** | Finds whatever is LISTENING on the port and kills it. | Stopping the hidden launcher, which has no window to close. |\n| **`scripts\u002Fsetup-lhm.ps1`** | Installs LibreHardwareMonitor and its scheduled task (one UAC prompt). | Once, to enable temperatures and GPU load. |\n\nNotes worth knowing:\n\n- Both launchers **probe the port first** and refuse to start a second copy. A duplicate would\n  just fail to bind and spam the log.\n- Both `cd` to their own folder first, so double-clicking from anywhere works. `run.py`\n  resolves `config.json` and `web\u002F` relative to the project root.\n- `desko.bat` forwards arguments, so `desko.bat --demo` and `desko.bat --port 8080` both work.\n- The port is hardcoded as `7777` in all three scripts purely for the \"already running\" check.\n  **If you change `port` in `config.json`, update it in these files too.** `run.py` itself\n  always reads the real value from config.\n- To autostart: press `Win+R`, type `shell:startup`, and drop a shortcut to `desko.bat`\n  (visible) or `desko-hidden.vbs` (invisible) in the folder that opens.\n\n---\n\n## How it works\n\n```\n   PC (Windows \u002F macOS \u002F Linux)                        PHONE (any browser)\n   ┌───────────────────────────────────┐               ┌────────────────────┐\n   │  run.py                           │               │                    │\n   │    │                              │               │   index.html       │\n   │    ├─ aiohttp ──── GET \u002F  ─────────────────────────▶   + app.js        │\n   │    │               \u002Fstatic\u002F*      │               │   + scenes\u002F*.js    │\n   │    │                              │               │                    │\n   │    ├─ WebSocket \u002Fws ◀═══════════════ diffs only ══▶   scene router     │\n   │    │                              │               │                    │\n   │    ├─ State ── set_section() ─────┤               │   ┌──────────────┐ │\n   │    │    deep-compares, broadcasts │               │   │ idle  music  │ │\n   │    │    only what changed         │               │   │ stats dev    │ │\n   │    │                              │               │   │ focus        │ │\n   │    ├─ collectors (8 async tasks)  │               │   └──────────────┘ │\n   │    │   ├ media ──── winsdk\u002FGSMTC ─┤ (WinRT thread)│                    │\n   │    │   ├ lyrics ─── LRCLIB → ovh  │               │   POST \u002Fapi\u002Fmedia  │\n   │    │   ├ sysstats ─ psutil + WMI ─┤ (COM thread)  │◀── \u002Fapi\u002Fvolume ────┤\n   │    │   ├ volume ─── pycaw ────────┤ (COM thread)  │                    │\n   │    │   ├ weather ── Open-Meteo    │               └────────────────────┘\n   │    │   └ git ────── git subprocess│\n   │    │        ▲ takes over `dev` when the editor goes quiet\n   │    ├─ focus ─── server-side timer │               ┌────────────────────┐\n   │    ├─ context ─ scene carousel    │◀── POST ──────│  VS Code extension │\n   │    └─ announce ─ mDNS desko.local │   \u002Fapi\u002Fvscode └────────────────────┘\n   └───────────────────────────────────┘                 event-driven, 15s heartbeat\n```\n\nOne `aiohttp` process serves the static frontend and a WebSocket. Collectors run as async\ntasks and push **state diffs only when something actually changes**, so the socket is quiet\nwhen nothing is happening. Three collectors that touch COM or WinRT (`media`, `sysstats`,\n`volume`) each own a dedicated thread, because those APIs are apartment-bound and would\notherwise block the event loop.\n\n**The Dev section has two writers and one arbiter.** The VS Code extension POSTs on real\neditor events. The server stamps `updatedAt` *only when the payload actually changed*, so an\nidle editor costs zero WebSocket traffic. Liveness is tracked separately, off the wire\nentirely, and the git collector watches it: when heartbeats stop, git claims the section.\n`dev.source` tells the frontend which one it's looking at.\n\nScene selection is a **carousel**. Every scene gets equal air time (`rotate_sec`, default\n60 s), wrapping `idle → music → stats → dev → focus`. A swipe overrides it for\n`override_timeout_sec`; the padlock freezes it indefinitely. *(An earlier build picked scenes\nby priority, but VS Code focus flickering made the display bounce between scenes every few\nseconds, which the carousel removed entirely.)*\n\nThe frontend is vanilla HTML\u002FCSS\u002FJS. No framework, no bundler, no build step, no CDN\nrequests, ES5-flavoured syntax throughout, because the target device is an old Chromium on a\nbudget phone and every one of those choices was load-bearing.\n\n**Design targets:** under 1% idle CPU, under 100 MB RAM, no database. See\n`IMPLEMENTATION_PLAN.md` for the original architecture and the binding data contracts.\n\n## Requirements\n\n| | Needed for | Without it |\n|---|---|---|\n| **Python 3.11+** | everything | required |\n| `aiohttp`, `psutil` | the server, CPU\u002FRAM\u002Fnet | required |\n| `qrcode` | the terminal QR code | URL still prints |\n| `zeroconf` | `http:\u002F\u002Fdesko.local` | numeric IP still works |\n| `git` on PATH | Dev scene without VS Code | Dev needs the editor running |\n| `winsdk` **(Win)** | Music scene, now playing | Music scene stays empty |\n| `wmi` and `pywin32` **(Win)** | CPU\u002FGPU temperatures | temp widgets hide |\n| `pycaw` and `comtypes` **(Win)** | volume slider | slider hides |\n| LibreHardwareMonitor | the sensors `wmi` reads | temp widgets hide |\n| VS Code and the bundled extension | live editor state on Dev | git fallback covers the rest |\n\n## Project layout\n\n```\nrun.py                     entrypoint, prints URL + QR, starts the server\ndesko.bat                  Windows double-click launcher (single-instance guard)\ndesko-hidden.vbs           start with no console window at all, logs to desko.log\ndesko-stop.cmd             stop the hidden server\nconfig.json                your settings (git-ignored, generated on first run)\nconfig.example.json        reference config, committed\n\ndesko\u002F\n  server.py                aiohttp app, routes, WebSocket, collector lifecycle\n  state.py                 shared state, diffing, pub\u002Fsub to connected clients\n  context.py               scene carousel, override and lock handling\n  focus.py                 server-side Pomodoro\n  announce.py              mDNS, so desko.local survives DHCP\n  demo.py                  --demo fake-data generator\n  collectors\u002F\n    media.py               Windows GSMTC now-playing (own WinRT thread)\n    lyrics.py              4-provider lyric chain plus cache\n    sysstats.py            psutil + LibreHardwareMonitor over WMI (own COM thread)\n    volume.py              system master volume via pycaw (own COM thread)\n    weather.py             Open-Meteo\n    vscode.py              POST \u002Fapi\u002Fvscode ingest and validation\n    git.py                 git fallback for the Dev scene, plus today's totals\n\nweb\u002F                       vanilla frontend, no build step\n  index.html               all five scenes plus the launcher\n  css\u002Fstyle.css            the whole theme, including html.perf\n  js\u002Fapp.js                WebSocket client, scene router, gestures, keep-awake\n  js\u002Fscenes\u002F*.js           one module per scene\n  config.html              the \u002Fconfig settings page\n\nvscode-extension\u002F          event-driven reporter (editor + git state -> POST \u002Fapi\u002Fvscode)\nscripts\u002Fsetup-lhm.ps1      one-time LibreHardwareMonitor installer\nTHIRD-PARTY-NOTICES.md     bundled font and downloaded tool licensing\n```\n\n---\n\n## Extending Desko\n\nDesko has exactly three kinds of moving part. Adding a feature means picking one and copying\nits nearest sibling.\n\n| You want to | Build a | It lives in |\n|---|---|---|\n| Put new **data** on the dashboard | **collector** | `desko\u002Fcollectors\u002F` |\n| Put a new **screen** in the carousel | **scene** | `web\u002Fjs\u002Fscenes\u002F` plus a `\u003Csection>` |\n| Let the phone **do** something to the PC | **control** | a route in `server.py`, or a WebSocket message type |\n\nThe contract between them is one sentence:\n\n> A collector writes a named **section** into `State`. The server broadcasts it **only if it\n> actually changed**. The frontend merges it and hands it to the scene that cares.\n\nThat's the whole architecture. There is no event bus, no plugin registry, no dependency\ninjection, and nothing is auto-discovered. Wiring is a handful of explicit lists, and the\nsections below tell you exactly which ones.\n\n### Add a collector\n\nWorked example: a `disk` section showing free space.\n\n\u003Cdetails open>\n\u003Csummary>\u003Cb>Five edits, in order\u003C\u002Fb>\u003C\u002Fsummary>\n\n**1. Write `desko\u002Fcollectors\u002Fdisk.py`.** Every collector is one `async def start(state, config, session)`\nthat loops forever:\n\n```python\n\"\"\"Free disk space.\"\"\"\nimport asyncio\nimport logging\nimport time\n\nimport psutil\n\nlog = logging.getLogger(\"desko.disk\")\n\n\nasync def start(state, config, session) -> None:\n    interval = float(config.get(\"poll\", {}).get(\"disk_sec\", 60))\n    while True:\n        try:\n            u = psutil.disk_usage(config.get(\"disk_path\") or \"\u002F\")\n            state.set_section(\"disk\", {\n                \"freeGb\": round(u.free \u002F 1024 ** 3, 1),\n                \"pct\": u.percent,\n                \"updatedAt\": time.time(),\n            })\n        except Exception as e:                  # never let the loop die\n            log.warning(\"disk read failed: %s\", e)\n            state.set_section(\"disk\", None)     # None tells the scene to hide\n        await asyncio.sleep(interval)\n```\n\n**2. Declare the section** in `desko\u002Fstate.py`, in the `_data` dict: `\"disk\": None`. This is what\na freshly connected phone receives in its `snapshot`.\n\n**3. Register the task** in `desko\u002Fserver.py`, inside `on_startup`:\n\n```python\ntasks.append(asyncio.create_task(disk_mod.start(state, config, app[\"http_session\"])))\n```\n\n**4. Add the poll interval** to `DEFAULT_CONFIG` in `run.py` **and** to `config.example.json`.\n`load_config` back-fills anything missing from the defaults, so nobody's existing\n`config.json` breaks when you add a key.\n\n**5. Route it to a scene** in `web\u002Fjs\u002Fapp.js`: add `disk: null` to the `state` object, then send\nthe section to whichever scene renders it, in `mergeUpdate`:\n\n```js\n} else if (section === \"sys\" || section === \"game\" || section === \"procs\" || section === \"disk\") {\n  if (scenes.stats && scenes.stats.onStateChange) try { scenes.stats.onStateChange(state); } catch (e) {}\n}\n```\n\nSections not listed there fall through to \"notify the active scene\", which is fine for data only\none scene uses. The explicit branches exist so a section can update a scene that **isn't** on\nscreen, which is what makes a scene correct the instant you swipe to it.\n\n\u003C\u002Fdetails>\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>The five rules a collector has to follow\u003C\u002Fb>\u003C\u002Fsummary>\n\n**1. Never raise out of the loop.** Nothing restarts a dead collector, and its section freezes at\nwhatever it last published. Wrap the body, log a warning, keep sleeping.\n\n**2. Publish `None` when the source is gone.** Every scene is written to hide a widget whose\nsection is null. That single convention is the entire degradation strategy, and it's why the\nserver boots fine on a Mac with half the collectors inert.\n\n**3. Import platform modules inside a guard, never at module top level.** A bare\n`import winsdk` crashes the process on Linux before anything gets the chance to degrade\ngracefully. Copy the `try\u002Fexcept` header from `media.py`, `volume.py` or `procs.py`:\n\n```python\n_HAS_ICONS = False\ntry:\n    import win32gui\n    from PIL import Image\n    _HAS_ICONS = True\nexcept Exception:\n    pass\n```\n\n**4. Use `patch_section` for chatty little fields.** `set_section` broadcasts the whole section.\nThe media collector updates the playhead 3x a second, and re-sending the base64 album art each\ntime would saturate the link, so it patches just `{\"position\": ...}`. Both sides merge partials,\nso omitted fields survive.\n\n**5. Anything that blocks needs its own thread.** The event loop also serves the WebSocket, the\nmedia position patches and the lyric timing, so a synchronous stall is visible on the phone as a\nstutter. The threshold in practice is a few milliseconds. Measured on a 367-process box:\n\n| Call | Cost | Verdict |\n|---|--:|---|\n| `psutil.process_iter([\"name\"])` | 2 ms | fine on the loop |\n| `+ memory_info` | 984 ms | **needs a thread** |\n| `+ cpu_times` | 1025 ms | (why per-app CPU was nearly free to add) |\n| WMI `Win32_Process` | 1923 ms | avoid |\n\n**Anything touching COM or WinRT needs a thread regardless of speed**, because those APIs are\napartment-bound. `media.py`, `sysstats.py`, `volume.py` and `procs.py` each own one. The pattern\nis always the same: the thread writes into a shared dict, and a cheap async loop publishes from\nit. `procs.py` is the shortest copy-paste template.\n\n\u003C\u002Fdetails>\n\n### Add a scene\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>One scene, five places to name it\u003C\u002Fb>\u003C\u002Fsummary>\n\nThere is no scene auto-registration, so a new scene has to be listed in five spots. Miss one and\nyou get a specific, diagnosable failure, listed here so you don't have to bisect it:\n\n| Edit | If you forget it |\n|---|---|\n| `\u003Csection class=\"scene x-scene\" data-scene=\"x\">` in `web\u002Findex.html` | scene switches to a blank frame |\n| `\u003Cscript src=\"\u002Fstatic\u002Fjs\u002Fscenes\u002Fx.js\">` in `web\u002Findex.html` | markup shows but never populates |\n| `SCENES` in `desko\u002Fstate.py` | swipe skips it, `override` for it is rejected |\n| `ROTATION` in `desko\u002Fcontext.py` | reachable by swipe, never by the carousel |\n| `SCENES` in `web\u002Fjs\u002Fapp.js` | `?scene=x` is ignored |\n\nThe module itself is an object on the `Desko.scenes` namespace, no imports and no exports:\n\n```js\nDesko.scenes.x = (function () {\n  var E = {};\n  return {\n    onEnter: function (state) { E.foo = document.getElementById(\"x-foo\"); render(state); },\n    onStateChange: function (state) { render(state); },\n    onTick: function (state, nowMs) {},\n    onExit: function () {},\n  };\n})();\n```\n\n| Hook | Called | Use it for |\n|---|---|---|\n| `onEnter(state)` | scene becomes visible | cache DOM lookups, wire listeners once, first paint |\n| `onStateChange(state)` | a routed section changed | all data rendering |\n| `onTick(state, nowMs)` | 4x\u002Fs, active scene only | anything animating between updates: clocks, countdowns, the interpolated playhead |\n| `onExit()` | scene leaves | close popups, stop anything you started |\n\n`onTick` exists because **the server never pushes a clock**. Sending a time update every second\nto keep a countdown moving would be pure waste, so scenes interpolate locally and the socket\nstays quiet. `onExit` matters more than it looks: the stats scene closes its process popup there,\nbecause otherwise a rotation leaves the popup floating over the Music scene.\n\n**If your scene can be empty**, teach `_scene_available()` in `context.py` to skip it. Auto-rotation\nconsults it, a deliberate swipe does not, because \"show me the thing I asked for, even if it's\nempty\" is the right answer to an explicit request. The Dev scene is the worked example.\n\n\u003C\u002Fdetails>\n\n### Add a control (phone to PC)\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>Pick the WebSocket or an HTTP route, then respect the thread boundary\u003C\u002Fb>\u003C\u002Fsummary>\n\n**WebSocket** for anything the server should remember, by adding a branch to `_handle_client_msg`\nin `server.py`. `perf` is the smallest possible example, all of three lines:\n\n```python\nelif t == \"perf\":\n    state.perf_mode = bool(msg.get(\"on\"))\n```\n\nNote it sets a **plain attribute**, not a section. `perf_mode` and `dev_seen_at` are deliberately\nkept off the broadcast state because they're inputs to the server, not things any client needs\ntold, and putting them in `_data` would push an update to the phone every heartbeat to announce\nthat nothing changed.\n\n**An HTTP route** for fire-and-forget actions, especially ones something other than the dashboard\nmight call. `POST \u002Fapi\u002Fvscode` is one; the extension is a separate process.\n\n**The one hard rule: never call COM from the event loop.** A control that drives a threaded\ncollector pushes onto a `queue.Queue` and lets the owning thread pop it. `state.request_media_command`\nand `state.request_volume` are the two existing examples, and both return immediately:\n\n```python\ndef request_volume(self, command: str) -> None:\n    if not (command == \"mute\" or command.startswith(\"set:\")):\n        return                                  # validate here, not on the thread\n    try:\n        self.volume_commands.put_nowait(command)\n    except Exception:\n        pass\n```\n\n\u003C\u002Fdetails>\n\n### Frontend house rules\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>Why the JS looks like it's from 2013\u003C\u002Fb>\u003C\u002Fsummary>\n\nThe target device is an old Chromium on a budget phone, and every constraint below is\nload-bearing rather than stylistic:\n\n- **`var` and function expressions.** There is currently not a single arrow function, `let` or\n  `const` in `web\u002Fjs\u002F`. Match that.\n- **No modules, no bundler, no build step, no CDN.** Plain `\u003Cscript>` tags attaching to one\n  global `Desko` namespace. Everything is served off the LAN, so the dashboard works with the\n  internet down.\n- **No new frontend dependencies.** If you need a library, you probably need less feature.\n- **Every expensive effect needs a `html.perf` counterpart.** Blur, glow, shadow transitions and\n  infinite animations must all collapse to something flat under performance mode. `style.css`\n  ends with a **PERF MODE** section (7 numbered groups, from line ~520) that strips each class of\n  cost and says why it was worth stripping. Add yours there, then verify with `?perf=1`.\n- **Use the CSS custom properties** in `:root` (`--primary`, `--muted-foreground`, `--border`,\n  `--accent`, `--bad`). No hardcoded hex.\n- **Design for 1520x720 landscape.** That's the realme 3 this was built for. It's the floor, not\n  a suggestion.\n\nMeasure before you argue with any of this: **`?fps=1`** puts a live frame-time readout on screen.\nWatch the `max` figure while lyrics are scrolling, since that's where a regression shows, not in\nthe average.\n\n\u003C\u002Fdetails>\n\n### Wire it into demo mode\n\n`desko\u002Fdemo.py` fabricates sections so scenes are reviewable with no media playing, no\nLibreHardwareMonitor, no VS Code and no game running. **Add fabricated data for your section\nthere.** It's the only way somebody on macOS can review your Windows-only feature, and the\nfastest way to test an empty or extreme state without arranging one for real. Demo mode reuses\nthe real `State` broadcast path, so the frontend genuinely cannot tell the difference.\n\n**It is currently incomplete, and that makes a good first contribution.** Today it covers:\n\n| Section | Fabricated |\n|---|:--:|\n| `media`, `lyrics`, `sys`, `dev`, `weather` | yes |\n| `focus`, `procs`, `volume`, `game` | **no** |\n\nIts own rotation is `music, stats, dev, idle`, so **the Focus scene never appears in demo mode\nat all**, and the Stats header icons and the SYS.RAM popup are empty because `procs` is never\nfilled in. Adding those means fabricating the sections in `start()` and adding `\"focus\"` to the\n`SCENES` tuple at the top of `demo.py`.\n\n### Before you open a PR\n\n```powershell\npython run.py --demo         # every scene still renders and cycles\npython run.py                # real data; watch the log for collector warnings\n```\n\n- **Check it degrades.** Rename or uninstall whatever your collector depends on and confirm the\n  widget hides instead of showing a dead `-`.\n- **Check the idle cost.** The design target is under 1% CPU and under 100 MB RAM at idle. A\n  collector polling something expensive on a short interval is the usual way that gets lost.\n- **Check the wire.** A section that re-broadcasts unchanged data every tick is a bug, even\n  though nothing visibly breaks. `set_section` deep-compares for exactly this reason, so if you\n  see constant traffic in `\u002Fapi\u002Fstate` with nothing happening, you're rebuilding an equal-but-not-identical\n  payload (a fresh `updatedAt` on every poll is the usual culprit).\n\n`IMPLEMENTATION_PLAN.md` holds the original architecture and the binding data contracts for each\nsection. `AGENTS.md` is the short version for coding agents.\n\n---\n\n## Troubleshooting\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>Can't open the URL on the phone\u003C\u002Fb>\u003C\u002Fsummary>\n\nThe first run triggers a Windows Firewall prompt. Allow Python on **Private networks**. If you\ndismissed it, re-run and allow it, or add the rule manually. Confirm both devices are on the\nsame WiFi, not one on 2.4 GHz guest and one on 5 GHz main.\n\u003C\u002Fdetails>\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>The IP keeps changing, 192.168.0.4 one day and .7 the next\u003C\u002Fb>\u003C\u002Fsummary>\n\nThat's the router's DHCP lease. Use `http:\u002F\u002Fdesko.local:7777`, which follows the IP\nautomatically. If the phone is too old for mDNS (Android 10\u002F11), add a **DHCP reservation**\nfor the PC's MAC in the router admin page. Desko prints the MAC under the QR at startup.\n\u003C\u002Fdetails>\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>\u003Ccode>desko.local\u003C\u002Fcode> shows \"not a secure connection\"\u003C\u002Fb>\u003C\u002Fsummary>\n\nChrome tried HTTPS. Type the full `http:\u002F\u002Fdesko.local:7777` including the scheme. On Android\n10\u002F11 there's no `.local` resolver at all, so use the numeric IP.\n\u003C\u002Fdetails>\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>No music detected\u003C\u002Fb>\u003C\u002Fsummary>\n\nPlay audio in an app or tab **on the PC**, not on the phone, and make sure\n`requirements-windows.txt` is installed (`winsdk`). Not available on macOS or Linux, see\n[Platform support](#platform-support).\n\u003C\u002Fdetails>\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>No temperatures or GPU load\u003C\u002Fb>\u003C\u002Fsummary>\n\nLibreHardwareMonitor isn't running, or isn't elevated. Run `scripts\u002Fsetup-lhm.ps1` once. Desko\nrelinks within about 20 s once LHM is up, with no restart needed.\n\u003C\u002Fdetails>\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>No lyrics for a song\u003C\u002Fb>\u003C\u002Fsummary>\n\nNot every track exists in LRCLIB or lyrics.ovh, and some only have unsynced text. Desko falls\nback to plain, then to an art-only card. If you want a specific song fixed for good, drop an\n`.lrc` into `cache\u002Flyrics\u002Fmanual\u002F`, see the Music section above.\n\u003C\u002Fdetails>\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>The Dev scene says GIT when VS Code is open\u003C\u002Fb>\u003C\u002Fsummary>\n\nThe extension isn't reporting. Check it's installed in the right folder for your editor\n(`.vscode-insiders\u002Fextensions` for Insiders), reload the window, and run *Desko: Ping now*\nfrom the command palette to force a report. If the scene says **OFFLINE** instead, there's no\nrepo to fall back to either, so set `git_repo_path` in `config.json`, or open a git workspace\nin VS Code once so Desko learns the path.\n\u003C\u002Fdetails>\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>The screen keeps turning off\u003C\u002Fb>\u003C\u002Fsummary>\n\nKeep-awake needs one tap on the page to arm, because browser autoplay policy won't let it\nstart the hidden video without a gesture. Tap once after loading. For a permanent display,\nenable Android's *Stay awake* as a backstop, or grant the Wake Lock flag described in Phone\nsetup.\n\u003C\u002Fdetails>\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>The UI feels sluggish, or the lyrics stutter\u003C\u002Fb>\u003C\u002Fsummary>\n\nTap the ⚡ bolt for [performance mode](#performance-mode). Add `?fps=1` to the URL to see\nwhether it actually helped.\n\u003C\u002Fdetails>\n\n---\n\n## Security\n\n> [!IMPORTANT]\n> **Desko has no authentication and is meant for your home LAN only.**\n\nThe server binds `0.0.0.0` by default, and anyone who can reach the port can read the\ndashboard, control your PC's media playback and volume, and change settings via `\u002Fconfig`.\nThat's a deliberate trade for zero-friction setup on a trusted network.\n\n- **Do not port-forward it** or expose it to the internet.\n- **Don't run it on public or shared WiFi** such as cafés, hostels, offices or campus networks.\n- To limit it to this machine while testing, set `\"host\": \"127.0.0.1\"` in `config.json`.\n\n`config.json` is git-ignored because it holds your location and machine-specific settings.\nKeep it that way if you fork this.\n\n---\n\n## License\n\nDesko is licensed under the **[Desko Noncommercial License 1.0](LICENSE)**.\n\nCopyright 2026 **typewriter03**.\n\nIn plain terms:\n\n| You may | You may not |\n|---|---|\n| Use it personally, for hobby projects, study, experiments and private entertainment | **Sell it**, or use it for any commercial purpose |\n| Modify it, build on it, and share your changes | Strip the license or the copyright notice |\n| Use it at a charity, school, university, public research body, or government institution | Sublicense it or transfer your rights to someone else |\n\n**Credit is mandatory, not a request.** The license's *Notices* section requires that anyone\nwho receives any part of Desko from you also receives a copy of these terms and the\n`Required Notice:` line naming the copyright holder. That line sits at the top of\n[`LICENSE`](LICENSE), so keeping the file intact is all it takes.\n\nIf you want to use Desko commercially, ask. The license is written so that a separate\nagreement with the copyright holder is the way to get that.\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>Why this license and not MIT\u003C\u002Fb>\u003C\u002Fsummary>\n\nMIT explicitly grants the right to **sell** the software, which is the opposite of the intent\nhere. So do Apache-2.0 and BSD. Creative Commons has a noncommercial variant, but Creative\nCommons themselves recommend against using CC licenses for software, and their \"NonCommercial\"\nwording is famously ambiguous at the edges.\n\nWhat's in `LICENSE` instead is a short, plain-English noncommercial license: every\nnoncommercial use stays wide open (personal, hobby, study, charity, education, public research,\ngovernment), while commercial use is reserved to the copyright holder and available by asking.\n\nTwo trade-offs worth knowing:\n\n- Because it restricts a field of use, this is **not** \"open source\" as the OSI defines the\n  term. Some package registries, Linux distros and corporate policies exclude such licenses on\n  principle. In exchange, nobody can take Desko, close it up and sell it.\n- It grants **copyright rights only**. No patent license is granted, and the *No Other Rights*\n  section makes that explicit by stating that these terms imply no other licenses.\n\n**The warranty disclaimer is not optional boilerplate.** The *No Liability* section is this\nlicense's equivalent of MIT's `AS IS` block, and it's the one paragraph that protects the\nauthor rather than the user. It disclaims implied warranties (merchantability, fitness for a\nparticular purpose) that some jurisdictions otherwise read into any supply of software, and it\ncaps liability for damages. Desko launches LibreHardwareMonitor with administrator rights and\nreads hardware sensors, so that clause matters more here than it would for a static site.\n\n\u003C\u002Fdetails>\n\n\u003Cdetails>\n\u003Csummary>\u003Cb>Do I need a license at all? (short version, for anyone reading this repo)\u003C\u002Fb>\u003C\u002Fsummary>\n\nYes. Three facts that surprise most people:\n\n1. **Copyright is automatic.** You own what you wrote the moment you wrote it. There's no form\n   to file and no fee for the copyright itself to exist.\n2. **Public on GitHub does not mean free to use.** With no LICENSE file, the default is *all\n   rights reserved*. People may read your code but have no legal right to copy, modify or run\n   it. The license is what changes that.\n3. **Adding one is the entire procedure.** A text file in the repo root plus your name on the\n   notice line. No lawyer, no registration, no paperwork.\n\nRegistration is a separate, optional thing. Some countries let you register a copyright with a\ngovernment office for a fee, which mainly matters if you intend to sue for statutory damages.\nFor a desk dashboard, skip it.\n\n\u003C\u002Fdetails>\n\nThird-party material, namely the bundled **Geist Mono** font (SIL OFL 1.1) and\n**LibreHardwareMonitor** (MPL-2.0, downloaded at runtime rather than redistributed), is\ndocumented in **[THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)**.\n","Desko 是一个将闲置手机变身为桌面常驻信息仪表盘的轻量级工具。它通过 PC 端 Python 后端采集系统状态（CPU\u002F内存）、Git 仓库状态、音乐播放（含同步卡拉OK歌词）、Pomodoro 计时器和精美时钟等数据，经 WebSocket 实时推送到手机浏览器展示，无需安装 App、不依赖云服务或构建流程。采用纯前端 Vanilla JS 实现，支持多场景自动轮播与手动切换，适合作为开发者、知识工作者或极简主义用户的物理桌面信息中枢。",2,"2026-08-05 02:30:08","CREATED_QUERY"]