Add tray controls for pause reload and diagnostics
This commit is contained in:
parent
77ae21d0f6
commit
e262b26db7
5 changed files with 192 additions and 8 deletions
90
src/aman.py
90
src/aman.py
|
|
@ -63,6 +63,7 @@ class Daemon:
|
||||||
self.verbose = verbose
|
self.verbose = verbose
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
self._shutdown_requested = threading.Event()
|
self._shutdown_requested = threading.Event()
|
||||||
|
self._paused = False
|
||||||
self.state = State.IDLE
|
self.state = State.IDLE
|
||||||
self.stream = None
|
self.stream = None
|
||||||
self.record = None
|
self.record = None
|
||||||
|
|
@ -108,6 +109,24 @@ class Daemon:
|
||||||
def request_shutdown(self):
|
def request_shutdown(self):
|
||||||
self._shutdown_requested.set()
|
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):
|
def toggle(self):
|
||||||
should_stop = False
|
should_stop = False
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
|
@ -115,6 +134,9 @@ class Daemon:
|
||||||
logging.info("shutdown in progress, trigger ignored")
|
logging.info("shutdown in progress, trigger ignored")
|
||||||
return
|
return
|
||||||
if self.state == State.IDLE:
|
if self.state == State.IDLE:
|
||||||
|
if self._paused:
|
||||||
|
logging.info("paused, trigger ignored")
|
||||||
|
return
|
||||||
self._start_recording_locked()
|
self._start_recording_locked()
|
||||||
return
|
return
|
||||||
if self.state == State.RECORDING:
|
if self.state == State.RECORDING:
|
||||||
|
|
@ -447,9 +469,10 @@ def _init_command(args: argparse.Namespace) -> int:
|
||||||
|
|
||||||
def _run_command(args: argparse.Namespace) -> int:
|
def _run_command(args: argparse.Namespace) -> int:
|
||||||
global _LOCK_HANDLE
|
global _LOCK_HANDLE
|
||||||
|
config_path = Path(args.config) if args.config else DEFAULT_CONFIG_PATH
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cfg = load(args.config)
|
cfg = load(str(config_path))
|
||||||
except ConfigValidationError as exc:
|
except ConfigValidationError as exc:
|
||||||
logging.error("startup failed: invalid config field '%s': %s", exc.field, exc.reason)
|
logging.error("startup failed: invalid config field '%s': %s", exc.field, exc.reason)
|
||||||
if exc.example_fix:
|
if exc.example_fix:
|
||||||
|
|
@ -463,7 +486,7 @@ def _run_command(args: argparse.Namespace) -> int:
|
||||||
logging.info("hotkey: %s", cfg.daemon.hotkey)
|
logging.info("hotkey: %s", cfg.daemon.hotkey)
|
||||||
logging.info(
|
logging.info(
|
||||||
"config (%s):\n%s",
|
"config (%s):\n%s",
|
||||||
args.config or str(Path.home() / ".config" / "aman" / "config.json"),
|
str(config_path),
|
||||||
json.dumps(redacted_dict(cfg), indent=2),
|
json.dumps(redacted_dict(cfg), indent=2),
|
||||||
)
|
)
|
||||||
logging.info(
|
logging.info(
|
||||||
|
|
@ -491,6 +514,10 @@ def _run_command(args: argparse.Namespace) -> int:
|
||||||
return
|
return
|
||||||
shutdown_once.set()
|
shutdown_once.set()
|
||||||
logging.info("%s, shutting down", reason)
|
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):
|
if not daemon.shutdown(timeout=5.0):
|
||||||
logging.warning("timed out waiting for idle state during shutdown")
|
logging.warning("timed out waiting for idle state during shutdown")
|
||||||
desktop.request_quit()
|
desktop.request_quit()
|
||||||
|
|
@ -501,18 +528,73 @@ def _run_command(args: argparse.Namespace) -> int:
|
||||||
signal.signal(signal.SIGINT, handle_signal)
|
signal.signal(signal.SIGINT, handle_signal)
|
||||||
signal.signal(signal.SIGTERM, 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:
|
try:
|
||||||
desktop.start_hotkey_listener(
|
desktop.start_hotkey_listener(
|
||||||
cfg.daemon.hotkey,
|
cfg.daemon.hotkey,
|
||||||
lambda: logging.info("hotkey pressed (dry-run)") if args.dry_run else daemon.toggle(),
|
hotkey_callback,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logging.error("hotkey setup failed: %s", exc)
|
logging.error("hotkey setup failed: %s", exc)
|
||||||
return 1
|
return 1
|
||||||
logging.info("ready")
|
logging.info("ready")
|
||||||
try:
|
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:
|
finally:
|
||||||
|
try:
|
||||||
|
desktop.stop_hotkey_listener()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
daemon.shutdown(timeout=1.0)
|
daemon.shutdown(timeout=1.0)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ class DesktopAdapter(Protocol):
|
||||||
def start_hotkey_listener(self, hotkey: str, callback: Callable[[], None]) -> None:
|
def start_hotkey_listener(self, hotkey: str, callback: Callable[[], None]) -> None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def stop_hotkey_listener(self) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
def validate_hotkey(self, hotkey: str) -> None:
|
def validate_hotkey(self, hotkey: str) -> None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
@ -26,7 +29,17 @@ class DesktopAdapter(Protocol):
|
||||||
) -> None:
|
) -> None:
|
||||||
raise NotImplementedError
|
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
|
raise NotImplementedError
|
||||||
|
|
||||||
def request_quit(self) -> None:
|
def request_quit(self) -> None:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ class WaylandAdapter:
|
||||||
def start_hotkey_listener(self, _hotkey: str, _callback: Callable[[], None]) -> None:
|
def start_hotkey_listener(self, _hotkey: str, _callback: Callable[[], None]) -> None:
|
||||||
raise SystemExit("Wayland hotkeys are not supported yet.")
|
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:
|
def validate_hotkey(self, _hotkey: str) -> None:
|
||||||
raise SystemExit("Wayland hotkeys are not supported yet.")
|
raise SystemExit("Wayland hotkeys are not supported yet.")
|
||||||
|
|
||||||
|
|
@ -26,7 +29,24 @@ class WaylandAdapter:
|
||||||
_ = remove_transcription_from_clipboard
|
_ = remove_transcription_from_clipboard
|
||||||
raise SystemExit("Wayland text injection is not supported yet.")
|
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.")
|
raise SystemExit("Wayland tray support is not available yet.")
|
||||||
|
|
||||||
def request_quit(self) -> None:
|
def request_quit(self) -> None:
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,14 @@ class X11Adapter:
|
||||||
self.indicator = None
|
self.indicator = None
|
||||||
self.status_icon = None
|
self.status_icon = None
|
||||||
self.menu = 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_lock = threading.Lock()
|
||||||
self._cancel_listener_stop_event: threading.Event | None = None
|
self._cancel_listener_stop_event: threading.Event | None = None
|
||||||
self._cancel_listener_callback: Callable[[], None] | 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:
|
if AppIndicator3 is not None:
|
||||||
self.indicator = AppIndicator3.Indicator.new(
|
self.indicator = AppIndicator3.Indicator.new(
|
||||||
"aman",
|
"aman",
|
||||||
|
|
@ -70,9 +75,28 @@ class X11Adapter:
|
||||||
def start_hotkey_listener(self, hotkey: str, callback: Callable[[], None]) -> None:
|
def start_hotkey_listener(self, hotkey: str, callback: Callable[[], None]) -> None:
|
||||||
mods, keysym = self._parse_hotkey(hotkey)
|
mods, keysym = self._parse_hotkey(hotkey)
|
||||||
self._validate_hotkey_registration(mods, keysym)
|
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()
|
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:
|
def validate_hotkey(self, hotkey: str) -> None:
|
||||||
mods, keysym = self._parse_hotkey(hotkey)
|
mods, keysym = self._parse_hotkey(hotkey)
|
||||||
self._validate_hotkey_registration(mods, keysym)
|
self._validate_hotkey_registration(mods, keysym)
|
||||||
|
|
@ -137,8 +161,35 @@ class X11Adapter:
|
||||||
text = clipboard.wait_for_text()
|
text = clipboard.wait_for_text()
|
||||||
return str(text) if text is not None else None
|
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()
|
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 = Gtk.MenuItem(label="Quit")
|
||||||
quit_item.connect("activate", lambda *_: self._handle_quit(on_quit))
|
quit_item.connect("activate", lambda *_: self._handle_quit(on_quit))
|
||||||
self.menu.append(quit_item)
|
self.menu.append(quit_item)
|
||||||
|
|
@ -340,6 +391,12 @@ class X11Adapter:
|
||||||
return "Idle"
|
return "Idle"
|
||||||
|
|
||||||
def _update_tray(self, state_getter: Callable[[], str]):
|
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()
|
state = state_getter()
|
||||||
icon_path = self._icon_path(state)
|
icon_path = self._icon_path(state)
|
||||||
if self.indicator is not None:
|
if self.indicator is not None:
|
||||||
|
|
|
||||||
|
|
@ -333,6 +333,18 @@ class DaemonTests(unittest.TestCase):
|
||||||
|
|
||||||
self.assertEqual(ai_processor.last_kwargs.get("profile"), "fast")
|
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):
|
class LockTests(unittest.TestCase):
|
||||||
def test_lock_rejects_second_instance(self):
|
def test_lock_rejects_second_instance(self):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue