Add onboarding wizard framework and startup hook

This commit is contained in:
Thales Maciel 2026-02-26 17:57:32 -03:00
parent ba9cb97720
commit 992d22a138
7 changed files with 520 additions and 10 deletions

View file

@ -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,

View file

@ -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)

View file

@ -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,

View file

@ -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,

View file

@ -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":

297
src/onboarding_ui.py Normal file
View file

@ -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("<span size='xx-large' weight='bold'>Welcome to Aman</span>")
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()

View file

@ -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__":