diff --git a/src/aman.py b/src/aman.py index c41bfd5..031bf97 100755 --- a/src/aman.py +++ b/src/aman.py @@ -15,10 +15,11 @@ from pathlib import Path from typing import Any from aiprocess import LlamaProcessor -from config import Config, ConfigValidationError, load, redacted_dict, validate +from config import Config, ConfigValidationError, load, redacted_dict, save, validate from constants import DEFAULT_CONFIG_PATH, MODEL_PATH, RECORD_TIMEOUT_SEC, STT_LANGUAGE from desktop import get_desktop_adapter from diagnostics import run_diagnostics +from onboarding_ui import OnboardingResult, run_onboarding_wizard from recorder import start_recording as start_audio_recording from recorder import stop_recording as stop_audio_recording from vocabulary import VocabularyEngine @@ -460,20 +461,93 @@ def _init_command(args: argparse.Namespace) -> int: return 1 cfg = Config() - validate(cfg) - config_path.parent.mkdir(parents=True, exist_ok=True) - config_path.write_text(f"{json.dumps(redacted_dict(cfg), indent=2)}\n", encoding="utf-8") + save(config_path, cfg) logging.info("wrote default config to %s", config_path) return 0 +def _run_setup_required_tray(desktop, config_path: Path) -> bool: + retry_setup = {"value": False} + + def setup_callback(): + retry_setup["value"] = True + desktop.request_quit() + + desktop.run_tray( + lambda: "setup_required", + lambda: None, + on_setup_wizard=setup_callback, + on_open_config=lambda: logging.info("config path: %s", config_path), + ) + return retry_setup["value"] + + +def _run_onboarding_until_config_ready(desktop, config_path: Path, initial_cfg: Config) -> Config | None: + draft_cfg = initial_cfg + while True: + result: OnboardingResult = run_onboarding_wizard(draft_cfg, desktop) + if result.completed and result.config is not None: + try: + saved_path = save(config_path, result.config) + except ConfigValidationError as exc: + logging.error("setup failed: invalid config field '%s': %s", exc.field, exc.reason) + if exc.example_fix: + logging.error("setup example fix: %s", exc.example_fix) + except Exception as exc: + logging.error("setup failed while writing config: %s", exc) + else: + logging.info("setup completed; config saved to %s", saved_path) + return result.config + draft_cfg = result.config + else: + if result.aborted_reason: + logging.info("setup was not completed (%s)", result.aborted_reason) + if not _run_setup_required_tray(desktop, config_path): + logging.info("setup required mode dismissed by user") + return None + + +def _load_runtime_config(config_path: Path) -> Config: + if config_path.exists(): + return load(str(config_path)) + raise FileNotFoundError(str(config_path)) + + def _run_command(args: argparse.Namespace) -> int: global _LOCK_HANDLE config_path = Path(args.config) if args.config else DEFAULT_CONFIG_PATH config_existed_before_start = config_path.exists() try: - cfg = load(str(config_path)) + _LOCK_HANDLE = _lock_single_instance() + except Exception as exc: + logging.error("startup failed: %s", exc) + return 1 + + try: + desktop = get_desktop_adapter() + except Exception as exc: + logging.error("startup failed: %s", exc) + return 1 + + if not config_existed_before_start: + cfg = _run_onboarding_until_config_ready(desktop, config_path, Config()) + if cfg is None: + return 0 + else: + try: + cfg = _load_runtime_config(config_path) + except ConfigValidationError as exc: + logging.error("startup failed: invalid config field '%s': %s", exc.field, exc.reason) + if exc.example_fix: + logging.error("example fix: %s", exc.example_fix) + return 1 + except Exception as exc: + logging.error("startup failed: %s", exc) + return 1 + + try: + validate(cfg) except ConfigValidationError as exc: logging.error("startup failed: invalid config field '%s': %s", exc.field, exc.reason) if exc.example_fix: @@ -482,7 +556,6 @@ def _run_command(args: argparse.Namespace) -> int: except Exception as exc: logging.error("startup failed: %s", exc) return 1 - _LOCK_HANDLE = _lock_single_instance() logging.info("hotkey: %s", cfg.daemon.hotkey) logging.info( @@ -490,9 +563,8 @@ def _run_command(args: argparse.Namespace) -> int: str(config_path), json.dumps(redacted_dict(cfg), indent=2), ) - if not config_existed_before_start and config_path.exists(): - logging.info("created default config at %s", config_path) - logging.info("next step: run `aman doctor --config %s`", config_path) + if not config_existed_before_start: + logging.info("first launch setup completed") logging.info( "runtime: pid=%s session=%s display=%s wayland_display=%s verbose=%s dry_run=%s", os.getpid(), @@ -505,7 +577,6 @@ def _run_command(args: argparse.Namespace) -> int: logging.info("model cache path: %s", MODEL_PATH) try: - desktop = get_desktop_adapter() daemon = Daemon(cfg, desktop, verbose=args.verbose) except Exception as exc: logging.error("startup failed: %s", exc) @@ -559,6 +630,30 @@ def _run_command(args: argparse.Namespace) -> int: cfg = new_cfg logging.info("config reloaded from %s", config_path) + def setup_wizard_callback(): + nonlocal cfg + if daemon.get_state() != State.IDLE: + logging.info("setup is available only while idle") + return + result = run_onboarding_wizard(cfg, desktop) + if not result.completed or result.config is None: + logging.info("setup canceled") + return + try: + save(config_path, result.config) + desktop.start_hotkey_listener(result.config.daemon.hotkey, hotkey_callback) + except ConfigValidationError as exc: + logging.error("setup failed: invalid config field '%s': %s", exc.field, exc.reason) + if exc.example_fix: + logging.error("setup example fix: %s", exc.example_fix) + return + except Exception as exc: + logging.error("setup failed: %s", exc) + return + daemon.apply_config(result.config) + cfg = result.config + logging.info("setup applied from tray") + def run_diagnostics_callback(): report = run_diagnostics(str(config_path)) if report.ok: @@ -588,6 +683,7 @@ def _run_command(args: argparse.Namespace) -> int: desktop.run_tray( daemon.get_state, lambda: shutdown("quit requested"), + on_setup_wizard=setup_wizard_callback, is_paused_getter=daemon.is_paused, on_toggle_pause=daemon.toggle_paused, on_reload_config=reload_config_callback, diff --git a/src/config.py b/src/config.py index c712826..73705b3 100644 --- a/src/config.py +++ b/src/config.py @@ -111,6 +111,13 @@ def load(path: str | None) -> Config: return cfg +def save(path: str | Path | None, cfg: Config) -> Path: + validate(cfg) + target = Path(path) if path else DEFAULT_CONFIG_PATH + _write_default_config(target, cfg) + return target + + def redacted_dict(cfg: Config) -> dict[str, Any]: return asdict(cfg) diff --git a/src/desktop.py b/src/desktop.py index d995b5e..a38ef61 100644 --- a/src/desktop.py +++ b/src/desktop.py @@ -34,6 +34,7 @@ class DesktopAdapter(Protocol): state_getter: Callable[[], str], on_quit: Callable[[], None], *, + on_setup_wizard: Callable[[], None] | None = None, is_paused_getter: Callable[[], bool] | None = None, on_toggle_pause: Callable[[], None] | None = None, on_reload_config: Callable[[], None] | None = None, diff --git a/src/desktop_wayland.py b/src/desktop_wayland.py index 25b8678..ca10df9 100644 --- a/src/desktop_wayland.py +++ b/src/desktop_wayland.py @@ -34,6 +34,7 @@ class WaylandAdapter: _state_getter: Callable[[], str], _on_quit: Callable[[], None], *, + on_setup_wizard: Callable[[], None] | None = None, is_paused_getter: Callable[[], bool] | None = None, on_toggle_pause: Callable[[], None] | None = None, on_reload_config: Callable[[], None] | None = None, @@ -41,6 +42,7 @@ class WaylandAdapter: on_open_config: Callable[[], None] | None = None, ) -> None: _ = ( + on_setup_wizard, is_paused_getter, on_toggle_pause, on_reload_config, diff --git a/src/desktop_x11.py b/src/desktop_x11.py index ef74550..f098e19 100644 --- a/src/desktop_x11.py +++ b/src/desktop_x11.py @@ -166,6 +166,7 @@ class X11Adapter: state_getter: Callable[[], str], on_quit: Callable[[], None], *, + on_setup_wizard: Callable[[], None] | None = None, is_paused_getter: Callable[[], bool] | None = None, on_toggle_pause: Callable[[], None] | None = None, on_reload_config: Callable[[], None] | None = None, @@ -174,6 +175,10 @@ class X11Adapter: ) -> None: self._pause_state_getter = is_paused_getter self.menu = Gtk.Menu() + if on_setup_wizard is not None: + setup_item = Gtk.MenuItem(label="Setup Aman...") + setup_item.connect("activate", lambda *_: on_setup_wizard()) + self.menu.append(setup_item) if on_toggle_pause is not None: self._pause_item = Gtk.MenuItem(label="Pause Aman") self._pause_item.connect("activate", lambda *_: on_toggle_pause()) @@ -382,6 +387,8 @@ class X11Adapter: return str(ASSETS_DIR / "idle.png") def _title(self, state: str) -> str: + if state == "setup_required": + return "Setup Required" if state == "recording": return "Recording" if state == "stt": diff --git a/src/onboarding_ui.py b/src/onboarding_ui.py new file mode 100644 index 0000000..83f0d6d --- /dev/null +++ b/src/onboarding_ui.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +import copy +import logging +import time +from dataclasses import dataclass + +import gi + +from config import Config +from recorder import list_input_devices, resolve_input_device, start_recording, stop_recording + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk # type: ignore[import-not-found] + + +@dataclass +class OnboardingResult: + completed: bool + config: Config | None + aborted_reason: str | None = None + + +class OnboardingWizard: + def __init__(self, initial_cfg: Config, desktop) -> None: + self._desktop = desktop + self._config = copy.deepcopy(initial_cfg) + self._result: OnboardingResult | None = None + self._devices = list_input_devices() + self._device_by_id = {str(device["index"]): device for device in self._devices} + + self._assistant = Gtk.Assistant() + self._assistant.set_title("Aman Setup") + self._assistant.set_default_size(760, 500) + self._assistant.set_modal(True) + self._assistant.set_keep_above(True) + self._assistant.set_position(Gtk.WindowPosition.CENTER_ALWAYS) + self._assistant.connect("cancel", self._on_cancel) + self._assistant.connect("close", self._on_cancel) + self._assistant.connect("apply", self._on_apply) + self._assistant.connect("prepare", self._on_prepare) + self._assistant.connect("destroy", self._on_cancel) + + self._welcome_page = self._build_welcome_page() + self._mic_page, self._mic_combo, self._mic_status = self._build_mic_page() + self._hotkey_page, self._hotkey_entry, self._hotkey_error = self._build_hotkey_page() + self._output_page, self._backend_combo = self._build_output_page() + self._profile_page, self._profile_combo = self._build_profile_page() + self._review_page, self._review_label = self._build_review_page() + + for page in ( + self._welcome_page, + self._mic_page, + self._hotkey_page, + self._output_page, + self._profile_page, + self._review_page, + ): + self._assistant.append_page(page) + + self._assistant.set_page_title(self._welcome_page, "Welcome") + self._assistant.set_page_type(self._welcome_page, Gtk.AssistantPageType.INTRO) + self._assistant.set_page_complete(self._welcome_page, True) + + self._assistant.set_page_title(self._mic_page, "Microphone") + self._assistant.set_page_type(self._mic_page, Gtk.AssistantPageType.CONTENT) + self._assistant.set_page_complete(self._mic_page, True) + + self._assistant.set_page_title(self._hotkey_page, "Hotkey") + self._assistant.set_page_type(self._hotkey_page, Gtk.AssistantPageType.CONTENT) + self._assistant.set_page_complete(self._hotkey_page, False) + + self._assistant.set_page_title(self._output_page, "Output") + self._assistant.set_page_type(self._output_page, Gtk.AssistantPageType.CONTENT) + self._assistant.set_page_complete(self._output_page, True) + + self._assistant.set_page_title(self._profile_page, "Profile") + self._assistant.set_page_type(self._profile_page, Gtk.AssistantPageType.CONTENT) + self._assistant.set_page_complete(self._profile_page, True) + + self._assistant.set_page_title(self._review_page, "Review") + self._assistant.set_page_type(self._review_page, Gtk.AssistantPageType.CONFIRM) + self._assistant.set_page_complete(self._review_page, True) + + self._initialize_widget_values() + self._validate_hotkey() + + def run(self) -> OnboardingResult: + self._assistant.show_all() + Gtk.main() + if self._result is None: + return OnboardingResult(completed=False, config=None, aborted_reason="closed") + return self._result + + def _build_welcome_page(self): + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + box.set_border_width(18) + title = Gtk.Label() + title.set_markup("Welcome to Aman") + title.set_xalign(0.0) + subtitle = Gtk.Label( + label=( + "This setup will configure your microphone, hotkey, output backend, " + "and writing profile." + ) + ) + subtitle.set_xalign(0.0) + subtitle.set_line_wrap(True) + box.pack_start(title, False, False, 0) + box.pack_start(subtitle, False, False, 0) + return box + + def _build_mic_page(self): + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + box.set_border_width(18) + + label = Gtk.Label(label="Choose your input device") + label.set_xalign(0.0) + box.pack_start(label, False, False, 0) + + combo = Gtk.ComboBoxText() + combo.append("", "System default") + for device in self._devices: + combo.append(str(device["index"]), f"{device['index']}: {device['name']}") + combo.set_active_id("") + box.pack_start(combo, False, False, 0) + + test_button = Gtk.Button(label="Test microphone") + status = Gtk.Label(label="") + status.set_xalign(0.0) + status.set_line_wrap(True) + test_button.connect("clicked", lambda *_: self._on_test_microphone()) + + box.pack_start(test_button, False, False, 0) + box.pack_start(status, False, False, 0) + return box, combo, status + + def _build_hotkey_page(self): + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + box.set_border_width(18) + label = Gtk.Label(label="Select the trigger hotkey (for example: Super+m)") + label.set_xalign(0.0) + box.pack_start(label, False, False, 0) + + entry = Gtk.Entry() + entry.set_placeholder_text("Super+m") + entry.connect("changed", lambda *_: self._validate_hotkey()) + box.pack_start(entry, False, False, 0) + + error = Gtk.Label(label="") + error.set_xalign(0.0) + error.set_line_wrap(True) + box.pack_start(error, False, False, 0) + return box, entry, error + + def _build_output_page(self): + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + box.set_border_width(18) + label = Gtk.Label(label="Choose how Aman injects text") + label.set_xalign(0.0) + box.pack_start(label, False, False, 0) + + combo = Gtk.ComboBoxText() + combo.append("clipboard", "Clipboard paste (recommended)") + combo.append("injection", "Simulated typing") + combo.set_active_id("clipboard") + box.pack_start(combo, False, False, 0) + return box, combo + + def _build_profile_page(self): + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + box.set_border_width(18) + label = Gtk.Label(label="Choose your writing profile") + label.set_xalign(0.0) + box.pack_start(label, False, False, 0) + + combo = Gtk.ComboBoxText() + combo.append("default", "Default") + combo.append("fast", "Fast (lower latency)") + combo.append("polished", "Polished") + combo.set_active_id("default") + box.pack_start(combo, False, False, 0) + return box, combo + + def _build_review_page(self): + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + box.set_border_width(18) + label = Gtk.Label(label="") + label.set_xalign(0.0) + label.set_line_wrap(True) + box.pack_start(label, False, False, 0) + return box, label + + def _initialize_widget_values(self) -> None: + hotkey = self._config.daemon.hotkey.strip() or "Super+m" + self._hotkey_entry.set_text(hotkey) + + backend = (self._config.injection.backend or "clipboard").strip().lower() + self._backend_combo.set_active_id(backend if backend in {"clipboard", "injection"} else "clipboard") + + profile = (self._config.ux.profile or "default").strip().lower() + if profile not in {"default", "fast", "polished"}: + profile = "default" + self._profile_combo.set_active_id(profile) + + resolved = resolve_input_device(self._config.recording.input) + if resolved is None: + self._mic_combo.set_active_id("") + else: + resolved_id = str(resolved) + self._mic_combo.set_active_id(resolved_id if resolved_id in self._device_by_id else "") + + def _on_test_microphone(self) -> None: + input_spec = self._selected_input_spec() + self._mic_status.set_text("Testing microphone...") + while Gtk.events_pending(): + Gtk.main_iteration() + try: + stream, record = start_recording(input_spec) + time.sleep(0.35) + audio = stop_recording(stream, record) + if getattr(audio, "size", 0) > 0: + self._mic_status.set_text("Microphone test successful.") + return + self._mic_status.set_text("No audio captured. Try another device.") + except Exception as exc: + self._mic_status.set_text(f"Microphone test failed: {exc}") + + def _selected_input_spec(self) -> str | int | None: + selected = self._mic_combo.get_active_id() + if not selected: + return "" + if selected.isdigit(): + return int(selected) + return selected + + def _validate_hotkey(self) -> bool: + hotkey = self._hotkey_entry.get_text().strip() + if not hotkey: + self._hotkey_error.set_text("Hotkey is required.") + self._assistant.set_page_complete(self._hotkey_page, False) + return False + try: + self._desktop.validate_hotkey(hotkey) + except Exception as exc: + self._hotkey_error.set_text(f"Hotkey is not available: {exc}") + self._assistant.set_page_complete(self._hotkey_page, False) + return False + self._hotkey_error.set_text("") + self._assistant.set_page_complete(self._hotkey_page, True) + return True + + def _on_prepare(self, _assistant, page) -> None: + if page is self._review_page: + summary = ( + "Review your settings before starting Aman:\n\n" + f"- Hotkey: {self._hotkey_entry.get_text().strip()}\n" + f"- Input: {self._describe_input_choice()}\n" + f"- Output backend: {self._backend_combo.get_active_id() or 'clipboard'}\n" + f"- Profile: {self._profile_combo.get_active_id() or 'default'}" + ) + self._review_label.set_text(summary) + + def _describe_input_choice(self) -> str: + selected = self._mic_combo.get_active_id() + if not selected: + return "System default" + device = self._device_by_id.get(selected) + if device is None: + return selected + return f"{device['index']}: {device['name']}" + + def _on_cancel(self, *_args) -> None: + if self._result is None: + self._result = OnboardingResult(completed=False, config=None, aborted_reason="cancelled") + Gtk.main_quit() + + def _on_apply(self, *_args) -> None: + if not self._validate_hotkey(): + return + cfg = copy.deepcopy(self._config) + cfg.daemon.hotkey = self._hotkey_entry.get_text().strip() + cfg.recording.input = self._selected_input_spec() + cfg.injection.backend = self._backend_combo.get_active_id() or "clipboard" + cfg.ux.profile = self._profile_combo.get_active_id() or "default" + self._result = OnboardingResult(completed=True, config=cfg, aborted_reason=None) + Gtk.main_quit() + + +def run_onboarding_wizard(initial_cfg: Config, desktop) -> OnboardingResult: + try: + Gtk.init([]) + except Exception: + pass + logging.info("opening onboarding wizard") + wizard = OnboardingWizard(initial_cfg, desktop) + return wizard.run() diff --git a/tests/test_aman_cli.py b/tests/test_aman_cli.py index c467225..05cfacd 100644 --- a/tests/test_aman_cli.py +++ b/tests/test_aman_cli.py @@ -12,7 +12,70 @@ if str(SRC) not in sys.path: sys.path.insert(0, str(SRC)) import aman +from config import Config from diagnostics import DiagnosticCheck, DiagnosticReport +from onboarding_ui import OnboardingResult + + +class _FakeDesktop: + def __init__(self): + self.hotkey = None + self.hotkey_callback = None + + def start_hotkey_listener(self, hotkey, callback): + self.hotkey = hotkey + self.hotkey_callback = callback + + def stop_hotkey_listener(self): + return + + def start_cancel_listener(self, callback): + _ = callback + return + + def stop_cancel_listener(self): + return + + def validate_hotkey(self, hotkey): + _ = hotkey + return + + def inject_text(self, text, backend, *, remove_transcription_from_clipboard=False): + _ = (text, backend, remove_transcription_from_clipboard) + return + + def run_tray(self, _state_getter, on_quit, **_kwargs): + on_quit() + + def request_quit(self): + return + + +class _FakeDaemon: + def __init__(self, cfg, _desktop, *, verbose=False): + self.cfg = cfg + self.verbose = verbose + self._paused = False + + def get_state(self): + return "idle" + + def is_paused(self): + return self._paused + + def toggle_paused(self): + self._paused = not self._paused + return self._paused + + def apply_config(self, cfg): + self.cfg = cfg + + def toggle(self): + return + + def shutdown(self, timeout=1.0): + _ = timeout + return True class AmanCliTests(unittest.TestCase): @@ -86,6 +149,43 @@ class AmanCliTests(unittest.TestCase): payload = json.loads(path.read_text(encoding="utf-8")) self.assertEqual(payload["daemon"]["hotkey"], "Cmd+m") + def test_run_command_missing_config_uses_onboarding_and_writes_file(self): + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "config.json" + args = aman._parse_cli_args(["run", "--config", str(path)]) + desktop = _FakeDesktop() + onboard_cfg = Config() + onboard_cfg.daemon.hotkey = "Super+m" + with patch("aman._lock_single_instance", return_value=object()), patch( + "aman.get_desktop_adapter", return_value=desktop + ), patch( + "aman.run_onboarding_wizard", + return_value=OnboardingResult(completed=True, config=onboard_cfg, aborted_reason=None), + ) as onboarding_mock, patch("aman.Daemon", _FakeDaemon): + exit_code = aman._run_command(args) + + self.assertEqual(exit_code, 0) + self.assertTrue(path.exists()) + self.assertEqual(desktop.hotkey, "Super+m") + onboarding_mock.assert_called_once() + + def test_run_command_missing_config_cancel_returns_without_starting_daemon(self): + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "config.json" + args = aman._parse_cli_args(["run", "--config", str(path)]) + desktop = _FakeDesktop() + with patch("aman._lock_single_instance", return_value=object()), patch( + "aman.get_desktop_adapter", return_value=desktop + ), patch( + "aman.run_onboarding_wizard", + return_value=OnboardingResult(completed=False, config=None, aborted_reason="cancelled"), + ), patch("aman.Daemon") as daemon_cls: + exit_code = aman._run_command(args) + + self.assertEqual(exit_code, 0) + self.assertFalse(path.exists()) + daemon_cls.assert_not_called() + if __name__ == "__main__":