diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f60453d..a69debd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -33,7 +33,7 @@ jobs:
libayatana-appindicator3-1
- name: Create project environment
run: |
- python -m venv .venv
+ python -m venv --system-site-packages .venv
. .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install uv build
@@ -69,7 +69,7 @@ jobs:
xvfb
- name: Create project environment
run: |
- python -m venv .venv
+ python -m venv --system-site-packages .venv
. .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install uv build
@@ -113,7 +113,7 @@ jobs:
libayatana-appindicator3-1
- name: Create project environment
run: |
- python -m venv .venv
+ python -m venv --system-site-packages .venv
. .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install uv build
diff --git a/AGENTS.md b/AGENTS.md
index 606c064..da6611c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -15,12 +15,13 @@
## Build, Test, and Development Commands
-- Install deps (X11): `uv sync`.
+- Install deps (X11): `python3 -m venv --system-site-packages .venv && . .venv/bin/activate && uv sync --active`.
- Run daemon: `uv run aman run --config ~/.config/aman/config.json`.
System packages (example names):
- Core: `portaudio`/`libportaudio2`.
+- GTK/X11 Python bindings: distro packages such as `python3-gi` / `python3-xlib`.
- X11 tray: `libayatana-appindicator3`.
## Coding Style & Naming Conventions
diff --git a/Makefile b/Makefile
index 3db6c38..fcc1172 100644
--- a/Makefile
+++ b/Makefile
@@ -47,7 +47,11 @@ check-default-model:
uv run aman-maint sync-default-model --check --report $(EVAL_OUTPUT) --artifacts $(MODEL_ARTIFACTS) --constants $(CONSTANTS_FILE)
sync:
- uv sync
+ @if [ ! -f .venv/pyvenv.cfg ] || ! grep -q '^include-system-site-packages = true' .venv/pyvenv.cfg; then \
+ rm -rf .venv; \
+ $(PYTHON) -m venv --system-site-packages .venv; \
+ fi
+ UV_PROJECT_ENVIRONMENT=$(CURDIR)/.venv uv sync
test:
$(PYTHON) -m unittest discover -s tests -p 'test_*.py'
diff --git a/docs/developer-workflows.md b/docs/developer-workflows.md
index 1f9cbdd..9602e5c 100644
--- a/docs/developer-workflows.md
+++ b/docs/developer-workflows.md
@@ -36,10 +36,15 @@ For `1.0.0`, the manual publication target is the forge release page at
`uv` workflow:
```bash
-uv sync
+python3 -m venv --system-site-packages .venv
+. .venv/bin/activate
+uv sync --active
uv run aman run --config ~/.config/aman/config.json
```
+Install the documented distro runtime dependencies first so the active virtualenv
+can see GTK/AppIndicator/X11 bindings from the system Python.
+
`pip` workflow:
```bash
diff --git a/packaging/arch/PKGBUILD.in b/packaging/arch/PKGBUILD.in
index 3eb3194..b6ce02f 100644
--- a/packaging/arch/PKGBUILD.in
+++ b/packaging/arch/PKGBUILD.in
@@ -15,22 +15,16 @@ prepare() {
cd "${srcdir}/aman-${pkgver}"
python -m build --wheel
python - <<'PY'
+import ast
from pathlib import Path
import re
-import tomllib
-project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
-exclude = {"pygobject", "python-xlib"}
-dependencies = project.get("project", {}).get("dependencies", [])
-filtered = []
-for dependency in dependencies:
- match = re.match(r"\s*([A-Za-z0-9_.-]+)", dependency)
- if not match:
- continue
- name = match.group(1).lower().replace("_", "-")
- if name in exclude:
- continue
- filtered.append(dependency.strip())
+text = Path("pyproject.toml").read_text(encoding="utf-8")
+match = re.search(r"(?ms)^\s*dependencies\s*=\s*\[(.*?)^\s*\]", text)
+if not match:
+ raise SystemExit("project dependencies not found in pyproject.toml")
+dependencies = ast.literal_eval("[" + match.group(1) + "]")
+filtered = [dependency.strip() for dependency in dependencies]
Path("dist/runtime-requirements.txt").write_text("\n".join(filtered) + "\n", encoding="utf-8")
PY
}
diff --git a/pyproject.toml b/pyproject.toml
index 326f777..de20737 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,8 +28,6 @@ dependencies = [
"faster-whisper",
"llama-cpp-python",
"numpy",
- "PyGObject",
- "python-xlib",
"sounddevice",
]
@@ -58,6 +56,9 @@ py-modules = [
"aman_runtime",
"config",
"config_ui",
+ "config_ui_audio",
+ "config_ui_pages",
+ "config_ui_runtime",
"constants",
"desktop",
"desktop_x11",
diff --git a/scripts/package_common.sh b/scripts/package_common.sh
index f9a13f9..62e1b4d 100755
--- a/scripts/package_common.sh
+++ b/scripts/package_common.sh
@@ -93,24 +93,18 @@ write_runtime_requirements() {
local output_path="$1"
require_command python3
python3 - "${output_path}" <<'PY'
+import ast
from pathlib import Path
import re
import sys
-import tomllib
output_path = Path(sys.argv[1])
-exclude = {"pygobject", "python-xlib"}
-project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
-dependencies = project.get("project", {}).get("dependencies", [])
-filtered = []
-for dependency in dependencies:
- match = re.match(r"\s*([A-Za-z0-9_.-]+)", dependency)
- if not match:
- continue
- name = match.group(1).lower().replace("_", "-")
- if name in exclude:
- continue
- filtered.append(dependency.strip())
+text = Path("pyproject.toml").read_text(encoding="utf-8")
+match = re.search(r"(?ms)^\s*dependencies\s*=\s*\[(.*?)^\s*\]", text)
+if not match:
+ raise SystemExit("project dependencies not found in pyproject.toml")
+dependencies = ast.literal_eval("[" + match.group(1) + "]")
+filtered = [dependency.strip() for dependency in dependencies]
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text("\n".join(filtered) + "\n", encoding="utf-8")
PY
diff --git a/scripts/package_portable.sh b/scripts/package_portable.sh
index 856df1c..314eec8 100755
--- a/scripts/package_portable.sh
+++ b/scripts/package_portable.sh
@@ -49,21 +49,16 @@ export_requirements() {
--python "${python_version}" >"${raw_path}"
python3 - "${raw_path}" "${output_path}" <<'PY'
from pathlib import Path
-import re
import sys
raw_path = Path(sys.argv[1])
output_path = Path(sys.argv[2])
lines = raw_path.read_text(encoding="utf-8").splitlines()
-exclude = {"pygobject", "python-xlib"}
filtered = []
for line in lines:
stripped = line.strip()
if not stripped or stripped == ".":
continue
- match = re.match(r"([A-Za-z0-9_.-]+)", stripped)
- if match and match.group(1).lower().replace("_", "-") in exclude:
- continue
filtered.append(line)
output_path.write_text("\n".join(filtered) + "\n", encoding="utf-8")
raw_path.unlink()
diff --git a/src/aman_run.py b/src/aman_run.py
index 2e5dc48..062fb51 100644
--- a/src/aman_run.py
+++ b/src/aman_run.py
@@ -8,7 +8,14 @@ import signal
import threading
from pathlib import Path
-from config import Config, ConfigValidationError, load, redacted_dict, save, validate
+from config import (
+ Config,
+ ConfigValidationError,
+ config_log_payload,
+ load,
+ save,
+ validate,
+)
from constants import DEFAULT_CONFIG_PATH, MODEL_PATH
from desktop import get_desktop_adapter
from diagnostics import (
@@ -232,7 +239,7 @@ def run_command(args) -> int:
logging.info(
"config (%s):\n%s",
str(config_path),
- json.dumps(redacted_dict(cfg), indent=2),
+ json.dumps(config_log_payload(cfg), indent=2),
)
if not config_existed_before_start:
logging.info("first launch settings completed")
diff --git a/src/config.py b/src/config.py
index 77491bd..3be995c 100644
--- a/src/config.py
+++ b/src/config.py
@@ -152,13 +152,35 @@ def save(path: str | Path | None, cfg: Config) -> Path:
return target
-def redacted_dict(cfg: Config) -> dict[str, Any]:
+def config_as_dict(cfg: Config) -> dict[str, Any]:
return asdict(cfg)
+def config_log_payload(cfg: Config) -> dict[str, Any]:
+ return {
+ "daemon_hotkey": cfg.daemon.hotkey,
+ "recording_input": cfg.recording.input,
+ "stt_provider": cfg.stt.provider,
+ "stt_model": cfg.stt.model,
+ "stt_device": cfg.stt.device,
+ "stt_language": cfg.stt.language,
+ "custom_whisper_path_configured": bool(
+ cfg.models.whisper_model_path.strip()
+ ),
+ "injection_backend": cfg.injection.backend,
+ "remove_transcription_from_clipboard": (
+ cfg.injection.remove_transcription_from_clipboard
+ ),
+ "safety_enabled": cfg.safety.enabled,
+ "safety_strict": cfg.safety.strict,
+ "ux_profile": cfg.ux.profile,
+ "strict_startup": cfg.advanced.strict_startup,
+ }
+
+
def _write_default_config(path: Path, cfg: Config) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(f"{json.dumps(redacted_dict(cfg), indent=2)}\n", encoding="utf-8")
+ path.write_text(f"{json.dumps(config_as_dict(cfg), indent=2)}\n", encoding="utf-8")
def validate(cfg: Config) -> None:
diff --git a/src/config_ui.py b/src/config_ui.py
index dcc6c39..54eaca8 100644
--- a/src/config_ui.py
+++ b/src/config_ui.py
@@ -3,29 +3,34 @@ from __future__ import annotations
import copy
import importlib.metadata
import logging
-import time
from dataclasses import dataclass
from pathlib import Path
import gi
-from config import (
- Config,
- DEFAULT_STT_PROVIDER,
+from config import Config, DEFAULT_STT_PROVIDER
+from config_ui_audio import AudioSettingsService
+from config_ui_pages import (
+ build_about_page,
+ build_advanced_page,
+ build_audio_page,
+ build_general_page,
+ build_help_page,
+)
+from config_ui_runtime import (
+ RUNTIME_MODE_EXPERT,
+ RUNTIME_MODE_MANAGED,
+ apply_canonical_runtime_defaults,
+ infer_runtime_mode,
)
from constants import DEFAULT_CONFIG_PATH
-from languages import COMMON_STT_LANGUAGE_OPTIONS, stt_language_label
-from recorder import list_input_devices, resolve_input_device, start_recording, stop_recording
+from languages import stt_language_label
gi.require_version("Gdk", "3.0")
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, Gtk # type: ignore[import-not-found]
-RUNTIME_MODE_MANAGED = "aman_managed"
-RUNTIME_MODE_EXPERT = "expert_custom"
-
-
@dataclass
class ConfigUiResult:
saved: bool
@@ -33,21 +38,6 @@ class ConfigUiResult:
closed_reason: str | None = None
-def infer_runtime_mode(cfg: Config) -> str:
- is_canonical = (
- cfg.stt.provider.strip().lower() == DEFAULT_STT_PROVIDER
- and not bool(cfg.models.allow_custom_models)
- and not cfg.models.whisper_model_path.strip()
- )
- return RUNTIME_MODE_MANAGED if is_canonical else RUNTIME_MODE_EXPERT
-
-
-def apply_canonical_runtime_defaults(cfg: Config) -> None:
- cfg.stt.provider = DEFAULT_STT_PROVIDER
- cfg.models.allow_custom_models = False
- cfg.models.whisper_model_path = ""
-
-
class ConfigWindow:
def __init__(
self,
@@ -61,7 +51,8 @@ class ConfigWindow:
self._config = copy.deepcopy(initial_cfg)
self._required = required
self._config_path = Path(config_path) if config_path else DEFAULT_CONFIG_PATH
- self._devices = list_input_devices()
+ self._audio_settings = AudioSettingsService()
+ self._devices = self._audio_settings.list_input_devices()
self._device_by_id = {str(device["index"]): device for device in self._devices}
self._row_to_section: dict[Gtk.ListBoxRow, str] = {}
self._runtime_mode = infer_runtime_mode(self._config)
@@ -115,11 +106,11 @@ class ConfigWindow:
self._stack.set_transition_duration(120)
body.pack_start(self._stack, True, True, 0)
- self._general_page = self._build_general_page()
- self._audio_page = self._build_audio_page()
- self._advanced_page = self._build_advanced_page()
- self._help_page = self._build_help_page()
- self._about_page = self._build_about_page()
+ self._general_page = build_general_page(self)
+ self._audio_page = build_audio_page(self)
+ self._advanced_page = build_advanced_page(self)
+ self._help_page = build_help_page(self, present_about_dialog=_present_about_dialog)
+ self._about_page = build_about_page(self, present_about_dialog=_present_about_dialog)
self._add_section("general", "General", self._general_page)
self._add_section("audio", "Audio", self._audio_page)
@@ -169,261 +160,6 @@ class ConfigWindow:
if section:
self._stack.set_visible_child_name(section)
- def _build_general_page(self) -> Gtk.Widget:
- grid = Gtk.Grid(column_spacing=12, row_spacing=10)
- grid.set_margin_start(14)
- grid.set_margin_end(14)
- grid.set_margin_top(14)
- grid.set_margin_bottom(14)
-
- hotkey_label = Gtk.Label(label="Trigger hotkey")
- hotkey_label.set_xalign(0.0)
- self._hotkey_entry = Gtk.Entry()
- self._hotkey_entry.set_placeholder_text("Super+m")
- self._hotkey_entry.connect("changed", lambda *_: self._validate_hotkey())
- grid.attach(hotkey_label, 0, 0, 1, 1)
- grid.attach(self._hotkey_entry, 1, 0, 1, 1)
-
- self._hotkey_error = Gtk.Label(label="")
- self._hotkey_error.set_xalign(0.0)
- self._hotkey_error.set_line_wrap(True)
- grid.attach(self._hotkey_error, 1, 1, 1, 1)
-
- backend_label = Gtk.Label(label="Text injection")
- backend_label.set_xalign(0.0)
- self._backend_combo = Gtk.ComboBoxText()
- self._backend_combo.append("clipboard", "Clipboard paste (recommended)")
- self._backend_combo.append("injection", "Simulated typing")
- grid.attach(backend_label, 0, 2, 1, 1)
- grid.attach(self._backend_combo, 1, 2, 1, 1)
-
- self._remove_clipboard_check = Gtk.CheckButton(
- label="Remove transcription from clipboard after paste"
- )
- self._remove_clipboard_check.set_hexpand(True)
- grid.attach(self._remove_clipboard_check, 1, 3, 1, 1)
-
- language_label = Gtk.Label(label="Transcription language")
- language_label.set_xalign(0.0)
- self._language_combo = Gtk.ComboBoxText()
- for code, label in COMMON_STT_LANGUAGE_OPTIONS:
- self._language_combo.append(code, label)
- grid.attach(language_label, 0, 4, 1, 1)
- grid.attach(self._language_combo, 1, 4, 1, 1)
-
- profile_label = Gtk.Label(label="Profile")
- profile_label.set_xalign(0.0)
- self._profile_combo = Gtk.ComboBoxText()
- self._profile_combo.append("default", "Default")
- self._profile_combo.append("fast", "Fast (lower latency)")
- self._profile_combo.append("polished", "Polished")
- grid.attach(profile_label, 0, 5, 1, 1)
- grid.attach(self._profile_combo, 1, 5, 1, 1)
-
- return grid
-
- def _build_audio_page(self) -> Gtk.Widget:
- box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
- box.set_margin_start(14)
- box.set_margin_end(14)
- box.set_margin_top(14)
- box.set_margin_bottom(14)
-
- input_label = Gtk.Label(label="Input device")
- input_label.set_xalign(0.0)
- box.pack_start(input_label, False, False, 0)
-
- self._mic_combo = Gtk.ComboBoxText()
- self._mic_combo.append("", "System default")
- for device in self._devices:
- self._mic_combo.append(str(device["index"]), f"{device['index']}: {device['name']}")
- box.pack_start(self._mic_combo, False, False, 0)
-
- test_button = Gtk.Button(label="Test microphone")
- test_button.connect("clicked", lambda *_: self._on_test_microphone())
- box.pack_start(test_button, False, False, 0)
-
- self._mic_status = Gtk.Label(label="")
- self._mic_status.set_xalign(0.0)
- self._mic_status.set_line_wrap(True)
- box.pack_start(self._mic_status, False, False, 0)
- return box
-
- def _build_advanced_page(self) -> Gtk.Widget:
- box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
- box.set_margin_start(14)
- box.set_margin_end(14)
- box.set_margin_top(14)
- box.set_margin_bottom(14)
-
- self._strict_startup_check = Gtk.CheckButton(label="Fail fast on startup validation errors")
- box.pack_start(self._strict_startup_check, False, False, 0)
-
- safety_title = Gtk.Label()
- safety_title.set_markup("Output safety")
- safety_title.set_xalign(0.0)
- box.pack_start(safety_title, False, False, 0)
-
- self._safety_enabled_check = Gtk.CheckButton(
- label="Enable fact-preservation guard (recommended)"
- )
- self._safety_enabled_check.connect("toggled", lambda *_: self._on_safety_guard_toggled())
- box.pack_start(self._safety_enabled_check, False, False, 0)
-
- self._safety_strict_check = Gtk.CheckButton(
- label="Strict mode: reject output when facts are changed"
- )
- box.pack_start(self._safety_strict_check, False, False, 0)
-
- runtime_title = Gtk.Label()
- runtime_title.set_markup("Runtime management")
- runtime_title.set_xalign(0.0)
- box.pack_start(runtime_title, False, False, 0)
-
- runtime_copy = Gtk.Label(
- label=(
- "Aman-managed mode handles the canonical editor model lifecycle for you. "
- "Expert mode keeps Aman open-source friendly by letting you use custom Whisper paths."
- )
- )
- runtime_copy.set_xalign(0.0)
- runtime_copy.set_line_wrap(True)
- box.pack_start(runtime_copy, False, False, 0)
-
- mode_label = Gtk.Label(label="Runtime mode")
- mode_label.set_xalign(0.0)
- box.pack_start(mode_label, False, False, 0)
-
- self._runtime_mode_combo = Gtk.ComboBoxText()
- self._runtime_mode_combo.append(RUNTIME_MODE_MANAGED, "Aman-managed (recommended)")
- self._runtime_mode_combo.append(RUNTIME_MODE_EXPERT, "Expert mode (custom Whisper path)")
- self._runtime_mode_combo.connect("changed", lambda *_: self._on_runtime_mode_changed(user_initiated=True))
- box.pack_start(self._runtime_mode_combo, False, False, 0)
-
- self._runtime_status_label = Gtk.Label(label="")
- self._runtime_status_label.set_xalign(0.0)
- self._runtime_status_label.set_line_wrap(True)
- box.pack_start(self._runtime_status_label, False, False, 0)
-
- self._expert_expander = Gtk.Expander(label="Expert options")
- self._expert_expander.set_expanded(False)
- box.pack_start(self._expert_expander, False, False, 0)
-
- expert_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
- expert_box.set_margin_start(10)
- expert_box.set_margin_end(10)
- expert_box.set_margin_top(8)
- expert_box.set_margin_bottom(8)
- self._expert_expander.add(expert_box)
-
- expert_warning = Gtk.InfoBar()
- expert_warning.set_show_close_button(False)
- expert_warning.set_message_type(Gtk.MessageType.WARNING)
- warning_label = Gtk.Label(
- label=(
- "Expert mode is best-effort and may require manual troubleshooting. "
- "Aman-managed mode is the canonical supported path."
- )
- )
- warning_label.set_xalign(0.0)
- warning_label.set_line_wrap(True)
- expert_warning.get_content_area().pack_start(warning_label, True, True, 0)
- expert_box.pack_start(expert_warning, False, False, 0)
-
- self._allow_custom_models_check = Gtk.CheckButton(
- label="Allow custom local model paths"
- )
- self._allow_custom_models_check.connect("toggled", lambda *_: self._on_runtime_widgets_changed())
- expert_box.pack_start(self._allow_custom_models_check, False, False, 0)
-
- whisper_model_path_label = Gtk.Label(label="Custom Whisper model path")
- whisper_model_path_label.set_xalign(0.0)
- expert_box.pack_start(whisper_model_path_label, False, False, 0)
- self._whisper_model_path_entry = Gtk.Entry()
- self._whisper_model_path_entry.connect("changed", lambda *_: self._on_runtime_widgets_changed())
- expert_box.pack_start(self._whisper_model_path_entry, False, False, 0)
-
- self._runtime_error = Gtk.Label(label="")
- self._runtime_error.set_xalign(0.0)
- self._runtime_error.set_line_wrap(True)
- expert_box.pack_start(self._runtime_error, False, False, 0)
-
- path_label = Gtk.Label(label="Config path")
- path_label.set_xalign(0.0)
- box.pack_start(path_label, False, False, 0)
-
- path_entry = Gtk.Entry()
- path_entry.set_editable(False)
- path_entry.set_text(str(self._config_path))
- box.pack_start(path_entry, False, False, 0)
-
- note = Gtk.Label(
- label=(
- "Tip: after editing the file directly, use Reload Config from the tray to apply changes."
- )
- )
- note.set_xalign(0.0)
- note.set_line_wrap(True)
- box.pack_start(note, False, False, 0)
- return box
-
- def _build_help_page(self) -> Gtk.Widget:
- box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
- box.set_margin_start(14)
- box.set_margin_end(14)
- box.set_margin_top(14)
- box.set_margin_bottom(14)
-
- help_text = Gtk.Label(
- label=(
- "Usage:\n"
- "- Press your hotkey to start recording.\n"
- "- Press the hotkey again to stop and process.\n"
- "- Press Esc while recording to cancel.\n\n"
- "Supported path:\n"
- "- Daily use runs through the tray and user service.\n"
- "- Aman-managed mode (recommended) handles model lifecycle for you.\n"
- "- Expert mode keeps custom Whisper paths available for advanced users.\n\n"
- "Recovery:\n"
- "- Use Run Diagnostics from the tray for a deeper self-check.\n"
- "- If that is not enough, run aman doctor, then aman self-check.\n"
- "- Next escalations are journalctl --user -u aman and aman run --verbose.\n\n"
- "Safety tips:\n"
- "- Keep fact guard enabled to prevent accidental name/number changes.\n"
- "- Strict safety blocks output on fact violations."
- )
- )
- help_text.set_xalign(0.0)
- help_text.set_line_wrap(True)
- box.pack_start(help_text, False, False, 0)
-
- about_button = Gtk.Button(label="Open About Dialog")
- about_button.connect("clicked", lambda *_: _present_about_dialog(self._dialog))
- box.pack_start(about_button, False, False, 0)
- return box
-
- def _build_about_page(self) -> Gtk.Widget:
- box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
- box.set_margin_start(14)
- box.set_margin_end(14)
- box.set_margin_top(14)
- box.set_margin_bottom(14)
-
- title = Gtk.Label()
- title.set_markup("Aman")
- title.set_xalign(0.0)
- box.pack_start(title, False, False, 0)
-
- subtitle = Gtk.Label(label="Local amanuensis for X11 desktop dictation and rewriting.")
- subtitle.set_xalign(0.0)
- subtitle.set_line_wrap(True)
- box.pack_start(subtitle, False, False, 0)
-
- about_button = Gtk.Button(label="About Aman")
- about_button.connect("clicked", lambda *_: _present_about_dialog(self._dialog))
- box.pack_start(about_button, False, False, 0)
- return box
-
def _initialize_widget_values(self) -> None:
hotkey = self._config.daemon.hotkey.strip() or "Super+m"
self._hotkey_entry.set_text(hotkey)
@@ -457,7 +193,7 @@ class ConfigWindow:
self._sync_runtime_mode_ui(user_initiated=False)
self._validate_runtime_settings()
- resolved = resolve_input_device(self._config.recording.input)
+ resolved = self._audio_settings.resolve_input_device(self._config.recording.input)
if resolved is None:
self._mic_combo.set_active_id("")
return
@@ -536,16 +272,8 @@ class ConfigWindow:
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}")
+ result = self._audio_settings.test_microphone(input_spec)
+ self._mic_status.set_text(result.message)
def _validate_hotkey(self) -> bool:
hotkey = self._hotkey_entry.get_text().strip()
diff --git a/src/config_ui_audio.py b/src/config_ui_audio.py
new file mode 100644
index 0000000..e2e8a53
--- /dev/null
+++ b/src/config_ui_audio.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass
+from typing import Any
+
+from recorder import (
+ list_input_devices,
+ resolve_input_device,
+ start_recording,
+ stop_recording,
+)
+
+
+@dataclass(frozen=True)
+class MicrophoneTestResult:
+ ok: bool
+ message: str
+
+
+class AudioSettingsService:
+ def list_input_devices(self) -> list[dict[str, Any]]:
+ return list_input_devices()
+
+ def resolve_input_device(self, input_spec: str | int | None) -> int | None:
+ return resolve_input_device(input_spec)
+
+ def test_microphone(
+ self,
+ input_spec: str | int | None,
+ *,
+ duration_sec: float = 0.35,
+ ) -> MicrophoneTestResult:
+ try:
+ stream, record = start_recording(input_spec)
+ time.sleep(duration_sec)
+ audio = stop_recording(stream, record)
+ except Exception as exc:
+ return MicrophoneTestResult(
+ ok=False,
+ message=f"Microphone test failed: {exc}",
+ )
+
+ if getattr(audio, "size", 0) > 0:
+ return MicrophoneTestResult(
+ ok=True,
+ message="Microphone test successful.",
+ )
+ return MicrophoneTestResult(
+ ok=False,
+ message="No audio captured. Try another device.",
+ )
diff --git a/src/config_ui_pages.py b/src/config_ui_pages.py
new file mode 100644
index 0000000..714ab37
--- /dev/null
+++ b/src/config_ui_pages.py
@@ -0,0 +1,293 @@
+from __future__ import annotations
+
+import gi
+
+from config_ui_runtime import RUNTIME_MODE_EXPERT, RUNTIME_MODE_MANAGED
+from languages import COMMON_STT_LANGUAGE_OPTIONS
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk # type: ignore[import-not-found]
+
+
+def _page_box() -> Gtk.Box:
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
+ box.set_margin_start(14)
+ box.set_margin_end(14)
+ box.set_margin_top(14)
+ box.set_margin_bottom(14)
+ return box
+
+
+def build_general_page(window) -> Gtk.Widget:
+ grid = Gtk.Grid(column_spacing=12, row_spacing=10)
+ grid.set_margin_start(14)
+ grid.set_margin_end(14)
+ grid.set_margin_top(14)
+ grid.set_margin_bottom(14)
+
+ hotkey_label = Gtk.Label(label="Trigger hotkey")
+ hotkey_label.set_xalign(0.0)
+ window._hotkey_entry = Gtk.Entry()
+ window._hotkey_entry.set_placeholder_text("Super+m")
+ window._hotkey_entry.connect("changed", lambda *_: window._validate_hotkey())
+ grid.attach(hotkey_label, 0, 0, 1, 1)
+ grid.attach(window._hotkey_entry, 1, 0, 1, 1)
+
+ window._hotkey_error = Gtk.Label(label="")
+ window._hotkey_error.set_xalign(0.0)
+ window._hotkey_error.set_line_wrap(True)
+ grid.attach(window._hotkey_error, 1, 1, 1, 1)
+
+ backend_label = Gtk.Label(label="Text injection")
+ backend_label.set_xalign(0.0)
+ window._backend_combo = Gtk.ComboBoxText()
+ window._backend_combo.append("clipboard", "Clipboard paste (recommended)")
+ window._backend_combo.append("injection", "Simulated typing")
+ grid.attach(backend_label, 0, 2, 1, 1)
+ grid.attach(window._backend_combo, 1, 2, 1, 1)
+
+ window._remove_clipboard_check = Gtk.CheckButton(
+ label="Remove transcription from clipboard after paste"
+ )
+ window._remove_clipboard_check.set_hexpand(True)
+ grid.attach(window._remove_clipboard_check, 1, 3, 1, 1)
+
+ language_label = Gtk.Label(label="Transcription language")
+ language_label.set_xalign(0.0)
+ window._language_combo = Gtk.ComboBoxText()
+ for code, label in COMMON_STT_LANGUAGE_OPTIONS:
+ window._language_combo.append(code, label)
+ grid.attach(language_label, 0, 4, 1, 1)
+ grid.attach(window._language_combo, 1, 4, 1, 1)
+
+ profile_label = Gtk.Label(label="Profile")
+ profile_label.set_xalign(0.0)
+ window._profile_combo = Gtk.ComboBoxText()
+ window._profile_combo.append("default", "Default")
+ window._profile_combo.append("fast", "Fast (lower latency)")
+ window._profile_combo.append("polished", "Polished")
+ grid.attach(profile_label, 0, 5, 1, 1)
+ grid.attach(window._profile_combo, 1, 5, 1, 1)
+
+ return grid
+
+
+def build_audio_page(window) -> Gtk.Widget:
+ box = _page_box()
+
+ input_label = Gtk.Label(label="Input device")
+ input_label.set_xalign(0.0)
+ box.pack_start(input_label, False, False, 0)
+
+ window._mic_combo = Gtk.ComboBoxText()
+ window._mic_combo.append("", "System default")
+ for device in window._devices:
+ window._mic_combo.append(
+ str(device["index"]),
+ f"{device['index']}: {device['name']}",
+ )
+ box.pack_start(window._mic_combo, False, False, 0)
+
+ test_button = Gtk.Button(label="Test microphone")
+ test_button.connect("clicked", lambda *_: window._on_test_microphone())
+ box.pack_start(test_button, False, False, 0)
+
+ window._mic_status = Gtk.Label(label="")
+ window._mic_status.set_xalign(0.0)
+ window._mic_status.set_line_wrap(True)
+ box.pack_start(window._mic_status, False, False, 0)
+ return box
+
+
+def build_advanced_page(window) -> Gtk.Widget:
+ box = _page_box()
+
+ window._strict_startup_check = Gtk.CheckButton(
+ label="Fail fast on startup validation errors"
+ )
+ box.pack_start(window._strict_startup_check, False, False, 0)
+
+ safety_title = Gtk.Label()
+ safety_title.set_markup("Output safety")
+ safety_title.set_xalign(0.0)
+ box.pack_start(safety_title, False, False, 0)
+
+ window._safety_enabled_check = Gtk.CheckButton(
+ label="Enable fact-preservation guard (recommended)"
+ )
+ window._safety_enabled_check.connect(
+ "toggled",
+ lambda *_: window._on_safety_guard_toggled(),
+ )
+ box.pack_start(window._safety_enabled_check, False, False, 0)
+
+ window._safety_strict_check = Gtk.CheckButton(
+ label="Strict mode: reject output when facts are changed"
+ )
+ box.pack_start(window._safety_strict_check, False, False, 0)
+
+ runtime_title = Gtk.Label()
+ runtime_title.set_markup("Runtime management")
+ runtime_title.set_xalign(0.0)
+ box.pack_start(runtime_title, False, False, 0)
+
+ runtime_copy = Gtk.Label(
+ label=(
+ "Aman-managed mode handles the canonical editor model lifecycle for you. "
+ "Expert mode keeps Aman open-source friendly by letting you use custom Whisper paths."
+ )
+ )
+ runtime_copy.set_xalign(0.0)
+ runtime_copy.set_line_wrap(True)
+ box.pack_start(runtime_copy, False, False, 0)
+
+ mode_label = Gtk.Label(label="Runtime mode")
+ mode_label.set_xalign(0.0)
+ box.pack_start(mode_label, False, False, 0)
+
+ window._runtime_mode_combo = Gtk.ComboBoxText()
+ window._runtime_mode_combo.append(
+ RUNTIME_MODE_MANAGED,
+ "Aman-managed (recommended)",
+ )
+ window._runtime_mode_combo.append(
+ RUNTIME_MODE_EXPERT,
+ "Expert mode (custom Whisper path)",
+ )
+ window._runtime_mode_combo.connect(
+ "changed",
+ lambda *_: window._on_runtime_mode_changed(user_initiated=True),
+ )
+ box.pack_start(window._runtime_mode_combo, False, False, 0)
+
+ window._runtime_status_label = Gtk.Label(label="")
+ window._runtime_status_label.set_xalign(0.0)
+ window._runtime_status_label.set_line_wrap(True)
+ box.pack_start(window._runtime_status_label, False, False, 0)
+
+ window._expert_expander = Gtk.Expander(label="Expert options")
+ window._expert_expander.set_expanded(False)
+ box.pack_start(window._expert_expander, False, False, 0)
+
+ expert_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
+ expert_box.set_margin_start(10)
+ expert_box.set_margin_end(10)
+ expert_box.set_margin_top(8)
+ expert_box.set_margin_bottom(8)
+ window._expert_expander.add(expert_box)
+
+ expert_warning = Gtk.InfoBar()
+ expert_warning.set_show_close_button(False)
+ expert_warning.set_message_type(Gtk.MessageType.WARNING)
+ warning_label = Gtk.Label(
+ label=(
+ "Expert mode is best-effort and may require manual troubleshooting. "
+ "Aman-managed mode is the canonical supported path."
+ )
+ )
+ warning_label.set_xalign(0.0)
+ warning_label.set_line_wrap(True)
+ expert_warning.get_content_area().pack_start(warning_label, True, True, 0)
+ expert_box.pack_start(expert_warning, False, False, 0)
+
+ window._allow_custom_models_check = Gtk.CheckButton(
+ label="Allow custom local model paths"
+ )
+ window._allow_custom_models_check.connect(
+ "toggled",
+ lambda *_: window._on_runtime_widgets_changed(),
+ )
+ expert_box.pack_start(window._allow_custom_models_check, False, False, 0)
+
+ whisper_model_path_label = Gtk.Label(label="Custom Whisper model path")
+ whisper_model_path_label.set_xalign(0.0)
+ expert_box.pack_start(whisper_model_path_label, False, False, 0)
+ window._whisper_model_path_entry = Gtk.Entry()
+ window._whisper_model_path_entry.connect(
+ "changed",
+ lambda *_: window._on_runtime_widgets_changed(),
+ )
+ expert_box.pack_start(window._whisper_model_path_entry, False, False, 0)
+
+ window._runtime_error = Gtk.Label(label="")
+ window._runtime_error.set_xalign(0.0)
+ window._runtime_error.set_line_wrap(True)
+ expert_box.pack_start(window._runtime_error, False, False, 0)
+
+ path_label = Gtk.Label(label="Config path")
+ path_label.set_xalign(0.0)
+ box.pack_start(path_label, False, False, 0)
+
+ path_entry = Gtk.Entry()
+ path_entry.set_editable(False)
+ path_entry.set_text(str(window._config_path))
+ box.pack_start(path_entry, False, False, 0)
+
+ note = Gtk.Label(
+ label=(
+ "Tip: after editing the file directly, use Reload Config from the tray to apply changes."
+ )
+ )
+ note.set_xalign(0.0)
+ note.set_line_wrap(True)
+ box.pack_start(note, False, False, 0)
+ return box
+
+
+def build_help_page(window, *, present_about_dialog) -> Gtk.Widget:
+ box = _page_box()
+
+ help_text = Gtk.Label(
+ label=(
+ "Usage:\n"
+ "- Press your hotkey to start recording.\n"
+ "- Press the hotkey again to stop and process.\n"
+ "- Press Esc while recording to cancel.\n\n"
+ "Supported path:\n"
+ "- Daily use runs through the tray and user service.\n"
+ "- Aman-managed mode (recommended) handles model lifecycle for you.\n"
+ "- Expert mode keeps custom Whisper paths available for advanced users.\n\n"
+ "Recovery:\n"
+ "- Use Run Diagnostics from the tray for a deeper self-check.\n"
+ "- If that is not enough, run aman doctor, then aman self-check.\n"
+ "- Next escalations are journalctl --user -u aman and aman run --verbose.\n\n"
+ "Safety tips:\n"
+ "- Keep fact guard enabled to prevent accidental name/number changes.\n"
+ "- Strict safety blocks output on fact violations."
+ )
+ )
+ help_text.set_xalign(0.0)
+ help_text.set_line_wrap(True)
+ box.pack_start(help_text, False, False, 0)
+
+ about_button = Gtk.Button(label="Open About Dialog")
+ about_button.connect(
+ "clicked",
+ lambda *_: present_about_dialog(window._dialog),
+ )
+ box.pack_start(about_button, False, False, 0)
+ return box
+
+
+def build_about_page(window, *, present_about_dialog) -> Gtk.Widget:
+ box = _page_box()
+
+ title = Gtk.Label()
+ title.set_markup("Aman")
+ title.set_xalign(0.0)
+ box.pack_start(title, False, False, 0)
+
+ subtitle = Gtk.Label(
+ label="Local amanuensis for X11 desktop dictation and rewriting."
+ )
+ subtitle.set_xalign(0.0)
+ subtitle.set_line_wrap(True)
+ box.pack_start(subtitle, False, False, 0)
+
+ about_button = Gtk.Button(label="About Aman")
+ about_button.connect(
+ "clicked",
+ lambda *_: present_about_dialog(window._dialog),
+ )
+ box.pack_start(about_button, False, False, 0)
+ return box
diff --git a/src/config_ui_runtime.py b/src/config_ui_runtime.py
new file mode 100644
index 0000000..e20c65e
--- /dev/null
+++ b/src/config_ui_runtime.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+from config import Config, DEFAULT_STT_PROVIDER
+
+
+RUNTIME_MODE_MANAGED = "aman_managed"
+RUNTIME_MODE_EXPERT = "expert_custom"
+
+
+def infer_runtime_mode(cfg: Config) -> str:
+ is_canonical = (
+ cfg.stt.provider.strip().lower() == DEFAULT_STT_PROVIDER
+ and not bool(cfg.models.allow_custom_models)
+ and not cfg.models.whisper_model_path.strip()
+ )
+ return RUNTIME_MODE_MANAGED if is_canonical else RUNTIME_MODE_EXPERT
+
+
+def apply_canonical_runtime_defaults(cfg: Config) -> None:
+ cfg.stt.provider = DEFAULT_STT_PROVIDER
+ cfg.models.allow_custom_models = False
+ cfg.models.whisper_model_path = ""
diff --git a/src/diagnostics.py b/src/diagnostics.py
index 162ee3e..2bd51a7 100644
--- a/src/diagnostics.py
+++ b/src/diagnostics.py
@@ -153,10 +153,6 @@ def run_self_check(config_path: str | None) -> DiagnosticReport:
return DiagnosticReport(checks=checks)
-def run_diagnostics(config_path: str | None) -> DiagnosticReport:
- return run_doctor(config_path)
-
-
def _resolved_config_path(config_path: str | Path | None) -> Path:
if config_path:
return Path(config_path)
diff --git a/src/recorder.py b/src/recorder.py
index 1dd26c6..bfd380f 100644
--- a/src/recorder.py
+++ b/src/recorder.py
@@ -22,16 +22,6 @@ def list_input_devices() -> list[dict]:
return devices
-def default_input_device() -> int | None:
- sd = _sounddevice()
- default = sd.default.device
- if isinstance(default, (tuple, list)) and default:
- return default[0]
- if isinstance(default, int):
- return default
- return None
-
-
def resolve_input_device(spec: str | int | None) -> int | None:
if spec is None:
return None
diff --git a/tests/test_aman_run.py b/tests/test_aman_run.py
index 1539ba5..fb3321d 100644
--- a/tests/test_aman_run.py
+++ b/tests/test_aman_run.py
@@ -205,6 +205,33 @@ class AmanRunTests(unittest.TestCase):
self.assertIn("startup.readiness: startup failed: warmup boom", rendered)
self.assertIn("next_step: run `aman self-check --config", rendered)
+ def test_run_command_logs_safe_config_payload(self):
+ with tempfile.TemporaryDirectory() as td:
+ path = Path(td) / "config.json"
+ path.write_text(json.dumps({"config_version": 1}) + "\n", encoding="utf-8")
+ custom_model_path = Path(td) / "custom-whisper.bin"
+ custom_model_path.write_text("model\n", encoding="utf-8")
+ args = aman_cli.parse_cli_args(["run", "--config", str(path)])
+ desktop = _FakeDesktop()
+ cfg = Config()
+ cfg.recording.input = "USB Mic"
+ cfg.models.allow_custom_models = True
+ cfg.models.whisper_model_path = str(custom_model_path)
+ cfg.vocabulary.terms = ["SensitiveTerm"]
+ with patch("aman_run.lock_single_instance", return_value=object()), patch(
+ "aman_run.get_desktop_adapter", return_value=desktop
+ ), patch("aman_run.load_runtime_config", return_value=cfg), patch(
+ "aman_run.Daemon", _FakeDaemon
+ ), self.assertLogs(level="INFO") as logs:
+ exit_code = aman_run.run_command(args)
+
+ self.assertEqual(exit_code, 0)
+ rendered = "\n".join(logs.output)
+ self.assertIn('"custom_whisper_path_configured": true', rendered)
+ self.assertIn('"recording_input": "USB Mic"', rendered)
+ self.assertNotIn(str(custom_model_path), rendered)
+ self.assertNotIn("SensitiveTerm", rendered)
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_config.py b/tests/test_config.py
index fd5d676..64f2d2c 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -9,7 +9,7 @@ SRC = ROOT / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
-from config import CURRENT_CONFIG_VERSION, load, redacted_dict
+from config import CURRENT_CONFIG_VERSION, Config, config_as_dict, config_log_payload, load
class ConfigTests(unittest.TestCase):
@@ -39,7 +39,7 @@ class ConfigTests(unittest.TestCase):
self.assertTrue(missing.exists())
written = json.loads(missing.read_text(encoding="utf-8"))
- self.assertEqual(written, redacted_dict(cfg))
+ self.assertEqual(written, config_as_dict(cfg))
def test_loads_nested_config(self):
payload = {
@@ -311,6 +311,18 @@ class ConfigTests(unittest.TestCase):
):
load(str(path))
+ def test_config_log_payload_omits_vocabulary_and_custom_model_path(self):
+ cfg = Config()
+ cfg.models.allow_custom_models = True
+ cfg.models.whisper_model_path = "/tmp/custom-whisper.bin"
+ cfg.vocabulary.terms = ["SensitiveTerm"]
+
+ payload = config_log_payload(cfg)
+
+ self.assertTrue(payload["custom_whisper_path_configured"])
+ self.assertNotIn("vocabulary", payload)
+ self.assertNotIn("whisper_model_path", payload)
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_config_ui_audio.py b/tests/test_config_ui_audio.py
new file mode 100644
index 0000000..7ccc502
--- /dev/null
+++ b/tests/test_config_ui_audio.py
@@ -0,0 +1,53 @@
+import sys
+import unittest
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[1]
+SRC = ROOT / "src"
+if str(SRC) not in sys.path:
+ sys.path.insert(0, str(SRC))
+
+from config_ui_audio import AudioSettingsService
+
+
+class AudioSettingsServiceTests(unittest.TestCase):
+ def test_microphone_test_reports_success_when_audio_is_captured(self):
+ service = AudioSettingsService()
+ with patch("config_ui_audio.start_recording", return_value=("stream", "record")), patch(
+ "config_ui_audio.stop_recording",
+ return_value=SimpleNamespace(size=4),
+ ), patch("config_ui_audio.time.sleep") as sleep_mock:
+ result = service.test_microphone("USB Mic", duration_sec=0.0)
+
+ self.assertTrue(result.ok)
+ self.assertEqual(result.message, "Microphone test successful.")
+ sleep_mock.assert_called_once_with(0.0)
+
+ def test_microphone_test_reports_empty_capture(self):
+ service = AudioSettingsService()
+ with patch("config_ui_audio.start_recording", return_value=("stream", "record")), patch(
+ "config_ui_audio.stop_recording",
+ return_value=SimpleNamespace(size=0),
+ ), patch("config_ui_audio.time.sleep"):
+ result = service.test_microphone("USB Mic", duration_sec=0.0)
+
+ self.assertFalse(result.ok)
+ self.assertEqual(result.message, "No audio captured. Try another device.")
+
+ def test_microphone_test_surfaces_recording_errors(self):
+ service = AudioSettingsService()
+ with patch(
+ "config_ui_audio.start_recording",
+ side_effect=RuntimeError("device missing"),
+ ), patch("config_ui_audio.time.sleep") as sleep_mock:
+ result = service.test_microphone("USB Mic", duration_sec=0.0)
+
+ self.assertFalse(result.ok)
+ self.assertEqual(result.message, "Microphone test failed: device missing")
+ sleep_mock.assert_not_called()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py
index cce1984..ceb8cbb 100644
--- a/tests/test_diagnostics.py
+++ b/tests/test_diagnostics.py
@@ -16,7 +16,6 @@ from diagnostics import (
DiagnosticCheck,
DiagnosticReport,
run_doctor,
- run_diagnostics,
run_self_check,
)
@@ -192,26 +191,6 @@ class DiagnosticsTests(unittest.TestCase):
self.assertIn("networked connection", results["model.cache"].next_step)
probe_model.assert_called_once()
- def test_run_diagnostics_alias_matches_doctor(self):
- cfg = Config()
- with tempfile.TemporaryDirectory() as td:
- config_path = Path(td) / "config.json"
- config_path.write_text('{"config_version":1}\n', encoding="utf-8")
- with patch.dict("os.environ", {"DISPLAY": ":0"}, clear=False), patch(
- "diagnostics.load_existing", return_value=cfg
- ), patch("diagnostics.list_input_devices", return_value=[{"index": 1, "name": "Mic"}]), patch(
- "diagnostics.resolve_input_device", return_value=1
- ), patch(
- "diagnostics.get_desktop_adapter", return_value=_FakeDesktop()
- ), patch(
- "diagnostics._run_systemctl_user",
- return_value=_Result(returncode=0, stdout="running\n"),
- ):
- report = run_diagnostics(str(config_path))
-
- self.assertEqual(report.status, "ok")
- self.assertEqual(len(report.checks), 7)
-
def test_report_json_schema_includes_status_and_next_step(self):
report = DiagnosticReport(
checks=[
diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py
new file mode 100644
index 0000000..ae474de
--- /dev/null
+++ b/tests/test_packaging_metadata.py
@@ -0,0 +1,55 @@
+import ast
+import re
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def _parse_toml_string_array(text: str, key: str) -> list[str]:
+ match = re.search(rf"(?ms)^\s*{re.escape(key)}\s*=\s*\[(.*?)^\s*\]", text)
+ if not match:
+ raise AssertionError(f"{key} array not found")
+ return ast.literal_eval("[" + match.group(1) + "]")
+
+
+class PackagingMetadataTests(unittest.TestCase):
+ def test_py_modules_matches_top_level_src_modules(self):
+ text = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
+ py_modules = sorted(_parse_toml_string_array(text, "py-modules"))
+ discovered = sorted(path.stem for path in (ROOT / "src").glob("*.py"))
+ self.assertEqual(py_modules, discovered)
+
+ def test_project_dependencies_exclude_native_gui_bindings(self):
+ text = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
+ dependencies = _parse_toml_string_array(text, "dependencies")
+ self.assertNotIn("PyGObject", dependencies)
+ self.assertNotIn("python-xlib", dependencies)
+
+ def test_runtime_requirements_follow_project_dependency_contract(self):
+ with tempfile.TemporaryDirectory() as td:
+ output_path = Path(td) / "requirements.txt"
+ script = (
+ f'source "{ROOT / "scripts" / "package_common.sh"}"\n'
+ f'write_runtime_requirements "{output_path}"\n'
+ )
+ subprocess.run(
+ ["bash", "-lc", script],
+ cwd=ROOT,
+ text=True,
+ capture_output=True,
+ check=True,
+ )
+
+ requirements = output_path.read_text(encoding="utf-8").splitlines()
+
+ self.assertIn("faster-whisper", requirements)
+ self.assertIn("llama-cpp-python", requirements)
+ self.assertNotIn("PyGObject", requirements)
+ self.assertNotIn("python-xlib", requirements)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_portable_bundle.py b/tests/test_portable_bundle.py
index e366400..56c0c24 100644
--- a/tests/test_portable_bundle.py
+++ b/tests/test_portable_bundle.py
@@ -208,15 +208,22 @@ class PortableBundleTests(unittest.TestCase):
self.assertTrue(tarball.exists())
self.assertTrue(checksum.exists())
self.assertTrue(wheel_path.exists())
+ prefix = f"aman-x11-linux-{version}"
with zipfile.ZipFile(wheel_path) as archive:
wheel_names = set(archive.namelist())
metadata_path = f"aman-{version}.dist-info/METADATA"
metadata = archive.read(metadata_path).decode("utf-8")
self.assertNotIn("desktop_wayland.py", wheel_names)
self.assertNotIn("Requires-Dist: pillow", metadata)
+ self.assertNotIn("Requires-Dist: PyGObject", metadata)
+ self.assertNotIn("Requires-Dist: python-xlib", metadata)
with tarfile.open(tarball, "r:gz") as archive:
names = set(archive.getnames())
- prefix = f"aman-x11-linux-{version}"
+ requirements_path = f"{prefix}/requirements/cp311.txt"
+ requirements_member = archive.extractfile(requirements_path)
+ if requirements_member is None:
+ self.fail(f"missing {requirements_path} in portable archive")
+ requirements_text = requirements_member.read().decode("utf-8")
self.assertIn(f"{prefix}/install.sh", names)
self.assertIn(f"{prefix}/uninstall.sh", names)
self.assertIn(f"{prefix}/portable_installer.py", names)
@@ -229,6 +236,8 @@ class PortableBundleTests(unittest.TestCase):
self.assertIn(f"{prefix}/requirements/cp311.txt", names)
self.assertIn(f"{prefix}/requirements/cp312.txt", names)
self.assertIn(f"{prefix}/systemd/aman.service.in", names)
+ self.assertNotIn("pygobject", requirements_text.lower())
+ self.assertNotIn("python-xlib", requirements_text.lower())
def test_fresh_install_creates_managed_paths_and_starts_service(self):
with tempfile.TemporaryDirectory() as tmp:
diff --git a/uv.lock b/uv.lock
index 63e57ec..6f18532 100644
--- a/uv.lock
+++ b/uv.lock
@@ -15,8 +15,6 @@ dependencies = [
{ name = "llama-cpp-python" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
- { name = "pygobject" },
- { name = "python-xlib" },
{ name = "sounddevice" },
]
@@ -25,8 +23,6 @@ requires-dist = [
{ name = "faster-whisper" },
{ name = "llama-cpp-python" },
{ name = "numpy" },
- { name = "pygobject" },
- { name = "python-xlib" },
{ name = "sounddevice" },
]
@@ -740,31 +736,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" },
]
-[[package]]
-name = "pycairo"
-version = "1.29.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/22/d9/1728840a22a4ef8a8f479b9156aa2943cd98c3907accd3849fb0d5f82bfd/pycairo-1.29.0.tar.gz", hash = "sha256:f3f7fde97325cae80224c09f12564ef58d0d0f655da0e3b040f5807bd5bd3142", size = 665871, upload-time = "2025-11-11T19:13:01.584Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/23/e2/c08847af2a103517f7785830706b6d1d55274494d76ab605eb744404c22f/pycairo-1.29.0-cp310-cp310-win32.whl", hash = "sha256:96c67e6caba72afd285c2372806a0175b1aa2f4537aa88fb4d9802d726effcd1", size = 751339, upload-time = "2025-11-11T19:11:21.266Z" },
- { url = "https://files.pythonhosted.org/packages/eb/36/2a934c6fd4f32d2011c4d9cc59a32e34e06a97dd9f4b138614078d39340b/pycairo-1.29.0-cp310-cp310-win_amd64.whl", hash = "sha256:65bddd944aee9f7d7d72821b1c87e97593856617c2820a78d589d66aa8afbd08", size = 845074, upload-time = "2025-11-11T19:11:27.111Z" },
- { url = "https://files.pythonhosted.org/packages/1b/f0/ee0a887d8c8a6833940263b7234aaa63d8d95a27d6130a9a053867ff057c/pycairo-1.29.0-cp310-cp310-win_arm64.whl", hash = "sha256:15b36aea699e2ff215cb6a21501223246032e572a3a10858366acdd69c81a1c8", size = 694758, upload-time = "2025-11-11T19:11:32.635Z" },
- { url = "https://files.pythonhosted.org/packages/31/92/1b904087e831806a449502786d47d3a468e5edb8f65755f6bd88e8038e53/pycairo-1.29.0-cp311-cp311-win32.whl", hash = "sha256:12757ebfb304b645861283c20585c9204c3430671fad925419cba04844d6dfed", size = 751342, upload-time = "2025-11-11T19:11:37.386Z" },
- { url = "https://files.pythonhosted.org/packages/db/09/a0ab6a246a7ede89e817d749a941df34f27a74bedf15551da51e86ae105e/pycairo-1.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:3391532db03f9601c1cee9ebfa15b7d1db183c6020f3e75c1348cee16825934f", size = 845036, upload-time = "2025-11-11T19:11:43.408Z" },
- { url = "https://files.pythonhosted.org/packages/3c/b2/bf455454bac50baef553e7356d36b9d16e482403bf132cfb12960d2dc2e7/pycairo-1.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:b69be8bb65c46b680771dc6a1a422b1cdd0cffb17be548f223e8cbbb6205567c", size = 694644, upload-time = "2025-11-11T19:11:48.599Z" },
- { url = "https://files.pythonhosted.org/packages/f6/28/6363087b9e60af031398a6ee5c248639eefc6cc742884fa2789411b1f73b/pycairo-1.29.0-cp312-cp312-win32.whl", hash = "sha256:91bcd7b5835764c616a615d9948a9afea29237b34d2ed013526807c3d79bb1d0", size = 751486, upload-time = "2025-11-11T19:11:54.451Z" },
- { url = "https://files.pythonhosted.org/packages/3a/d2/d146f1dd4ef81007686ac52231dd8f15ad54cf0aa432adaefc825475f286/pycairo-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f01c3b5e49ef9411fff6bc7db1e765f542dc1c9cfed4542958a5afa3a8b8e76", size = 845383, upload-time = "2025-11-11T19:12:01.551Z" },
- { url = "https://files.pythonhosted.org/packages/01/16/6e6f33bb79ec4a527c9e633915c16dc55a60be26b31118dbd0d5859e8c51/pycairo-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:eafe3d2076f3533535ad4a361fa0754e0ee66b90e548a3a0f558fed00b1248f2", size = 694518, upload-time = "2025-11-11T19:12:06.561Z" },
- { url = "https://files.pythonhosted.org/packages/f0/21/3f477dc318dd4e84a5ae6301e67284199d7e5a2384f3063714041086b65d/pycairo-1.29.0-cp313-cp313-win32.whl", hash = "sha256:3eb382a4141591807073274522f7aecab9e8fa2f14feafd11ac03a13a58141d7", size = 750949, upload-time = "2025-11-11T19:12:12.198Z" },
- { url = "https://files.pythonhosted.org/packages/43/34/7d27a333c558d6ac16dbc12a35061d389735e99e494ee4effa4ec6d99bed/pycairo-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:91114e4b3fbf4287c2b0788f83e1f566ce031bda49cf1c3c3c19c3e986e95c38", size = 844149, upload-time = "2025-11-11T19:12:19.171Z" },
- { url = "https://files.pythonhosted.org/packages/15/43/e782131e23df69e5c8e631a016ed84f94bbc4981bf6411079f57af730a23/pycairo-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:09b7f69a5ff6881e151354ea092137b97b0b1f0b2ab4eb81c92a02cc4a08e335", size = 693595, upload-time = "2025-11-11T19:12:23.445Z" },
- { url = "https://files.pythonhosted.org/packages/2d/fa/87eaeeb9d53344c769839d7b2854db7ff2cd596211e00dd1b702eeb1838f/pycairo-1.29.0-cp314-cp314-win32.whl", hash = "sha256:69e2a7968a3fbb839736257bae153f547bca787113cc8d21e9e08ca4526e0b6b", size = 767198, upload-time = "2025-11-11T19:12:42.336Z" },
- { url = "https://files.pythonhosted.org/packages/3c/90/3564d0f64d0a00926ab863dc3c4a129b1065133128e96900772e1c4421f8/pycairo-1.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:e91243437a21cc4c67c401eff4433eadc45745275fa3ade1a0d877e50ffb90da", size = 871579, upload-time = "2025-11-11T19:12:48.982Z" },
- { url = "https://files.pythonhosted.org/packages/5e/91/93632b6ba12ad69c61991e3208bde88486fdfc152be8cfdd13444e9bc650/pycairo-1.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:b72200ea0e5f73ae4c788cd2028a750062221385eb0e6d8f1ecc714d0b4fdf82", size = 719537, upload-time = "2025-11-11T19:12:55.016Z" },
- { url = "https://files.pythonhosted.org/packages/93/23/37053c039f8d3b9b5017af9bc64d27b680c48a898d48b72e6d6583cf0155/pycairo-1.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5e45fce6185f553e79e4ef1722b8e98e6cde9900dbc48cb2637a9ccba86f627a", size = 874015, upload-time = "2025-11-11T19:12:28.47Z" },
- { url = "https://files.pythonhosted.org/packages/d7/54/123f6239685f5f3f2edc123f1e38d2eefacebee18cf3c532d2f4bd51d0ef/pycairo-1.29.0-cp314-cp314t-win_arm64.whl", hash = "sha256:caba0837a4b40d47c8dfb0f24cccc12c7831e3dd450837f2a356c75f21ce5a15", size = 721404, upload-time = "2025-11-11T19:12:36.919Z" },
-]
-
[[package]]
name = "pycparser"
version = "3.0"
@@ -774,27 +745,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
-[[package]]
-name = "pygobject"
-version = "3.54.5"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pycairo" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/d3/a5/68f883df1d8442e3b267cb92105a4b2f0de819bd64ac9981c2d680d3f49f/pygobject-3.54.5.tar.gz", hash = "sha256:b6656f6348f5245606cf15ea48c384c7f05156c75ead206c1b246c80a22fb585", size = 1274658, upload-time = "2025-10-18T13:45:03.121Z" }
-
-[[package]]
-name = "python-xlib"
-version = "0.33"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "six" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" },
-]
-
[[package]]
name = "pyyaml"
version = "6.0.3"
@@ -877,15 +827,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
-[[package]]
-name = "six"
-version = "1.17.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
-]
-
[[package]]
name = "sounddevice"
version = "0.5.5"