feat(settings): run local VocaGateway from Advanced (podman-first) (#774)
* feat(settings): run local VocaGateway from Speech Engine (podman-first) Add an optional Local VocaGateway panel next to Remote Server so Linux users can start the pinned v0.1.0 compose project with podman (docker fallback), watch Live/Pairable/Ready status, pair a phone without loopback QR URLs, and preset remote_api to the gateway transcriptions endpoint. Stop preserves volumes; Flatpak fails closed; tray offers Stop only when this session manages the gateway. Default on-device engine stays whisper.cpp. * fix(gateway-embed): drop nonexistent compose cpu profile VocaGateway v0.1.0 exposes default service gateway plus native/cuda/vulkan profiles, not cpu. Start and stop the default service without --profile, keep the latest image pin rejection, and prefer save_config for the remote_api preset. * fix(gateway-embed): keep compose --profile cpu per embed contract v0.1.0 has no cpu profile entry, but Compose still starts the unprofiled gateway service when --profile cpu is set, and keeps cuda/vulkan/native off. That matches the frozen desktop embed contract and stays forward-compatible. * fix(gateway-embed): harden embed path for settings and compose Keep podman/docker probing off the GTK thread, reject link-local and loopback public URLs for phone QR, sanitize compose .env image overrides, and cap bootstrap token reads so a hostile file cannot blow memory. * fix(gateway-embed): keep LAN toggle and phone QR in sync When Allow LAN flips while compose is already managed, rewrite publish host and PUBLIC_URL and recreate the gateway before Pairable comes back. Also reject default docker/podman bridge addresses for phone QR. * test(gateway-embed): pin bridge and LAN-gate pairing cases Cover 172.17.0.2 / link-local not becoming Pairable, and the mid-run LAN toggle path that must wait for compose republish before advertising a phone URL. * style(gateway-embed): black format embed modules * fix(gateway-embed): reject short-form loopback and localhost.* for QR Align Pairable/QR host checks with vocagateway desktop-embed policy so 127.1 / localhost.* never become phone-reachable display URLs. * fix(gateway-embed): detach Settings status listener on destroy Gateway poll callbacks were left registered after Settings closed, so idle_add could touch destroyed GTK widgets while compose stayed up. * fix(gateway-embed): pass host_env to bridge ip probe _bridge_iface_ips shells out to host ip; without host_env the AST gate fails and AppImage bundles can hand the wrong GLib to ip. * fix(packaging): ship gateway_embed in the AppImage The explicit setuptools packages list omitted the new package, so the bundle booted from source trees but died with ModuleNotFoundError on every distro boot test. * style(packaging): black the gateway_embed package guard * fix(settings): pack local VocaGateway into the Advanced island The rebase onto main auto-merged textually onto the simple Speech Model page. Keep the gateway card on advanced_box, next to Remote Server. * fix(gateway-embed): treat missing Compose as unavailable When podman or docker is present but Compose is not, stop advertising Run. Return empty compose_args, show an install-Compose hint, and cover it with a unit test. Also add module loggers on the new gateway_embed modules and annotate the gateway Settings/tray callbacks Greptile flagged. just lock found no lockfile drift. * fix(gateway-embed): keep Allow LAN bind matched to the switch Snapshot the LAN flag for start/republish, record what compose actually got, then republish again if the switch moved mid-flight so 0.0.0.0 never lingers while Allow LAN reads off. * fix(gateway-embed): detect leftover compose after Quit Quit does not stop compose, so a LAN-published gateway can keep running. Settings and the tray treated that as Stopped because managed_by_us is only process memory. Probe compose after runtime detection, show Stop when it is actually up, and let Stop work even if this session did not start it. * fix(gateway-embed): restore LAN switch from leftover compose Quit leaves compose running, so the next launch now reads VOCAGATEWAY_PUBLISH_HOST from the embed .env, adopts the project as managed, and keeps Allow LAN honest so republish can close 0.0.0.0. * fix(gateway-embed): restore .env if LAN republish fails compose recreate can fail after write_env_file already pointed VOCAGATEWAY_PUBLISH_HOST at loopback. Restore the previous .env so orphan adopt still sees a live 0.0.0.0 bind, and snap Allow LAN back to the running publish mode. * style(gateway-embed): type helper args and black MagicMock * refactor(types): move Readable protocol to common_types AGENTS.md keeps Protocol interfaces in common_types. Pairing now imports the shared Readable surface instead of a local _Readable helper. * fix(gateway-embed): satisfy mypy on rebased embed code * fix(audio): keep duck restore point when a set reports failure
This commit is contained in:
@@ -39,7 +39,7 @@ No Voca account is required. Models download once. After that, speech-to-text st
|
||||
|
||||
- **On-device after model download**: Local engines; speech-to-text stays on your machine
|
||||
- **X11 and Wayland**: Text injection via xdotool, IBus, wtype, ydotool, or clipboard fallback
|
||||
- **Several engines**: whisper.cpp (default), OpenAI Whisper, Faster Whisper, VOSK, Parakeet, plus optional remote HTTP API
|
||||
- **Several engines**: whisper.cpp (default), OpenAI Whisper, Faster Whisper, VOSK, Parakeet, plus optional remote HTTP API or local VocaGateway from Settings (not on-device)
|
||||
- **GPU acceleration**: Vulkan for AMD, Intel, and NVIDIA with whisper.cpp
|
||||
- **Toggle or push-to-talk**: New installs default to hold Right Alt; existing configs keep their shortcut
|
||||
- **System tray + settings**: Searchable sidebar, Speech Model simple setup with Advanced as an island, status icons, audio feedback
|
||||
@@ -309,6 +309,7 @@ Neural VAD (Silero) is used when `onnxruntime` is available; install via `pip in
|
||||
| [Support](SUPPORT.md) | Where to get help |
|
||||
| [Distribution compatibility](docs/DISTRO_COMPATIBILITY.md) | Distro matrix and session notes |
|
||||
| [Remote HTTP API](docs/HTTP_REMOTE.md) | Offload transcription to a server |
|
||||
| [Local VocaGateway (optional)](docs/GATEWAY_EMBED.md) | Start a local gateway from Settings; not on-device |
|
||||
| [Contributing](CONTRIBUTING.md) | Dev setup, style, PR process |
|
||||
| [Security](SECURITY.md) | Supported versions and vulnerability reporting |
|
||||
| [Docs index](docs/README.md) | Full documentation map |
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Local VocaGateway from Vocalinux (optional)
|
||||
|
||||
Vocalinux can start a **local** [VocaGateway](https://github.com/VocaHQ/vocagateway)
|
||||
container from **Settings → Speech Model → Advanced**, next to Remote Server. This is an
|
||||
optional power-user path. It is **not** on-device processing: microphone audio
|
||||
still leaves the desktop client and is transcribed by the gateway process on
|
||||
your machine (or LAN).
|
||||
|
||||
The default speech engine remains **whisper.cpp** on-device. Enabling a local
|
||||
gateway never changes that default by itself. Use **Use this Gateway** only when
|
||||
you want Vocalinux to talk to the gateway over the existing Remote API settings
|
||||
(`remote_api_*`, endpoint `/v1/audio/transcriptions`). See [HTTP_REMOTE.md](HTTP_REMOTE.md).
|
||||
|
||||
## Requirements (Linux MVP)
|
||||
|
||||
- **podman** (preferred) or **docker**, with a working `compose` plugin (or
|
||||
`podman-compose` / `docker-compose`).
|
||||
- Vocalinux does **not** bundle a container engine inside the AppImage.
|
||||
- **Flatpak**: the sandbox cannot reach the host container socket. Run the
|
||||
native package or AppImage, or start VocaGateway on the host yourself and
|
||||
point Remote Server at it.
|
||||
|
||||
## What Vocalinux does
|
||||
|
||||
1. Pins upstream **VocaGateway `v0.1.0`** (git tag) under your XDG cache and uses
|
||||
that checkout's `compose.yaml` with Compose project name `vocagateway`.
|
||||
2. Writes a private bootstrap token under `~/.config/vocalinux/gateway_embed/`
|
||||
(mode `600`) and a matching Compose `.env`.
|
||||
3. Runs `compose -f compose.yaml --profile cpu up -d` for the `gateway` service.
|
||||
The v0.1.0 GitHub release has no published container image assets, so the first
|
||||
start **builds** the local image `vocagateway:v0.1.0` from that tag (override with
|
||||
`VOCAGATEWAY_IMAGE`; never `latest`). Later starts reuse the image/volume.
|
||||
4. Polls `GET /health/live` and `GET /health/ready`, and (when live) authenticated
|
||||
`GET /v1/admin/pairing` for phone pairing.
|
||||
5. Stops with `compose down` **without** `-v`, so the named data volume (models,
|
||||
config) is kept.
|
||||
|
||||
## Status labels
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| Stopped | Not running. |
|
||||
| Starting | Compose up in progress, or live probe not green yet. |
|
||||
| Live | Process answers `/health/live`. |
|
||||
| Pairable | Live, plus a **non-loopback** phone URL and bearer token (QR-safe). |
|
||||
| Ready | `/health/ready` is 200 (model selected in the gateway WebUI). |
|
||||
| Error | Start/stop or probe failure (see the subtitle). |
|
||||
|
||||
Pairable can appear before Ready: you can pair a phone while a model is still
|
||||
downloading.
|
||||
|
||||
## LAN / phone
|
||||
|
||||
By default the published host is `127.0.0.1` (desktop-only). Turn on **Allow LAN
|
||||
access for Phone** to set `VOCAGATEWAY_PUBLISH_HOST=0.0.0.0` and to advertise a
|
||||
LAN URL in pairing. Open port `8765` in your firewall only on trusted networks.
|
||||
Never put `127.0.0.1` / `localhost` in the phone QR. If a LAN toggle fails,
|
||||
compose keeps the previous bind, Vocalinux restores the embed `.env`, and the
|
||||
switch snaps back with an error (Stop and Run again, or retry).
|
||||
|
||||
## Tray and Quit
|
||||
|
||||
The tray menu offers **Stop local Gateway** whenever compose is actually running
|
||||
(Starting, Live, Pairable, Ready, or Error), including a leftover from a previous
|
||||
Vocalinux session. Vocalinux does not auto-start the gateway on login in v1.
|
||||
|
||||
**Quit does not stop compose.** The pinned compose file uses `restart:
|
||||
unless-stopped`, so a LAN-published gateway on `0.0.0.0:8765` keeps running after
|
||||
you quit Vocalinux. The next launch restores the Allow LAN switch from the running
|
||||
compose publish host. Use **Stop local Gateway** in Settings or the tray when you
|
||||
want it gone. Do not assume Quit tears it down.
|
||||
|
||||
## Honesty
|
||||
|
||||
VocaGateway is optional self-hosted infrastructure for the Voca family. There is
|
||||
no Voca account and no hosted Voca cloud. Prefer a trusted LAN, Tailscale, or
|
||||
HTTPS. Do not expose port 8765 to the public internet.
|
||||
@@ -11,6 +11,10 @@ Pick whichever your server exposes. The rest of this guide applies to both.
|
||||
|
||||
> **Tip: share the server with your phone.** These HTTP formats are open enough that the same self-hosted server can also back mobile dictation apps. On Android, apps like *Dictate* and *Transcribro* speak OpenAI-compatible Whisper; on iOS, Shortcuts-based dictation clients can hit the same endpoint. Run one Whisper server, point your laptop and phone at it, and you get consistent dictation everywhere without uploading audio to a third party.
|
||||
|
||||
## Local VocaGateway (optional)
|
||||
|
||||
On Linux you can start an optional local [VocaGateway](https://github.com/VocaHQ/vocagateway) from **Settings → Speech Model → Advanced** (podman-first, docker fallback). That path uses the same Remote API settings described below, with endpoint `/v1/audio/transcriptions`. It is **not** on-device recognition: audio goes to the gateway container on your machine. Details: [GATEWAY_EMBED.md](GATEWAY_EMBED.md).
|
||||
|
||||
## How it works
|
||||
|
||||
When the **Remote API** engine is active, Vocalinux:
|
||||
|
||||
@@ -91,8 +91,9 @@ if IBus.Engine.__gtype__.name in ("void", "invalid"):
|
||||
raise SystemExit("IBus typelib loaded but its library did not")
|
||||
|
||||
from vocalinux.text_injection import text_injector # noqa: F401
|
||||
from vocalinux import gateway_embed # noqa: F401
|
||||
|
||||
print(" Gtk 3.0, IBus and text_injection all import")
|
||||
print(" Gtk 3.0, IBus, text_injection and gateway_embed all import")
|
||||
PY
|
||||
|
||||
echo "== Host binaries still run when the app spawns them =="
|
||||
|
||||
@@ -125,6 +125,7 @@ required-version = ">=0.12,<0.13"
|
||||
packages = [
|
||||
"vocalinux",
|
||||
"vocalinux.audio",
|
||||
"vocalinux.gateway_embed",
|
||||
"vocalinux.resources",
|
||||
"vocalinux.resources.icons",
|
||||
"vocalinux.resources.icons.scalable",
|
||||
|
||||
@@ -500,9 +500,24 @@ class PlaybackDucker:
|
||||
logger.warning("Could not lower sink %s", sink_id, exc_info=True)
|
||||
return
|
||||
if not applied:
|
||||
# The sink was not changed, so the record would restore nothing
|
||||
# useful and might later skip a real user volume.
|
||||
self._forget()
|
||||
# A reported failure is only the tool's word for it: the
|
||||
# command can still have applied before the failure was
|
||||
# reported (a dropped reply, a daemon disconnect). Re-read
|
||||
# the sink and drop the record only when it provably stayed
|
||||
# at the original level; otherwise restore() is the only way
|
||||
# back and the record has to stay.
|
||||
try:
|
||||
verify = self._control.volume_of(sink_id)
|
||||
except Exception:
|
||||
verify = None
|
||||
if verify is not None and channels_match(verify, original_channels):
|
||||
self._forget()
|
||||
return
|
||||
logger.warning(
|
||||
"Sink %s reported a failed volume set without confirming it was "
|
||||
"unchanged; keeping the restore point",
|
||||
sink_id,
|
||||
)
|
||||
return
|
||||
logger.info(
|
||||
"Lowered sink %s from %s to %s (%d%%) while dictating",
|
||||
@@ -568,10 +583,7 @@ class PlaybackDucker:
|
||||
self._forget()
|
||||
|
||||
def _current_percent(self) -> int:
|
||||
from ..ui.config_manager import (
|
||||
DEFAULT_PLAYBACK_DUCK_PERCENT,
|
||||
clamp_playback_duck_percent,
|
||||
)
|
||||
from ..ui.config_manager import DEFAULT_PLAYBACK_DUCK_PERCENT, clamp_playback_duck_percent
|
||||
|
||||
try:
|
||||
return clamp_playback_duck_percent(self._percent_fn())
|
||||
|
||||
@@ -127,3 +127,9 @@ class CancelableTimer(Protocol):
|
||||
def cancel(self) -> None:
|
||||
"""Drop the scheduled call if it has not started."""
|
||||
...
|
||||
|
||||
|
||||
class Readable(Protocol):
|
||||
"""Minimal read() surface for urlopen-like responses."""
|
||||
|
||||
def read(self, n: int = ...) -> bytes: ...
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Optional local VocaGateway lifecycle helpers for Vocalinux.
|
||||
|
||||
This is an opt-in path for power users who want to run
|
||||
https://github.com/VocaHQ/vocagateway on the same Linux box via podman
|
||||
(or docker). It is not on-device processing: audio still leaves the
|
||||
desktop client and goes to the local gateway container.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from .manager import GatewayEmbedManager, get_gateway_embed_manager
|
||||
from .preset import remote_api_preset_from_pairing
|
||||
from .runtime import ContainerRuntime, detect_container_runtime
|
||||
from .status import GatewayStatus
|
||||
from .urls import is_loopback_url, reject_loopback_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"ContainerRuntime",
|
||||
"GatewayEmbedManager",
|
||||
"GatewayStatus",
|
||||
"detect_container_runtime",
|
||||
"get_gateway_embed_manager",
|
||||
"is_loopback_url",
|
||||
"reject_loopback_url",
|
||||
"remote_api_preset_from_pairing",
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""HTTP health probes for a local VocaGateway."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_BASE_URL = "http://127.0.0.1:8765"
|
||||
DEFAULT_TIMEOUT = 2.5
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HealthSnapshot:
|
||||
live: bool
|
||||
ready: bool
|
||||
live_status: int | None = None
|
||||
ready_status: int | None = None
|
||||
error: str = ""
|
||||
|
||||
|
||||
def _get_status(url: str, *, timeout: float) -> tuple[int | None, str]:
|
||||
request = urllib.request.Request(url, method="GET")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return int(response.status), ""
|
||||
except urllib.error.HTTPError as exc:
|
||||
return int(exc.code), ""
|
||||
except Exception as exc: # noqa: BLE001 - surface as soft error for UI
|
||||
return None, str(exc)
|
||||
|
||||
|
||||
def probe_health(
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
*,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> HealthSnapshot:
|
||||
"""Probe ``/health/live`` and ``/health/ready``."""
|
||||
root = base_url.rstrip("/")
|
||||
live_status, live_err = _get_status(f"{root}/health/live", timeout=timeout)
|
||||
ready_status, ready_err = _get_status(f"{root}/health/ready", timeout=timeout)
|
||||
live = live_status == 200
|
||||
ready = ready_status == 200
|
||||
error = ""
|
||||
if not live and live_err:
|
||||
error = live_err
|
||||
elif not ready and ready_err and ready_status is None:
|
||||
error = ready_err
|
||||
return HealthSnapshot(
|
||||
live=live,
|
||||
ready=ready,
|
||||
live_status=live_status,
|
||||
ready_status=ready_status,
|
||||
error=error,
|
||||
)
|
||||
@@ -0,0 +1,445 @@
|
||||
"""Session-scoped manager shared by Settings and the tray."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
from typing import Any, Callable, List, Optional
|
||||
|
||||
from .health import probe_health
|
||||
from .pairing import PairingInfo, fetch_pairing
|
||||
from .preset import apply_remote_api_preset, remote_api_preset_from_pairing
|
||||
from .runner import GatewayRunner, read_lan_publish_from_env
|
||||
from .status import GatewayStatus
|
||||
from .urls import reject_loopback_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
StatusListener = Callable[[GatewayStatus, str], None]
|
||||
|
||||
|
||||
def _guess_lan_url(port: int = 8765) -> Optional[str]:
|
||||
"""Best-effort LAN IPv4 for PUBLIC_URL when user opts into LAN publish."""
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
sock.connect(("8.8.8.8", 80))
|
||||
ip = sock.getsockname()[0]
|
||||
finally:
|
||||
sock.close()
|
||||
except OSError:
|
||||
return None
|
||||
if not ip or ip.startswith("127."):
|
||||
return None
|
||||
candidate = f"http://{ip}:{port}"
|
||||
return reject_loopback_url(candidate)
|
||||
|
||||
|
||||
class GatewayEmbedManager:
|
||||
"""Owns start/stop, health polling, and managed-by-us tray visibility."""
|
||||
|
||||
def __init__(self, runner: GatewayRunner | None = None):
|
||||
self.runner = runner or GatewayRunner()
|
||||
self._status = GatewayStatus.STOPPED
|
||||
self._status_detail = ""
|
||||
self._pairing: Optional[PairingInfo] = None
|
||||
self._token: str = ""
|
||||
self._listeners: List[StatusListener] = []
|
||||
self._lock = threading.RLock()
|
||||
self._poll_stop = threading.Event()
|
||||
self._poll_thread: Optional[threading.Thread] = None
|
||||
self.lan_publish = False
|
||||
# What compose was last started/republished with (pairing uses this).
|
||||
self._compose_lan_publish: Optional[bool] = None
|
||||
self._runtime_detect_started = False
|
||||
self._republish_started = False
|
||||
if not self.runner.runtime_ready:
|
||||
self.begin_runtime_detection()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# listeners (GTK should register wrappers that idle_add into UI)
|
||||
# ------------------------------------------------------------------
|
||||
def add_listener(self, callback: StatusListener) -> None:
|
||||
with self._lock:
|
||||
if callback not in self._listeners:
|
||||
self._listeners.append(callback)
|
||||
|
||||
def remove_listener(self, callback: StatusListener) -> None:
|
||||
with self._lock:
|
||||
if callback in self._listeners:
|
||||
self._listeners.remove(callback)
|
||||
|
||||
def _emit(self, status: GatewayStatus, detail: str = "") -> None:
|
||||
self._status = status
|
||||
self._status_detail = detail
|
||||
listeners = list(self._listeners)
|
||||
for callback in listeners:
|
||||
try:
|
||||
callback(status, detail)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("gateway status listener failed")
|
||||
|
||||
@property
|
||||
def status(self) -> GatewayStatus:
|
||||
return self._status
|
||||
|
||||
@property
|
||||
def status_detail(self) -> str:
|
||||
return self._status_detail
|
||||
|
||||
@property
|
||||
def pairing(self) -> Optional[PairingInfo]:
|
||||
return self._pairing
|
||||
|
||||
@property
|
||||
def managed_by_us(self) -> bool:
|
||||
return bool(self.runner.managed_by_us)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self.runner.available
|
||||
|
||||
@property
|
||||
def unavailable_hint(self) -> str:
|
||||
return self.runner.unavailable_hint
|
||||
|
||||
@property
|
||||
def runtime_ready(self) -> bool:
|
||||
return self.runner.runtime_ready
|
||||
|
||||
def begin_runtime_detection(self) -> None:
|
||||
"""Probe podman/docker off the GTK main thread, then adopt leftover compose."""
|
||||
with self._lock:
|
||||
if self._runtime_detect_started:
|
||||
return
|
||||
self._runtime_detect_started = True
|
||||
|
||||
def _worker() -> None:
|
||||
try:
|
||||
self.runner.ensure_runtime()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("container runtime detection failed")
|
||||
if not self.runner.available:
|
||||
self._emit(self._status, self.runner.unavailable_hint)
|
||||
return
|
||||
try:
|
||||
from .paths_embed import ensure_token_file, token_file_path
|
||||
|
||||
if os.path.isfile(token_file_path()):
|
||||
self._token = ensure_token_file()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("could not load existing gateway token")
|
||||
self._adopt_orphan_compose()
|
||||
status = self.refresh_status()
|
||||
if status is not GatewayStatus.STOPPED:
|
||||
self._start_polling()
|
||||
|
||||
# Runtime already resolved (tests, or a runner passed in): probe now.
|
||||
# Production still uses the worker thread so GTK never waits on compose ps.
|
||||
if self.runner.runtime_ready:
|
||||
_worker()
|
||||
return
|
||||
threading.Thread(
|
||||
target=_worker,
|
||||
name="vocalinux-gateway-runtime",
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def _adopt_orphan_compose(self) -> None:
|
||||
"""Restore LAN truth and take ownership of leftover compose after Quit."""
|
||||
if self.runner.managed_by_us:
|
||||
return
|
||||
if not self.runner.is_compose_running():
|
||||
return
|
||||
published = read_lan_publish_from_env()
|
||||
self._compose_lan_publish = published
|
||||
self.lan_publish = published
|
||||
self.runner.managed_by_us = True
|
||||
logger.info("adopted leftover vocagateway compose (lan_publish=%s)", published)
|
||||
|
||||
def start_async(self, *, lan_publish: bool = False) -> None:
|
||||
self.lan_publish = lan_publish
|
||||
thread = threading.Thread(
|
||||
target=self._start_worker,
|
||||
name="vocalinux-gateway-start",
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
def stop_async(self) -> None:
|
||||
thread = threading.Thread(
|
||||
target=self._stop_worker,
|
||||
name="vocalinux-gateway-stop",
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
def apply_lan_publish(self, lan_publish: bool) -> None:
|
||||
"""Persist desired LAN flag; republish compose when we manage it.
|
||||
|
||||
Until republish finishes, pairing stays non-Pairable so QR never claims
|
||||
LAN while still bound to 127.0.0.1 (or the previous publish mode).
|
||||
"""
|
||||
self.lan_publish = bool(lan_publish)
|
||||
if not self.runner.managed_by_us:
|
||||
# Next Run will pick up lan_publish; clear stale QR if any.
|
||||
if (
|
||||
self._compose_lan_publish is not None
|
||||
and self._compose_lan_publish != self.lan_publish
|
||||
):
|
||||
self._pairing = None
|
||||
return
|
||||
if self._compose_lan_publish == self.lan_publish:
|
||||
return
|
||||
# Drop Pairable immediately; republish worker restores it when safe.
|
||||
self._pairing = None
|
||||
with self._lock:
|
||||
if self._republish_started:
|
||||
return
|
||||
self._republish_started = True
|
||||
thread = threading.Thread(
|
||||
target=self._republish_worker,
|
||||
name="vocalinux-gateway-republish",
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
def _republish_worker(self) -> None:
|
||||
succeeded = False
|
||||
try:
|
||||
with self._lock:
|
||||
published = bool(self.lan_publish)
|
||||
self._emit(
|
||||
GatewayStatus.STARTING,
|
||||
"Updating LAN publish so the pairing URL matches…",
|
||||
)
|
||||
public_url = _guess_lan_url() if published else None
|
||||
if published and not public_url:
|
||||
logger.info("LAN republish enabled but LAN IP could not be guessed")
|
||||
result = self.runner.republish(lan_publish=published, public_url=public_url)
|
||||
if not result.ok:
|
||||
# Recreate failed: live bind is still the previous publish mode.
|
||||
if self._compose_lan_publish is not None:
|
||||
self.lan_publish = bool(self._compose_lan_publish)
|
||||
self._emit(
|
||||
GatewayStatus.ERROR,
|
||||
"Could not update LAN publish; the previous bind is still in use. "
|
||||
"Stop and Run again, or retry.",
|
||||
)
|
||||
return
|
||||
self._compose_lan_publish = published
|
||||
self._pairing = None
|
||||
self.refresh_status()
|
||||
succeeded = True
|
||||
finally:
|
||||
with self._lock:
|
||||
self._republish_started = False
|
||||
if succeeded:
|
||||
self._reconcile_lan_after_compose()
|
||||
|
||||
def _effective_lan_for_pairing(self) -> bool:
|
||||
"""True only when desired LAN matches what compose actually published."""
|
||||
return bool(self.lan_publish) and self._compose_lan_publish is True
|
||||
|
||||
def _reconcile_lan_after_compose(self) -> None:
|
||||
"""If Allow LAN moved during start/republish, match compose to the switch."""
|
||||
if not self.runner.managed_by_us:
|
||||
return
|
||||
if self._compose_lan_publish != bool(self.lan_publish):
|
||||
self.apply_lan_publish(self.lan_publish)
|
||||
|
||||
def _start_worker(self) -> None:
|
||||
with self._lock:
|
||||
published = bool(self.lan_publish)
|
||||
self._emit(GatewayStatus.STARTING, "Fetching VocaGateway v0.1.0 and starting compose…")
|
||||
public_url = _guess_lan_url() if published else None
|
||||
if published and not public_url:
|
||||
# Still start with 0.0.0.0 publish; user can set PUBLIC_URL later.
|
||||
logger.info("LAN publish enabled but LAN IP could not be guessed")
|
||||
|
||||
try:
|
||||
from .paths_embed import ensure_token_file
|
||||
|
||||
self.runner.ensure_runtime()
|
||||
self._token = ensure_token_file()
|
||||
result = self.runner.start(lan_publish=published, public_url=public_url)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.runner.managed_by_us = False
|
||||
self._emit(GatewayStatus.ERROR, str(exc))
|
||||
return
|
||||
|
||||
if not result.ok:
|
||||
self._emit(GatewayStatus.ERROR, result.message)
|
||||
return
|
||||
|
||||
self._compose_lan_publish = published
|
||||
self._start_polling()
|
||||
self.refresh_status()
|
||||
self._reconcile_lan_after_compose()
|
||||
|
||||
def _stop_worker(self) -> None:
|
||||
self._stop_polling()
|
||||
with self._lock:
|
||||
self._emit(GatewayStatus.STARTING, "Stopping local VocaGateway…")
|
||||
result = self.runner.stop(wipe_volumes=False)
|
||||
self._pairing = None
|
||||
self._compose_lan_publish = None
|
||||
if not result.ok:
|
||||
self._emit(GatewayStatus.ERROR, result.message)
|
||||
return
|
||||
self._emit(GatewayStatus.STOPPED, "")
|
||||
|
||||
def _start_polling(self) -> None:
|
||||
self._stop_polling()
|
||||
self._poll_stop = threading.Event()
|
||||
|
||||
def _loop() -> None:
|
||||
while not self._poll_stop.wait(2.0):
|
||||
try:
|
||||
self.refresh_status()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("gateway poll failed")
|
||||
|
||||
self._poll_thread = threading.Thread(
|
||||
target=_loop, name="vocalinux-gateway-poll", daemon=True
|
||||
)
|
||||
self._poll_thread.start()
|
||||
|
||||
def _stop_polling(self) -> None:
|
||||
self._poll_stop.set()
|
||||
self._poll_thread = None
|
||||
|
||||
def refresh_status(self) -> GatewayStatus:
|
||||
"""Probe health (+ pairing when live) and emit status."""
|
||||
if self._status is GatewayStatus.STARTING and not self.runner.managed_by_us:
|
||||
# Mid start/stop worker; leave label alone unless we have live.
|
||||
pass
|
||||
|
||||
health = probe_health(self.runner.local_base_url())
|
||||
running = self.runner.managed_by_us or self.runner.is_compose_running()
|
||||
|
||||
pairable = False
|
||||
if health.live and self._token:
|
||||
try:
|
||||
public = _guess_lan_url() if self._effective_lan_for_pairing() else None
|
||||
# Avoid re-downloading QR SVG every poll (hot-path memory churn).
|
||||
need_qr = True
|
||||
cached = self._pairing
|
||||
if cached is not None and cached.qr_svg and cached.display_url:
|
||||
need_qr = False
|
||||
info = fetch_pairing(
|
||||
self.runner.local_base_url(),
|
||||
self._token,
|
||||
public_url=public,
|
||||
fetch_qr=need_qr,
|
||||
)
|
||||
if (
|
||||
not info.qr_svg
|
||||
and cached is not None
|
||||
and cached.qr_svg
|
||||
and cached.display_url == info.display_url
|
||||
):
|
||||
info = PairingInfo(
|
||||
version=info.version,
|
||||
url=info.url,
|
||||
token=info.token,
|
||||
display_url=info.display_url,
|
||||
raw_payload=info.raw_payload,
|
||||
qr_svg=cached.qr_svg,
|
||||
)
|
||||
self._pairing = info
|
||||
pairable = info.pairable
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Live but pairing endpoint not ready / auth mismatch.
|
||||
logger.info("pairing fetch failed: %s", type(exc).__name__)
|
||||
# If LAN publish is off, loopback URL is expected: not Pairable.
|
||||
pairable = False
|
||||
|
||||
status = GatewayStatus.from_health(
|
||||
live=health.live,
|
||||
ready=health.ready,
|
||||
pairable=pairable,
|
||||
error=False,
|
||||
starting=running and not health.live,
|
||||
running=running,
|
||||
)
|
||||
detail = ""
|
||||
if status is GatewayStatus.LIVE:
|
||||
if self.lan_publish and self._compose_lan_publish is not True:
|
||||
detail = (
|
||||
"LAN is on in settings, but publish still needs a restart. "
|
||||
"Stop and Run again (or wait for republish) before pairing a phone."
|
||||
)
|
||||
elif not self.lan_publish and self._compose_lan_publish is True:
|
||||
detail = (
|
||||
"LAN is off in settings, but publish is still open on the LAN. "
|
||||
"Wait for republish to finish (or Stop and Run again)."
|
||||
)
|
||||
else:
|
||||
detail = "Gateway process is up. Download a model in the WebUI to become Ready."
|
||||
elif status is GatewayStatus.PAIRABLE:
|
||||
detail = "Phone can pair. Dictation Ready still needs a selected model."
|
||||
elif status is GatewayStatus.READY:
|
||||
detail = "Ready for dictation."
|
||||
elif status is GatewayStatus.STARTING:
|
||||
detail = self._status_detail or "Starting…"
|
||||
elif status is GatewayStatus.STOPPED and health.error and running:
|
||||
detail = health.error
|
||||
status = GatewayStatus.ERROR
|
||||
|
||||
self._emit(status, detail)
|
||||
return status
|
||||
|
||||
def use_this_gateway(self, config_manager: Any) -> dict[str, str]:
|
||||
"""Fill remote_api_* from the current pairing (Ready or Pairable)."""
|
||||
info = self._pairing
|
||||
if info is None or not info.token:
|
||||
raise RuntimeError("No pairing info yet. Wait until status is Pairable or Ready.")
|
||||
|
||||
# Prefer non-loopback; for desktop-only use, allow loopback URL in remote_api
|
||||
# (phone QR still rejects loopback separately).
|
||||
url = info.display_url or info.url
|
||||
if self._effective_lan_for_pairing():
|
||||
guessed = _guess_lan_url()
|
||||
if guessed:
|
||||
url = guessed
|
||||
# Desktop "Use this Gateway" may legitimately use 127.0.0.1 when LAN is off.
|
||||
if not url:
|
||||
raise RuntimeError("Gateway URL missing from pairing payload.")
|
||||
|
||||
preset = remote_api_preset_from_pairing(
|
||||
url=url,
|
||||
token=info.token,
|
||||
prefer_public=bool(reject_loopback_url(url)),
|
||||
)
|
||||
# When prefer_public rejected loopback, still allow explicit loopback for desktop.
|
||||
if not reject_loopback_url(url):
|
||||
preset = remote_api_preset_from_pairing(
|
||||
url=url,
|
||||
token=info.token,
|
||||
prefer_public=False,
|
||||
)
|
||||
apply_remote_api_preset(config_manager, preset)
|
||||
return dict(preset)
|
||||
|
||||
|
||||
_MANAGER: Optional[GatewayEmbedManager] = None
|
||||
_MANAGER_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def get_gateway_embed_manager() -> GatewayEmbedManager:
|
||||
global _MANAGER
|
||||
with _MANAGER_LOCK:
|
||||
if _MANAGER is None:
|
||||
_MANAGER = GatewayEmbedManager()
|
||||
return _MANAGER
|
||||
|
||||
|
||||
def reset_gateway_embed_manager_for_tests() -> None:
|
||||
global _MANAGER
|
||||
with _MANAGER_LOCK:
|
||||
if _MANAGER is not None:
|
||||
_MANAGER._stop_polling()
|
||||
_MANAGER = None
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Fetch pairing payload and optional QR SVG from a local VocaGateway."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from ..common_types import Readable
|
||||
from .urls import is_unusable_phone_url, reject_loopback_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Adversarial: never pull an unbounded SVG into the desktop process.
|
||||
MAX_QR_SVG_BYTES = 512 * 1024
|
||||
MAX_PAIRING_JSON_BYTES = 64 * 1024
|
||||
DEFAULT_TIMEOUT = 3.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PairingInfo:
|
||||
"""Decoded vocaphone-pair-v1 style payload."""
|
||||
|
||||
version: int
|
||||
url: str
|
||||
token: str
|
||||
display_url: Optional[str]
|
||||
raw_payload: dict[str, Any]
|
||||
qr_svg: Optional[bytes] = None
|
||||
|
||||
@property
|
||||
def pairable(self) -> bool:
|
||||
"""True when a non-loopback phone URL and token are present."""
|
||||
return bool(self.display_url and self.token)
|
||||
|
||||
|
||||
def _read_capped(response: Readable, limit: int) -> bytes:
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while True:
|
||||
piece = response.read(min(65536, limit - total + 1))
|
||||
if not piece:
|
||||
break
|
||||
total += len(piece)
|
||||
if total > limit:
|
||||
raise ValueError(f"response exceeded {limit} byte cap")
|
||||
chunks.append(piece)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _authorized_get(
|
||||
url: str,
|
||||
token: str,
|
||||
*,
|
||||
timeout: float,
|
||||
limit: int,
|
||||
) -> bytes:
|
||||
headers = {}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
request = urllib.request.Request(url, headers=headers, method="GET")
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return _read_capped(response, limit)
|
||||
|
||||
|
||||
def decode_pairing_payload(data: dict[str, Any] | str) -> PairingInfo:
|
||||
"""Decode gateway pairing JSON.
|
||||
|
||||
Accepts either the gateway envelope ``{"payload": {...}}`` / ``{"payload":
|
||||
"<json-string>"}`` or a bare ``{v,url,token}`` object.
|
||||
"""
|
||||
if isinstance(data, str):
|
||||
data = json.loads(data)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("pairing response must be an object")
|
||||
|
||||
payload = data.get("payload", data)
|
||||
if isinstance(payload, str):
|
||||
payload = json.loads(payload)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("pairing payload must be an object")
|
||||
|
||||
version = int(payload.get("v") or payload.get("version") or 1)
|
||||
url = str(payload.get("url") or "").strip()
|
||||
token = str(payload.get("token") or "").strip()
|
||||
if not url or not token:
|
||||
raise ValueError("pairing payload missing url or token")
|
||||
|
||||
display = reject_loopback_url(url)
|
||||
return PairingInfo(
|
||||
version=version,
|
||||
url=url,
|
||||
token=token,
|
||||
display_url=display,
|
||||
raw_payload=dict(payload),
|
||||
)
|
||||
|
||||
|
||||
def fetch_pairing(
|
||||
base_url: str,
|
||||
bearer_token: str,
|
||||
*,
|
||||
public_url: str | None = None,
|
||||
fetch_qr: bool = True,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> PairingInfo:
|
||||
"""GET ``/v1/admin/pairing`` and optionally ``/qr.svg``."""
|
||||
root = base_url.rstrip("/")
|
||||
query = ""
|
||||
if public_url and not is_unusable_phone_url(public_url):
|
||||
from urllib.parse import quote
|
||||
|
||||
query = f"?url={quote(public_url, safe=':/')}"
|
||||
|
||||
body = _authorized_get(
|
||||
f"{root}/v1/admin/pairing{query}",
|
||||
bearer_token,
|
||||
timeout=timeout,
|
||||
limit=MAX_PAIRING_JSON_BYTES,
|
||||
)
|
||||
info = decode_pairing_payload(json.loads(body.decode("utf-8")))
|
||||
|
||||
qr_svg: Optional[bytes] = None
|
||||
if fetch_qr and info.display_url:
|
||||
try:
|
||||
qr_svg = _authorized_get(
|
||||
f"{root}/v1/admin/pairing/qr.svg{query}",
|
||||
bearer_token,
|
||||
timeout=timeout,
|
||||
limit=MAX_QR_SVG_BYTES,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Soft failure: UI falls back to URL+token text.
|
||||
logger.info("QR SVG unavailable: %s", type(exc).__name__)
|
||||
|
||||
return PairingInfo(
|
||||
version=info.version,
|
||||
url=info.url,
|
||||
token=info.token,
|
||||
display_url=info.display_url,
|
||||
raw_payload=info.raw_payload,
|
||||
qr_svg=qr_svg,
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""XDG paths for the embedded gateway checkout and secrets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
|
||||
from vocalinux.utils.paths import config_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Keep pin explicit: never float on latest.
|
||||
GATEWAY_RELEASE_TAG = "v0.1.0"
|
||||
GATEWAY_GIT_URL = "https://github.com/VocaHQ/vocagateway.git"
|
||||
COMPOSE_PROJECT = "vocagateway"
|
||||
DEFAULT_PORT = 8765
|
||||
|
||||
# Bootstrap tokens are hex; refuse unbounded or hostile token files.
|
||||
MAX_TOKEN_FILE_BYTES = 4096
|
||||
_TOKEN_RE = re.compile(r"^[0-9a-fA-F]{32,2048}$")
|
||||
|
||||
|
||||
def xdg_cache_home() -> str:
|
||||
return os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache")
|
||||
|
||||
|
||||
def gateway_cache_dir() -> str:
|
||||
return os.path.join(xdg_cache_home(), "vocalinux", f"vocagateway-{GATEWAY_RELEASE_TAG}")
|
||||
|
||||
|
||||
def gateway_embed_config_dir() -> str:
|
||||
return os.path.join(config_dir(), "gateway_embed")
|
||||
|
||||
|
||||
def token_file_path() -> str:
|
||||
return os.path.join(gateway_embed_config_dir(), "token")
|
||||
|
||||
|
||||
def env_file_path() -> str:
|
||||
return os.path.join(gateway_embed_config_dir(), ".env")
|
||||
|
||||
|
||||
def ensure_token_file(path: str | None = None) -> str:
|
||||
"""Create a mode-600 bootstrap token if missing; return the token string."""
|
||||
token_path = path or token_file_path()
|
||||
os.makedirs(os.path.dirname(token_path), mode=0o700, exist_ok=True)
|
||||
if os.path.isfile(token_path):
|
||||
with open(token_path, "rb") as handle:
|
||||
raw = handle.read(MAX_TOKEN_FILE_BYTES + 1)
|
||||
if len(raw) <= MAX_TOKEN_FILE_BYTES:
|
||||
existing = raw.decode("utf-8", errors="replace").strip()
|
||||
if _TOKEN_RE.fullmatch(existing):
|
||||
return existing
|
||||
token = secrets.token_hex(32)
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
||||
fd = os.open(token_path, flags, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(token)
|
||||
handle.write("\n")
|
||||
try:
|
||||
os.chmod(token_path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
return token
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Map a paired local gateway onto Vocalinux remote_api settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .urls import reject_loopback_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GATEWAY_TRANSCRIPTIONS_ENDPOINT = "/v1/audio/transcriptions"
|
||||
|
||||
|
||||
def remote_api_preset_from_pairing(
|
||||
*,
|
||||
url: str,
|
||||
token: str,
|
||||
prefer_public: bool = True,
|
||||
) -> dict[str, str]:
|
||||
"""Return speech_recognition keys for "Use this Gateway".
|
||||
|
||||
Matches docs/HTTP_REMOTE.md / Gateway docs/configuration.md:
|
||||
engine ``remote_api``, OpenAI-compatible transcriptions path, bearer key.
|
||||
"""
|
||||
cleaned = (url or "").strip().rstrip("/")
|
||||
if prefer_public:
|
||||
display = reject_loopback_url(cleaned)
|
||||
if display:
|
||||
cleaned = display.rstrip("/")
|
||||
if not cleaned:
|
||||
raise ValueError("gateway URL is required")
|
||||
if not (token or "").strip():
|
||||
raise ValueError("gateway token is required")
|
||||
|
||||
return {
|
||||
"engine": "remote_api",
|
||||
"remote_api_url": cleaned,
|
||||
"remote_api_key": token.strip(),
|
||||
"remote_api_endpoint": GATEWAY_TRANSCRIPTIONS_ENDPOINT,
|
||||
"remote_api_model": "whisper-1",
|
||||
}
|
||||
|
||||
|
||||
def apply_remote_api_preset(config_manager: Any, preset: Mapping[str, str]) -> None:
|
||||
"""Write *preset* into an existing ConfigManager and save."""
|
||||
for key, value in preset.items():
|
||||
config_manager.set("speech_recognition", key, value)
|
||||
config_manager.save_config()
|
||||
@@ -0,0 +1,483 @@
|
||||
"""Compose lifecycle for project ``vocagateway`` pinned to release v0.1.0."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Mapping, Optional, Sequence
|
||||
|
||||
from vocalinux.utils.host_process import host_env
|
||||
|
||||
from .paths_embed import (
|
||||
COMPOSE_PROJECT,
|
||||
DEFAULT_PORT,
|
||||
GATEWAY_GIT_URL,
|
||||
GATEWAY_RELEASE_TAG,
|
||||
ensure_token_file,
|
||||
env_file_path,
|
||||
gateway_cache_dir,
|
||||
)
|
||||
from .runtime import ContainerRuntime, RuntimeInfo, resolve_runtime_info
|
||||
from .sandbox import SandboxState, detect_sandbox
|
||||
from .urls import reject_loopback_url
|
||||
|
||||
DEFAULT_IMAGE = "vocagateway:v0.1.0"
|
||||
# Compose --profile cpu: selects a future cpu profile if present; on v0.1.0
|
||||
# the unprofiled gateway service still starts, while cuda/vulkan/native stay off.
|
||||
|
||||
# Image refs only: block newline / metachar injection into the compose .env.
|
||||
_IMAGE_RE = re.compile(
|
||||
r"^(?:[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)"
|
||||
r"(?:/(?:[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*))*"
|
||||
r"(?::[A-Za-z0-9_][A-Za-z0-9._-]{0,127})?$"
|
||||
)
|
||||
|
||||
|
||||
def _default_run(args: Sequence[str], **kwargs: Any) -> subprocess.CompletedProcess:
|
||||
"""Run a host binary with AppImage library paths stripped."""
|
||||
env = kwargs.pop("env", None)
|
||||
return subprocess.run(args, env=host_env(env), **kwargs)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunnerResult:
|
||||
ok: bool
|
||||
message: str = ""
|
||||
returncode: int = 0
|
||||
|
||||
|
||||
def _env_line_safe(value: str) -> bool:
|
||||
"""True when *value* can sit on a single compose .env line."""
|
||||
return bool(value) and not any(ch in value for ch in "\n\r\0")
|
||||
|
||||
|
||||
def pin_gateway_image(raw: str | None) -> str:
|
||||
"""Return a pinned image ref; never ``:latest`` or hostile overrides."""
|
||||
image = (raw or "").strip() or DEFAULT_IMAGE
|
||||
if (
|
||||
not _env_line_safe(image)
|
||||
or image.endswith(":latest")
|
||||
or image == "latest"
|
||||
or not _IMAGE_RE.fullmatch(image)
|
||||
):
|
||||
return DEFAULT_IMAGE
|
||||
return image
|
||||
|
||||
|
||||
def write_env_file(
|
||||
*,
|
||||
token: str,
|
||||
lan_publish: bool,
|
||||
public_url: str | None = None,
|
||||
path: str | None = None,
|
||||
) -> str:
|
||||
"""Write compose ``.env`` (mode 600). Never log the token."""
|
||||
if not _env_line_safe(token):
|
||||
raise ValueError("gateway token contains invalid characters for .env")
|
||||
env_path = path or env_file_path()
|
||||
os.makedirs(os.path.dirname(env_path), mode=0o700, exist_ok=True)
|
||||
publish_host = "0.0.0.0" if lan_publish else "127.0.0.1"
|
||||
image = pin_gateway_image(os.environ.get("VOCAGATEWAY_IMAGE"))
|
||||
lines = [
|
||||
f"VOCAGATEWAY_TOKEN={token}",
|
||||
f"VOCAGATEWAY_PUBLISH_HOST={publish_host}",
|
||||
f"VOCAGATEWAY_PUBLISH_PORT={DEFAULT_PORT}",
|
||||
f"VOCAGATEWAY_PORT={DEFAULT_PORT}",
|
||||
# Pin image; v0.1.0 release has no published GHCR assets yet, so the
|
||||
# first start builds locally into this tag (or VOCAGATEWAY_IMAGE).
|
||||
f"VOCAGATEWAY_IMAGE={image}",
|
||||
]
|
||||
# Only phone-reachable PUBLIC_URL belongs in compose discovery / QR.
|
||||
safe_public = reject_loopback_url(public_url) if public_url else None
|
||||
if safe_public and _env_line_safe(safe_public):
|
||||
lines.append(f"VOCAGATEWAY_PUBLIC_URL={safe_public}")
|
||||
lines.append(f"VOCAGATEWAY_PAIRING_URL={safe_public}")
|
||||
content = "\n".join(lines) + "\n"
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
||||
fd = os.open(env_path, flags, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(content)
|
||||
try:
|
||||
os.chmod(env_path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
return env_path
|
||||
|
||||
|
||||
def read_lan_publish_from_env(path: str | None = None) -> bool:
|
||||
"""True when embed ``.env`` publishes ``0.0.0.0`` (Allow LAN on)."""
|
||||
env_path = path or env_file_path()
|
||||
try:
|
||||
with open(env_path, encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
if line.startswith("VOCAGATEWAY_PUBLISH_HOST="):
|
||||
return line.split("=", 1)[1].strip() == "0.0.0.0"
|
||||
except OSError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _read_env_bytes(path: str) -> bytes | None:
|
||||
"""Return existing compose ``.env`` bytes, or None when missing."""
|
||||
try:
|
||||
with open(path, "rb") as handle:
|
||||
return handle.read()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _write_env_bytes(path: str, content: bytes) -> None:
|
||||
"""Replace compose ``.env`` with *content* (mode 600). Never log it."""
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
||||
fd = os.open(path, flags, 0o600)
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
handle.write(content)
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _restore_env_snapshot(path: str, snapshot: bytes | None) -> None:
|
||||
"""Put a pre-republish ``.env`` back so file truth matches a live bind."""
|
||||
if snapshot is None:
|
||||
return
|
||||
try:
|
||||
_write_env_bytes(path, snapshot)
|
||||
except OSError:
|
||||
logger.warning("could not restore gateway embed .env after failed republish")
|
||||
|
||||
|
||||
def ensure_gateway_checkout(
|
||||
*,
|
||||
cache_dir: str | None = None,
|
||||
run: Callable[..., subprocess.CompletedProcess] = _default_run,
|
||||
) -> str:
|
||||
"""Clone or fetch vocagateway @ v0.1.0 into the XDG cache."""
|
||||
dest = cache_dir or gateway_cache_dir()
|
||||
os.makedirs(os.path.dirname(dest), mode=0o755, exist_ok=True)
|
||||
git = shutil.which("git")
|
||||
if not git:
|
||||
raise RuntimeError("git is required to fetch VocaGateway v0.1.0 compose sources")
|
||||
|
||||
if os.path.isdir(os.path.join(dest, ".git")):
|
||||
run(
|
||||
[git, "-C", dest, "fetch", "--tags", "--force", "origin", GATEWAY_RELEASE_TAG],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=120,
|
||||
)
|
||||
completed = run(
|
||||
[git, "-C", dest, "checkout", "-f", GATEWAY_RELEASE_TAG],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(f"failed to checkout {GATEWAY_RELEASE_TAG} in {dest}")
|
||||
return dest
|
||||
|
||||
if os.path.exists(dest):
|
||||
shutil.rmtree(dest)
|
||||
|
||||
completed = run(
|
||||
[
|
||||
git,
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
"--branch",
|
||||
GATEWAY_RELEASE_TAG,
|
||||
GATEWAY_GIT_URL,
|
||||
dest,
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=180,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
stderr = (completed.stderr or b"").decode("utf-8", errors="replace")[:400]
|
||||
raise RuntimeError(f"git clone of vocagateway {GATEWAY_RELEASE_TAG} failed: {stderr}")
|
||||
return dest
|
||||
|
||||
|
||||
class GatewayRunner:
|
||||
"""Start/stop the compose project without wiping volumes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
runtime: RuntimeInfo | None = None,
|
||||
sandbox: SandboxState | None = None,
|
||||
*,
|
||||
run: Callable[..., subprocess.CompletedProcess] = _default_run,
|
||||
lazy_runtime: bool = True,
|
||||
) -> None:
|
||||
self.sandbox = sandbox if sandbox is not None else detect_sandbox()
|
||||
self._run = run
|
||||
self._lock = threading.RLock()
|
||||
self._runtime_lock = threading.Lock()
|
||||
self.managed_by_us = False
|
||||
self.last_error = ""
|
||||
self._checkout: Optional[str] = None
|
||||
if runtime is not None:
|
||||
self._runtime: RuntimeInfo = runtime
|
||||
self._runtime_ready = True
|
||||
elif lazy_runtime:
|
||||
# Do not probe podman/docker on the GTK main thread.
|
||||
self._runtime = RuntimeInfo(
|
||||
kind=ContainerRuntime.NONE,
|
||||
binary=None,
|
||||
hint="Detecting container runtime…",
|
||||
)
|
||||
self._runtime_ready = False
|
||||
else:
|
||||
self._runtime = resolve_runtime_info()
|
||||
self._runtime_ready = True
|
||||
|
||||
@property
|
||||
def runtime(self) -> RuntimeInfo:
|
||||
return self._runtime
|
||||
|
||||
@runtime.setter
|
||||
def runtime(self, value: RuntimeInfo) -> None:
|
||||
self._runtime = value
|
||||
|
||||
@property
|
||||
def runtime_ready(self) -> bool:
|
||||
return self._runtime_ready
|
||||
|
||||
def ensure_runtime(self) -> RuntimeInfo:
|
||||
"""Resolve container runtime; safe to call from a worker thread."""
|
||||
if self._runtime_ready:
|
||||
return self._runtime
|
||||
with self._runtime_lock:
|
||||
if not self._runtime_ready:
|
||||
self._runtime = resolve_runtime_info()
|
||||
self._runtime_ready = True
|
||||
return self._runtime
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
if not self._runtime_ready:
|
||||
return False
|
||||
return (
|
||||
not self.sandbox.blocked
|
||||
and self._runtime.kind is not ContainerRuntime.NONE
|
||||
and bool(self._runtime.compose_args)
|
||||
)
|
||||
|
||||
@property
|
||||
def unavailable_hint(self) -> str:
|
||||
if self.sandbox.blocked:
|
||||
return self.sandbox.hint
|
||||
if not self._runtime_ready:
|
||||
return "Detecting container runtime…"
|
||||
if self._runtime.hint:
|
||||
return self._runtime.hint
|
||||
return "No container runtime available."
|
||||
|
||||
def local_base_url(self) -> str:
|
||||
return f"http://127.0.0.1:{DEFAULT_PORT}"
|
||||
|
||||
def _compose(
|
||||
self,
|
||||
args: Sequence[str],
|
||||
*,
|
||||
cwd: str,
|
||||
env_file: str,
|
||||
extra_env: Mapping[str, str] | None = None,
|
||||
timeout: float = 600,
|
||||
) -> subprocess.CompletedProcess:
|
||||
self.ensure_runtime()
|
||||
argv = list(self._runtime.compose_args) + ["--env-file", env_file, "-p", COMPOSE_PROJECT]
|
||||
argv.extend(args)
|
||||
env = host_env()
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
logger.info(
|
||||
"gateway compose: %s (cwd=%s)",
|
||||
" ".join(argv[:6] + ["…"] if len(argv) > 6 else argv),
|
||||
cwd,
|
||||
)
|
||||
return self._run(
|
||||
argv,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def prepare(self, *, lan_publish: bool, public_url: str | None = None) -> tuple[str, str, str]:
|
||||
"""Ensure checkout + token + env. Returns (checkout, token, env_path)."""
|
||||
with self._lock:
|
||||
self.ensure_runtime()
|
||||
if self.sandbox.blocked:
|
||||
raise RuntimeError(self.sandbox.hint)
|
||||
if not self.available:
|
||||
raise RuntimeError(self.unavailable_hint)
|
||||
checkout = ensure_gateway_checkout(run=self._run)
|
||||
self._checkout = checkout
|
||||
token = ensure_token_file()
|
||||
env_path = write_env_file(
|
||||
token=token,
|
||||
lan_publish=lan_publish,
|
||||
public_url=public_url,
|
||||
)
|
||||
compose_file = os.path.join(checkout, "compose.yaml")
|
||||
if not os.path.isfile(compose_file):
|
||||
raise RuntimeError(f"compose.yaml missing in {checkout}")
|
||||
return checkout, token, env_path
|
||||
|
||||
def start(self, *, lan_publish: bool = False, public_url: str | None = None) -> RunnerResult:
|
||||
"""``compose --profile cpu up -d`` for service gateway (builds if needed)."""
|
||||
with self._lock:
|
||||
try:
|
||||
checkout, _token, env_path = self.prepare(
|
||||
lan_publish=lan_publish, public_url=public_url
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.last_error = str(exc)
|
||||
return RunnerResult(ok=False, message=str(exc), returncode=1)
|
||||
|
||||
completed = self._compose(
|
||||
[
|
||||
"-f",
|
||||
"compose.yaml",
|
||||
"--profile",
|
||||
"cpu",
|
||||
"up",
|
||||
"-d",
|
||||
"--build",
|
||||
"gateway",
|
||||
],
|
||||
cwd=checkout,
|
||||
env_file=env_path,
|
||||
timeout=900,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
stderr = (completed.stderr or b"").decode("utf-8", errors="replace")
|
||||
# Never include .env contents; stderr from compose is OK to trim.
|
||||
message = stderr.strip()[-800:] or "compose up failed"
|
||||
self.last_error = message
|
||||
self.managed_by_us = False
|
||||
return RunnerResult(ok=False, message=message, returncode=completed.returncode)
|
||||
|
||||
self.managed_by_us = True
|
||||
self.last_error = ""
|
||||
return RunnerResult(ok=True, message="started", returncode=0)
|
||||
|
||||
def stop(self, *, wipe_volumes: bool = False) -> RunnerResult:
|
||||
"""``compose down`` without ``-v`` by default (preserve models/token volume)."""
|
||||
with self._lock:
|
||||
if wipe_volumes:
|
||||
# Explicitly refused in v1 UI path; keep for tests only.
|
||||
raise RuntimeError("volume wipe is not allowed from the desktop UI")
|
||||
checkout = self._checkout or gateway_cache_dir()
|
||||
env_path = env_file_path()
|
||||
self.ensure_runtime()
|
||||
if not os.path.isdir(checkout) or not self._runtime.compose_args:
|
||||
self.managed_by_us = False
|
||||
return RunnerResult(ok=True, message="nothing to stop")
|
||||
if not os.path.isfile(env_path):
|
||||
# Still try down with a throwaway env so project name matches.
|
||||
token = ensure_token_file()
|
||||
env_path = write_env_file(token=token, lan_publish=False)
|
||||
|
||||
completed = self._compose(
|
||||
["-f", "compose.yaml", "--profile", "cpu", "down"],
|
||||
cwd=checkout,
|
||||
env_file=env_path,
|
||||
timeout=180,
|
||||
)
|
||||
self.managed_by_us = False
|
||||
if completed.returncode != 0:
|
||||
stderr = (completed.stderr or b"").decode("utf-8", errors="replace")
|
||||
message = stderr.strip()[-800:] or "compose down failed"
|
||||
self.last_error = message
|
||||
return RunnerResult(ok=False, message=message, returncode=completed.returncode)
|
||||
self.last_error = ""
|
||||
return RunnerResult(ok=True, message="stopped", returncode=0)
|
||||
|
||||
def republish(self, *, lan_publish: bool, public_url: str | None = None) -> RunnerResult:
|
||||
"""Rewrite publish host / PUBLIC_URL and recreate the gateway service.
|
||||
|
||||
Used when LAN is toggled while compose is already managed by us so the
|
||||
phone-facing bind and pairing URL match the new setting.
|
||||
|
||||
``prepare`` writes the new ``VOCAGATEWAY_PUBLISH_HOST`` before
|
||||
``compose up --force-recreate``. If recreate fails, the old container
|
||||
is still bound to the previous host, so restore the snapshot ``.env``.
|
||||
"""
|
||||
with self._lock:
|
||||
if not self.managed_by_us:
|
||||
return RunnerResult(
|
||||
ok=False,
|
||||
message="gateway is not managed by this session",
|
||||
returncode=1,
|
||||
)
|
||||
env_path = env_file_path()
|
||||
previous_env = _read_env_bytes(env_path)
|
||||
try:
|
||||
checkout, _token, env_path = self.prepare(
|
||||
lan_publish=lan_publish, public_url=public_url
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_restore_env_snapshot(env_file_path(), previous_env)
|
||||
self.last_error = str(exc)
|
||||
return RunnerResult(ok=False, message=str(exc), returncode=1)
|
||||
|
||||
completed = self._compose(
|
||||
[
|
||||
"-f",
|
||||
"compose.yaml",
|
||||
"--profile",
|
||||
"cpu",
|
||||
"up",
|
||||
"-d",
|
||||
"--force-recreate",
|
||||
"--no-build",
|
||||
"gateway",
|
||||
],
|
||||
cwd=checkout,
|
||||
env_file=env_path,
|
||||
timeout=300,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
_restore_env_snapshot(env_path, previous_env)
|
||||
stderr = (completed.stderr or b"").decode("utf-8", errors="replace")
|
||||
message = stderr.strip()[-800:] or "compose republish failed"
|
||||
self.last_error = message
|
||||
return RunnerResult(ok=False, message=message, returncode=completed.returncode)
|
||||
|
||||
self.managed_by_us = True
|
||||
self.last_error = ""
|
||||
return RunnerResult(ok=True, message="republished", returncode=0)
|
||||
|
||||
def is_compose_running(self) -> bool:
|
||||
"""Best-effort ``compose ps -q gateway``."""
|
||||
checkout = self._checkout or gateway_cache_dir()
|
||||
env_path = env_file_path()
|
||||
if not os.path.isdir(checkout) or not os.path.isfile(env_path):
|
||||
return False
|
||||
if not self._runtime_ready or not self._runtime.compose_args:
|
||||
return False
|
||||
try:
|
||||
completed = self._compose(
|
||||
["-f", "compose.yaml", "--profile", "cpu", "ps", "-q", "gateway"],
|
||||
cwd=checkout,
|
||||
env_file=env_path,
|
||||
timeout=30,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
out = (completed.stdout or b"").decode("utf-8", errors="replace").strip()
|
||||
return completed.returncode == 0 and bool(out)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Detect a usable container runtime (podman first, then docker)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Callable, Optional, Sequence
|
||||
|
||||
from vocalinux.utils.host_process import host_env
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContainerRuntime(str, Enum):
|
||||
PODMAN = "podman"
|
||||
DOCKER = "docker"
|
||||
NONE = "none"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeInfo:
|
||||
kind: ContainerRuntime
|
||||
binary: Optional[str] = None
|
||||
compose_args: tuple[str, ...] = ()
|
||||
hint: str = ""
|
||||
|
||||
|
||||
def _which(name: str) -> Optional[str]:
|
||||
return shutil.which(name)
|
||||
|
||||
|
||||
def _run_ok(argv: Sequence[str], *, timeout: float = 3.0) -> bool:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
list(argv),
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
env=host_env(),
|
||||
)
|
||||
return completed.returncode == 0
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
|
||||
def _compose_argv(
|
||||
binary: str,
|
||||
kind: ContainerRuntime,
|
||||
*,
|
||||
path_lookup: Callable[[str], Optional[str]],
|
||||
probe: Callable[..., bool],
|
||||
) -> tuple[str, ...]:
|
||||
if probe([binary, "compose", "version"]):
|
||||
return (binary, "compose")
|
||||
legacy = "podman-compose" if kind is ContainerRuntime.PODMAN else "docker-compose"
|
||||
legacy_path = path_lookup(legacy)
|
||||
if legacy_path and probe([legacy_path, "version"]):
|
||||
return (legacy_path,)
|
||||
return ()
|
||||
|
||||
|
||||
def detect_container_runtime(
|
||||
*,
|
||||
path_lookup: Callable[[str], Optional[str]] = _which,
|
||||
probe: Callable[..., bool] = _run_ok,
|
||||
) -> RuntimeInfo:
|
||||
"""Prefer podman, fall back to docker, else none with an install hint."""
|
||||
podman = path_lookup("podman")
|
||||
if podman and (probe([podman, "info"]) or probe([podman, "version"])):
|
||||
compose_args = _compose_argv(
|
||||
podman, ContainerRuntime.PODMAN, path_lookup=path_lookup, probe=probe
|
||||
)
|
||||
return RuntimeInfo(
|
||||
kind=ContainerRuntime.PODMAN,
|
||||
binary=podman,
|
||||
compose_args=compose_args,
|
||||
hint=(
|
||||
""
|
||||
if compose_args
|
||||
else (
|
||||
"podman is installed but Compose is missing. "
|
||||
"Install the Compose plugin or podman-compose, then reopen Settings."
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
docker = path_lookup("docker")
|
||||
if docker and (probe([docker, "info"]) or probe([docker, "version"])):
|
||||
compose_args = _compose_argv(
|
||||
docker, ContainerRuntime.DOCKER, path_lookup=path_lookup, probe=probe
|
||||
)
|
||||
return RuntimeInfo(
|
||||
kind=ContainerRuntime.DOCKER,
|
||||
binary=docker,
|
||||
compose_args=compose_args,
|
||||
hint=(
|
||||
""
|
||||
if compose_args
|
||||
else (
|
||||
"docker is installed but Compose is missing. "
|
||||
"Install the Compose plugin or docker-compose, then reopen Settings."
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
if podman:
|
||||
return RuntimeInfo(
|
||||
kind=ContainerRuntime.NONE,
|
||||
binary=None,
|
||||
hint=(
|
||||
"podman is installed but not usable right now. "
|
||||
"Start the podman service or check permissions, then reopen Settings."
|
||||
),
|
||||
)
|
||||
if docker:
|
||||
return RuntimeInfo(
|
||||
kind=ContainerRuntime.NONE,
|
||||
binary=None,
|
||||
hint=(
|
||||
"docker is installed but not usable right now. "
|
||||
"Start the docker service or add your user to the docker group, "
|
||||
"then reopen Settings."
|
||||
),
|
||||
)
|
||||
|
||||
return RuntimeInfo(
|
||||
kind=ContainerRuntime.NONE,
|
||||
binary=None,
|
||||
hint=(
|
||||
"Install podman (preferred) or docker to run a local VocaGateway. "
|
||||
"Vocalinux does not bundle a container engine in the AppImage."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def resolve_runtime_info() -> RuntimeInfo:
|
||||
"""Production entry point."""
|
||||
return detect_container_runtime()
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Sandbox / packaging detection for container socket access."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SandboxState:
|
||||
"""Whether the desktop process can reach a host container engine."""
|
||||
|
||||
blocked: bool
|
||||
kind: str # "", "flatpak", "appimage"
|
||||
hint: str = ""
|
||||
|
||||
|
||||
def detect_sandbox(
|
||||
environ: dict[str, str] | None = None,
|
||||
) -> SandboxState:
|
||||
"""Fail closed inside Flatpak; warn for AppImage with a docs hint.
|
||||
|
||||
Flatpak cannot see the host podman/docker socket unless the user has
|
||||
explicitly granted filesystem/socket permissions. We do not try to
|
||||
smuggle a daemon into the sandbox.
|
||||
"""
|
||||
env = environ if environ is not None else os.environ
|
||||
flatpak_id = (env.get("FLATPAK_ID") or "").strip()
|
||||
if flatpak_id:
|
||||
return SandboxState(
|
||||
blocked=True,
|
||||
kind="flatpak",
|
||||
hint=(
|
||||
"Vocalinux is running as a Flatpak and cannot reach the host "
|
||||
"podman/docker socket. Run Vocalinux from the native package or "
|
||||
"AppImage, or start VocaGateway on the host and point Remote "
|
||||
"Server at it. See docs/GATEWAY_EMBED.md."
|
||||
),
|
||||
)
|
||||
|
||||
appimage = (env.get("APPIMAGE") or "").strip()
|
||||
if appimage:
|
||||
# AppImage often can reach the host socket; still surface honesty.
|
||||
return SandboxState(
|
||||
blocked=False,
|
||||
kind="appimage",
|
||||
hint=(
|
||||
"Running from an AppImage. Local VocaGateway uses the host "
|
||||
"podman/docker socket when available; the AppImage does not "
|
||||
"bundle a container engine."
|
||||
),
|
||||
)
|
||||
|
||||
return SandboxState(blocked=False, kind="", hint="")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Status labels for the embedded local VocaGateway."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from enum import Enum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GatewayStatus(str, Enum):
|
||||
"""User-visible lifecycle states for the local gateway."""
|
||||
|
||||
STOPPED = "Stopped"
|
||||
STARTING = "Starting"
|
||||
LIVE = "Live"
|
||||
PAIRABLE = "Pairable"
|
||||
READY = "Ready"
|
||||
ERROR = "Error"
|
||||
|
||||
@classmethod
|
||||
def from_health(
|
||||
cls,
|
||||
*,
|
||||
live: bool,
|
||||
ready: bool,
|
||||
pairable: bool,
|
||||
error: bool = False,
|
||||
starting: bool = False,
|
||||
running: bool = False,
|
||||
) -> "GatewayStatus":
|
||||
"""Map health probes into a single status label.
|
||||
|
||||
Ready implies Live. Pairable is Live plus a phone-reachable
|
||||
(non-loopback) URL and auth material, and may precede Ready.
|
||||
"""
|
||||
if error:
|
||||
return cls.ERROR
|
||||
if starting:
|
||||
return cls.STARTING
|
||||
if not running and not live:
|
||||
return cls.STOPPED
|
||||
if ready:
|
||||
return cls.READY
|
||||
if pairable:
|
||||
return cls.PAIRABLE
|
||||
if live:
|
||||
return cls.LIVE
|
||||
if running:
|
||||
return cls.STARTING
|
||||
return cls.STOPPED
|
||||
@@ -0,0 +1,184 @@
|
||||
"""URL helpers for pairing / QR display (never advertise unreachable hosts).
|
||||
|
||||
Phone QR and Pairable must only use hosts a phone can open on the LAN (or
|
||||
an explicit public override). We reject:
|
||||
|
||||
* loopback / unspecified: 127.0.0.0/8 (including short-form 127.1), ::1,
|
||||
localhost / localhost.*, 0.0.0.0, ::
|
||||
* link-local: 169.254.0.0/16, fe80::/10
|
||||
* default container bridges (not host-facing LAN): 172.17.0.0/16 (docker0),
|
||||
10.88.0.0/16 (podman default). Broader RFC1918 (10/8, 172.16/12, 192.168/16)
|
||||
stays allowed when lan_publish is on so real LAN NICs still work.
|
||||
|
||||
Optional: if docker0/podman0/cni-podman0 addresses are readable, those exact
|
||||
interface IPs are also rejected even outside the default ranges.
|
||||
|
||||
Aligned with vocagateway desktop-embed pairing host policy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
from functools import lru_cache
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from vocalinux.utils.host_process import host_env
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LOOPBACK_HOSTS = frozenset(
|
||||
{
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"[::1]",
|
||||
"0.0.0.0",
|
||||
"[::]",
|
||||
}
|
||||
)
|
||||
|
||||
# Default engine bridges only — not the whole of 172.16.0.0/12 (private LAN).
|
||||
_CONTAINER_BRIDGE_NETWORKS = (
|
||||
ipaddress.ip_network("172.17.0.0/16"), # docker0 default
|
||||
ipaddress.ip_network("10.88.0.0/16"), # podman default
|
||||
)
|
||||
|
||||
_BRIDGE_IFACE_NAMES = frozenset(
|
||||
{
|
||||
"docker0",
|
||||
"podman0",
|
||||
"cni-podman0",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _normalize_host(host: str | None) -> str | None:
|
||||
if not host:
|
||||
return None
|
||||
cleaned = host.strip().lower().rstrip(".")
|
||||
if cleaned.startswith("[") and cleaned.endswith("]"):
|
||||
cleaned = cleaned[1:-1]
|
||||
# Zone indices (fe80::1%eth0) are not phone-QR material.
|
||||
if "%" in cleaned:
|
||||
cleaned = cleaned.split("%", 1)[0]
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _parse_short_ipv4_host(host: str) -> ipaddress.IPv4Address | None:
|
||||
"""Parse abbreviated IPv4 (``127.1``) that ``ip_address`` rejects."""
|
||||
try:
|
||||
return ipaddress.IPv4Address(socket.inet_aton(host))
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_host_ip(host: str | None) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
|
||||
cleaned = _normalize_host(host)
|
||||
if not cleaned:
|
||||
return None
|
||||
try:
|
||||
return ipaddress.ip_address(cleaned)
|
||||
except ValueError:
|
||||
return _parse_short_ipv4_host(cleaned)
|
||||
|
||||
|
||||
def is_loopback_host(host: str | None) -> bool:
|
||||
"""Return True when *host* is loopback, unspecified, or localhost[.]."""
|
||||
cleaned = _normalize_host(host)
|
||||
if cleaned is None:
|
||||
return True
|
||||
if cleaned in _LOOPBACK_HOSTS or cleaned == "localhost" or cleaned.startswith("localhost."):
|
||||
return True
|
||||
addr = _parse_host_ip(cleaned)
|
||||
if addr is None:
|
||||
return False
|
||||
return bool(addr.is_loopback or addr.is_unspecified)
|
||||
|
||||
|
||||
def is_link_local_host(host: str | None) -> bool:
|
||||
"""Return True when *host* is link-local (not phone-reachable across LAN)."""
|
||||
addr = _parse_host_ip(host)
|
||||
if addr is None:
|
||||
return False
|
||||
return bool(addr.is_link_local)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _bridge_iface_ips() -> frozenset[str]:
|
||||
"""Best-effort IPs bound to docker0 / podman0 / cni-podman0."""
|
||||
import subprocess
|
||||
|
||||
found: set[str] = set()
|
||||
for iface in _BRIDGE_IFACE_NAMES:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["ip", "-o", "addr", "show", "dev", iface],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=1.0,
|
||||
text=True,
|
||||
env=host_env(),
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
continue
|
||||
if completed.returncode != 0 or not completed.stdout:
|
||||
continue
|
||||
for token in completed.stdout.replace("/", " ").split():
|
||||
ip = _parse_host_ip(token)
|
||||
if ip is not None and not ip.is_loopback:
|
||||
found.add(str(ip))
|
||||
return frozenset(found)
|
||||
|
||||
|
||||
def is_container_bridge_host(host: str | None) -> bool:
|
||||
"""True for default docker0/podman bridge addresses (not phone-reachable LAN)."""
|
||||
addr = _parse_host_ip(host)
|
||||
if addr is None:
|
||||
return False
|
||||
if any(addr in net for net in _CONTAINER_BRIDGE_NETWORKS):
|
||||
return True
|
||||
try:
|
||||
return str(addr) in _bridge_iface_ips()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("bridge iface probe failed", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def is_unusable_phone_host(host: str | None) -> bool:
|
||||
"""True when a phone QR must not advertise this host."""
|
||||
return is_loopback_host(host) or is_link_local_host(host) or is_container_bridge_host(host)
|
||||
|
||||
|
||||
def is_loopback_url(url: str | None) -> bool:
|
||||
"""Return True when *url* points at a loopback / unspecified host."""
|
||||
if not url or not str(url).strip():
|
||||
return True
|
||||
try:
|
||||
parsed = urlparse(str(url).strip())
|
||||
except Exception:
|
||||
return True
|
||||
return is_loopback_host(parsed.hostname)
|
||||
|
||||
|
||||
def is_unusable_phone_url(url: str | None) -> bool:
|
||||
"""True when *url* is loopback, link-local, or a container-bridge address."""
|
||||
if not url or not str(url).strip():
|
||||
return True
|
||||
try:
|
||||
parsed = urlparse(str(url).strip())
|
||||
except Exception:
|
||||
return True
|
||||
return is_unusable_phone_host(parsed.hostname)
|
||||
|
||||
|
||||
def reject_loopback_url(url: str | None) -> str | None:
|
||||
"""Return *url* when phone-reachable, else None.
|
||||
|
||||
Rejects loopback, link-local, and default container-bridge hosts so callers
|
||||
never put those into a QR or Pairable display.
|
||||
"""
|
||||
if is_unusable_phone_url(url):
|
||||
return None
|
||||
return str(url).strip()
|
||||
@@ -212,6 +212,10 @@ DEFAULT_CONFIG = {
|
||||
# Tag last announced via desktop notification (avoids re-notifying every 6h).
|
||||
"last_notified_version": "",
|
||||
},
|
||||
# Optional local VocaGateway (podman/docker). Never flips the default engine.
|
||||
"gateway_embed": {
|
||||
"lan_publish": False, # VOCAGATEWAY_PUBLISH_HOST=0.0.0.0 when True (Phone on LAN)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ gi.require_version("Gdk", "3.0")
|
||||
from gi.repository import Gdk, GLib, GObject, Gtk, Pango # noqa: E402
|
||||
|
||||
from ..common_types import RecognitionState, _EvdevCaptureDevice # noqa: E402
|
||||
from ..gateway_embed import GatewayStatus, get_gateway_embed_manager # noqa: E402
|
||||
from ..speech_recognition.silero_vad import is_silero_available # noqa: E402
|
||||
from ..utils import parakeet_model_info as parakeet # noqa: E402
|
||||
from ..utils.faster_whisper_model_info import delete_model as delete_faster_whisper_model
|
||||
@@ -2582,6 +2583,7 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self._build_simple_model_section()
|
||||
self._build_engine_section()
|
||||
self._build_remote_server_section()
|
||||
self._build_gateway_embed_section()
|
||||
self._build_audio_section()
|
||||
self._build_auto_pause_section()
|
||||
self._build_model_keepalive_section()
|
||||
@@ -3669,7 +3671,7 @@ class SettingsDialog(Gtk.Dialog):
|
||||
_,
|
||||
) = _make_expander_card(
|
||||
"Advanced",
|
||||
"Engine, model, and remote server",
|
||||
"Engine, model, remote server, and local VocaGateway",
|
||||
)
|
||||
self.advanced_box.set_spacing(12)
|
||||
self.advanced_box.set_margin_top(8)
|
||||
@@ -5480,6 +5482,309 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self.remote_server_group.hide()
|
||||
self.remote_status_label.hide()
|
||||
|
||||
def _build_gateway_embed_section(self) -> None:
|
||||
"""Optional local VocaGateway controls (podman-first). Lives on the Advanced island."""
|
||||
self._gateway_manager = get_gateway_embed_manager()
|
||||
# Do not clobber LAN restored from leftover compose (orphan adopt).
|
||||
if not self._gateway_manager.managed_by_us:
|
||||
self._gateway_manager.lan_publish = bool(
|
||||
self.config_manager.get("gateway_embed", "lan_publish", False)
|
||||
)
|
||||
lan_publish = bool(self._gateway_manager.lan_publish)
|
||||
|
||||
self.gateway_embed_group = PreferencesGroup(
|
||||
title="Local VocaGateway",
|
||||
description=(
|
||||
"Optional self-hosted gateway via podman (or docker). "
|
||||
"Audio goes to that container on this machine; it is not on-device "
|
||||
"whisper.cpp. Pinned to VocaGateway v0.1.0."
|
||||
),
|
||||
keywords=(
|
||||
"vocagateway",
|
||||
"gateway",
|
||||
"podman",
|
||||
"docker",
|
||||
"pairing",
|
||||
"phone",
|
||||
"remote",
|
||||
),
|
||||
)
|
||||
|
||||
self.gateway_run_btn = Gtk.Button(label="Run VocaGateway locally")
|
||||
self.gateway_run_btn.set_size_request(_CONTROL_WIDTH, -1)
|
||||
self.gateway_run_btn.connect("clicked", self._on_gateway_run_clicked)
|
||||
self.gateway_embed_group.add_row(
|
||||
PreferenceRow(
|
||||
title="Local gateway",
|
||||
subtitle="Start or stop the compose project vocagateway",
|
||||
widget=self.gateway_run_btn,
|
||||
)
|
||||
)
|
||||
|
||||
self.gateway_status_label = Gtk.Label(label="Stopped", xalign=0)
|
||||
self.gateway_status_label.get_style_context().add_class("status-info")
|
||||
self.gateway_embed_group.add_row(
|
||||
PreferenceRow(
|
||||
title="Status",
|
||||
subtitle="Stopped, Starting, Live, Pairable, Ready, or Error",
|
||||
widget=self.gateway_status_label,
|
||||
)
|
||||
)
|
||||
|
||||
self.gateway_detail_label = Gtk.Label(label="", xalign=0, wrap=True)
|
||||
self.gateway_detail_label.set_max_width_chars(55)
|
||||
self.gateway_detail_label.get_style_context().add_class("preference-row-subtitle")
|
||||
detail_row = Gtk.ListBoxRow()
|
||||
detail_row.set_activatable(False)
|
||||
detail_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||
detail_box.set_margin_start(16)
|
||||
detail_box.set_margin_end(16)
|
||||
detail_box.set_margin_top(4)
|
||||
detail_box.set_margin_bottom(8)
|
||||
detail_box.pack_start(self.gateway_detail_label, False, False, 0)
|
||||
detail_row.add(detail_box)
|
||||
self.gateway_embed_group.add_row(detail_row)
|
||||
|
||||
self.gateway_lan_switch = Gtk.Switch()
|
||||
self.gateway_lan_switch.set_active(lan_publish)
|
||||
self.gateway_lan_switch.connect("notify::active", self._on_gateway_lan_toggled)
|
||||
self.gateway_embed_group.add_row(
|
||||
PreferenceRow(
|
||||
title="Allow LAN access for Phone",
|
||||
subtitle=(
|
||||
"Publishes 0.0.0.0:8765 and prefers a LAN URL in pairing. "
|
||||
"Open the port in your firewall on trusted networks only."
|
||||
),
|
||||
widget=self.gateway_lan_switch,
|
||||
)
|
||||
)
|
||||
|
||||
self.gateway_use_btn = Gtk.Button(label="Use this Gateway")
|
||||
self.gateway_use_btn.set_sensitive(False)
|
||||
self.gateway_use_btn.set_tooltip_text(
|
||||
"Fill Remote Server with this gateway URL and bearer token "
|
||||
"(engine remote_api, endpoint /v1/audio/transcriptions)"
|
||||
)
|
||||
self.gateway_use_btn.connect("clicked", self._on_gateway_use_clicked)
|
||||
self.gateway_embed_group.add_row(
|
||||
PreferenceRow(
|
||||
title="Remote API preset",
|
||||
subtitle="Points Vocalinux at the local gateway",
|
||||
widget=self.gateway_use_btn,
|
||||
)
|
||||
)
|
||||
|
||||
self.gateway_qr_image = Gtk.Image()
|
||||
self.gateway_qr_image.set_no_show_all(True)
|
||||
self.gateway_pairing_label = Gtk.Label(label="", xalign=0, wrap=True, selectable=True)
|
||||
self.gateway_pairing_label.set_max_width_chars(55)
|
||||
self.gateway_pairing_label.get_style_context().add_class("preference-row-subtitle")
|
||||
pairing_row = Gtk.ListBoxRow()
|
||||
pairing_row.set_activatable(False)
|
||||
pairing_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||
pairing_box.set_margin_start(16)
|
||||
pairing_box.set_margin_end(16)
|
||||
pairing_box.set_margin_top(8)
|
||||
pairing_box.set_margin_bottom(12)
|
||||
title = Gtk.Label(label="Phone pairing", xalign=0)
|
||||
title.get_style_context().add_class("preference-row-title")
|
||||
pairing_box.pack_start(title, False, False, 0)
|
||||
pairing_box.pack_start(self.gateway_qr_image, False, False, 0)
|
||||
pairing_box.pack_start(self.gateway_pairing_label, False, False, 0)
|
||||
pairing_row.add(pairing_box)
|
||||
self.gateway_embed_group.add_row(pairing_row)
|
||||
|
||||
if self.advanced_box is not None:
|
||||
self.advanced_box.pack_start(self.gateway_embed_group, False, False, 0)
|
||||
|
||||
self._gateway_manager.add_listener(self._on_gateway_status_from_worker)
|
||||
self.connect("destroy", self._on_gateway_embed_dialog_destroy)
|
||||
# Runtime probe is async so Settings never blocks on podman/docker.
|
||||
# When runtime is already ready, this probes leftover compose immediately.
|
||||
self._gateway_manager.begin_runtime_detection()
|
||||
if not self._gateway_manager.runtime_ready:
|
||||
self.gateway_run_btn.set_sensitive(False)
|
||||
self.gateway_detail_label.set_text("Detecting container runtime…")
|
||||
elif not self._gateway_manager.available:
|
||||
self.gateway_run_btn.set_sensitive(False)
|
||||
self.gateway_detail_label.set_text(self._gateway_manager.unavailable_hint)
|
||||
else:
|
||||
hint = getattr(self._gateway_manager.runner.sandbox, "hint", "") or ""
|
||||
if hint:
|
||||
self.gateway_detail_label.set_text(hint)
|
||||
|
||||
self._apply_gateway_status_ui(
|
||||
self._gateway_manager.status, self._gateway_manager.status_detail
|
||||
)
|
||||
|
||||
def _on_gateway_embed_dialog_destroy(self, *_args) -> None:
|
||||
"""Drop status listener so polls never touch a destroyed Settings dialog."""
|
||||
manager = getattr(self, "_gateway_manager", None)
|
||||
if manager is not None:
|
||||
manager.remove_listener(self._on_gateway_status_from_worker)
|
||||
|
||||
def _on_gateway_status_from_worker(self, status: GatewayStatus, detail: str) -> None:
|
||||
"""Marshal status updates onto the GTK main loop."""
|
||||
GLib.idle_add(self._apply_gateway_status_ui, status, detail)
|
||||
|
||||
def _apply_gateway_status_ui(self, status: GatewayStatus, detail: str) -> bool:
|
||||
if not hasattr(self, "gateway_status_label"):
|
||||
return False
|
||||
if not self._dialog_is_alive():
|
||||
return False
|
||||
label = status.value if isinstance(status, GatewayStatus) else str(status)
|
||||
self.gateway_status_label.set_text(label)
|
||||
if detail:
|
||||
self.gateway_detail_label.set_text(detail)
|
||||
|
||||
runningish = status in {
|
||||
GatewayStatus.STARTING,
|
||||
GatewayStatus.LIVE,
|
||||
GatewayStatus.PAIRABLE,
|
||||
GatewayStatus.READY,
|
||||
GatewayStatus.ERROR,
|
||||
}
|
||||
if not self._gateway_manager.runtime_ready:
|
||||
self.gateway_run_btn.set_sensitive(False)
|
||||
if not detail:
|
||||
self.gateway_detail_label.set_text("Detecting container runtime…")
|
||||
elif self._gateway_manager.available:
|
||||
if runningish or self._gateway_manager.managed_by_us:
|
||||
self.gateway_run_btn.set_label("Stop local Gateway")
|
||||
else:
|
||||
self.gateway_run_btn.set_label("Run VocaGateway locally")
|
||||
self.gateway_run_btn.set_sensitive(status is not GatewayStatus.STARTING)
|
||||
else:
|
||||
self.gateway_run_btn.set_sensitive(False)
|
||||
if not detail:
|
||||
self.gateway_detail_label.set_text(self._gateway_manager.unavailable_hint)
|
||||
|
||||
can_use = status in {GatewayStatus.PAIRABLE, GatewayStatus.READY}
|
||||
self.gateway_use_btn.set_sensitive(can_use)
|
||||
self._sync_gateway_lan_switch()
|
||||
self._update_gateway_pairing_widgets()
|
||||
return False
|
||||
|
||||
def _sync_gateway_lan_switch(self) -> None:
|
||||
"""Keep Allow LAN honest with compose; skip the toggle handler."""
|
||||
desired = bool(self._gateway_manager.lan_publish)
|
||||
if bool(self.gateway_lan_switch.get_active()) != desired:
|
||||
was_init = bool(getattr(self, "_initializing", False))
|
||||
self._initializing = True
|
||||
try:
|
||||
self.gateway_lan_switch.set_active(desired)
|
||||
finally:
|
||||
self._initializing = was_init
|
||||
saved = bool(self.config_manager.get("gateway_embed", "lan_publish", False))
|
||||
if saved != desired:
|
||||
self.config_manager.set("gateway_embed", "lan_publish", desired)
|
||||
self.config_manager.save_config()
|
||||
|
||||
def _update_gateway_pairing_widgets(self) -> None:
|
||||
info = self._gateway_manager.pairing
|
||||
if info is None:
|
||||
self._gateway_qr_cache_key = None
|
||||
self.gateway_qr_image.hide()
|
||||
self.gateway_qr_image.clear()
|
||||
self.gateway_pairing_label.set_text(
|
||||
"Pairing QR appears when the gateway is Live with a phone-reachable URL "
|
||||
"(enable LAN for Phone, or set VOCAGATEWAY_PUBLIC_URL)."
|
||||
)
|
||||
return
|
||||
if not info.display_url:
|
||||
self._gateway_qr_cache_key = None
|
||||
self.gateway_qr_image.hide()
|
||||
self.gateway_qr_image.clear()
|
||||
self.gateway_pairing_label.set_text(
|
||||
"Gateway is live on loopback only. Enable LAN access for Phone "
|
||||
"(or configure a non-loopback PUBLIC_URL) before pairing another device. "
|
||||
"Token is held for Use this Gateway and is not shown here."
|
||||
)
|
||||
return
|
||||
|
||||
self.gateway_pairing_label.set_text(
|
||||
f"URL: {info.display_url}\n"
|
||||
"Token is available to Use this Gateway and the QR; it is not logged."
|
||||
)
|
||||
if info.qr_svg:
|
||||
cache_key = (info.display_url, len(info.qr_svg))
|
||||
if getattr(self, "_gateway_qr_cache_key", None) != cache_key:
|
||||
pixbuf = self._pixbuf_from_svg_bytes(info.qr_svg)
|
||||
if pixbuf is not None:
|
||||
self.gateway_qr_image.set_from_pixbuf(pixbuf)
|
||||
self._gateway_qr_cache_key = cache_key
|
||||
if getattr(self, "_gateway_qr_cache_key", None) == cache_key:
|
||||
self.gateway_qr_image.show()
|
||||
else:
|
||||
self._gateway_qr_cache_key = None
|
||||
self.gateway_qr_image.hide()
|
||||
self.gateway_qr_image.clear()
|
||||
|
||||
def _pixbuf_from_svg_bytes(self, data: bytes) -> Any:
|
||||
"""Best-effort SVG to GdkPixbuf; returns None when loaders are missing."""
|
||||
try:
|
||||
from gi.repository import GdkPixbuf
|
||||
|
||||
loader = GdkPixbuf.PixbufLoader.new_with_type("svg")
|
||||
loader.write(data[: 512 * 1024])
|
||||
loader.close()
|
||||
return loader.get_pixbuf()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _on_gateway_run_clicked(self, widget: Any) -> None:
|
||||
if not self._gateway_manager.available:
|
||||
return
|
||||
status = self._gateway_manager.status
|
||||
if (
|
||||
status
|
||||
in {
|
||||
GatewayStatus.STARTING,
|
||||
GatewayStatus.LIVE,
|
||||
GatewayStatus.PAIRABLE,
|
||||
GatewayStatus.READY,
|
||||
GatewayStatus.ERROR,
|
||||
}
|
||||
or self._gateway_manager.managed_by_us
|
||||
):
|
||||
self._gateway_manager.stop_async()
|
||||
else:
|
||||
lan = bool(self.gateway_lan_switch.get_active())
|
||||
self._gateway_manager.lan_publish = lan
|
||||
self._gateway_manager.start_async(lan_publish=lan)
|
||||
|
||||
def _on_gateway_lan_toggled(self, widget: Any, _pspec: Any = None) -> None:
|
||||
if getattr(self, "_initializing", False):
|
||||
return
|
||||
active = bool(self.gateway_lan_switch.get_active())
|
||||
self.config_manager.set("gateway_embed", "lan_publish", active)
|
||||
self.config_manager.save_config()
|
||||
# Republish when compose is already running so QR/bind match LAN.
|
||||
self._gateway_manager.apply_lan_publish(active)
|
||||
self._update_gateway_pairing_widgets()
|
||||
|
||||
def _on_gateway_use_clicked(self, widget: Any) -> None:
|
||||
try:
|
||||
preset = self._gateway_manager.use_this_gateway(self.config_manager)
|
||||
except Exception as exc:
|
||||
self.gateway_detail_label.set_text(str(exc))
|
||||
return
|
||||
self._applying_settings = True
|
||||
try:
|
||||
self.remote_api_url_entry.set_text(preset["remote_api_url"])
|
||||
self.remote_api_key_entry.set_text(preset["remote_api_key"])
|
||||
self.remote_api_endpoint_combo.set_active_id(preset["remote_api_endpoint"])
|
||||
self.remote_api_model_entry.set_text(preset.get("remote_api_model") or "whisper-1")
|
||||
if hasattr(self, "engine_combo"):
|
||||
self.engine_combo.set_active_id("remote_api")
|
||||
finally:
|
||||
self._applying_settings = False
|
||||
self._auto_apply_settings()
|
||||
self.gateway_detail_label.set_text(
|
||||
"Remote Server now points at this gateway " f"({preset['remote_api_endpoint']})."
|
||||
)
|
||||
self._update_engine_specific_ui()
|
||||
|
||||
def _on_power_user_toggled(self, widget, state):
|
||||
"""Handle the power-user opt-in toggle."""
|
||||
if self._initializing or self._applying_settings:
|
||||
|
||||
@@ -10,7 +10,7 @@ import os
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Optional
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import gi
|
||||
|
||||
@@ -37,6 +37,7 @@ from gi.repository import GdkPixbuf, Gio, GLib, GObject, Gtk
|
||||
# Import local modules - Use protocols to avoid circular imports
|
||||
from ..auto_pause_monitor import DEFAULT_POLL_INTERVAL_SECONDS, AutoPauseMonitor
|
||||
from ..common_types import RecognitionState, SpeechRecognitionManagerProtocol, TextInjectorProtocol
|
||||
from ..gateway_embed import GatewayStatus, get_gateway_embed_manager
|
||||
from ..model_keepalive import DEFAULT_IDLE_TIMEOUT_SECONDS, ModelKeepAlive
|
||||
from ..suspend_handler import SuspendHandler
|
||||
from ..utils.host_process import host_env
|
||||
@@ -343,6 +344,11 @@ class TrayIndicator:
|
||||
self._add_menu_separator()
|
||||
self._add_menu_item("Settings", self._on_settings_clicked)
|
||||
self._add_menu_item("View Logs", self._on_logs_clicked)
|
||||
self._gateway_stop_menu_item = self._add_menu_item(
|
||||
"Stop local Gateway", self._on_stop_local_gateway_clicked
|
||||
)
|
||||
self._gateway_stop_menu_item.set_no_show_all(True)
|
||||
self._gateway_stop_menu_item.hide()
|
||||
self._add_menu_separator()
|
||||
# Hidden until a background check finds a newer release.
|
||||
self._update_menu_item = self._add_menu_item(
|
||||
@@ -363,6 +369,12 @@ class TrayIndicator:
|
||||
self._update_menu_item.hide()
|
||||
else:
|
||||
self._show_update_menu_item(self._pending_update)
|
||||
self._gateway_stop_menu_item.hide()
|
||||
self._gateway_manager = get_gateway_embed_manager()
|
||||
self._gateway_manager.add_listener(self._on_gateway_status_for_tray)
|
||||
# Runtime detect + leftover compose probe (Quit does not stop compose).
|
||||
self._gateway_manager.begin_runtime_detection()
|
||||
self._sync_gateway_stop_menu(self._gateway_manager.status)
|
||||
|
||||
# Update the UI based on the initial state
|
||||
self._update_ui(RecognitionState.IDLE)
|
||||
@@ -1154,6 +1166,32 @@ class TrayIndicator:
|
||||
self._input_monitor.cancel()
|
||||
self._input_monitor = None
|
||||
|
||||
def _on_gateway_status_for_tray(self, status: GatewayStatus, detail: str) -> None:
|
||||
"""Update tray Stop local Gateway visibility from a worker thread."""
|
||||
GLib.idle_add(self._sync_gateway_stop_menu, status)
|
||||
|
||||
def _sync_gateway_stop_menu(self, status: GatewayStatus) -> bool:
|
||||
item = getattr(self, "_gateway_stop_menu_item", None)
|
||||
if item is None:
|
||||
return False
|
||||
# Show Stop for leftover compose too; managed_by_us is session memory only.
|
||||
show = status in {
|
||||
GatewayStatus.STARTING,
|
||||
GatewayStatus.LIVE,
|
||||
GatewayStatus.PAIRABLE,
|
||||
GatewayStatus.READY,
|
||||
GatewayStatus.ERROR,
|
||||
}
|
||||
if show:
|
||||
item.show()
|
||||
else:
|
||||
item.hide()
|
||||
return False
|
||||
|
||||
def _on_stop_local_gateway_clicked(self, widget: Any) -> None:
|
||||
"""Stop local compose, including leftovers from a previous session."""
|
||||
get_gateway_embed_manager().stop_async()
|
||||
|
||||
def _on_quit_clicked(self, widget):
|
||||
"""Handle click on the Quit menu item."""
|
||||
logger.debug("Quit clicked")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Regression guards for AppImage packaging."""
|
||||
|
||||
import re
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -17,6 +18,7 @@ BOOT_FAMILIES = {
|
||||
}
|
||||
PINS = APPIMAGE / "tool_checksums.txt"
|
||||
REQUIREMENTS = REPO_ROOT / "requirements"
|
||||
PYPROJECT = REPO_ROOT / "pyproject.toml"
|
||||
WORKFLOWS = REPO_ROOT / ".github" / "workflows"
|
||||
#: Workflows that build an AppImage, and so decide which distros it runs on.
|
||||
APPIMAGE_WORKFLOWS = ("unified-pipeline.yml", "release.yml", "nightly.yml")
|
||||
@@ -300,3 +302,25 @@ def test_boot_test_has_a_package_recipe_for_every_distro_it_runs_on():
|
||||
if family is None or family not in case_block.group(0):
|
||||
missing.append(distro)
|
||||
assert not missing, f"boot-test.sh installs nothing for: {missing}"
|
||||
|
||||
|
||||
def test_setuptools_packages_cover_every_python_subpackage():
|
||||
"""AppImage and Flatpak install the wheel built from an explicit packages
|
||||
list. A new subpackage left off that list boots fine from a source tree and
|
||||
dies in the bundle with ModuleNotFoundError (#774).
|
||||
"""
|
||||
declared = set(
|
||||
tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))["tool"]["setuptools"]["packages"]
|
||||
)
|
||||
src_root = REPO_ROOT / "src" / "vocalinux"
|
||||
discovered = {
|
||||
(
|
||||
"vocalinux"
|
||||
if init.parent == src_root
|
||||
else "vocalinux." + ".".join(init.parent.relative_to(src_root).parts)
|
||||
)
|
||||
for init in src_root.rglob("__init__.py")
|
||||
if "__pycache__" not in init.parts
|
||||
}
|
||||
missing = sorted(discovered - declared)
|
||||
assert not missing, f"add these to tool.setuptools.packages in pyproject.toml: {missing}"
|
||||
|
||||
@@ -0,0 +1,978 @@
|
||||
"""Unit tests for local VocaGateway embed helpers (no real containers)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from vocalinux.gateway_embed.pairing import (
|
||||
MAX_QR_SVG_BYTES,
|
||||
decode_pairing_payload,
|
||||
)
|
||||
from vocalinux.gateway_embed.preset import (
|
||||
GATEWAY_TRANSCRIPTIONS_ENDPOINT,
|
||||
remote_api_preset_from_pairing,
|
||||
)
|
||||
from vocalinux.gateway_embed.runtime import ContainerRuntime, detect_container_runtime
|
||||
from vocalinux.gateway_embed.sandbox import detect_sandbox
|
||||
from vocalinux.gateway_embed.status import GatewayStatus
|
||||
from vocalinux.gateway_embed.urls import is_loopback_url, reject_loopback_url
|
||||
from vocalinux.ui.config_manager import DEFAULT_CONFIG
|
||||
|
||||
|
||||
class TestLoopbackRejection(unittest.TestCase):
|
||||
def test_rejects_localhost_variants(self):
|
||||
for url in (
|
||||
"http://127.0.0.1:8765",
|
||||
"http://localhost:8765",
|
||||
"http://[::1]:8765",
|
||||
"https://127.0.0.1/",
|
||||
"",
|
||||
None,
|
||||
):
|
||||
self.assertTrue(is_loopback_url(url), url)
|
||||
self.assertIsNone(reject_loopback_url(url))
|
||||
|
||||
def test_rejects_short_form_loopback_and_localhost_star(self):
|
||||
# Contract / gateway policy: 127.1 and localhost.* must never be Pairable.
|
||||
for url in (
|
||||
"http://127.1:8765",
|
||||
"http://127.0.1:8765",
|
||||
"http://localhost.localdomain:8765",
|
||||
"http://localhost.foo:8765",
|
||||
):
|
||||
self.assertTrue(is_loopback_url(url), url)
|
||||
self.assertIsNone(reject_loopback_url(url), url)
|
||||
|
||||
def test_rejects_link_local_for_qr(self):
|
||||
for url in (
|
||||
"http://169.254.10.20:8765",
|
||||
"http://[fe80::1]:8765",
|
||||
):
|
||||
self.assertIsNone(reject_loopback_url(url), url)
|
||||
|
||||
def test_rejects_default_container_bridge(self):
|
||||
for url in (
|
||||
"http://172.17.0.1:8765",
|
||||
"http://172.17.0.2:8765",
|
||||
"http://10.88.0.1:8765",
|
||||
):
|
||||
self.assertIsNone(reject_loopback_url(url), url)
|
||||
|
||||
def test_accepts_lan(self):
|
||||
url = "http://192.168.1.20:8765"
|
||||
self.assertFalse(is_loopback_url(url))
|
||||
self.assertEqual(reject_loopback_url(url), url)
|
||||
|
||||
|
||||
class TestRuntimeDetector(unittest.TestCase):
|
||||
def test_prefers_podman(self):
|
||||
def lookup(name):
|
||||
return {
|
||||
"podman": "/usr/bin/podman",
|
||||
"docker": "/usr/bin/docker",
|
||||
}.get(name)
|
||||
|
||||
def probe(argv, **_kwargs):
|
||||
return argv[0] in {"/usr/bin/podman", "/usr/bin/docker"} and argv[1] in {
|
||||
"info",
|
||||
"version",
|
||||
"compose",
|
||||
}
|
||||
|
||||
info = detect_container_runtime(path_lookup=lookup, probe=probe)
|
||||
self.assertEqual(info.kind, ContainerRuntime.PODMAN)
|
||||
self.assertEqual(info.binary, "/usr/bin/podman")
|
||||
|
||||
def test_falls_back_to_docker(self):
|
||||
def lookup(name):
|
||||
return {"docker": "/usr/bin/docker"}.get(name)
|
||||
|
||||
def probe(argv, **_kwargs):
|
||||
return argv[0] == "/usr/bin/docker"
|
||||
|
||||
info = detect_container_runtime(path_lookup=lookup, probe=probe)
|
||||
self.assertEqual(info.kind, ContainerRuntime.DOCKER)
|
||||
|
||||
def test_none_with_hint(self):
|
||||
info = detect_container_runtime(path_lookup=lambda _n: None, probe=lambda *_a, **_k: False)
|
||||
self.assertEqual(info.kind, ContainerRuntime.NONE)
|
||||
self.assertIn("podman", info.hint.lower())
|
||||
|
||||
def test_engine_without_compose_is_unavailable(self):
|
||||
def lookup(name):
|
||||
return {"podman": "/usr/bin/podman"}.get(name)
|
||||
|
||||
def probe(argv, **_kwargs):
|
||||
# Engine probes succeed; the compose plugin probe (argv[1]=="compose") fails.
|
||||
return (
|
||||
bool(argv)
|
||||
and argv[0] == "/usr/bin/podman"
|
||||
and len(argv) > 1
|
||||
and argv[1] in {"info", "version"}
|
||||
)
|
||||
|
||||
info = detect_container_runtime(path_lookup=lookup, probe=probe)
|
||||
self.assertEqual(info.kind, ContainerRuntime.PODMAN)
|
||||
self.assertEqual(info.compose_args, ())
|
||||
self.assertIn("compose", info.hint.lower())
|
||||
|
||||
from vocalinux.gateway_embed.runner import GatewayRunner
|
||||
|
||||
runner = GatewayRunner(
|
||||
runtime=info,
|
||||
sandbox=detect_sandbox({}),
|
||||
run=MagicMock(),
|
||||
)
|
||||
self.assertFalse(runner.available)
|
||||
self.assertEqual(runner.unavailable_hint, info.hint)
|
||||
self.assertNotIn("No container runtime", runner.unavailable_hint)
|
||||
|
||||
|
||||
class TestStatusMachine(unittest.TestCase):
|
||||
def test_ready_beats_pairable(self):
|
||||
self.assertEqual(
|
||||
GatewayStatus.from_health(live=True, ready=True, pairable=True, running=True),
|
||||
GatewayStatus.READY,
|
||||
)
|
||||
|
||||
def test_pairable_before_ready(self):
|
||||
self.assertEqual(
|
||||
GatewayStatus.from_health(live=True, ready=False, pairable=True, running=True),
|
||||
GatewayStatus.PAIRABLE,
|
||||
)
|
||||
|
||||
def test_live_only(self):
|
||||
self.assertEqual(
|
||||
GatewayStatus.from_health(live=True, ready=False, pairable=False, running=True),
|
||||
GatewayStatus.LIVE,
|
||||
)
|
||||
|
||||
def test_starting(self):
|
||||
self.assertEqual(
|
||||
GatewayStatus.from_health(
|
||||
live=False, ready=False, pairable=False, running=True, starting=True
|
||||
),
|
||||
GatewayStatus.STARTING,
|
||||
)
|
||||
|
||||
def test_stopped(self):
|
||||
self.assertEqual(
|
||||
GatewayStatus.from_health(live=False, ready=False, pairable=False, running=False),
|
||||
GatewayStatus.STOPPED,
|
||||
)
|
||||
|
||||
def test_error(self):
|
||||
self.assertEqual(
|
||||
GatewayStatus.from_health(
|
||||
live=False, ready=False, pairable=False, error=True, running=False
|
||||
),
|
||||
GatewayStatus.ERROR,
|
||||
)
|
||||
|
||||
|
||||
class TestRemoteApiPreset(unittest.TestCase):
|
||||
def test_maps_openai_path(self):
|
||||
preset = remote_api_preset_from_pairing(
|
||||
url="http://192.168.1.20:8765",
|
||||
token="a" * 32,
|
||||
)
|
||||
self.assertEqual(preset["engine"], "remote_api")
|
||||
self.assertEqual(preset["remote_api_url"], "http://192.168.1.20:8765")
|
||||
self.assertEqual(preset["remote_api_key"], "a" * 32)
|
||||
self.assertEqual(preset["remote_api_endpoint"], GATEWAY_TRANSCRIPTIONS_ENDPOINT)
|
||||
|
||||
def test_rejects_empty(self):
|
||||
with self.assertRaises(ValueError):
|
||||
remote_api_preset_from_pairing(url="", token="x" * 32)
|
||||
|
||||
|
||||
class TestPairingDecode(unittest.TestCase):
|
||||
def test_payload_object(self):
|
||||
info = decode_pairing_payload(
|
||||
{
|
||||
"payload": {
|
||||
"v": 1,
|
||||
"url": "http://10.0.0.5:8765",
|
||||
"token": "tokentokentokentokentokentoken12",
|
||||
}
|
||||
}
|
||||
)
|
||||
self.assertEqual(info.display_url, "http://10.0.0.5:8765")
|
||||
self.assertTrue(info.pairable)
|
||||
|
||||
def test_payload_string_and_loopback_hidden(self):
|
||||
raw = json.dumps({"v": 1, "url": "http://127.0.0.1:8765", "token": "t" * 32})
|
||||
info = decode_pairing_payload({"payload": raw})
|
||||
self.assertIsNone(info.display_url)
|
||||
self.assertFalse(info.pairable)
|
||||
|
||||
def test_docker_bridge_not_pairable(self):
|
||||
info = decode_pairing_payload(
|
||||
{
|
||||
"payload": {
|
||||
"v": 1,
|
||||
"url": "http://172.17.0.2:8765",
|
||||
"token": "tokentokentokentokentokentoken12",
|
||||
}
|
||||
}
|
||||
)
|
||||
self.assertIsNone(info.display_url)
|
||||
self.assertFalse(info.pairable)
|
||||
|
||||
def test_link_local_not_pairable(self):
|
||||
info = decode_pairing_payload(
|
||||
{
|
||||
"payload": {
|
||||
"v": 1,
|
||||
"url": "http://169.254.1.2:8765",
|
||||
"token": "tokentokentokentokentokentoken12",
|
||||
}
|
||||
}
|
||||
)
|
||||
self.assertIsNone(info.display_url)
|
||||
self.assertFalse(info.pairable)
|
||||
|
||||
def test_short_form_loopback_not_pairable(self):
|
||||
info = decode_pairing_payload(
|
||||
{
|
||||
"payload": {
|
||||
"v": 1,
|
||||
"url": "http://127.1:8765",
|
||||
"token": "tokentokentokentokentokentoken12",
|
||||
}
|
||||
}
|
||||
)
|
||||
self.assertIsNone(info.display_url)
|
||||
self.assertFalse(info.pairable)
|
||||
|
||||
def test_qr_download_capped(self):
|
||||
from vocalinux.gateway_embed.pairing import _read_capped
|
||||
|
||||
class FakeResp:
|
||||
def __init__(self, payload: bytes):
|
||||
self._buf = io.BytesIO(payload)
|
||||
|
||||
def read(self, n=-1):
|
||||
return self._buf.read(n if n is not None else -1)
|
||||
|
||||
huge = b"x" * (MAX_QR_SVG_BYTES + 10)
|
||||
with self.assertRaises(ValueError):
|
||||
_read_capped(FakeResp(huge), MAX_QR_SVG_BYTES)
|
||||
|
||||
|
||||
class TestSandbox(unittest.TestCase):
|
||||
def test_flatpak_fails_closed(self):
|
||||
state = detect_sandbox({"FLATPAK_ID": "com.vocahq.Vocalinux"})
|
||||
self.assertTrue(state.blocked)
|
||||
self.assertEqual(state.kind, "flatpak")
|
||||
self.assertIn("Flatpak", state.hint)
|
||||
|
||||
def test_host_ok(self):
|
||||
state = detect_sandbox({})
|
||||
self.assertFalse(state.blocked)
|
||||
|
||||
|
||||
class TestDefaultEngineUnchanged(unittest.TestCase):
|
||||
def test_default_still_whisper_cpp(self):
|
||||
self.assertEqual(DEFAULT_CONFIG["speech_recognition"]["engine"], "whisper_cpp")
|
||||
self.assertIn("gateway_embed", DEFAULT_CONFIG)
|
||||
self.assertFalse(DEFAULT_CONFIG["gateway_embed"]["lan_publish"])
|
||||
|
||||
|
||||
class TestRunnerNoVolumeWipe(unittest.TestCase):
|
||||
def test_stop_refuses_wipe_flag(self):
|
||||
from vocalinux.gateway_embed.runner import GatewayRunner
|
||||
from vocalinux.gateway_embed.runtime import RuntimeInfo
|
||||
|
||||
runner = GatewayRunner(
|
||||
runtime=RuntimeInfo(
|
||||
kind=ContainerRuntime.PODMAN,
|
||||
binary="/usr/bin/podman",
|
||||
compose_args=("/usr/bin/podman", "compose"),
|
||||
),
|
||||
sandbox=detect_sandbox({}),
|
||||
run=MagicMock(),
|
||||
)
|
||||
with self.assertRaises(RuntimeError):
|
||||
runner.stop(wipe_volumes=True)
|
||||
|
||||
|
||||
class TestComposeCpuProfile(unittest.TestCase):
|
||||
def test_start_uses_profile_cpu(self):
|
||||
from vocalinux.gateway_embed.runner import GatewayRunner
|
||||
from vocalinux.gateway_embed.runtime import RuntimeInfo
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(argv, **kwargs):
|
||||
calls.append(list(argv))
|
||||
completed = MagicMock()
|
||||
completed.returncode = 0
|
||||
completed.stdout = b""
|
||||
completed.stderr = b""
|
||||
return completed
|
||||
|
||||
runner = GatewayRunner(
|
||||
runtime=RuntimeInfo(
|
||||
kind=ContainerRuntime.PODMAN,
|
||||
binary="/usr/bin/podman",
|
||||
compose_args=("/usr/bin/podman", "compose"),
|
||||
),
|
||||
sandbox=detect_sandbox({}),
|
||||
run=fake_run,
|
||||
)
|
||||
with patch.object(runner, "prepare", return_value=("/tmp/gw", "t" * 32, "/tmp/.env")):
|
||||
result = runner.start(lan_publish=False)
|
||||
self.assertTrue(result.ok)
|
||||
compose_calls = [c for c in calls if c[:2] == ["/usr/bin/podman", "compose"]]
|
||||
self.assertTrue(compose_calls)
|
||||
argv = compose_calls[0]
|
||||
self.assertIn("--profile", argv)
|
||||
self.assertEqual(argv[argv.index("--profile") + 1], "cpu")
|
||||
self.assertIn("up", argv)
|
||||
self.assertIn("-d", argv)
|
||||
self.assertIn("gateway", argv)
|
||||
|
||||
|
||||
class TestImagePin(unittest.TestCase):
|
||||
def test_rejects_latest_override(self):
|
||||
import tempfile
|
||||
|
||||
from vocalinux.gateway_embed.runner import write_env_file
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env_path = f"{tmp}/.env"
|
||||
with patch.dict("os.environ", {"VOCAGATEWAY_IMAGE": "vocagateway:latest"}):
|
||||
write_env_file(token="a" * 32, lan_publish=False, path=env_path)
|
||||
body = Path(env_path).read_text(encoding="utf-8")
|
||||
self.assertIn("VOCAGATEWAY_IMAGE=vocagateway:v0.1.0", body)
|
||||
self.assertNotIn(":latest", body)
|
||||
|
||||
def test_rejects_newline_image_injection(self):
|
||||
import tempfile
|
||||
|
||||
from vocalinux.gateway_embed.runner import write_env_file
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env_path = f"{tmp}/.env"
|
||||
evil = "vocagateway:v0.1.0\nEVIL=1"
|
||||
with patch.dict("os.environ", {"VOCAGATEWAY_IMAGE": evil}):
|
||||
write_env_file(token="a" * 32, lan_publish=False, path=env_path)
|
||||
body = Path(env_path).read_text(encoding="utf-8")
|
||||
self.assertIn("VOCAGATEWAY_IMAGE=vocagateway:v0.1.0\n", body)
|
||||
self.assertNotIn("EVIL=", body)
|
||||
|
||||
def test_omits_loopback_public_url(self):
|
||||
import tempfile
|
||||
|
||||
from vocalinux.gateway_embed.runner import write_env_file
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env_path = f"{tmp}/.env"
|
||||
write_env_file(
|
||||
token="a" * 32,
|
||||
lan_publish=True,
|
||||
public_url="http://127.0.0.1:8765",
|
||||
path=env_path,
|
||||
)
|
||||
body = Path(env_path).read_text(encoding="utf-8")
|
||||
self.assertNotIn("VOCAGATEWAY_PUBLIC_URL=", body)
|
||||
|
||||
def test_read_lan_publish_from_env_matches_write(self):
|
||||
import tempfile
|
||||
|
||||
from vocalinux.gateway_embed.runner import (
|
||||
read_lan_publish_from_env,
|
||||
write_env_file,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env_path = f"{tmp}/.env"
|
||||
write_env_file(token="a" * 32, lan_publish=True, path=env_path)
|
||||
self.assertTrue(read_lan_publish_from_env(env_path))
|
||||
write_env_file(token="a" * 32, lan_publish=False, path=env_path)
|
||||
self.assertFalse(read_lan_publish_from_env(env_path))
|
||||
self.assertFalse(read_lan_publish_from_env(f"{tmp}/missing.env"))
|
||||
|
||||
|
||||
class TestTokenFileCap(unittest.TestCase):
|
||||
def test_oversized_token_file_regenerated(self):
|
||||
import tempfile
|
||||
|
||||
from vocalinux.gateway_embed.paths_embed import ensure_token_file
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = f"{tmp}/token"
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(b"a" * 9000)
|
||||
token = ensure_token_file(path)
|
||||
self.assertEqual(len(token), 64)
|
||||
self.assertLess(Path(path).stat().st_size, 200)
|
||||
|
||||
|
||||
class TestLanPublishGate(unittest.TestCase):
|
||||
def _manager(self):
|
||||
from vocalinux.gateway_embed.manager import GatewayEmbedManager
|
||||
from vocalinux.gateway_embed.runner import GatewayRunner
|
||||
from vocalinux.gateway_embed.runtime import RuntimeInfo
|
||||
|
||||
runner = GatewayRunner(
|
||||
runtime=RuntimeInfo(
|
||||
kind=ContainerRuntime.PODMAN,
|
||||
binary="/usr/bin/podman",
|
||||
compose_args=("/usr/bin/podman", "compose"),
|
||||
),
|
||||
sandbox=detect_sandbox({}),
|
||||
run=MagicMock(),
|
||||
)
|
||||
return GatewayEmbedManager(runner=runner), runner
|
||||
|
||||
def test_lan_toggle_without_republish_not_pairable(self):
|
||||
"""Desired LAN alone must not advertise Pairable on a loopback bind."""
|
||||
from vocalinux.gateway_embed.pairing import PairingInfo
|
||||
|
||||
manager, runner = self._manager()
|
||||
runner.managed_by_us = True
|
||||
manager.lan_publish = True
|
||||
manager._compose_lan_publish = False
|
||||
manager._token = "t" * 32
|
||||
self.assertFalse(manager._effective_lan_for_pairing())
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_fetch(base_url, token, *, public_url=None, fetch_qr=True, timeout=3.0):
|
||||
captured["public_url"] = public_url
|
||||
return PairingInfo(
|
||||
version=1,
|
||||
url="http://127.0.0.1:8765",
|
||||
token=token,
|
||||
display_url=None,
|
||||
raw_payload={"v": 1, "url": "http://127.0.0.1:8765", "token": token},
|
||||
)
|
||||
|
||||
with patch("vocalinux.gateway_embed.manager.probe_health") as health:
|
||||
health.return_value = MagicMock(live=True, ready=False, error="")
|
||||
with patch("vocalinux.gateway_embed.manager.fetch_pairing", side_effect=fake_fetch):
|
||||
status = manager.refresh_status()
|
||||
self.assertIsNone(captured.get("public_url"))
|
||||
self.assertEqual(status, GatewayStatus.LIVE)
|
||||
self.assertIsNotNone(manager.pairing)
|
||||
self.assertFalse(manager.pairing.pairable)
|
||||
|
||||
def test_apply_lan_publish_republishes_when_managed(self):
|
||||
from vocalinux.gateway_embed.pairing import PairingInfo
|
||||
from vocalinux.gateway_embed.runner import RunnerResult
|
||||
|
||||
manager, runner = self._manager()
|
||||
runner.managed_by_us = True
|
||||
manager.lan_publish = False
|
||||
manager._compose_lan_publish = False
|
||||
manager._pairing = PairingInfo(
|
||||
version=1,
|
||||
url="http://127.0.0.1:8765",
|
||||
token="t" * 32,
|
||||
display_url=None,
|
||||
raw_payload={},
|
||||
)
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_republish(*, lan_publish, public_url=None):
|
||||
calls["n"] += 1
|
||||
self.assertTrue(lan_publish)
|
||||
return RunnerResult(ok=True, message="republished")
|
||||
|
||||
with patch("threading.Thread") as fake_thread:
|
||||
fake_thread.return_value = MagicMock()
|
||||
with patch.object(runner, "republish", side_effect=fake_republish):
|
||||
with patch.object(manager, "refresh_status", return_value=GatewayStatus.LIVE):
|
||||
manager.apply_lan_publish(True)
|
||||
self.assertIsNone(manager.pairing)
|
||||
self.assertTrue(manager._republish_started)
|
||||
# Run worker synchronously (Thread.start was mocked).
|
||||
manager._republish_worker()
|
||||
self.assertEqual(calls["n"], 1)
|
||||
self.assertTrue(manager._compose_lan_publish)
|
||||
self.assertTrue(manager.lan_publish)
|
||||
self.assertFalse(manager._republish_started)
|
||||
|
||||
def test_start_worker_snapshots_lan_if_switch_flips_mid_start(self):
|
||||
from vocalinux.gateway_embed.runner import RunnerResult
|
||||
|
||||
manager, runner = self._manager()
|
||||
manager.lan_publish = True
|
||||
manager._compose_lan_publish = None
|
||||
|
||||
def fake_start(*, lan_publish, public_url=None):
|
||||
self.assertTrue(lan_publish)
|
||||
manager.lan_publish = False
|
||||
runner.managed_by_us = True
|
||||
return RunnerResult(ok=True, message="started")
|
||||
|
||||
with patch("threading.Thread") as fake_thread:
|
||||
fake_thread.return_value = MagicMock()
|
||||
with patch(
|
||||
"vocalinux.gateway_embed.paths_embed.ensure_token_file",
|
||||
return_value="t" * 32,
|
||||
):
|
||||
with patch.object(runner, "ensure_runtime"):
|
||||
with patch.object(runner, "start", side_effect=fake_start):
|
||||
with patch.object(manager, "_start_polling"):
|
||||
with patch.object(manager, "refresh_status"):
|
||||
manager._start_worker()
|
||||
|
||||
self.assertIs(manager._compose_lan_publish, True)
|
||||
self.assertFalse(manager.lan_publish)
|
||||
self.assertTrue(manager._republish_started)
|
||||
|
||||
def test_republish_worker_snapshots_lan_if_switch_flips_mid_republish(self):
|
||||
from vocalinux.gateway_embed.runner import RunnerResult
|
||||
|
||||
manager, runner = self._manager()
|
||||
runner.managed_by_us = True
|
||||
manager.lan_publish = True
|
||||
manager._compose_lan_publish = False
|
||||
manager._republish_started = True
|
||||
|
||||
def fake_republish(*, lan_publish, public_url=None):
|
||||
self.assertTrue(lan_publish)
|
||||
manager.lan_publish = False
|
||||
return RunnerResult(ok=True, message="republished")
|
||||
|
||||
with patch("threading.Thread") as fake_thread:
|
||||
fake_thread.return_value = MagicMock()
|
||||
with patch.object(runner, "republish", side_effect=fake_republish):
|
||||
with patch.object(manager, "refresh_status"):
|
||||
manager._republish_worker()
|
||||
|
||||
self.assertIs(manager._compose_lan_publish, True)
|
||||
self.assertFalse(manager.lan_publish)
|
||||
self.assertTrue(manager._republish_started)
|
||||
|
||||
def test_live_detail_warns_when_lan_off_but_compose_still_open(self):
|
||||
from vocalinux.gateway_embed.pairing import PairingInfo
|
||||
|
||||
manager, runner = self._manager()
|
||||
runner.managed_by_us = True
|
||||
manager.lan_publish = False
|
||||
manager._compose_lan_publish = True
|
||||
manager._token = "t" * 32
|
||||
|
||||
def fake_fetch(base_url, token, *, public_url=None, fetch_qr=True, timeout=3.0):
|
||||
return PairingInfo(
|
||||
version=1,
|
||||
url="http://127.0.0.1:8765",
|
||||
token=token,
|
||||
display_url=None,
|
||||
raw_payload={"v": 1, "url": "http://127.0.0.1:8765", "token": token},
|
||||
)
|
||||
|
||||
with patch("vocalinux.gateway_embed.manager.probe_health") as health:
|
||||
health.return_value = MagicMock(live=True, ready=False, error="")
|
||||
with patch("vocalinux.gateway_embed.manager.fetch_pairing", side_effect=fake_fetch):
|
||||
status = manager.refresh_status()
|
||||
self.assertEqual(status, GatewayStatus.LIVE)
|
||||
self.assertIn("still open on the LAN", manager.status_detail)
|
||||
self.assertFalse(manager._effective_lan_for_pairing())
|
||||
|
||||
def test_omits_bridge_public_url_from_env(self):
|
||||
import tempfile
|
||||
|
||||
from vocalinux.gateway_embed.runner import write_env_file
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env_path = f"{tmp}/.env"
|
||||
write_env_file(
|
||||
token="a" * 32,
|
||||
lan_publish=True,
|
||||
public_url="http://172.17.0.2:8765",
|
||||
path=env_path,
|
||||
)
|
||||
body = Path(env_path).read_text(encoding="utf-8")
|
||||
self.assertNotIn("VOCAGATEWAY_PUBLIC_URL=", body)
|
||||
|
||||
|
||||
class TestOrphanComposeDetect(unittest.TestCase):
|
||||
"""Leftover compose after Quit must not look Stopped."""
|
||||
|
||||
def _manager(self):
|
||||
from vocalinux.gateway_embed.manager import GatewayEmbedManager
|
||||
from vocalinux.gateway_embed.runner import GatewayRunner
|
||||
from vocalinux.gateway_embed.runtime import RuntimeInfo
|
||||
|
||||
runner = GatewayRunner(
|
||||
runtime=RuntimeInfo(
|
||||
kind=ContainerRuntime.PODMAN,
|
||||
binary="/usr/bin/podman",
|
||||
compose_args=("/usr/bin/podman", "compose"),
|
||||
),
|
||||
sandbox=detect_sandbox({}),
|
||||
run=MagicMock(),
|
||||
)
|
||||
runner.managed_by_us = False
|
||||
return GatewayEmbedManager(runner=runner), runner
|
||||
|
||||
def test_refresh_status_does_not_stay_stopped_for_orphan(self):
|
||||
manager, runner = self._manager()
|
||||
self.assertFalse(manager.managed_by_us)
|
||||
self.assertEqual(manager.status, GatewayStatus.STOPPED)
|
||||
|
||||
with patch.object(runner, "is_compose_running", return_value=True):
|
||||
with patch("vocalinux.gateway_embed.manager.probe_health") as health:
|
||||
health.return_value = MagicMock(live=True, ready=False, error="")
|
||||
status = manager.refresh_status()
|
||||
|
||||
self.assertEqual(status, GatewayStatus.LIVE)
|
||||
self.assertNotEqual(status, GatewayStatus.STOPPED)
|
||||
self.assertFalse(manager.managed_by_us)
|
||||
stoppable = {
|
||||
GatewayStatus.STARTING,
|
||||
GatewayStatus.LIVE,
|
||||
GatewayStatus.PAIRABLE,
|
||||
GatewayStatus.READY,
|
||||
GatewayStatus.ERROR,
|
||||
}
|
||||
self.assertIn(status, stoppable)
|
||||
|
||||
def test_refresh_status_starting_when_compose_up_but_not_live(self):
|
||||
manager, runner = self._manager()
|
||||
with patch.object(runner, "is_compose_running", return_value=True):
|
||||
with patch("vocalinux.gateway_embed.manager.probe_health") as health:
|
||||
health.return_value = MagicMock(live=False, ready=False, error="")
|
||||
status = manager.refresh_status()
|
||||
self.assertEqual(status, GatewayStatus.STARTING)
|
||||
self.assertNotEqual(status, GatewayStatus.STOPPED)
|
||||
|
||||
def test_runtime_ready_probe_adopts_orphan_and_starts_polling(self):
|
||||
manager, runner = self._manager()
|
||||
self.assertTrue(manager.runtime_ready)
|
||||
self.assertFalse(manager._runtime_detect_started)
|
||||
|
||||
with patch.object(runner, "is_compose_running", return_value=True):
|
||||
with patch("vocalinux.gateway_embed.manager.probe_health") as health:
|
||||
health.return_value = MagicMock(live=True, ready=False, error="")
|
||||
with patch.object(manager, "_start_polling") as poll:
|
||||
manager.begin_runtime_detection()
|
||||
|
||||
self.assertEqual(manager.status, GatewayStatus.LIVE)
|
||||
poll.assert_called_once()
|
||||
|
||||
def test_orphan_probe_loads_existing_token(self):
|
||||
import tempfile
|
||||
|
||||
manager, runner = self._manager()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
token_path = f"{tmp}/token"
|
||||
existing = "ab" * 32
|
||||
with open(token_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(existing + "\n")
|
||||
with patch(
|
||||
"vocalinux.gateway_embed.paths_embed.token_file_path",
|
||||
return_value=token_path,
|
||||
):
|
||||
with patch.object(runner, "is_compose_running", return_value=True):
|
||||
with patch("vocalinux.gateway_embed.manager.probe_health") as health:
|
||||
health.return_value = MagicMock(live=False, ready=False, error="")
|
||||
with patch.object(manager, "_start_polling"):
|
||||
manager.begin_runtime_detection()
|
||||
self.assertEqual(manager._token, existing)
|
||||
|
||||
def test_stop_worker_stops_orphan_without_managed_by_us(self):
|
||||
from vocalinux.gateway_embed.runner import RunnerResult
|
||||
|
||||
manager, runner = self._manager()
|
||||
self.assertFalse(manager.managed_by_us)
|
||||
|
||||
with patch.object(
|
||||
runner, "stop", return_value=RunnerResult(ok=True, message="stopped")
|
||||
) as stop:
|
||||
manager._stop_worker()
|
||||
|
||||
stop.assert_called_once_with(wipe_volumes=False)
|
||||
self.assertEqual(manager.status, GatewayStatus.STOPPED)
|
||||
self.assertFalse(manager.managed_by_us)
|
||||
|
||||
def test_stop_async_starts_worker_for_orphan(self):
|
||||
manager, runner = self._manager()
|
||||
self.assertFalse(manager.managed_by_us)
|
||||
with patch("threading.Thread") as fake_thread:
|
||||
started = MagicMock()
|
||||
fake_thread.return_value = started
|
||||
manager.stop_async()
|
||||
fake_thread.assert_called_once()
|
||||
kwargs = fake_thread.call_args.kwargs
|
||||
self.assertEqual(kwargs.get("name"), "vocalinux-gateway-stop")
|
||||
self.assertEqual(kwargs.get("target").__func__, manager._stop_worker.__func__)
|
||||
started.start.assert_called_once()
|
||||
|
||||
def test_orphan_adopt_restores_lan_on_from_env(self):
|
||||
import tempfile
|
||||
|
||||
from vocalinux.gateway_embed.runner import write_env_file
|
||||
|
||||
manager, runner = self._manager()
|
||||
self.assertFalse(runner.managed_by_us)
|
||||
manager.lan_publish = False
|
||||
manager._compose_lan_publish = None
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env_path = f"{tmp}/.env"
|
||||
write_env_file(token="a" * 32, lan_publish=True, path=env_path)
|
||||
with patch(
|
||||
"vocalinux.gateway_embed.runner.env_file_path",
|
||||
return_value=env_path,
|
||||
):
|
||||
with patch.object(runner, "is_compose_running", return_value=True):
|
||||
with patch("vocalinux.gateway_embed.manager.probe_health") as health:
|
||||
health.return_value = MagicMock(live=True, ready=False, error="")
|
||||
with patch.object(manager, "_start_polling"):
|
||||
manager.begin_runtime_detection()
|
||||
|
||||
self.assertIs(manager._compose_lan_publish, True)
|
||||
self.assertTrue(manager.lan_publish)
|
||||
self.assertTrue(runner.managed_by_us)
|
||||
self.assertTrue(manager.managed_by_us)
|
||||
|
||||
def test_orphan_adopt_restores_lan_off_from_loopback_env(self):
|
||||
import tempfile
|
||||
|
||||
from vocalinux.gateway_embed.runner import write_env_file
|
||||
|
||||
manager, runner = self._manager()
|
||||
manager.lan_publish = True
|
||||
manager._compose_lan_publish = None
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env_path = f"{tmp}/.env"
|
||||
write_env_file(token="a" * 32, lan_publish=False, path=env_path)
|
||||
with patch(
|
||||
"vocalinux.gateway_embed.runner.env_file_path",
|
||||
return_value=env_path,
|
||||
):
|
||||
with patch.object(runner, "is_compose_running", return_value=True):
|
||||
with patch("vocalinux.gateway_embed.manager.probe_health") as health:
|
||||
health.return_value = MagicMock(live=True, ready=False, error="")
|
||||
with patch.object(manager, "_start_polling"):
|
||||
manager.begin_runtime_detection()
|
||||
|
||||
self.assertIs(manager._compose_lan_publish, False)
|
||||
self.assertFalse(manager.lan_publish)
|
||||
self.assertTrue(runner.managed_by_us)
|
||||
|
||||
def test_apply_lan_publish_republishes_after_orphan_lan_adopt(self):
|
||||
import tempfile
|
||||
|
||||
from vocalinux.gateway_embed.runner import RunnerResult, write_env_file
|
||||
|
||||
manager, runner = self._manager()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env_path = f"{tmp}/.env"
|
||||
write_env_file(token="a" * 32, lan_publish=True, path=env_path)
|
||||
with patch(
|
||||
"vocalinux.gateway_embed.runner.env_file_path",
|
||||
return_value=env_path,
|
||||
):
|
||||
with patch.object(runner, "is_compose_running", return_value=True):
|
||||
with patch("vocalinux.gateway_embed.manager.probe_health") as health:
|
||||
health.return_value = MagicMock(live=True, ready=False, error="")
|
||||
with patch.object(manager, "_start_polling"):
|
||||
manager.begin_runtime_detection()
|
||||
|
||||
self.assertTrue(runner.managed_by_us)
|
||||
self.assertIs(manager._compose_lan_publish, True)
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_republish(*, lan_publish, public_url=None):
|
||||
calls["n"] += 1
|
||||
self.assertFalse(lan_publish)
|
||||
return RunnerResult(ok=True, message="republished")
|
||||
|
||||
with patch("threading.Thread") as fake_thread:
|
||||
fake_thread.return_value = MagicMock()
|
||||
with patch.object(runner, "republish", side_effect=fake_republish):
|
||||
with patch.object(manager, "refresh_status", return_value=GatewayStatus.LIVE):
|
||||
manager.apply_lan_publish(False)
|
||||
self.assertTrue(manager._republish_started)
|
||||
manager._republish_worker()
|
||||
|
||||
self.assertEqual(calls["n"], 1)
|
||||
self.assertIs(manager._compose_lan_publish, False)
|
||||
self.assertFalse(manager.lan_publish)
|
||||
|
||||
|
||||
class TestFailedRepublishEnvRestore(unittest.TestCase):
|
||||
"""Failed force-recreate must not leave .env lying about the live bind."""
|
||||
|
||||
def _podman_runner(self, run):
|
||||
from vocalinux.gateway_embed.runner import GatewayRunner
|
||||
from vocalinux.gateway_embed.runtime import RuntimeInfo
|
||||
|
||||
return GatewayRunner(
|
||||
runtime=RuntimeInfo(
|
||||
kind=ContainerRuntime.PODMAN,
|
||||
binary="/usr/bin/podman",
|
||||
compose_args=("/usr/bin/podman", "compose"),
|
||||
),
|
||||
sandbox=detect_sandbox({}),
|
||||
run=run,
|
||||
)
|
||||
|
||||
def test_failed_republish_restores_previous_env_publish_host(self):
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from vocalinux.gateway_embed.runner import (
|
||||
read_lan_publish_from_env,
|
||||
write_env_file,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env_path = f"{tmp}/.env"
|
||||
checkout = f"{tmp}/checkout"
|
||||
os.makedirs(checkout)
|
||||
Path(checkout, "compose.yaml").write_text("services: {}\n", encoding="utf-8")
|
||||
write_env_file(token="a" * 32, lan_publish=True, path=env_path)
|
||||
self.assertTrue(read_lan_publish_from_env(env_path))
|
||||
|
||||
def fake_run(argv, **_kwargs):
|
||||
completed = MagicMock()
|
||||
completed.returncode = 1
|
||||
completed.stdout = b""
|
||||
completed.stderr = b"force-recreate failed"
|
||||
return completed
|
||||
|
||||
runner = self._podman_runner(fake_run)
|
||||
runner.managed_by_us = True
|
||||
with patch("vocalinux.gateway_embed.runner.env_file_path", return_value=env_path):
|
||||
with patch(
|
||||
"vocalinux.gateway_embed.runner.ensure_gateway_checkout",
|
||||
return_value=checkout,
|
||||
):
|
||||
with patch(
|
||||
"vocalinux.gateway_embed.runner.ensure_token_file",
|
||||
return_value="a" * 32,
|
||||
):
|
||||
result = runner.republish(lan_publish=False)
|
||||
|
||||
self.assertFalse(result.ok)
|
||||
self.assertTrue(read_lan_publish_from_env(env_path))
|
||||
body = Path(env_path).read_text(encoding="utf-8")
|
||||
self.assertIn("VOCAGATEWAY_PUBLISH_HOST=0.0.0.0", body)
|
||||
self.assertNotIn("VOCAGATEWAY_PUBLISH_HOST=127.0.0.1", body)
|
||||
|
||||
def test_failed_republish_reverts_manager_lan_publish_to_compose(self):
|
||||
from vocalinux.gateway_embed.manager import GatewayEmbedManager
|
||||
from vocalinux.gateway_embed.runner import RunnerResult
|
||||
|
||||
runner = self._podman_runner(MagicMock())
|
||||
runner.managed_by_us = True
|
||||
manager = GatewayEmbedManager(runner=runner)
|
||||
manager.lan_publish = False
|
||||
manager._compose_lan_publish = True
|
||||
manager._republish_started = True
|
||||
|
||||
def fake_republish(*, lan_publish, public_url=None):
|
||||
self.assertFalse(lan_publish)
|
||||
return RunnerResult(ok=False, message="force-recreate failed")
|
||||
|
||||
with patch("threading.Thread") as fake_thread:
|
||||
fake_thread.return_value = MagicMock()
|
||||
with patch.object(runner, "republish", side_effect=fake_republish):
|
||||
manager._republish_worker()
|
||||
|
||||
self.assertIs(manager._compose_lan_publish, True)
|
||||
self.assertTrue(manager.lan_publish)
|
||||
self.assertEqual(manager.status, GatewayStatus.ERROR)
|
||||
self.assertIn("previous bind is still in use", manager.status_detail)
|
||||
self.assertIn("Stop and Run", manager.status_detail)
|
||||
self.assertFalse(manager._republish_started)
|
||||
|
||||
def test_orphan_adopt_after_failed_republish_still_sees_lan_from_env(self):
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from vocalinux.gateway_embed.manager import GatewayEmbedManager
|
||||
from vocalinux.gateway_embed.runner import (
|
||||
read_lan_publish_from_env,
|
||||
write_env_file,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env_path = f"{tmp}/.env"
|
||||
checkout = f"{tmp}/checkout"
|
||||
os.makedirs(checkout)
|
||||
Path(checkout, "compose.yaml").write_text("services: {}\n", encoding="utf-8")
|
||||
write_env_file(token="a" * 32, lan_publish=True, path=env_path)
|
||||
|
||||
def fake_run(argv, **_kwargs):
|
||||
completed = MagicMock()
|
||||
completed.returncode = 1
|
||||
completed.stdout = b""
|
||||
completed.stderr = b"force-recreate failed"
|
||||
return completed
|
||||
|
||||
runner = self._podman_runner(fake_run)
|
||||
runner.managed_by_us = True
|
||||
with patch("vocalinux.gateway_embed.runner.env_file_path", return_value=env_path):
|
||||
with patch(
|
||||
"vocalinux.gateway_embed.runner.ensure_gateway_checkout",
|
||||
return_value=checkout,
|
||||
):
|
||||
with patch(
|
||||
"vocalinux.gateway_embed.runner.ensure_token_file",
|
||||
return_value="a" * 32,
|
||||
):
|
||||
result = runner.republish(lan_publish=False)
|
||||
self.assertFalse(result.ok)
|
||||
self.assertTrue(read_lan_publish_from_env(env_path))
|
||||
|
||||
next_runner = self._podman_runner(MagicMock())
|
||||
manager = GatewayEmbedManager(runner=next_runner)
|
||||
manager.lan_publish = False
|
||||
manager._compose_lan_publish = None
|
||||
with patch.object(next_runner, "is_compose_running", return_value=True):
|
||||
with patch("vocalinux.gateway_embed.manager.probe_health") as health:
|
||||
health.return_value = MagicMock(live=True, ready=False, error="")
|
||||
with patch.object(manager, "_start_polling"):
|
||||
manager.begin_runtime_detection()
|
||||
|
||||
self.assertTrue(manager.lan_publish)
|
||||
self.assertIs(manager._compose_lan_publish, True)
|
||||
self.assertTrue(next_runner.managed_by_us)
|
||||
|
||||
|
||||
class TestManagerListenerCleanup(unittest.TestCase):
|
||||
def test_remove_listener_stops_callbacks(self):
|
||||
"""Settings destroy must be able to detach without further emits."""
|
||||
from vocalinux.gateway_embed.manager import GatewayEmbedManager
|
||||
from vocalinux.gateway_embed.runner import GatewayRunner
|
||||
from vocalinux.gateway_embed.runtime import RuntimeInfo
|
||||
|
||||
runner = GatewayRunner(
|
||||
runtime=RuntimeInfo(
|
||||
kind=ContainerRuntime.PODMAN,
|
||||
binary="/usr/bin/podman",
|
||||
compose_args=("/usr/bin/podman", "compose"),
|
||||
),
|
||||
sandbox=detect_sandbox({}),
|
||||
run=MagicMock(),
|
||||
)
|
||||
manager = GatewayEmbedManager(runner=runner)
|
||||
seen: list[str] = []
|
||||
|
||||
def listener(status, detail):
|
||||
seen.append(detail)
|
||||
|
||||
manager.add_listener(listener)
|
||||
manager._emit(manager.status, "one")
|
||||
manager.remove_listener(listener)
|
||||
manager._emit(manager.status, "two")
|
||||
self.assertEqual(seen, ["one"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -264,6 +264,47 @@ def test_unwritable_restore_point_does_not_lower_the_sink(
|
||||
assert not (tmp_path / duck.PENDING_RECORD_NAME).exists()
|
||||
|
||||
|
||||
def test_failed_set_that_applied_keeps_the_restore_point(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A set that reports failure can still have applied; keep the way back."""
|
||||
sink = FakeSink(volume=0.5)
|
||||
ducker = _ducker(tmp_path, sink, percent=20)
|
||||
|
||||
def apply_but_report_false(sink_id: str, linear: tuple[float, ...]) -> bool:
|
||||
recorded: Union[float, tuple[float, ...]] = linear[0] if len(linear) == 1 else linear
|
||||
sink.sets.append((sink_id, recorded))
|
||||
sink._channels = tuple(linear)
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(sink, "set_volume", apply_but_report_false)
|
||||
ducker.duck()
|
||||
|
||||
assert sink.volume == pytest.approx(0.1)
|
||||
assert (tmp_path / duck.PENDING_RECORD_NAME).exists()
|
||||
|
||||
ducker.restore()
|
||||
assert sink.volume == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_failed_set_confirmed_unchanged_drops_the_record(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
sink = FakeSink(volume=0.5)
|
||||
ducker = _ducker(tmp_path, sink, percent=20)
|
||||
|
||||
def reject(sink_id: str, linear: tuple[float, ...]) -> bool:
|
||||
recorded: Union[float, tuple[float, ...]] = linear[0] if len(linear) == 1 else linear
|
||||
sink.sets.append((sink_id, recorded))
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(sink, "set_volume", reject)
|
||||
ducker.duck()
|
||||
|
||||
assert sink.volume == pytest.approx(0.5)
|
||||
assert not (tmp_path / duck.PENDING_RECORD_NAME).exists()
|
||||
|
||||
|
||||
def test_percent_100_does_not_change_the_sink(tmp_path: Path) -> None:
|
||||
sink = FakeSink(volume=0.5)
|
||||
ducker = _ducker(tmp_path, sink, percent=100)
|
||||
|
||||
@@ -534,6 +534,8 @@ class TestSettingsDialogInstantApply(unittest.TestCase):
|
||||
# separate Advanced page, which the advanced_tab assertions below still guard.
|
||||
self.assertIn("self.advanced_box.pack_start(self.remote_server_group", source_code)
|
||||
self.assertIn("self.advanced_box.pack_start(self.remote_status_label", source_code)
|
||||
self.assertIn("self.advanced_box.pack_start(self.gateway_embed_group", source_code)
|
||||
self.assertNotIn("self.content_box.pack_start(self.gateway_embed_group", source_code)
|
||||
self.assertIn("self.remote_api_model_entry", source_code)
|
||||
self.assertIn("OpenAI/FunASR", source_code)
|
||||
self.assertNotIn("advanced_tab.pack_start(self.remote_server_group", source_code)
|
||||
|
||||
Reference in New Issue
Block a user