Add tray controls for pause reload and diagnostics

This commit is contained in:
Thales Maciel 2026-02-26 17:42:53 -03:00
parent 77ae21d0f6
commit e262b26db7
5 changed files with 192 additions and 8 deletions

View file

@ -63,6 +63,7 @@ class Daemon:
self.verbose = verbose
self.lock = threading.Lock()
self._shutdown_requested = threading.Event()
self._paused = False
self.state = State.IDLE
self.stream = None
self.record = None
@ -108,6 +109,24 @@ class Daemon:
def request_shutdown(self):
self._shutdown_requested.set()
def is_paused(self) -> bool:
with self.lock:
return self._paused
def toggle_paused(self) -> bool:
with self.lock:
self._paused = not self._paused
paused = self._paused
logging.info("pause %s", "enabled" if paused else "disabled")
return paused
def apply_config(self, cfg: Config) -> None:
with self.lock:
self.cfg = cfg
self.vocabulary = VocabularyEngine(cfg.vocabulary)
self._stt_hint_kwargs_cache = None
logging.info("applied new runtime config")
def toggle(self):
should_stop = False
with self.lock:
@ -115,6 +134,9 @@ class Daemon:
logging.info("shutdown in progress, trigger ignored")
return
if self.state == State.IDLE:
if self._paused:
logging.info("paused, trigger ignored")
return
self._start_recording_locked()
return
if self.state == State.RECORDING:
@ -447,9 +469,10 @@ def _init_command(args: argparse.Namespace) -> int:
def _run_command(args: argparse.Namespace) -> int:
global _LOCK_HANDLE
config_path = Path(args.config) if args.config else DEFAULT_CONFIG_PATH
try:
cfg = load(args.config)
cfg = load(str(config_path))
except ConfigValidationError as exc:
logging.error("startup failed: invalid config field '%s': %s", exc.field, exc.reason)
if exc.example_fix:
@ -463,7 +486,7 @@ def _run_command(args: argparse.Namespace) -> int:
logging.info("hotkey: %s", cfg.daemon.hotkey)
logging.info(
"config (%s):\n%s",
args.config or str(Path.home() / ".config" / "aman" / "config.json"),
str(config_path),
json.dumps(redacted_dict(cfg), indent=2),
)
logging.info(
@ -491,6 +514,10 @@ def _run_command(args: argparse.Namespace) -> int:
return
shutdown_once.set()
logging.info("%s, shutting down", reason)
try:
desktop.stop_hotkey_listener()
except Exception as exc:
logging.debug("failed to stop hotkey listener: %s", exc)
if not daemon.shutdown(timeout=5.0):
logging.warning("timed out waiting for idle state during shutdown")
desktop.request_quit()
@ -501,18 +528,73 @@ def _run_command(args: argparse.Namespace) -> int:
signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal)
def hotkey_callback():
if args.dry_run:
logging.info("hotkey pressed (dry-run)")
return
daemon.toggle()
def reload_config_callback():
nonlocal cfg
try:
new_cfg = load(str(config_path))
except ConfigValidationError as exc:
logging.error("reload failed: invalid config field '%s': %s", exc.field, exc.reason)
if exc.example_fix:
logging.error("reload example fix: %s", exc.example_fix)
return
except Exception as exc:
logging.error("reload failed: %s", exc)
return
try:
desktop.start_hotkey_listener(new_cfg.daemon.hotkey, hotkey_callback)
except Exception as exc:
logging.error("reload failed: could not apply hotkey '%s': %s", new_cfg.daemon.hotkey, exc)
return
daemon.apply_config(new_cfg)
cfg = new_cfg
logging.info("config reloaded from %s", config_path)
def run_diagnostics_callback():
report = run_diagnostics(str(config_path))
if report.ok:
logging.info("diagnostics passed (%d checks)", len(report.checks))
return
failed = [check for check in report.checks if not check.ok]
logging.warning("diagnostics failed (%d/%d checks)", len(failed), len(report.checks))
for check in failed:
if check.hint:
logging.warning("%s: %s | hint: %s", check.id, check.message, check.hint)
else:
logging.warning("%s: %s", check.id, check.message)
def open_config_path_callback():
logging.info("config path: %s", config_path)
try:
desktop.start_hotkey_listener(
cfg.daemon.hotkey,
lambda: logging.info("hotkey pressed (dry-run)") if args.dry_run else daemon.toggle(),
hotkey_callback,
)
except Exception as exc:
logging.error("hotkey setup failed: %s", exc)
return 1
logging.info("ready")
try:
desktop.run_tray(daemon.get_state, lambda: shutdown("quit requested"))
desktop.run_tray(
daemon.get_state,
lambda: shutdown("quit requested"),
is_paused_getter=daemon.is_paused,
on_toggle_pause=daemon.toggle_paused,
on_reload_config=reload_config_callback,
on_run_diagnostics=run_diagnostics_callback,
on_open_config=open_config_path_callback,
)
finally:
try:
desktop.stop_hotkey_listener()
except Exception:
pass
daemon.shutdown(timeout=1.0)
return 0

View file

@ -8,6 +8,9 @@ class DesktopAdapter(Protocol):
def start_hotkey_listener(self, hotkey: str, callback: Callable[[], None]) -> None:
raise NotImplementedError
def stop_hotkey_listener(self) -> None:
raise NotImplementedError
def validate_hotkey(self, hotkey: str) -> None:
raise NotImplementedError
@ -26,7 +29,17 @@ class DesktopAdapter(Protocol):
) -> None:
raise NotImplementedError
def run_tray(self, state_getter: Callable[[], str], on_quit: Callable[[], None]) -> None:
def run_tray(
self,
state_getter: Callable[[], str],
on_quit: Callable[[], None],
*,
is_paused_getter: Callable[[], bool] | None = None,
on_toggle_pause: Callable[[], None] | None = None,
on_reload_config: Callable[[], None] | None = None,
on_run_diagnostics: Callable[[], None] | None = None,
on_open_config: Callable[[], None] | None = None,
) -> None:
raise NotImplementedError
def request_quit(self) -> None:

View file

@ -7,6 +7,9 @@ class WaylandAdapter:
def start_hotkey_listener(self, _hotkey: str, _callback: Callable[[], None]) -> None:
raise SystemExit("Wayland hotkeys are not supported yet.")
def stop_hotkey_listener(self) -> None:
raise SystemExit("Wayland hotkeys are not supported yet.")
def validate_hotkey(self, _hotkey: str) -> None:
raise SystemExit("Wayland hotkeys are not supported yet.")
@ -26,7 +29,24 @@ class WaylandAdapter:
_ = remove_transcription_from_clipboard
raise SystemExit("Wayland text injection is not supported yet.")
def run_tray(self, _state_getter: Callable[[], str], _on_quit: Callable[[], None]) -> None:
def run_tray(
self,
_state_getter: Callable[[], str],
_on_quit: Callable[[], None],
*,
is_paused_getter: Callable[[], bool] | None = None,
on_toggle_pause: Callable[[], None] | None = None,
on_reload_config: Callable[[], None] | None = None,
on_run_diagnostics: Callable[[], None] | None = None,
on_open_config: Callable[[], None] | None = None,
) -> None:
_ = (
is_paused_getter,
on_toggle_pause,
on_reload_config,
on_run_diagnostics,
on_open_config,
)
raise SystemExit("Wayland tray support is not available yet.")
def request_quit(self) -> None:

View file

@ -42,9 +42,14 @@ class X11Adapter:
self.indicator = None
self.status_icon = None
self.menu = None
self._hotkey_listener_lock = threading.Lock()
self._hotkey_listener_stop_event: threading.Event | None = None
self._hotkey_listener_thread: threading.Thread | None = None
self._cancel_listener_lock = threading.Lock()
self._cancel_listener_stop_event: threading.Event | None = None
self._cancel_listener_callback: Callable[[], None] | None = None
self._pause_item: Gtk.MenuItem | None = None
self._pause_state_getter: Callable[[], bool] | None = None
if AppIndicator3 is not None:
self.indicator = AppIndicator3.Indicator.new(
"aman",
@ -70,9 +75,28 @@ class X11Adapter:
def start_hotkey_listener(self, hotkey: str, callback: Callable[[], None]) -> None:
mods, keysym = self._parse_hotkey(hotkey)
self._validate_hotkey_registration(mods, keysym)
thread = threading.Thread(target=self._listen, args=(mods, keysym, callback), daemon=True)
stop_event = threading.Event()
thread = threading.Thread(
target=self._listen,
args=(mods, keysym, callback, stop_event),
daemon=True,
)
with self._hotkey_listener_lock:
previous_stop_event = self._hotkey_listener_stop_event
self._hotkey_listener_stop_event = stop_event
self._hotkey_listener_thread = thread
if previous_stop_event is not None:
previous_stop_event.set()
thread.start()
def stop_hotkey_listener(self) -> None:
with self._hotkey_listener_lock:
stop_event = self._hotkey_listener_stop_event
self._hotkey_listener_stop_event = None
self._hotkey_listener_thread = None
if stop_event is not None:
stop_event.set()
def validate_hotkey(self, hotkey: str) -> None:
mods, keysym = self._parse_hotkey(hotkey)
self._validate_hotkey_registration(mods, keysym)
@ -137,8 +161,35 @@ class X11Adapter:
text = clipboard.wait_for_text()
return str(text) if text is not None else None
def run_tray(self, state_getter: Callable[[], str], on_quit: Callable[[], None]) -> None:
def run_tray(
self,
state_getter: Callable[[], str],
on_quit: Callable[[], None],
*,
is_paused_getter: Callable[[], bool] | None = None,
on_toggle_pause: Callable[[], None] | None = None,
on_reload_config: Callable[[], None] | None = None,
on_run_diagnostics: Callable[[], None] | None = None,
on_open_config: Callable[[], None] | None = None,
) -> None:
self._pause_state_getter = is_paused_getter
self.menu = Gtk.Menu()
if on_toggle_pause is not None:
self._pause_item = Gtk.MenuItem(label="Pause Aman")
self._pause_item.connect("activate", lambda *_: on_toggle_pause())
self.menu.append(self._pause_item)
if on_reload_config is not None:
reload_item = Gtk.MenuItem(label="Reload Config")
reload_item.connect("activate", lambda *_: on_reload_config())
self.menu.append(reload_item)
if on_run_diagnostics is not None:
diagnostics_item = Gtk.MenuItem(label="Run Diagnostics")
diagnostics_item.connect("activate", lambda *_: on_run_diagnostics())
self.menu.append(diagnostics_item)
if on_open_config is not None:
open_config_item = Gtk.MenuItem(label="Open Config Path")
open_config_item.connect("activate", lambda *_: on_open_config())
self.menu.append(open_config_item)
quit_item = Gtk.MenuItem(label="Quit")
quit_item.connect("activate", lambda *_: self._handle_quit(on_quit))
self.menu.append(quit_item)
@ -340,6 +391,12 @@ class X11Adapter:
return "Idle"
def _update_tray(self, state_getter: Callable[[], str]):
if self._pause_item is not None and self._pause_state_getter is not None:
try:
paused = self._pause_state_getter()
self._pause_item.set_label("Resume Aman" if paused else "Pause Aman")
except Exception:
self._pause_item.set_label("Pause Aman")
state = state_getter()
icon_path = self._icon_path(state)
if self.indicator is not None:

View file

@ -333,6 +333,18 @@ class DaemonTests(unittest.TestCase):
self.assertEqual(ai_processor.last_kwargs.get("profile"), "fast")
@patch("aman.start_audio_recording")
def test_paused_state_blocks_recording_start(self, start_mock):
desktop = FakeDesktop()
daemon = self._build_daemon(desktop, FakeModel(), verbose=False)
self.assertTrue(daemon.toggle_paused())
daemon.toggle()
start_mock.assert_not_called()
self.assertEqual(daemon.get_state(), aman.State.IDLE)
self.assertEqual(desktop.cancel_listener_start_calls, 0)
class LockTests(unittest.TestCase):
def test_lock_rejects_second_instance(self):