Add onboarding wizard framework and startup hook
This commit is contained in:
parent
ba9cb97720
commit
992d22a138
7 changed files with 520 additions and 10 deletions
116
src/aman.py
116
src/aman.py
|
|
@ -15,10 +15,11 @@ from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from aiprocess import LlamaProcessor
|
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 constants import DEFAULT_CONFIG_PATH, MODEL_PATH, RECORD_TIMEOUT_SEC, STT_LANGUAGE
|
||||||
from desktop import get_desktop_adapter
|
from desktop import get_desktop_adapter
|
||||||
from diagnostics import run_diagnostics
|
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 start_recording as start_audio_recording
|
||||||
from recorder import stop_recording as stop_audio_recording
|
from recorder import stop_recording as stop_audio_recording
|
||||||
from vocabulary import VocabularyEngine
|
from vocabulary import VocabularyEngine
|
||||||
|
|
@ -460,20 +461,93 @@ def _init_command(args: argparse.Namespace) -> int:
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
cfg = Config()
|
cfg = Config()
|
||||||
validate(cfg)
|
save(config_path, 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")
|
|
||||||
logging.info("wrote default config to %s", config_path)
|
logging.info("wrote default config to %s", config_path)
|
||||||
return 0
|
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:
|
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
|
config_path = Path(args.config) if args.config else DEFAULT_CONFIG_PATH
|
||||||
config_existed_before_start = config_path.exists()
|
config_existed_before_start = config_path.exists()
|
||||||
|
|
||||||
try:
|
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:
|
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:
|
||||||
|
|
@ -482,7 +556,6 @@ def _run_command(args: argparse.Namespace) -> int:
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logging.error("startup failed: %s", exc)
|
logging.error("startup failed: %s", exc)
|
||||||
return 1
|
return 1
|
||||||
_LOCK_HANDLE = _lock_single_instance()
|
|
||||||
|
|
||||||
logging.info("hotkey: %s", cfg.daemon.hotkey)
|
logging.info("hotkey: %s", cfg.daemon.hotkey)
|
||||||
logging.info(
|
logging.info(
|
||||||
|
|
@ -490,9 +563,8 @@ def _run_command(args: argparse.Namespace) -> int:
|
||||||
str(config_path),
|
str(config_path),
|
||||||
json.dumps(redacted_dict(cfg), indent=2),
|
json.dumps(redacted_dict(cfg), indent=2),
|
||||||
)
|
)
|
||||||
if not config_existed_before_start and config_path.exists():
|
if not config_existed_before_start:
|
||||||
logging.info("created default config at %s", config_path)
|
logging.info("first launch setup completed")
|
||||||
logging.info("next step: run `aman doctor --config %s`", config_path)
|
|
||||||
logging.info(
|
logging.info(
|
||||||
"runtime: pid=%s session=%s display=%s wayland_display=%s verbose=%s dry_run=%s",
|
"runtime: pid=%s session=%s display=%s wayland_display=%s verbose=%s dry_run=%s",
|
||||||
os.getpid(),
|
os.getpid(),
|
||||||
|
|
@ -505,7 +577,6 @@ def _run_command(args: argparse.Namespace) -> int:
|
||||||
logging.info("model cache path: %s", MODEL_PATH)
|
logging.info("model cache path: %s", MODEL_PATH)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
desktop = get_desktop_adapter()
|
|
||||||
daemon = Daemon(cfg, desktop, verbose=args.verbose)
|
daemon = Daemon(cfg, desktop, verbose=args.verbose)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logging.error("startup failed: %s", exc)
|
logging.error("startup failed: %s", exc)
|
||||||
|
|
@ -559,6 +630,30 @@ def _run_command(args: argparse.Namespace) -> int:
|
||||||
cfg = new_cfg
|
cfg = new_cfg
|
||||||
logging.info("config reloaded from %s", config_path)
|
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():
|
def run_diagnostics_callback():
|
||||||
report = run_diagnostics(str(config_path))
|
report = run_diagnostics(str(config_path))
|
||||||
if report.ok:
|
if report.ok:
|
||||||
|
|
@ -588,6 +683,7 @@ def _run_command(args: argparse.Namespace) -> int:
|
||||||
desktop.run_tray(
|
desktop.run_tray(
|
||||||
daemon.get_state,
|
daemon.get_state,
|
||||||
lambda: shutdown("quit requested"),
|
lambda: shutdown("quit requested"),
|
||||||
|
on_setup_wizard=setup_wizard_callback,
|
||||||
is_paused_getter=daemon.is_paused,
|
is_paused_getter=daemon.is_paused,
|
||||||
on_toggle_pause=daemon.toggle_paused,
|
on_toggle_pause=daemon.toggle_paused,
|
||||||
on_reload_config=reload_config_callback,
|
on_reload_config=reload_config_callback,
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,13 @@ def load(path: str | None) -> Config:
|
||||||
return cfg
|
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]:
|
def redacted_dict(cfg: Config) -> dict[str, Any]:
|
||||||
return asdict(cfg)
|
return asdict(cfg)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ class DesktopAdapter(Protocol):
|
||||||
state_getter: Callable[[], str],
|
state_getter: Callable[[], str],
|
||||||
on_quit: Callable[[], None],
|
on_quit: Callable[[], None],
|
||||||
*,
|
*,
|
||||||
|
on_setup_wizard: Callable[[], None] | None = None,
|
||||||
is_paused_getter: Callable[[], bool] | None = None,
|
is_paused_getter: Callable[[], bool] | None = None,
|
||||||
on_toggle_pause: Callable[[], None] | None = None,
|
on_toggle_pause: Callable[[], None] | None = None,
|
||||||
on_reload_config: Callable[[], None] | None = None,
|
on_reload_config: Callable[[], None] | None = None,
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ class WaylandAdapter:
|
||||||
_state_getter: Callable[[], str],
|
_state_getter: Callable[[], str],
|
||||||
_on_quit: Callable[[], None],
|
_on_quit: Callable[[], None],
|
||||||
*,
|
*,
|
||||||
|
on_setup_wizard: Callable[[], None] | None = None,
|
||||||
is_paused_getter: Callable[[], bool] | None = None,
|
is_paused_getter: Callable[[], bool] | None = None,
|
||||||
on_toggle_pause: Callable[[], None] | None = None,
|
on_toggle_pause: Callable[[], None] | None = None,
|
||||||
on_reload_config: Callable[[], None] | None = None,
|
on_reload_config: Callable[[], None] | None = None,
|
||||||
|
|
@ -41,6 +42,7 @@ class WaylandAdapter:
|
||||||
on_open_config: Callable[[], None] | None = None,
|
on_open_config: Callable[[], None] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
_ = (
|
_ = (
|
||||||
|
on_setup_wizard,
|
||||||
is_paused_getter,
|
is_paused_getter,
|
||||||
on_toggle_pause,
|
on_toggle_pause,
|
||||||
on_reload_config,
|
on_reload_config,
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,7 @@ class X11Adapter:
|
||||||
state_getter: Callable[[], str],
|
state_getter: Callable[[], str],
|
||||||
on_quit: Callable[[], None],
|
on_quit: Callable[[], None],
|
||||||
*,
|
*,
|
||||||
|
on_setup_wizard: Callable[[], None] | None = None,
|
||||||
is_paused_getter: Callable[[], bool] | None = None,
|
is_paused_getter: Callable[[], bool] | None = None,
|
||||||
on_toggle_pause: Callable[[], None] | None = None,
|
on_toggle_pause: Callable[[], None] | None = None,
|
||||||
on_reload_config: Callable[[], None] | None = None,
|
on_reload_config: Callable[[], None] | None = None,
|
||||||
|
|
@ -174,6 +175,10 @@ class X11Adapter:
|
||||||
) -> None:
|
) -> None:
|
||||||
self._pause_state_getter = is_paused_getter
|
self._pause_state_getter = is_paused_getter
|
||||||
self.menu = Gtk.Menu()
|
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:
|
if on_toggle_pause is not None:
|
||||||
self._pause_item = Gtk.MenuItem(label="Pause Aman")
|
self._pause_item = Gtk.MenuItem(label="Pause Aman")
|
||||||
self._pause_item.connect("activate", lambda *_: on_toggle_pause())
|
self._pause_item.connect("activate", lambda *_: on_toggle_pause())
|
||||||
|
|
@ -382,6 +387,8 @@ class X11Adapter:
|
||||||
return str(ASSETS_DIR / "idle.png")
|
return str(ASSETS_DIR / "idle.png")
|
||||||
|
|
||||||
def _title(self, state: str) -> str:
|
def _title(self, state: str) -> str:
|
||||||
|
if state == "setup_required":
|
||||||
|
return "Setup Required"
|
||||||
if state == "recording":
|
if state == "recording":
|
||||||
return "Recording"
|
return "Recording"
|
||||||
if state == "stt":
|
if state == "stt":
|
||||||
|
|
|
||||||
297
src/onboarding_ui.py
Normal file
297
src/onboarding_ui.py
Normal 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()
|
||||||
|
|
@ -12,7 +12,70 @@ if str(SRC) not in sys.path:
|
||||||
sys.path.insert(0, str(SRC))
|
sys.path.insert(0, str(SRC))
|
||||||
|
|
||||||
import aman
|
import aman
|
||||||
|
from config import Config
|
||||||
from diagnostics import DiagnosticCheck, DiagnosticReport
|
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):
|
class AmanCliTests(unittest.TestCase):
|
||||||
|
|
@ -86,6 +149,43 @@ class AmanCliTests(unittest.TestCase):
|
||||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
self.assertEqual(payload["daemon"]["hotkey"], "Cmd+m")
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue