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 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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
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))
|
||||
|
||||
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__":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue