Turn a spare phone into an always-on desk dashboard.
Music with karaoke lyrics · live PC vitals · git status · a clock that actually looks good · a Pomodoro timer, pushed from your PC to any old phone on your WiFi.
That old phone in your drawer has a perfectly good screen. Desko gives it a job.
A single lightweight Python process runs on your PC, collects things worth looking at, and pushes them over a WebSocket to one browser tab on the phone sitting on your desk stand. No app to install, no account, no cloud, no build tooling. You open a URL and it just runs, forever.
The display cycles through five scenes on a timer. Swipe to take manual control, tap the padlock to pin one.
┌──────────────────────────────────────────────────────────────────────┐
│ ⛶ DESKO OS 0.8.5 │ ● LINKED OMEN-PC 192.168.0.7 │ 21:04 🔋87% ⚡🔒│
├──────────────────────────────────────────────────────────────────────┤
│ ┌────────────┐ MEDIA.TRACK PLAYING │
│ │ │ EAGLES · HOTEL CALIFORNIA │
│ │ ▓▓ album │ ┌─ LYRICS ─────────────────────────────────────┐ │
│ │ ▓▓ art │ │ on a dark desert highway │ │
│ │ │ │ ▌ cool wind in my hair │ │ ← active line
│ └────────────┘ │ warm smell of colitas │ │ glows + scrolls
│ ◉ YT MUSIC │ rising up through the air │ │
│ └──────────────────────────────────────────────┘ │
│ 0:44 ▬▬▬▬▬▬░░░░░░░░░░░░░░░░░░ -4:15 ⏮ ⏸ ⏭ 🔊 │
└──────────────────────────────────────────────────────────────────────┘
ambient background = the album art, blurred, filling the whole screen
Everything degrades gracefully. No media playing, no LibreHardwareMonitor, no VS Code, and
those widgets just hide. Nothing crashes, nothing shows a dead -.
| Scene | What's on it | |
|---|---|---|
| 🕐 | Idle | Big IST clock plus London secondary, date, live weather, phone battery, link latency, PC uptime |
| 🎵 | 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 |
| 📊 | Stats | CPU/GPU load and temperature, RAM, network up/down, 60-second sparklines, session timer, game name when a configured process is running |
| 💻 | Dev | Workspace, branch, ahead/behind, changed files, cursor position, last commit, today's commits and lines. Live from VS Code, and from git alone once the editor is closed |
| 🍅 | Focus | Pomodoro countdown ring, work/break auto-flow, cycle counter, adjustable lengths. Server-side, so it's identical on every device and survives reloads |
Music ambient album art, synced lyrics |
Stats load, temps, 60s sparklines |
Idle clock, weather, link and uptime |
Rotation order · idle → music → stats → dev → focus · wrapping, rotate_sec each
git clone https://github.com/typewriter03/Desko.git
cd Desko
python -m pip install -r requirements.txt
python -m pip install -r requirements-windows.txt # Windows only: media, temps, volume
python run.pyThe terminal prints two URLs and a scannable QR code. Open one on your phone's browser. Same WiFi is the only requirement.
Tip
On Windows, double-click desko.bat instead. It keeps a window
open showing the URL and QR, and closing the window stops the server. Running it twice just
tells you it's already up.
| URL | Use it when |
|---|---|
http://desko.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:// so Chrome doesn't force HTTPS. |
http://<current-ip>:7777 |
Also in the QR. Changes whenever the DHCP lease moves. On Android 10/11 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. |
python run.py --demo # or: desko.bat --demoDemo mode fabricates media, lyrics, stats, git and weather, then cycles Music, Stats, Dev and Idle every 20 s. Works on any OS with zero setup, no platform deps needed. (Focus, the process list and the volume slider aren't fabricated yet, so they stay empty here. See Extending Desko.)
The server is cross-platform Python. Only some collectors are Windows-bound, and every one
of them is import-guarded. Here is what actually happens on a machine with no Windows modules
available, measured rather than assumed by booting the server with winsdk, wmi,
pythoncom, pycaw and comtypes blocked at import:
| Feature | Windows | macOS / Linux | Why |
|---|---|---|---|
| Server, WebSocket, scene carousel | ✅ | ✅ | pure Python |
| Idle: clock, date, weather, battery | ✅ | ✅ | Open-Meteo plus the phone's own Battery API |
| Focus: Pomodoro | ✅ | ✅ | server-side timer, no OS calls |
| Stats: CPU %, RAM, network, sparklines | ✅ | ✅ | psutil is cross-platform |
| Stats: CPU/GPU temperature, GPU load | ✅ | ❌ | reads LibreHardwareMonitor over WMI |
| Dev: branch, changes, commit, today | ✅ | ✅ | plain git subprocesses |
| Dev: live editor state | ✅ | ✅ | the VS Code extension is just JS plus HTTP |
| Music: now playing, album art, lyrics | ✅ | ❌ | Windows GSMTC media session |
| Music: PC volume slider | ✅ | ❌ | pycaw / Core Audio |
http://desko.local (mDNS) |
✅ | ✅ | zeroconf |
--demo mode |
✅ | ✅ | fabricated data |
What that looks like in practice
Booting on a non-Windows machine gives you a working dashboard with Idle, Stats, Dev and Focus. The Music scene stays empty and the temperature widgets hide. Verbatim output of the degradation test:
sections after 6s with all Windows modules blocked:
sys {'cpuPercent': 21.8, 'ramPercent': 82.2, 'netDownKbs': 2869.5,
'cpuTempC': None, 'gpuTempC': None, 'gpuPercent': None}
media ABSENT
volume ABSENT
weather {'tempC': 29.5, 'city': 'Bengaluru'}
dev {'source': 'git', 'branch': 'master', 'dirty': 16}
focus {'running': False}
[!NOTE] That test ran on Windows with the platform modules hidden. It proves the import guards and the degradation paths, not that Desko has been run on a real Mac. Nothing in the code is known to be Windows-path-dependent, but treat macOS as untested rather than supported.
What macOS parity would take, if you want it:
- Now playing. A
nowplaying-clistyle shim, or AppleScript against Music and Spotify. Apple's privateMediaRemoteframework has been progressively locked down for third-party processes, so the AppleScript route is the durable one. - Volume.
osascript -e 'set volume output volume N', as a smallvolume.pysibling. - Temps.
powermetricsneeds root;iStatsandsmcare the usual third-party route.
The Dev scene needs nothing. desko/collectors/git.py shells out to git and the VS Code
extension is plain JavaScript, so both already work anywhere.
Three steps, once
- Open the URL (or scan the QR with the phone camera).
- Add to Home Screen via Chrome menu → Add to home screen. You get a fullscreen, chromeless app with a proper icon. Launch it from there, not from the browser.
- Keep the screen on. Desko holds the display awake itself with a hidden always-playing
video, the one trick that works over plain HTTP. For a permanently-mounted display, also
enable Android's Stay awake:
- Settings → About phone → tap Build number 7 times to unlock Developer options
- Settings → System → Developer options → enable Stay awake
- Keep it plugged in.
Optional: enable the real Wake Lock API (cheaper on the battery)
Desko's keep-awake falls back to a looping hidden video because the proper
Screen Wake Lock API
requires a secure context, and http:// on a LAN isn't one. Playing a video forever costs a
decoder instance and real battery.
You can grant the exception, once, on the phone:
- Open
chrome://flags/#unsafely-treat-insecure-origin-as-secure - Set Enabled, and enter
http://desko.local:7777in the box - Relaunch Chrome
The bundled keep-awake shim feature-detects the native API and switches to it automatically, with no code change. The entry is a full origin including the port, and Chrome sometimes clears flags across major updates, so re-check it there if the screen starts sleeping again.
If the display freezes until you touch it
Android suspends timers and can drop a WiFi TCP connection without closing it. Desko detects both. An unanswered ping for 15 s forces a reconnect, and a gap of over 2 s in the render tick re-probes the link, so it recovers on its own within seconds. If it still stalls:
- Settings → Battery → App battery management → allow Chrome to run in the background. If you launched Desko from the home screen, that's a separate app entry, so set it there too.
- Turn off any WiFi power-saving toggle in the WiFi advanced settings.
- Enable the Wake Lock flag above, so keep-awake stops relying on the video fallback.
| Gesture | Does |
|---|---|
| Swipe ← → | Previous / next scene (takes manual control) |
| Double-tap | Open the Desko home screen |
| Tap a lyric line | Seek the PC's playback to that moment (synced lyrics only) |
| 🔒 Padlock (top right) | Freeze the current scene so nothing auto-switches. Open = AUTO, closed = LOCKED |
| ⚡ Bolt (top right) | Performance mode, which flattens the theme for weak GPUs |
| ⛶ Corners (top left) | Fullscreen |
Old phones have old GPUs. The Realme 3 this was built for chokes on full-resolution backdrop blur, layered glows, and half a dozen infinite animations all compositing at once.
Tap the ⚡ bolt in the system bar to strip all of it: no blur, no glows, no looping animation, no scanline overlay, no edge-fade mask, opaque panels instead of translucent ones. Same green-on-dark identity, same layout, none of the fill-rate cost.
- The setting sticks per device (
localStorage), and applies before first paint, so there's no flash of the expensive theme on reload. - Auto-enables by default if your phone has OS-level Reduce motion switched on. An explicit choice always wins over that.
- Force it from a URL with
?perf=1or?perf=0. Useful becauselocalStorageis per-origin, sodesko.localand the raw IP keep separate settings. - Measure it:
?fps=1puts a live frame-time readout in the corner. Watch themaxnumber while lyrics scroll, because that's where the difference shows, not in the average.
Two ways, both fine:
- In the browser. Open
http://desko.local:7777/config, a settings page for the weather city, game list, Pomodoro lengths, rotation speed and port. It tells you which changes apply live and which need a restart. - By hand. Edit
config.json(created on first run fromconfig.example.json) and restart.
Note
Any key you leave out is filled from the defaults in run.py, so an old config.json keeps
working after an update. You don't have to re-add new keys by hand.
🎵 Music: now playing, lyrics, volume (Windows)
Install requirements-windows.txt for winsdk. Then just play audio in any app or browser
tab on the PC. Windows' media session API sees Chrome, Edge, Brave, Spotify, and most
desktop players. Nothing to configure per-app.
Lyrics resolve through a chain, first hit wins:
cache/lyrics/manual/<slug>.lrc, a file you dropped in yourself- LRCLIB exact match, retried across cleaned-up query variants
- LRCLIB search, looser matching, still synced
- lyrics.ovh, an independent catalogue, plain text only
Step 2's cleanup matters more than it sounds. Windows reports whatever the browser tab is
called, so Channa Mereya (Official Video) [Lyrics] by Arijit Singh - Topic used to be a
guaranteed miss. It now resolves. Results are cached including "no lyrics", so instrumentals
aren't re-fetched every run, but a network failure is never cached as a miss.
To force lyrics for a specific song, drop an .lrc at
cache/lyrics/manual/<artist>-<title>.lrc, lowercase, with non-alphanumerics collapsed to -
(arijit-singh-channa-mereya.lrc). Bare <title>.lrc also matches, and .txt works for
unsynced text. Manual files are read before the cache, so edits apply on the next track
change with no restart.
Volume. The slider drives the PC's system master volume via pycaw, tracks changes you
make on the PC, and the speaker button mutes. Without pycaw the control just hides.
📊 Stats: temperatures and GPU load (Windows, one-time install)
CPU, RAM and network need nothing but psutil. Temps and GPU % need
LibreHardwareMonitor, which this script installs for you:
pwsh -ExecutionPolicy Bypass -File scripts/setup-lhm.ps1It puts LHM (portable) under %LOCALAPPDATA%\Desko\lhm and registers an elevated scheduled
task Desko-LibreHardwareMonitor so it starts at login with the admin rights the sensors
need. Desko also relaunches LHM itself whenever temps go offline by triggering that task,
so there's no UAC prompt, and a closed or crashed LHM is back within about 20 s.
Re-running the script is safe. If LHM is already there it skips the download and just repairs the task. Without any of it, the temperature widgets simply hide.
💻 Dev: VS Code and git (any OS)
The Dev scene has two independent data sources, so it keeps working whether or not your editor is open.
1. The VS Code extension (fast path). Copy it in:
Copy-Item -Recurse vscode-extension "$env:USERPROFILE\.vscode\extensions\desko-status-0.1.0"# macOS / Linux
cp -r vscode-extension ~/.vscode/extensions/desko-status-0.1.0Then reload the window (Ctrl/Cmd+Shift+P → Developer: Reload Window). It reports the
workspace, branch, ahead/behind, the real changed-file list, the current file, cursor
position and line ending, and the last commit.
It's event-driven, not polled. It subscribes to the git extension's state.onDidChange
plus editor focus, selection and save events, so a branch switch reaches the phone in well
under a second. Identical payloads are dropped rather than re-sent, and a 15 s heartbeat
exists purely to prove the editor is alive. It also picks the repo that owns the file you're
actually looking at, so multi-root workspaces resolve correctly.
2. The git collector (the floor). When the extension stops reporting for
vscode_stale_sec (default 45 s), desko/collectors/git.py takes the section over and reads
the repo directly. The chip in the panel header changes from ACTIVE to GIT, and the
editor-only fields blank out rather than showing what happened to be open when you quit.
It follows whatever workspace VS Code last reported, so it needs no configuration. Open a
folder once and Desko remembers which repo to watch. Set git_repo_path to pin it somewhere
else. It also computes today's commits and lines changed, which rides along under either
source.
If neither source has data, auto-rotation skips the Dev scene entirely rather than parking 60 s on an empty screen. A manual swipe still lands on it.
🍅 Focus: Pomodoro (any OS)
No setup at all. Open the Focus scene and press play. Work and break phases auto-flow, the
lengths are adjustable from the scene itself or /config, and the timer lives on the
server, so every connected device shows the same countdown and a page reload doesn't
reset it.
🌤️ Weather (any OS)
Leave weather_city empty to auto-detect by IP, or set it explicitly ("Bengaluru, IN",
"Berlin, DE"). Powered by Open-Meteo, free, no API key. Offline, the widget hides rather
than showing stale numbers.
Four helpers ship in the repo root. All of them are optional, since python run.py is always
equivalent.
| File | Double-click behaviour | Use it when |
|---|---|---|
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. |
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. |
desko-stop.cmd |
Finds whatever is LISTENING on the port and kills it. | Stopping the hidden launcher, which has no window to close. |
scripts/setup-lhm.ps1 |
Installs LibreHardwareMonitor and its scheduled task (one UAC prompt). | Once, to enable temperatures and GPU load. |
Notes worth knowing:
- Both launchers probe the port first and refuse to start a second copy. A duplicate would just fail to bind and spam the log.
- Both
cdto their own folder first, so double-clicking from anywhere works.run.pyresolvesconfig.jsonandweb/relative to the project root. desko.batforwards arguments, sodesko.bat --demoanddesko.bat --port 8080both work.- The port is hardcoded as
7777in all three scripts purely for the "already running" check. If you changeportinconfig.json, update it in these files too.run.pyitself always reads the real value from config. - To autostart: press
Win+R, typeshell:startup, and drop a shortcut todesko.bat(visible) ordesko-hidden.vbs(invisible) in the folder that opens.
PC (Windows / macOS / Linux) PHONE (any browser)
┌───────────────────────────────────┐ ┌────────────────────┐
│ run.py │ │ │
│ │ │ │ index.html │
│ ├─ aiohttp ──── GET / ─────────────────────────▶ + app.js │
│ │ /static/* │ │ + scenes/*.js │
│ │ │ │ │
│ ├─ WebSocket /ws ◀═══════════════ diffs only ══▶ scene router │
│ │ │ │ │
│ ├─ State ── set_section() ─────┤ │ ┌──────────────┐ │
│ │ deep-compares, broadcasts │ │ │ idle music │ │
│ │ only what changed │ │ │ stats dev │ │
│ │ │ │ │ focus │ │
│ ├─ collectors (8 async tasks) │ │ └──────────────┘ │
│ │ ├ media ──── winsdk/GSMTC ─┤ (WinRT thread)│ │
│ │ ├ lyrics ─── LRCLIB → ovh │ │ POST /api/media │
│ │ ├ sysstats ─ psutil + WMI ─┤ (COM thread) │◀── /api/volume ────┤
│ │ ├ volume ─── pycaw ────────┤ (COM thread) │ │
│ │ ├ weather ── Open-Meteo │ └────────────────────┘
│ │ └ git ────── git subprocess│
│ │ ▲ takes over `dev` when the editor goes quiet
│ ├─ focus ─── server-side timer │ ┌────────────────────┐
│ ├─ context ─ scene carousel │◀── POST ──────│ VS Code extension │
│ └─ announce ─ mDNS desko.local │ /api/vscode └────────────────────┘
└───────────────────────────────────┘ event-driven, 15s heartbeat
One aiohttp process serves the static frontend and a WebSocket. Collectors run as async
tasks and push state diffs only when something actually changes, so the socket is quiet
when nothing is happening. Three collectors that touch COM or WinRT (media, sysstats,
volume) each own a dedicated thread, because those APIs are apartment-bound and would
otherwise block the event loop.
The Dev section has two writers and one arbiter. The VS Code extension POSTs on real
editor events. The server stamps updatedAt only when the payload actually changed, so an
idle editor costs zero WebSocket traffic. Liveness is tracked separately, off the wire
entirely, and the git collector watches it: when heartbeats stop, git claims the section.
dev.source tells the frontend which one it's looking at.
Scene selection is a carousel. Every scene gets equal air time (rotate_sec, default
60 s), wrapping idle → music → stats → dev → focus. A swipe overrides it for
override_timeout_sec; the padlock freezes it indefinitely. (An earlier build picked scenes
by priority, but VS Code focus flickering made the display bounce between scenes every few
seconds, which the carousel removed entirely.)
The frontend is vanilla HTML/CSS/JS. No framework, no bundler, no build step, no CDN requests, ES5-flavoured syntax throughout, because the target device is an old Chromium on a budget phone and every one of those choices was load-bearing.
Design targets: under 1% idle CPU, under 100 MB RAM, no database. See
IMPLEMENTATION_PLAN.md for the original architecture and the binding data contracts.
| Needed for | Without it | |
|---|---|---|
| Python 3.11+ | everything | required |
aiohttp, psutil |
the server, CPU/RAM/net | required |
qrcode |
the terminal QR code | URL still prints |
zeroconf |
http://desko.local |
numeric IP still works |
git on PATH |
Dev scene without VS Code | Dev needs the editor running |
winsdk (Win) |
Music scene, now playing | Music scene stays empty |
wmi and pywin32 (Win) |
CPU/GPU temperatures | temp widgets hide |
pycaw and comtypes (Win) |
volume slider | slider hides |
| LibreHardwareMonitor | the sensors wmi reads |
temp widgets hide |
| VS Code and the bundled extension | live editor state on Dev | git fallback covers the rest |
run.py entrypoint, prints URL + QR, starts the server
desko.bat Windows double-click launcher (single-instance guard)
desko-hidden.vbs start with no console window at all, logs to desko.log
desko-stop.cmd stop the hidden server
config.json your settings (git-ignored, generated on first run)
config.example.json reference config, committed
desko/
server.py aiohttp app, routes, WebSocket, collector lifecycle
state.py shared state, diffing, pub/sub to connected clients
context.py scene carousel, override and lock handling
focus.py server-side Pomodoro
announce.py mDNS, so desko.local survives DHCP
demo.py --demo fake-data generator
collectors/
media.py Windows GSMTC now-playing (own WinRT thread)
lyrics.py 4-provider lyric chain plus cache
sysstats.py psutil + LibreHardwareMonitor over WMI (own COM thread)
volume.py system master volume via pycaw (own COM thread)
weather.py Open-Meteo
vscode.py POST /api/vscode ingest and validation
git.py git fallback for the Dev scene, plus today's totals
web/ vanilla frontend, no build step
index.html all five scenes plus the launcher
css/style.css the whole theme, including html.perf
js/app.js WebSocket client, scene router, gestures, keep-awake
js/scenes/*.js one module per scene
config.html the /config settings page
vscode-extension/ event-driven reporter (editor + git state -> POST /api/vscode)
scripts/setup-lhm.ps1 one-time LibreHardwareMonitor installer
THIRD-PARTY-NOTICES.md bundled font and downloaded tool licensing
Desko has exactly three kinds of moving part. Adding a feature means picking one and copying its nearest sibling.
| You want to | Build a | It lives in |
|---|---|---|
| Put new data on the dashboard | collector | desko/collectors/ |
| Put a new screen in the carousel | scene | web/js/scenes/ plus a <section> |
| Let the phone do something to the PC | control | a route in server.py, or a WebSocket message type |
The contract between them is one sentence:
A collector writes a named section into
State. The server broadcasts it only if it actually changed. The frontend merges it and hands it to the scene that cares.
That's the whole architecture. There is no event bus, no plugin registry, no dependency injection, and nothing is auto-discovered. Wiring is a handful of explicit lists, and the sections below tell you exactly which ones.
Worked example: a disk section showing free space.
Five edits, in order
1. Write desko/collectors/disk.py. Every collector is one async def start(state, config, session)
that loops forever:
"""Free disk space."""
import asyncio
import logging
import time
import psutil
log = logging.getLogger("desko.disk")
async def start(state, config, session) -> None:
interval = float(config.get("poll", {}).get("disk_sec", 60))
while True:
try:
u = psutil.disk_usage(config.get("disk_path") or "/")
state.set_section("disk", {
"freeGb": round(u.free / 1024 ** 3, 1),
"pct": u.percent,
"updatedAt": time.time(),
})
except Exception as e: # never let the loop die
log.warning("disk read failed: %s", e)
state.set_section("disk", None) # None tells the scene to hide
await asyncio.sleep(interval)2. Declare the section in desko/state.py, in the _data dict: "disk": None. This is what
a freshly connected phone receives in its snapshot.
3. Register the task in desko/server.py, inside on_startup:
tasks.append(asyncio.create_task(disk_mod.start(state, config, app["http_session"])))4. Add the poll interval to DEFAULT_CONFIG in run.py and to config.example.json.
load_config back-fills anything missing from the defaults, so nobody's existing
config.json breaks when you add a key.
5. Route it to a scene in web/js/app.js: add disk: null to the state object, then send
the section to whichever scene renders it, in mergeUpdate:
} else if (section === "sys" || section === "game" || section === "procs" || section === "disk") {
if (scenes.stats && scenes.stats.onStateChange) try { scenes.stats.onStateChange(state); } catch (e) {}
}Sections not listed there fall through to "notify the active scene", which is fine for data only one scene uses. The explicit branches exist so a section can update a scene that isn't on screen, which is what makes a scene correct the instant you swipe to it.
The five rules a collector has to follow
1. Never raise out of the loop. Nothing restarts a dead collector, and its section freezes at whatever it last published. Wrap the body, log a warning, keep sleeping.
2. Publish None when the source is gone. Every scene is written to hide a widget whose
section is null. That single convention is the entire degradation strategy, and it's why the
server boots fine on a Mac with half the collectors inert.
3. Import platform modules inside a guard, never at module top level. A bare
import winsdk crashes the process on Linux before anything gets the chance to degrade
gracefully. Copy the try/except header from media.py, volume.py or procs.py:
_HAS_ICONS = False
try:
import win32gui
from PIL import Image
_HAS_ICONS = True
except Exception:
pass4. Use patch_section for chatty little fields. set_section broadcasts the whole section.
The media collector updates the playhead 3x a second, and re-sending the base64 album art each
time would saturate the link, so it patches just {"position": ...}. Both sides merge partials,
so omitted fields survive.
5. Anything that blocks needs its own thread. The event loop also serves the WebSocket, the media position patches and the lyric timing, so a synchronous stall is visible on the phone as a stutter. The threshold in practice is a few milliseconds. Measured on a 367-process box:
| Call | Cost | Verdict |
|---|---|---|
psutil.process_iter(["name"]) |
2 ms | fine on the loop |
+ memory_info |
984 ms | needs a thread |
+ cpu_times |
1025 ms | (why per-app CPU was nearly free to add) |
WMI Win32_Process |
1923 ms | avoid |
Anything touching COM or WinRT needs a thread regardless of speed, because those APIs are
apartment-bound. media.py, sysstats.py, volume.py and procs.py each own one. The pattern
is always the same: the thread writes into a shared dict, and a cheap async loop publishes from
it. procs.py is the shortest copy-paste template.
One scene, five places to name it
There is no scene auto-registration, so a new scene has to be listed in five spots. Miss one and you get a specific, diagnosable failure, listed here so you don't have to bisect it:
| Edit | If you forget it |
|---|---|
<section class="scene x-scene" data-scene="x"> in web/index.html |
scene switches to a blank frame |
<script src="/static/js/scenes/x.js"> in web/index.html |
markup shows but never populates |
SCENES in desko/state.py |
swipe skips it, override for it is rejected |
ROTATION in desko/context.py |
reachable by swipe, never by the carousel |
SCENES in web/js/app.js |
?scene=x is ignored |
The module itself is an object on the Desko.scenes namespace, no imports and no exports:
Desko.scenes.x = (function () {
var E = {};
return {
onEnter: function (state) { E.foo = document.getElementById("x-foo"); render(state); },
onStateChange: function (state) { render(state); },
onTick: function (state, nowMs) {},
onExit: function () {},
};
})();| Hook | Called | Use it for |
|---|---|---|
onEnter(state) |
scene becomes visible | cache DOM lookups, wire listeners once, first paint |
onStateChange(state) |
a routed section changed | all data rendering |
onTick(state, nowMs) |
4x/s, active scene only | anything animating between updates: clocks, countdowns, the interpolated playhead |
onExit() |
scene leaves | close popups, stop anything you started |
onTick exists because the server never pushes a clock. Sending a time update every second
to keep a countdown moving would be pure waste, so scenes interpolate locally and the socket
stays quiet. onExit matters more than it looks: the stats scene closes its process popup there,
because otherwise a rotation leaves the popup floating over the Music scene.
If your scene can be empty, teach _scene_available() in context.py to skip it. Auto-rotation
consults it, a deliberate swipe does not, because "show me the thing I asked for, even if it's
empty" is the right answer to an explicit request. The Dev scene is the worked example.
Pick the WebSocket or an HTTP route, then respect the thread boundary
WebSocket for anything the server should remember, by adding a branch to _handle_client_msg
in server.py. perf is the smallest possible example, all of three lines:
elif t == "perf":
state.perf_mode = bool(msg.get("on"))Note it sets a plain attribute, not a section. perf_mode and dev_seen_at are deliberately
kept off the broadcast state because they're inputs to the server, not things any client needs
told, and putting them in _data would push an update to the phone every heartbeat to announce
that nothing changed.
An HTTP route for fire-and-forget actions, especially ones something other than the dashboard
might call. POST /api/vscode is one; the extension is a separate process.
The one hard rule: never call COM from the event loop. A control that drives a threaded
collector pushes onto a queue.Queue and lets the owning thread pop it. state.request_media_command
and state.request_volume are the two existing examples, and both return immediately:
def request_volume(self, command: str) -> None:
if not (command == "mute" or command.startswith("set:")):
return # validate here, not on the thread
try:
self.volume_commands.put_nowait(command)
except Exception:
passWhy the JS looks like it's from 2013
The target device is an old Chromium on a budget phone, and every constraint below is load-bearing rather than stylistic:
varand function expressions. There is currently not a single arrow function,letorconstinweb/js/. Match that.- No modules, no bundler, no build step, no CDN. Plain
<script>tags attaching to one globalDeskonamespace. Everything is served off the LAN, so the dashboard works with the internet down. - No new frontend dependencies. If you need a library, you probably need less feature.
- Every expensive effect needs a
html.perfcounterpart. Blur, glow, shadow transitions and infinite animations must all collapse to something flat under performance mode.style.cssends with a PERF MODE section (7 numbered groups, from line ~520) that strips each class of cost and says why it was worth stripping. Add yours there, then verify with?perf=1. - Use the CSS custom properties in
:root(--primary,--muted-foreground,--border,--accent,--bad). No hardcoded hex. - Design for 1520x720 landscape. That's the realme 3 this was built for. It's the floor, not a suggestion.
Measure before you argue with any of this: ?fps=1 puts a live frame-time readout on screen.
Watch the max figure while lyrics are scrolling, since that's where a regression shows, not in
the average.
desko/demo.py fabricates sections so scenes are reviewable with no media playing, no
LibreHardwareMonitor, no VS Code and no game running. Add fabricated data for your section
there. It's the only way somebody on macOS can review your Windows-only feature, and the
fastest way to test an empty or extreme state without arranging one for real. Demo mode reuses
the real State broadcast path, so the frontend genuinely cannot tell the difference.
It is currently incomplete, and that makes a good first contribution. Today it covers:
| Section | Fabricated |
|---|---|
media, lyrics, sys, dev, weather |
yes |
focus, procs, volume, game |
no |
Its own rotation is music, stats, dev, idle, so the Focus scene never appears in demo mode
at all, and the Stats header icons and the SYS.RAM popup are empty because procs is never
filled in. Adding those means fabricating the sections in start() and adding "focus" to the
SCENES tuple at the top of demo.py.
python run.py --demo # every scene still renders and cycles
python run.py # real data; watch the log for collector warnings- Check it degrades. Rename or uninstall whatever your collector depends on and confirm the
widget hides instead of showing a dead
-. - Check the idle cost. The design target is under 1% CPU and under 100 MB RAM at idle. A collector polling something expensive on a short interval is the usual way that gets lost.
- Check the wire. A section that re-broadcasts unchanged data every tick is a bug, even
though nothing visibly breaks.
set_sectiondeep-compares for exactly this reason, so if you see constant traffic in/api/statewith nothing happening, you're rebuilding an equal-but-not-identical payload (a freshupdatedAton every poll is the usual culprit).
IMPLEMENTATION_PLAN.md holds the original architecture and the binding data contracts for each
section. AGENTS.md is the short version for coding agents.
Can't open the URL on the phone
The first run triggers a Windows Firewall prompt. Allow Python on Private networks. If you dismissed it, re-run and allow it, or add the rule manually. Confirm both devices are on the same WiFi, not one on 2.4 GHz guest and one on 5 GHz main.
The IP keeps changing, 192.168.0.4 one day and .7 the next
That's the router's DHCP lease. Use http://desko.local:7777, which follows the IP
automatically. If the phone is too old for mDNS (Android 10/11), add a DHCP reservation
for the PC's MAC in the router admin page. Desko prints the MAC under the QR at startup.
desko.local shows "not a secure connection"
Chrome tried HTTPS. Type the full http://desko.local:7777 including the scheme. On Android
10/11 there's no .local resolver at all, so use the numeric IP.
No music detected
Play audio in an app or tab on the PC, not on the phone, and make sure
requirements-windows.txt is installed (winsdk). Not available on macOS or Linux, see
Platform support.
No temperatures or GPU load
LibreHardwareMonitor isn't running, or isn't elevated. Run scripts/setup-lhm.ps1 once. Desko
relinks within about 20 s once LHM is up, with no restart needed.
No lyrics for a song
Not every track exists in LRCLIB or lyrics.ovh, and some only have unsynced text. Desko falls
back to plain, then to an art-only card. If you want a specific song fixed for good, drop an
.lrc into cache/lyrics/manual/, see the Music section above.
The Dev scene says GIT when VS Code is open
The extension isn't reporting. Check it's installed in the right folder for your editor
(.vscode-insiders/extensions for Insiders), reload the window, and run Desko: Ping now
from the command palette to force a report. If the scene says OFFLINE instead, there's no
repo to fall back to either, so set git_repo_path in config.json, or open a git workspace
in VS Code once so Desko learns the path.
The screen keeps turning off
Keep-awake needs one tap on the page to arm, because browser autoplay policy won't let it start the hidden video without a gesture. Tap once after loading. For a permanent display, enable Android's Stay awake as a backstop, or grant the Wake Lock flag described in Phone setup.
The UI feels sluggish, or the lyrics stutter
Tap the ⚡ bolt for performance mode. Add ?fps=1 to the URL to see
whether it actually helped.
Important
Desko has no authentication and is meant for your home LAN only.
The server binds 0.0.0.0 by default, and anyone who can reach the port can read the
dashboard, control your PC's media playback and volume, and change settings via /config.
That's a deliberate trade for zero-friction setup on a trusted network.
- Do not port-forward it or expose it to the internet.
- Don't run it on public or shared WiFi such as cafés, hostels, offices or campus networks.
- To limit it to this machine while testing, set
"host": "127.0.0.1"inconfig.json.
config.json is git-ignored because it holds your location and machine-specific settings.
Keep it that way if you fork this.
Desko is licensed under the Desko Noncommercial License 1.0.
Copyright 2026 typewriter03.
In plain terms:
| You may | You may not |
|---|---|
| Use it personally, for hobby projects, study, experiments and private entertainment | Sell it, or use it for any commercial purpose |
| Modify it, build on it, and share your changes | Strip the license or the copyright notice |
| Use it at a charity, school, university, public research body, or government institution | Sublicense it or transfer your rights to someone else |
Credit is mandatory, not a request. The license's Notices section requires that anyone
who receives any part of Desko from you also receives a copy of these terms and the
Required Notice: line naming the copyright holder. That line sits at the top of
LICENSE, so keeping the file intact is all it takes.
If you want to use Desko commercially, ask. The license is written so that a separate agreement with the copyright holder is the way to get that.
Why this license and not MIT
MIT explicitly grants the right to sell the software, which is the opposite of the intent here. So do Apache-2.0 and BSD. Creative Commons has a noncommercial variant, but Creative Commons themselves recommend against using CC licenses for software, and their "NonCommercial" wording is famously ambiguous at the edges.
What's in LICENSE instead is a short, plain-English noncommercial license: every
noncommercial use stays wide open (personal, hobby, study, charity, education, public research,
government), while commercial use is reserved to the copyright holder and available by asking.
Two trade-offs worth knowing:
- Because it restricts a field of use, this is not "open source" as the OSI defines the term. Some package registries, Linux distros and corporate policies exclude such licenses on principle. In exchange, nobody can take Desko, close it up and sell it.
- It grants copyright rights only. No patent license is granted, and the No Other Rights section makes that explicit by stating that these terms imply no other licenses.
The warranty disclaimer is not optional boilerplate. The No Liability section is this
license's equivalent of MIT's AS IS block, and it's the one paragraph that protects the
author rather than the user. It disclaims implied warranties (merchantability, fitness for a
particular purpose) that some jurisdictions otherwise read into any supply of software, and it
caps liability for damages. Desko launches LibreHardwareMonitor with administrator rights and
reads hardware sensors, so that clause matters more here than it would for a static site.
Do I need a license at all? (short version, for anyone reading this repo)
Yes. Three facts that surprise most people:
- Copyright is automatic. You own what you wrote the moment you wrote it. There's no form to file and no fee for the copyright itself to exist.
- Public on GitHub does not mean free to use. With no LICENSE file, the default is all rights reserved. People may read your code but have no legal right to copy, modify or run it. The license is what changes that.
- Adding one is the entire procedure. A text file in the repo root plus your name on the notice line. No lawyer, no registration, no paperwork.
Registration is a separate, optional thing. Some countries let you register a copyright with a government office for a fee, which mainly matters if you intend to sue for statutory damages. For a desk dashboard, skip it.
Third-party material, namely the bundled Geist Mono font (SIL OFL 1.1) and LibreHardwareMonitor (MPL-2.0, downloaded at runtime rather than redistributed), is documented in THIRD-PARTY-NOTICES.md.




{ "host": "0.0.0.0", // "127.0.0.1" to keep it on this machine only "port": 7777, "mdns_name": "desko", // stable URL name -> http://desko.local:7777 "rotate_sec": 60, // seconds each scene holds in the carousel "weather_city": "", // e.g. "Pune, IN"; empty = auto-detect by IP "weather_lat": null, // set both to skip geocoding entirely "weather_lon": null, "game_processes": ["cs2.exe"], // lowercase .exe names -> Stats scene header "focus_work_min": 25, // Pomodoro defaults "focus_break_min": 5, "override_timeout_sec": 300, // how long a manual swipe holds before auto resumes "vscode_stale_sec": 45, // no editor heartbeat for this long -> git takes over "git_repo_path": "", // Dev fallback repo; empty = follow VS Code's workspace "lhm_enabled": true, // LibreHardwareMonitor for temps + GPU "poll": { "media_sec": 0.3, // playhead freshness (karaoke sync depends on it) "sysstats_sec": 1.0, "temps_sec": 3.0, "weather_sec": 1800, "volume_sec": 0.5, "git_sec": 10 // only polls while VS Code is NOT reporting } }