` zones
every grid-size change even though the canvas path never reads
them.
- `_anyTintedWidget()` (called from `renderFrame` every tick) now
caches its boolean and is invalidated from every widget mutation
path. Was a linear scan over `widgetNodes` 30 ×/s on the renderer
fast-path.
### Added — Wallpaper-bundle / bridge version handshake + stale-bundle banner
Lively + Wallpaper Engine import the wallpaper bundle as a *snapshot*
— Lively caches the ZIP under a random-hash folder, WE Workshop is
on its own publishing cycle. So a user who updates the bridge via
the tray's auto-update doesn't automatically get the matching new
wallpaper-page JS, and they had no way to spot the drift short of
noticing missing features.
This release closes the loop:
- `installer/build.ps1` stamps the bridge version into the
wallpaper bundle's `index.html` at staging time (replaces a new
`__WALLPAPER_VERSION__` placeholder in a `
` tag). Lively
ZIPs + the WE single-bundle both get the same stamp.
- On WS connect the wallpaper page sends `{type:"hello",
wallpaperVersion:"1.2.X"}`. The bridge's WS loop compares against
`APP_VERSION` using the same `_parse_version` semver helper the
update checker already uses, and on a *strictly older* bundle
pushes `{type:"version-mismatch", bridge:…, wallpaper:…}` back on
the same writer. Same-version and newer bundles get no banner.
- The wallpaper page renders a subdued amber banner bottom-right:
*"Wallpaper bundle out of date. Bridge: 1.2.12 · Wallpaper:
1.2.5. Open the tray menu → Re-import wallpaper bundles (or
Configurator → System) and re-add the wallpaper in Lively / WE."*
Pointer-events: none, so it never eats wallpaper-host input.
- The Re-import button in Configurator → System has been wired
since v1.2.1; the banner just funnels users to it.
When the page is served from the dev tree (no installer stamp),
the meta tag still says `__WALLPAPER_VERSION__` and the page
reports `"dev"`. The bridge short-circuits that case — no banner —
so local development can drift from `APP_VERSION` freely.
### Added — `Frame rate` setting (Performance / Balanced / Quality)
The 30 Hz cap is now user-tunable, paired with a read-only plugin
send-rate readout. New per-screen `frameRate` setting (Configurator
→ Glow card) with three buckets:
- **Performance — 20 Hz** — biggest CPU/GPU win on weak hardware.
- **Balanced — 30 Hz** (default) — perceptually identical to 60 Hz
on the blurred glow layer, half the work.
- **Quality — 60 Hz** — matches the plugin's typical max rate.
Both ends of the pipeline read the same value: the bridge's
`Broadcaster.broadcast_frame` caps outgoing per-screen at this
rate, the wallpaper page's `renderFrame` caps at the same. So no
WS frame ever crosses that the page would just drop. Joins
`gridRenderer` + `glassQuality` in `_BUNDLE_FORBIDDEN_KEYS` — Look
bundles can't override a hardware preference.
Next to the dropdown, the Configurator surfaces a live readout of
the active screen's *actual* incoming plugin rate
("plugin: 165 fps") via a new `measuredPluginFps` field in
`/config`. The `UdpReceiver` counts inbound frames per screen in
a 1 s sliding window and the Configurator's existing 5 s
`/config` poll keeps it fresh. Useful for picking a cap that
matches the workload without guessing — a 165 fps Solid Color
benefits a lot from the 20 Hz Performance cap.
### Performance — Bridge-side 30 Hz broadcast cap
Same tester reported the wallpaper page was at ~10 % CPU even with
*Solid Color* active in SignalRGB (not Crystal Glow), where the
per-zone colour cache should hit 100 % and the renderFrame body
does almost no work. Diagnosis: the SignalRGB plugin sends UDP at
the rate it can compute frames — for cheap effects (Solid Color,
simple gradients) that's often 150–200 fps. The bridge relayed
every one of those, the wallpaper page received them as WS
messages, allocated a `Uint8Array` view, fired `onmessage`, and
*then* the 30 Hz renderFrame cap dropped 80 % of them. The per-
frame WebView2 work (WS decode + dispatch + the Uint8Array view
construction) dominated the actual paint.
Added a matching 30 Hz cap on `Broadcaster.broadcast_frame` itself,
keyed per screen. Effective outgoing rate is now 30 Hz max
regardless of how fast the plugin is sending; the wallpaper page
sees one frame per render cycle instead of 5–6.
### Performance — pause-detect rAF probe throttled 60 Hz → ~5 Hz
A tester reported the wallpaper-page CPU baseline was ~10 % vs
~3 % for other Lively wallpapers, and Lively's pause-mode CPU
spiked 1-7 % even with nothing on screen. Root cause: the
`probeRafLoop` that detects when Lively or the OS pauses our page
was running at the page's full rAF rate (~60 Hz) just to take a
timestamp on every frame. Even with an empty callback body, the
chain of `requestAnimationFrame` calls keeps WebView2's compositor
warm continuously.
Throttled the probe via a `setTimeout(…, 200) → requestAnimationFrame`
cascade. Effective rAF rate drops from ~60 Hz to ~5 Hz, which still
distinguishes a paused page (rAF doesn't fire at all → timestamp
freezes → the 250 ms setInterval consumer sees >500 ms staleness)
from a running one. Saves the bulk of the idle baseline.
### Performance — Standby card no longer burns ~20 % GPU on its own
A tester reported that with **no widgets placed and the bridge not
running** Lively was still sitting at ~20 % GPU. With the bridge
disconnected the only thing on screen is the standby card
(`"SignalRGB Wallpaper Bridge offline — Start SignalRGBBridge.exe"`),
which carried two expensive CSS habits:
- A `backdrop-filter: blur(14 px) saturate(140 %)` on the card
itself — per-frame re-blur of the wallpaper underneath the
~400×220 px card area, burning ~10-15 % GPU continuously.
Dropped; background opacity bumped from `0.78` → `0.94` so the
card still reads as a "frosted" surface without the blur cost.
- The pulsing-ring animation on the standby icon used
`@keyframes` on `box-shadow` (`0 px → 14 px → 0 px` spread).
`box-shadow` animations are *paint* operations — the browser
re-rasterises the element on every keyframe interpolation step.
Moved the pulse to a `::after` pseudo-element and switched the
animated property to `transform: scale + opacity`, which stay
on the compositor and cost essentially nothing.
The card stays visible with the same text and the same visual
rhythm (slow scan line + pulsing icon ring); the bridge-not-
running state now sits at compositor-idle GPU instead of ~20 %.
### Hardened — Look bundles can no longer override perf prefs
`quick_look_apply` filters `gridRenderer` and `glassQuality` out
of incoming bundle settings. These are *user* hardware preferences;
a cosmetic "Cyberpunk Vibes"-style Look shouldn't be flipping the
DOM/Canvas renderer or killing the glass blur just because the
bundle author happened to have a strong CPU + weak GPU. Two new
keys join `widgets` / `mirrorOf` / `cycle` as bundle-forbidden.
## [1.2.11-beta] - 2026-05-28
> Beta: makes the **tray's "Download + install update"** flow
> robust. A user hit the "Failed to load Python DLL python313.dll"
> error again — but only after the tray-triggered silent update,
> not on fresh installs or on manual launch from the Start menu.
> Two coordinated fixes target both halves of the failure mode
> (DLL load + the new bridge not coming up afterwards).
### Fixed — DLL load failure + missing auto-restart after tray update
Symptom: after `Tray → Updates → Download + install update`, the
post-install Inno `[Run]` step popped a *"Failed to load Python
DLL `…\_MEI…\python313.dll`. LoadLibrary: The specified module
could not be found"* dialog and the new bridge never started.
Starting the bridge manually from the Start menu worked. Diagnosis:
the bundled DLLs (including the v1.2.6 vcruntime fix) are correct,
but Inno's `[Run]` launches the bridge in a token / process-
ancestry context that on some AV / EDR / Controlled-Folder-Access
setups refuses `LoadLibrary` on `%TEMP%\_MEI
\` — the
PyInstaller `--onefile` extraction dir.
Fix is two-layered:
**Layer 1: structural.** PyInstaller switched from `--onefile` to
`--onedir`. The bundle is now a directory under `{app}\` containing
`SignalRGBBridge.exe` + `_internal\` (python313.dll, vcruntime,
.pyd extension modules, HTML/CSS data files). There is no
extraction step at launch and no temp directory involved — the OS
loader resolves DLL dependencies straight from the install dir, so
the `%TEMP%` LoadLibrary failure mode is removed entirely. Side-
effect bonus: bridge startup is ~2x faster (no 8000-file extract
per launch).
**Layer 2: launch path.** The tray's `_download_install_worker`
now drops `autostart` from the silent installer's `/MERGETASKS`
string — so Inno's `[Run]` won't try to launch the bridge in its
own context — and instead spawns a detached `cmd.exe` child of the
*current* bridge process to schedule the relaunch ~25 s later
(`ping -n 26 127.0.0.1 >NUL && start "" /B ""`). The cmd
inherits the bridge's user-context token (`CREATE_BREAKAWAY_FROM_JOB`
keeps it alive after the bridge's `os._exit`), so the new bridge
launches as a normal user process — no Inno-context contamination,
no AV gate. The Registry autostart entry still installs, so the
bridge also comes up cleanly at the next Windows login.
### Migration notes
Existing v1.2.6 → v1.2.10 installs that upgrade to v1.2.11 will
see a small layout change in their install directory: the
`_internal\` subfolder appears next to `SignalRGBBridge.exe`. The
exe path stays identical (`{app}\SignalRGBBridge.exe`), so Start-
menu shortcuts, the Registry autostart entry, the Lively / WE
bundle paths and the SignalRGB plugin are all unaffected.
## [1.2.10-beta] - 2026-05-28
> Beta: the permanent-fix candidate that supersedes the v1.2.9
> diagnostic build. Now-Playing / SMTC is **re-enabled** but the
> idle-gate on every poller is now widget-aware — pollers only
> fire when a widget that consumes their data is actually placed
> somewhere. Zero cost when nothing reads the data, even with the
> wallpaper page running.
### Hardened — Widget-aware poller idle-gate
User observed their v1.2.6 leak surfaced after a 12 h run with
**no widgets configured at all**. v1.2.8's idle-gate was too loose
to catch that case: it only checked "is a wallpaper page
connected?" and the answer was yes (Lively was running), so the
SMTC / LHM / sysstats pollers continued their 1 Hz IPC chain —
into receivers (NPSMSvc → Spotify → DWM → WebView2) that nobody
on our end was even consuming.
v1.2.10 tightens the gate. A new `BridgeRuntime.placed_widget_types()`
returns the set of widget-type strings currently placed across
all screens; each poller gets a closure that returns True iff
at least one client is connected AND at least one widget in its
"served types" set is placed.
| Poller | Polls when these widgets are placed |
| ----------------- | ----------------------------------- |
| `NowPlayingPoller`| `now-playing` |
| `HwMonPoller` | `hardware-sensor` |
| `SysStatsPoller` | `cpu-meter` / `ram-meter` / `hardware-sensor` / `now-playing` |
Pure-client widgets (clock, calendar, sticky-note, countdown,
picture-frame, quote, weather, rss, audio-spectrum) need no
backend data so no poller fires for them. A user with only those
placed sees the bridge sit at essentially zero CPU + flat memory
no matter how long Lively / WE runs.
### Changed — `ENABLE_NOWPLAYING` defaults back to True
The v1.2.9 hard-kill-switch is kept (flip to False to re-arm
the diagnostic build) but defaults to True now that the proper
fix is wired up. Existing v1.2.9 installs that confirmed the
SMTC cascade was the source should move to v1.2.10 to get the
feature back — it'll only fire when actually needed.
## [1.2.9-beta] - 2026-05-28
> Diagnostic beta. Same hardening as v1.2.8 **plus** the entire
> Now-Playing / SMTC code path is hard-disabled, so the user
> reporting the 12 h memory build-up can run with this for a day
> and tell us whether the SMTC cascade
> (Bridge → NPSMSvc → Spotify → DWM → WebView2) was the source.
### Changed — Now-Playing feature fully removed (diagnostic build)
A new `ENABLE_NOWPLAYING = False` constant at the top of `bridge.py`
gates every SMTC touchpoint:
- `NowPlayingPoller` is never constructed. No `winrt` import, no
`SMTCManager` handle, no 1 Hz IPC roundtrip.
- The `now-playing` widget type is removed from `WIDGET_DEFAULTS`
on startup, so the palette in Configurator + Builder hides it
and any incoming `widget-add` for that type is silently rejected.
- `SysStatsPoller` is passed `nowplaying=None`, so its 1 Hz
JSON push omits the `nowPlaying` field entirely.
Persisted config is **not** mutated — existing now-playing widget
entries stay in the JSON, the page just never receives data and
renders the widget's idle placeholder. Flip the constant back to
`True` and rebuild to restore.
If 12 h with v1.2.9 stays flat: confirmed the SMTC cascade was
the source, and the v1.2.8 manager-cache + idle-gate is the right
permanent fix. If v1.2.9 still grows: the suspect is somewhere
else and we have a much narrower set of suspects to look at.
## [1.2.8-beta] - 2026-05-28
> Beta: continues the v1.2.7 leak hunt with three targeted fixes after
> the user spotted that NPSMSvc / Spotify / DWM / WebView2 all spike
> together when the bridge is under stress — the *cascade* is the load,
> not just our process.
### Hardened — Widget-command thread spawn replaced with executor pool
`_on_widget_command` used to `threading.Thread(target=run).start()`
for every WS message — `widget-update`, `setting-update`,
`viewport`, `quick-look-apply`, `widgets-set`, the lot. A single
Builder per-tile drag fires 60-100 `widget-update` frames; a slider
in the Configurator fires `setting-update` at the same rate; Quick
Looks bundles cascade through `widgets-set` + `setting-update` +
`preset-save`. Over a 12 h session that meant thousands of
short-lived OS threads. They exited cleanly (daemon=True) but
Windows commits thread stack pages lazily and the high-water marks
accumulate in the process commit charge, plus each thread's run()
closure pinned the message dict + the deep-copied config in
`_mutate_screen` until the disk write returned (slow under OneDrive
sync). The handler now submits to the asyncio loop's default
ThreadPoolExecutor (~32 workers, recycled) which preserves the
off-loop file-write isolation but caps thread count.
### Hardened — SMTC manager cached + idle-gated
`NowPlayingPoller._tick` called
`GlobalSystemMediaTransportControlsSessionManager.request_async()`
every second. The WinRT spec says this returns a singleton, but the
COM ref-counting in the winrt-Python bindings isn't always clean on
repeated calls, and each call triggers an IPC roundtrip
Bridge → NPSMSvc → registered media app (Spotify, Edge, Groove, …)
that the receiving app responds to by re-marshalling its metadata
and cover art. The user observed all four
(NPSMSvc / Spotify / DWM / WebView2) spiking together when the bridge
was hot — confirming the cascade.
The poller now resolves the manager exactly once on the first tick
and reuses the cached reference on every subsequent tick. The
visible knock-on for the user is the Task Manager "red" group going
quiet outside of actual track changes.
### Hardened — Pollers skip work when no wallpaper page is connected
`NowPlayingPoller`, `HwMonPoller` and `SysStatsPoller` previously
polled (and pushed) at their 1 Hz cadence forever regardless of
whether a wallpaper page was connected to consume the snapshot.
Closing Lively / Wallpaper Engine left the bridge driving an IPC
load nobody could observe. All three now check
`Broadcaster.has_any_clients()` (lock-free dict-values scan) before
the expensive part of each tick and short-circuit when nothing is
connected. The HwMon snapshot from the last successful poll is kept
so the Configurator's `/hwmon/sensors` HTTP picker still returns
something useful when a user opens the picker without an active
wallpaper.
## [1.2.7-beta] - 2026-05-28
> Beta: same "high CPU + ~556 MB after 12 h" pattern resurfaced for a
> user even on top of v1.2.1's per-frame backpressure fix. This beta
> hardens the bridge's relay loop so the bridge stops doing per-frame
> work when nobody's listening, brings the other `push_*` channels in
> line with the broadcaster's backpressure, and adds a periodic GC +
> diag heartbeat so the next report can pinpoint exactly which counter
> moves.
### Hardened — Bridge no longer encodes / schedules per-frame work when paused or no clients
`UdpReceiver.datagram_received` previously created an `asyncio.Task` for
every inbound UDP frame and let `broadcast_frame` decide whether to
short-circuit (paused / no clients). With the SignalRGB plugin pushing
60+ Hz × N screens forever regardless of bridge state, that meant
~120+ throwaway tasks per second over a 12 h session — each one
allocates a coroutine, a future, and (when it ran) a fresh
`encode_binary_frame` bytes object. Tasks completed quickly so the
*reachable* set stayed bounded, but the constant churn fragments
CPython's pymalloc heap; arenas allocated during bursty load aren't
reliably returned to the OS, so process RSS drifts up over hours even
without a true reference leak.
v1.2.7 gates the work in `datagram_received` itself (sync, on the
selector thread): if `get_paused()` is True or `has_clients_for(screen)`
returns false, the datagram is dropped before any task is created and
before any frame buffer is allocated. The plugin keeps sending; the
bridge silently absorbs.
### Hardened — `push_sysstats` / `push_pause` / `push_reload_all` / `push_settings` now share broadcaster backpressure
`broadcast_frame` got per-client write-buffer backpressure in v1.2.1
(skip-when-buffer > 256 KiB) but the other four push channels still
wrote unconditionally. None of them are high-rate so the bound was
small, but a slow / wedged client would let the `StreamWriter`'s
internal buffer grow uncapped on each tick of sysstats forever. All
four channels now read `transport.get_write_buffer_size()` and skip
the write when over the cap. They also snapshot the client list and
early-return without encoding JSON when no clients are connected — a
small per-second CPU win for the headless-bridge case.
### Added — Periodic `gc.collect()` + diagnostic heartbeat
A daemon task on the bridge's asyncio loop runs `gc.collect()` every
60 s to nudge generation-2 collection to release empty arenas back to
the OS, and once every 5 minutes prints one `[diag]` log line with
the process RSS, live asyncio task count, connected client count, and
in-flight chunked-frame partials. Cost is negligible (a forced GC on
an idle Python heap is sub-ms) and the heartbeat lets the next
diagnostics export carry a memory-curve over time so we can attribute
any future drift to a specific counter rather than guessing again.
## [1.2.6-beta] - 2026-05-26
> Beta: fixes the "Failed to load Python DLL" install error some users
> hit, plus a deep dive that found video screen-backgrounds were
> broken end-to-end since v1.2.0 + a couple of latent bridge issues.
### Fixed — "Failed to load Python DLL python313.dll" on some machines
A user hit `Failed to load Python DLL ...python313.dll. LoadLibrary:
The specified module could not be found.` on the update launch. The
misleading message actually means a *dependency* of python313.dll —
the MSVC runtime (`vcruntime140.dll` / `vcruntime140_1.dll`) — wasn't
found. The bridge is built from the Microsoft Store Python, and
PyInstaller doesn't reliably pull those DLLs into the `--onefile`
bundle from that Python distribution (they live in System32 on the
build machine so the local exe runs fine, masking it). Users without
the VC++ 2015-2022 Redistributable then hit the error.
`build.ps1` now explicitly `--add-binary`s `vcruntime140.dll` +
`vcruntime140_1.dll` from System32 into the bundle, so they're always
present regardless of the build Python or the user's installed
runtimes.
### Fixed — Video screen-backgrounds were broken end-to-end (since v1.2.0)
Two latent bugs in the v1.2.0 "video backgrounds" feature, only
reachable for video set as a *screen* background (the live-preview
+ Builder path):
1. **`_update_background` saved every upload as `.png`** regardless
of content, so an MP4 landed as `screen-N-.png`. The wallpaper
page's video detection (`VIDEO_BG_EXTS`) keys off the URL
extension → it never recognised the file as a video and tried to
paint it as a still. v1.2.6 magic-byte-sniffs the upload and saves
the real extension (`.mp4` / `.webm` / `.mov` / `.m4v` / `.mkv`).
2. **The `/image` proxy rejected video extensions with 415** and had
no HTTP Range support. Browsers require `206 Partial Content`
range responses to play a `