Skip to content

Commit 4e3d2b2

Browse files
author
Dennis Sepede
committed
fix(presence): update progress dialog + tray reactive while dashboard open
Two UX fixes Dennis pointed out: 1) The auto-update flow had almost no feedback. A single "Downloading..." balloon, then silence, then UAC prompted out of nowhere when the relaunch happened. Easy to read as "nothing happened, the update is broken". Now we show a modal customtkinter window with: - a real progress bar (Content-Length driven) - MB-of-MB counter ("7.4 MB of 19.2 MB") - current step ("Downloading...", "Restarting...") - an explicit "Approve the Windows UAC prompt when it appears" hint before the relaunch The download itself now exposes a progress_cb in updater.py so the dialog can drive its bar without us guessing. 2) The tray menu stopped responding while the dashboard window was open. Both pystray and WebView2 want exclusive ownership of the main thread's message loop on Windows, so as long as the webview was running pystray was effectively paused — right-click on the tray icon did nothing. The dashboard now runs on a worker thread. pystray keeps the main thread, the webview gets its own. Clicking "Open BusyLight" a second time with the window already open is a no-op (we track `_webview_open`). Bumps presence to 0.3.14. Firmware unchanged from 0.3.12.
1 parent f766f32 commit 4e3d2b2

5 files changed

Lines changed: 258 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
## [0.3.13] - 2026-05-25
10+
## [0.3.14] - 2026-05-25
11+
12+
### Fixed
13+
- **Tray menu stopped responding while the dashboard was open**: WebView2 and pystray both want the main thread for their message loop on Windows, so opening the dashboard blocked tray right-clicks until the WebView window closed. The dashboard now runs on a worker thread, leaving the tray reactive. Clicking "Open BusyLight" a second time with the window already open is a no-op (Windows brings the existing one to front).
14+
- **Update flow had almost no feedback**: a single "Downloading…" balloon, then silence, then UAC prompted out of nowhere — easy to read as "nothing happened". Installing now shows a modal progress dialog with a real progress bar, MB-of-MB counter, current step ("Downloading…", "Restarting…"), and an explicit "Approve the Windows UAC prompt when it appears" hint before the relaunch.
15+
16+
1117

1218
### Fixed
1319
- **IN_CALL reverted after ~2s when set manually**: the presence loop unconditionally treated IN_CALL as a mic-driven state. When a user clicked IN_CALL in the web UI and the mic was idle, the next tick pushed `last_manual_state` over the top, reverting the LED. Now the loop distinguishes between "presence-pushed IN_CALL (mic was busy)" and "user-set IN_CALL (anywhere else)" via `last_pushed_state` tracking and only reverts the first kind.

presence_helper/src/busylight_presence/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
previous manual state when the call ends.
77
"""
88

9-
__version__ = "0.3.13"
9+
__version__ = "0.3.14"

presence_helper/src/busylight_presence/tray.py

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from . import __version__
3232
from .icons import status_icon
3333
from .settings_window import SettingsWindow
34+
from .update_progress_window import UpdateProgressWindow
3435
from .webui_bridge import WebUiBridge
3536
from .webview_window import open_device_webui
3637
from .updater import (
@@ -75,6 +76,8 @@ def __init__(
7576
self._update_lock = threading.Lock()
7677
self._pending_firmware_update: Optional[LatestRelease] = None
7778
self._firmware_update_lock = threading.Lock()
79+
self._webview_thread: Optional[threading.Thread] = None
80+
self._webview_open = False
7881

7982
# ------------------------------------------------------------------
8083
# Lifecycle
@@ -207,11 +210,35 @@ def _open_dashboard(self, _icon, _item):
207210
# The "dashboard" is now the firmware's own web UI, served by
208211
# the local bridge over USB serial. Same exact look and feel
209212
# as the device's HTTP UI, but doesn't require Wi-Fi.
213+
#
214+
# Run the WebView on a worker thread so it doesn't block
215+
# pystray's main-thread message loop — otherwise right-click
216+
# on the tray icon stops responding while the dashboard is
217+
# open (both WebView2 and pystray want exclusive ownership
218+
# of the main thread on Windows).
219+
if self._webview_open:
220+
# Already showing; let the OS bring it to front rather
221+
# than spawning a second window.
222+
return
210223
try:
211224
url = self._bridge.start()
212-
open_device_webui(url)
213225
except Exception as e: # noqa: BLE001
214-
log.exception("web UI failed: %s", e)
226+
log.exception("bridge start failed: %s", e)
227+
return
228+
229+
def runner() -> None:
230+
self._webview_open = True
231+
try:
232+
open_device_webui(url)
233+
except Exception as e: # noqa: BLE001
234+
log.exception("webview failed: %s", e)
235+
finally:
236+
self._webview_open = False
237+
238+
self._webview_thread = threading.Thread(
239+
target=runner, daemon=True, name="busylight-webview",
240+
)
241+
self._webview_thread.start()
215242

216243
def _open_settings_from_dashboard(self) -> None:
217244
# Dashboard closes itself before calling us, so we can just
@@ -378,17 +405,47 @@ def _on_update_click(self, _icon, _item) -> None:
378405
).start()
379406

380407
def _install_update(self, release: LatestRelease) -> None:
408+
# Modal progress dialog so the user sees what's going on
409+
# instead of just a brief balloon and a silent UAC prompt.
410+
progress = UpdateProgressWindow(
411+
title=f"Installing BusyLight {release.tag}"
412+
)
413+
progress.start()
414+
progress.set_message(
415+
f"Downloading {release.tag} from GitHub…"
416+
)
381417
try:
382-
self._notify(
383-
"Downloading BusyLight update",
384-
f"Fetching {release.tag} from GitHub…",
385-
)
386-
new_exe = download_exe(release)
418+
def cb(written: int, total: int) -> None:
419+
if total > 0:
420+
progress.set_progress(written / total)
421+
mb_done = written / (1024 * 1024)
422+
mb_tot = total / (1024 * 1024)
423+
progress.set_message(
424+
f"Downloading {release.tag} from GitHub…"
425+
f"\n{mb_done:.1f} MB of {mb_tot:.1f} MB"
426+
)
427+
else:
428+
progress.set_indeterminate()
429+
new_exe = download_exe(release, progress_cb=cb)
387430
except Exception as e: # noqa: BLE001
388431
log.exception("update download failed: %s", e)
432+
progress.set_message(f"Download failed: {e}")
433+
progress.set_progress(0.0)
434+
import time
435+
time.sleep(3.0)
436+
progress.close()
389437
self._notify("Update failed", str(e))
390438
return
439+
391440
try:
441+
progress.set_progress(1.0)
442+
progress.set_message(
443+
"Download complete. Restarting BusyLight…\n"
444+
"Approve the Windows UAC prompt when it appears."
445+
)
446+
import time
447+
time.sleep(1.5)
448+
progress.close()
392449
self._stop_event.set() # let worker threads wind down
393450
install_and_restart(new_exe)
394451
except SystemExit:
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Progress dialog for the in-app updater.
2+
3+
A small customtkinter window with a header, a description label that
4+
updates as the install progresses ("Downloading… 7.4 MB / 19 MB",
5+
"Installing…", "Restarting…"), and an indeterminate-or-determinate
6+
progress bar.
7+
8+
Lives on its own thread so it doesn't fight pystray's main-thread
9+
message loop. The caller updates it via `set_progress()` /
10+
`set_message()` from any thread; we marshall the calls onto the Tk
11+
thread via `after()`.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import logging
17+
import queue
18+
import threading
19+
import tkinter as tk
20+
from typing import Optional
21+
22+
import customtkinter as ctk
23+
24+
from .bead import render_bead
25+
26+
log = logging.getLogger(__name__)
27+
28+
29+
class UpdateProgressWindow:
30+
"""Thread-safe progress dialog. Construct, call `start()` to show,
31+
then `set_progress` / `set_message` from anywhere. Call `close()`
32+
when done."""
33+
34+
def __init__(self, title: str = "BusyLight update") -> None:
35+
self._title = title
36+
self._thread: Optional[threading.Thread] = None
37+
self._root: Optional[ctk.CTk] = None
38+
self._cmd_q: queue.Queue[tuple[str, object]] = queue.Queue()
39+
self._ready_event = threading.Event()
40+
41+
def start(self) -> None:
42+
if self._thread is not None:
43+
return
44+
self._thread = threading.Thread(
45+
target=self._run, daemon=True, name="update-progress",
46+
)
47+
self._thread.start()
48+
# Wait until Tk root + widgets exist so the first set_*
49+
# actually has somewhere to land.
50+
self._ready_event.wait(timeout=3.0)
51+
52+
def set_message(self, text: str) -> None:
53+
self._cmd_q.put(("message", text))
54+
55+
def set_progress(self, fraction: float) -> None:
56+
"""`fraction` is 0.0 to 1.0. Pass None for indeterminate."""
57+
self._cmd_q.put(("progress", max(0.0, min(1.0, fraction))))
58+
59+
def set_indeterminate(self) -> None:
60+
self._cmd_q.put(("indeterminate", None))
61+
62+
def close(self) -> None:
63+
self._cmd_q.put(("close", None))
64+
if self._thread is not None:
65+
self._thread.join(timeout=2.0)
66+
self._thread = None
67+
68+
# ------------------------------------------------------------------
69+
# Tk thread
70+
# ------------------------------------------------------------------
71+
def _run(self) -> None:
72+
try:
73+
ctk.set_appearance_mode("light")
74+
ctk.set_default_color_theme("blue")
75+
root = ctk.CTk()
76+
self._root = root
77+
root.title(self._title)
78+
root.geometry("440x220")
79+
root.resizable(False, False)
80+
root.attributes("-topmost", True)
81+
root.protocol("WM_DELETE_WINDOW", lambda: None) # disable X
82+
83+
outer = ctk.CTkFrame(root, fg_color="transparent")
84+
outer.pack(fill="both", expand=True, padx=22, pady=18)
85+
86+
head = ctk.CTkFrame(outer, fg_color="transparent")
87+
head.pack(fill="x")
88+
bead_img = render_bead("available", 38)
89+
ctk_bead = ctk.CTkImage(light_image=bead_img, size=(38, 38))
90+
bead_lbl = ctk.CTkLabel(head, text="", image=ctk_bead)
91+
bead_lbl.image = ctk_bead
92+
bead_lbl.pack(side="left")
93+
ctk.CTkLabel(
94+
head,
95+
text=self._title,
96+
font=ctk.CTkFont("Segoe UI Semibold", 15),
97+
anchor="w",
98+
).pack(side="left", padx=(10, 0))
99+
100+
msg_var = tk.StringVar(value="Starting…")
101+
ctk.CTkLabel(
102+
outer,
103+
textvariable=msg_var,
104+
font=ctk.CTkFont("Segoe UI", 11),
105+
text_color="#3a3d42",
106+
wraplength=400,
107+
anchor="w",
108+
justify="left",
109+
).pack(fill="x", pady=(12, 8))
110+
111+
bar = ctk.CTkProgressBar(outer, height=14)
112+
bar.pack(fill="x", pady=(0, 8))
113+
bar.set(0.0)
114+
115+
pct_var = tk.StringVar(value="")
116+
ctk.CTkLabel(
117+
outer,
118+
textvariable=pct_var,
119+
font=ctk.CTkFont("JetBrains Mono", 10),
120+
text_color="#5d6066",
121+
anchor="e",
122+
).pack(fill="x")
123+
124+
indeterminate_running = {"on": False}
125+
126+
def pump() -> None:
127+
# Drain any pending commands from the worker thread.
128+
while True:
129+
try:
130+
cmd, payload = self._cmd_q.get_nowait()
131+
except queue.Empty:
132+
break
133+
if cmd == "message":
134+
msg_var.set(str(payload))
135+
elif cmd == "progress":
136+
if indeterminate_running["on"]:
137+
bar.stop()
138+
bar.configure(mode="determinate")
139+
indeterminate_running["on"] = False
140+
f = float(payload) # type: ignore[arg-type]
141+
bar.set(f)
142+
pct_var.set(f"{int(f * 100)} %")
143+
elif cmd == "indeterminate":
144+
if not indeterminate_running["on"]:
145+
bar.configure(mode="indeterminate")
146+
bar.start()
147+
indeterminate_running["on"] = True
148+
pct_var.set("")
149+
elif cmd == "close":
150+
try:
151+
root.destroy()
152+
except tk.TclError:
153+
pass
154+
return
155+
root.after(50, pump)
156+
157+
self._ready_event.set()
158+
root.after(50, pump)
159+
root.mainloop()
160+
except Exception as e: # noqa: BLE001
161+
log.exception("update progress window crashed: %s", e)
162+
self._ready_event.set()

presence_helper/src/busylight_presence/updater.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,16 @@ def _parse_version(s: str) -> tuple[int, int, int]:
150150
# Download + self-replace
151151
# ---------------------------------------------------------------------------
152152

153-
def download_exe(release: LatestRelease, dest_dir: Optional[Path] = None) -> Path:
154-
"""Download the BusyLightPresence.exe asset to a temp location and
155-
return the path. Raises on failure so the tray can show an error.
153+
def download_exe(
154+
release: LatestRelease,
155+
dest_dir: Optional[Path] = None,
156+
progress_cb=None,
157+
) -> Path:
158+
"""Download the BusyLightPresence.exe asset to a temp location.
159+
160+
`progress_cb(written, total)` is called as bytes arrive, so the
161+
caller can drive a progress bar. Total is taken from the
162+
Content-Length header (or the asset metadata as a fallback).
156163
"""
157164
if release.exe_asset is None:
158165
raise RuntimeError("release has no BusyLightPresence.exe asset")
@@ -166,12 +173,26 @@ def download_exe(release: LatestRelease, dest_dir: Optional[Path] = None) -> Pat
166173
headers={"User-Agent": f"busylight-presence/{_self_version()}"},
167174
)
168175
with urllib.request.urlopen(req, timeout=120) as resp:
176+
# Prefer the server-reported Content-Length; fall back to the
177+
# asset metadata GitHub provides if it's missing.
178+
total = (
179+
int(resp.headers.get("Content-Length") or 0)
180+
or release.exe_asset.size
181+
or 0
182+
)
183+
written = 0
169184
with open(target, "wb") as f:
170185
while True:
171186
chunk = resp.read(64 * 1024)
172187
if not chunk:
173188
break
174189
f.write(chunk)
190+
written += len(chunk)
191+
if progress_cb:
192+
try:
193+
progress_cb(written, total)
194+
except Exception: # noqa: BLE001
195+
pass
175196
log.info("downloaded %s (%d bytes)", target, target.stat().st_size)
176197
return target
177198

0 commit comments

Comments
 (0)