feat(stt): lancement & cycle de vie du client — auto-start, app de bureau, auto-update (#18)

* feat(stt): auto-start via `stt --install-service` (phase 6)

Ajoute `stt --install-service` / `--uninstall-service` : génère et active un
service systemd --user (lié à graphical-session.target) qui lance le HUD + voix
à l'ouverture de session. Le kiosk existant (ui.kiosk) fournit l'« écran Jarvis ».

- cli : _service_unit (génération du unit, fonction pure testable),
  _cmd_install_service (écrit le unit + import-environment DISPLAY/WAYLAND +
  daemon-reload/enable/start), _cmd_uninstall_service.
- Docs : admin/ia/stt.md (phase 6 → , section Auto-start + caveat DISPLAY),
  stt/README.
- Bump client 0.2.1 → 0.3.0.

Phase 6 de la roadmap STT. Reste : test sur poste, outils Hermes (7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qjq5jHiqNepnobJpHYpCa

* feat(stt): app de bureau (fenêtre type Discord) + auto-update

Rend STT lançable comme une application de bureau et ajoute la mise à jour intégrée.

- Mode fenêtre « app » : _open_browser ouvre le HUD en fenêtre chromeless
  (--app=, type Discord) en plus du kiosk plein écran ; support Brave ajouté.
  Sélection via --window {app,kiosk,none} ou [ui].window_mode.
- stt --install-desktop / --uninstall-desktop : entrée .desktop + icône
  (stt/hud/icon.svg, aussi favicon du HUD) → STT dans le menu/dock, lance --window app.
- stt --update : pipx reinstall depuis la source git (via clé SSH), affiche
  « ancienne → nouvelle » ; stt --version.
- Docs (admin/ia/stt.md, stt/README) + config (window_mode) + bump 0.3.0 → 0.4.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qjq5jHiqNepnobJpHYpCa

* fix(stt): détection navigateur cross-distro (AlmaLinux/Fedora + Debian/Ubuntu)

_open_browser tente les binaires natifs (rpm + deb : brave-browser / chromium /
chromium-browser / google-chrome…) puis Flatpak (com.brave.Browser,
org.chromium.Chromium, com.google.Chrome…), sinon repli sur le navigateur par
défaut. systemd --user, lanceurs .desktop et pipx sont déjà identiques sur les
deux familles.

Doc : note de compatibilité distros. Bump 0.4.0 → 0.4.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qjq5jHiqNepnobJpHYpCa

* docs(stt): journal 2026-06-18 + PROGRESS + ligne STT du CLAUDE.md

- progress/2026-06-18.md : HUD avancé, auto-start, app de bureau, auto-update, cross-distro
- PROGRESS.md : entrée du jour
- CLAUDE.md : ligne STT enrichie (HUD, auto-start, app de bureau, auto-update)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qjq5jHiqNepnobJpHYpCa

* feat(stt): fenêtre app autonome (profil + WM class dédiés, hors session Brave)

Le mode app s'attachait à la session Brave de l'utilisateur (même profil/icône) →
« ça sent le Brave ». On lance désormais une instance séparée : --class=STT-Funk
+ --user-data-dir dédié (~/.local/share/stt/app-profile), et le .desktop déclare
StartupWMClass=STT-Funk → fenêtre autonome avec sa propre icône de barre des tâches.
(Flatpak : --class seul, sandbox.) Une vraie fenêtre native (pywebview) reste une
option ultérieure. Bump 0.4.1 → 0.4.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qjq5jHiqNepnobJpHYpCa

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-19 00:05:02 +02:00 committed by GitHub
parent 2658f92c2f
commit 5f1e34dad9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 444 additions and 17 deletions

View file

@ -30,6 +30,10 @@ stt --setup # génère ~/.config/stt/stt.toml (dont [server] url)
stt --text # chat texte simple (sans micro ni HUD) — idéal pour tester
stt # voix + HUD ; dis "Ok Hermès …"
stt --model claude # choisir le modèle : hermes (défaut) | claude | qwen | opus
stt --install-service # auto-start au login (systemd --user, kiosk « écran Jarvis »)
stt --install-desktop # ajoute STT au menu des applis → fenêtre dédiée (type Discord)
stt --window app # ouvre le HUD en fenêtre app (sinon : kiosk plein écran)
stt --update # met à jour depuis git (plus de désinstall/réinstall manuel)
```
En mode texte : commandes `/model <nom>`, `/models`, `/quit`.
Prérequis voix : micro, Piper + voix dans `~/.local/share/piper/`, `aplay` (alsa-utils).

View file

@ -23,7 +23,8 @@ chat_timeout_sec = 60
[ui]
theme = "arc-reactor"
avatar = "default"
kiosk = true # ouvre le HUD en plein écran (kiosk) au lancement
kiosk = true # (compat) plein écran si window_mode absent
window_mode = "kiosk" # "app" (fenêtre, type Discord) | "kiosk" (plein écran) | "none"
ws_host = "127.0.0.1"
ws_port = 9300 # websocket états ↔ HUD (doit matcher hud/index.html)
http_port = 9301 # HTTP statique qui sert le HUD

View file

@ -1,6 +1,6 @@
[project]
name = "stt"
version = "0.2.1"
version = "0.4.2"
description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)"
requires-python = ">=3.11"
readme = "README.md"

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import argparse
import asyncio
import os
import sys
@ -22,6 +23,193 @@ def _cmd_setup() -> int:
return 0
SERVICE_NAME = "stt.service"
def _service_unit(stt_bin: str) -> str:
"""Contenu du unit systemd --user : lance `stt` (HUD + voix) avec la session graphique."""
return f"""[Unit]
Description=STT Assistant vocal Jarvis (HUD + voix)
After=graphical-session.target
PartOf=graphical-session.target
[Service]
Type=simple
ExecStart={stt_bin}
Restart=on-failure
RestartSec=5
Environment=PYTHONUNBUFFERED=1
StandardOutput=journal
StandardError=journal
SyslogIdentifier=stt
[Install]
WantedBy=graphical-session.target
"""
def _cmd_install_service() -> int:
"""Installe et démarre le service systemd --user (auto-start au login + kiosk)."""
import shutil
import subprocess
from pathlib import Path
stt_bin = shutil.which("stt") or os.path.realpath(sys.argv[0])
unit_dir = Path.home() / ".config" / "systemd" / "user"
unit_dir.mkdir(parents=True, exist_ok=True)
unit_path = unit_dir / SERVICE_NAME
unit_path.write_text(_service_unit(stt_bin))
print(f"✓ Unit écrit : {unit_path}")
print(f" ExecStart = {stt_bin}")
def sc(*args):
subprocess.run(["systemctl", "--user", *args], check=False)
# Rendre DISPLAY/WAYLAND dispo au gestionnaire systemd --user (pour le kiosk)
subprocess.run(
["systemctl", "--user", "import-environment",
"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY"],
check=False,
)
sc("daemon-reload")
sc("enable", SERVICE_NAME)
sc("start", SERVICE_NAME)
print("\n✓ Service activé et démarré (systemd --user).")
print("\nCommandes utiles :")
print(" systemctl --user status stt")
print(" journalctl --user -u stt -f")
print(" systemctl --user restart stt")
print(" stt --uninstall-service")
print("\nDémarrer dès le boot (kiosk dédié, sans login manuel) :")
print(' sudo loginctl enable-linger "$USER" # + auto-login du bureau')
print("\nLe HUD s'ouvre en kiosk si [ui].kiosk = true (défaut).")
print("Si l'écran ne s'ouvre pas : voir le caveat DISPLAY dans admin/ia/stt.md.")
return 0
def _cmd_uninstall_service() -> int:
"""Arrête, désactive et supprime le service systemd --user."""
import subprocess
from pathlib import Path
def sc(*args):
subprocess.run(["systemctl", "--user", *args], check=False)
sc("stop", SERVICE_NAME)
sc("disable", SERVICE_NAME)
unit_path = Path.home() / ".config" / "systemd" / "user" / SERVICE_NAME
if unit_path.exists():
unit_path.unlink()
print(f"✓ Unit supprimé : {unit_path}")
else:
print("(unit déjà absent)")
sc("daemon-reload")
print("✓ Service STT désinstallé.")
return 0
def _desktop_entry(stt_bin: str, icon_path: str) -> str:
"""Contenu du lanceur .desktop : STT comme application de bureau (fenêtre app)."""
return f"""[Desktop Entry]
Type=Application
Name=STT Assistant vocal
GenericName=Assistant vocal
Comment=Assistant vocal Jarvis du homelab Funk
Exec={stt_bin} --window app
Icon={icon_path}
Terminal=false
Categories=Utility;AudioVideo;
Keywords=jarvis;assistant;voix;hermes;
StartupNotify=true
StartupWMClass=STT-Funk
"""
def _cmd_install_desktop() -> int:
"""Crée l'entrée de menu (.desktop) + icône → STT lançable comme une appli (type Discord)."""
import shutil
import subprocess
from pathlib import Path
stt_bin = shutil.which("stt") or os.path.realpath(sys.argv[0])
icon_path = Path(__file__).resolve().parent / "hud" / "icon.svg"
apps_dir = Path.home() / ".local" / "share" / "applications"
apps_dir.mkdir(parents=True, exist_ok=True)
entry = apps_dir / "stt.desktop"
entry.write_text(_desktop_entry(stt_bin, str(icon_path)))
entry.chmod(0o755)
subprocess.run(["update-desktop-database", str(apps_dir)], check=False)
print(f"✓ Lanceur : {entry}")
print(f" Icône : {icon_path}")
print(f" Exec : {stt_bin} --window app")
print("\nSTT apparaît dans le menu des applications (épinglable au dock).")
print("Il s'ouvre en fenêtre « app » (sans onglets), comme Discord.")
print("Retirer : stt --uninstall-desktop")
return 0
def _cmd_uninstall_desktop() -> int:
"""Supprime le lanceur de bureau."""
import subprocess
from pathlib import Path
entry = Path.home() / ".local" / "share" / "applications" / "stt.desktop"
if entry.exists():
entry.unlink()
print(f"✓ Lanceur supprimé : {entry}")
else:
print("(lanceur déjà absent)")
subprocess.run(["update-desktop-database", str(entry.parent)], check=False)
return 0
def _installed_version() -> str | None:
from importlib.metadata import PackageNotFoundError, version
try:
return version("stt")
except PackageNotFoundError:
return None
def _cmd_version() -> int:
print(_installed_version() or "version inconnue (pas installé via pip/pipx)")
return 0
def _cmd_update() -> int:
"""Met à jour STT depuis sa source git (pipx reinstall) — sans désinstall/réinstall manuel."""
import re
import shutil
import subprocess
pipx = shutil.which("pipx")
if not pipx:
print("pipx introuvable — mise à jour automatique impossible.")
print("Réinstalle manuellement depuis la source git.")
return 1
before = _installed_version()
if before:
print(f"Version installée : {before}")
print("Récupération de la dernière version depuis git (pipx reinstall)…\n")
res = subprocess.run([pipx, "reinstall", "stt"], capture_output=True, text=True)
out = ((res.stdout or "") + (res.stderr or "")).strip()
if out:
print(out)
m = re.search(r"installed package stt ([0-9][^\s,]*)", out)
after = m.group(1) if m else None
print()
if after and before and after != before:
print(f"✓ Mis à jour : {before}{after}")
elif after:
print(f"✓ Déjà à jour ({after}).")
else:
print("✓ Réinstallation terminée — vérifie avec : stt --version")
return res.returncode
def _make_client(args):
from stt.api import ServerClient
from stt.config import load_config, resolve_model
@ -85,6 +273,11 @@ def _cmd_voice(args) -> int:
from stt.voice import VoiceEngine
cfg, client = _make_client(args)
# mode d'ouverture du HUD : --window > config window_mode > kiosk (compat) > none
ui = cfg["ui"]
ui["window_mode"] = (
args.window or ui.get("window_mode") or ("kiosk" if ui.get("kiosk") else "none")
)
print(f"STT-server : {client.url} — modèle '{client.model}'")
if not client.healthy():
print(" ⚠️ serveur injoignable — vérifie [server] url et le réseau")
@ -116,10 +309,35 @@ def main(argv: list[str] | None = None) -> int:
p.add_argument("--model", help="Modèle : hermes, claude, qwen, opus (ou alias LiteLLM)")
p.add_argument("--server", help="Force l'URL du STT-server (sinon config)")
p.add_argument("--no-tts", action="store_true", help="Réponses texte seulement (voix)")
p.add_argument("--window", choices=["app", "kiosk", "none"],
help="Ouverture du HUD : app (fenêtre, type Discord) | kiosk (plein écran) | none")
p.add_argument("--install-service", action="store_true",
help="Installe le service systemd --user (auto-start au login + kiosk)")
p.add_argument("--uninstall-service", action="store_true",
help="Désinstalle le service systemd --user")
p.add_argument("--install-desktop", action="store_true",
help="Crée le lanceur de bureau (.desktop + icône) — appli type Discord")
p.add_argument("--uninstall-desktop", action="store_true",
help="Supprime le lanceur de bureau")
p.add_argument("--update", action="store_true",
help="Met à jour STT depuis git (pipx reinstall)")
p.add_argument("--version", action="store_true", help="Affiche la version installée")
args = p.parse_args(argv)
if args.version:
return _cmd_version()
if args.setup:
return _cmd_setup()
if args.update:
return _cmd_update()
if args.install_service:
return _cmd_install_service()
if args.uninstall_service:
return _cmd_uninstall_service()
if args.install_desktop:
return _cmd_install_desktop()
if args.uninstall_desktop:
return _cmd_uninstall_desktop()
if args.text:
return _cmd_text(args)
return _cmd_voice(args)

View file

@ -52,6 +52,9 @@ DEFAULT_CONFIG: dict[str, Any] = {
"theme": "arc-reactor",
"avatar": "default",
"kiosk": True,
# window_mode : "app" (fenêtre type Discord) | "kiosk" (plein écran) | "none".
# Si absent, repli sur kiosk ci-dessus (compat).
"window_mode": "kiosk",
"ws_host": "127.0.0.1",
"ws_port": 9300,
"http_port": 9301,

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="STT — Assistant vocal">
<defs>
<radialGradient id="bg" cx="50%" cy="38%" r="75%">
<stop offset="0%" stop-color="#10202f"/>
<stop offset="100%" stop-color="#070b12"/>
</radialGradient>
</defs>
<rect width="128" height="128" rx="30" fill="url(#bg)"/>
<circle cx="64" cy="64" r="38" fill="none" stroke="#ff5d6c" stroke-width="6"/>
<circle cx="64" cy="64" r="24" fill="none" stroke="#f0c9cf" stroke-width="3" opacity=".65"/>
<circle cx="64" cy="64" r="6" fill="#ff5d6c"/>
</svg>

After

Width:  |  Height:  |  Size: 658 B

View file

@ -4,6 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>STT — Assistant vocal</title>
<link rel="icon" href="icon.svg" />
<style>
/* ============================================================
THÈME — variables CSS faciles à régler

View file

@ -97,10 +97,11 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None:
async with websockets.serve(handler, ui["ws_host"], ui["ws_port"]):
url = f"http://{ui['ws_host']}:{ui['http_port']}/"
print(f" HUD : {url}")
if ui.get("kiosk"):
mode = ui.get("window_mode") or ("kiosk" if ui.get("kiosk") else "none")
if mode != "none":
# ?v=<timestamp> : URL unique à chaque lancement → le navigateur ne peut
# pas servir une version cachée du HUD (« ancien HUD persistant »).
_open_browser(f"{url}?v={int(time.time())}", kiosk=True)
_open_browser(f"{url}?v={int(time.time())}", mode)
await asyncio.Future() # tourne indéfiniment
@ -112,16 +113,54 @@ def _run_engine(engine, emit) -> None:
emit({"type": "error", "text": f"moteur vocal : {e}"})
def _open_browser(url: str, kiosk: bool) -> None:
def _open_browser(url: str, mode: str) -> None:
"""Ouvre le HUD comme une fenêtre d'application dédiée (pas la session Brave courante).
On force un profil + une WM class propres (`--class=STT-Funk`, `--user-data-dir`) la
fenêtre est une instance séparée, avec sa propre icône / entrée de barre des tâches
(associée au lanceur .desktop via `StartupWMClass`), au lieu de se fondre dans Brave.
mode : 'app' (fenêtre chromeless type Discord) | 'kiosk' (plein écran).
Cross-distro : binaire natif (rpm/deb) Flatpak repli navigateur par défaut.
"""
import shutil
import subprocess
import webbrowser
from pathlib import Path
for browser in ("chromium", "google-chrome", "chromium-browser"):
profile = Path.home() / ".local" / "share" / "stt" / "app-profile"
profile.mkdir(parents=True, exist_ok=True)
win = ["--kiosk", url] if mode == "kiosk" else [f"--app={url}"]
# Instance autonome (profil + classe dédiés) — pour les navigateurs natifs.
native = [
"--class=STT-Funk", f"--user-data-dir={profile}",
"--no-first-run", "--no-default-browser-check", *win,
]
# Flatpak est déjà sandboxé : pas de --user-data-dir (chemin hôte inaccessible).
flatpak = ["--class=STT-Funk", *win]
# 1) Binaire natif (rpm/deb)
for browser in (
"brave-browser", "brave",
"chromium", "chromium-browser",
"google-chrome", "google-chrome-stable",
"microsoft-edge", "vivaldi",
):
if shutil.which(browser):
flag = "--kiosk" if kiosk else "--new-window"
subprocess.Popen([browser, flag, url])
subprocess.Popen([browser, *native])
return
# 2) Flatpak (universel — souvent la voie sur Fedora/AlmaLinux)
if shutil.which("flatpak"):
for appid in ("com.brave.Browser", "org.chromium.Chromium",
"com.google.Chrome", "com.microsoft.Edge"):
installed = subprocess.run(
["flatpak", "info", appid], capture_output=True
).returncode == 0
if installed:
subprocess.Popen(["flatpak", "run", appid, *flatpak])
return
# 3) Repli : onglet dans le navigateur par défaut
webbrowser.open(url)