Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ The public landing page is in [`web/`](web/) and deploys to
[vocagateway.vocahq.com](https://vocagateway.vocahq.com/).

Install it once on a machine you control, then pair phone clients to that host.
Desktop embed is Planned. A shipped VocaLinux can already point its `remote_api`
engine at `POST /v1/audio/transcriptions` on this host. You can self-host on
macOS or Linux, or use Docker Compose on Linux `amd64`/`arm64`. There is no Voca
account and no hosted Voca cloud.
Desktop embed is Planned
([docs/desktop-embed.md](docs/desktop-embed.md)). A shipped VocaLinux can already
point its `remote_api` engine at `POST /v1/audio/transcriptions` on this host.
You can self-host on macOS or Linux, or use Docker Compose on Linux
`amd64`/`arm64`. There is no Voca account and no hosted Voca cloud.

The gateway takes bounded recordings from
[vocaphone](https://github.com/VocaHQ/vocaphone) (iOS/Android), and from
Expand Down Expand Up @@ -927,12 +928,13 @@ For backup, update, and native-vs-container guidance, continue with
[deployment.md](docs/deployment.md). For pairing, paths, and env vars, see
[configuration.md](docs/configuration.md). For failures, see
[troubleshooting.md](docs/troubleshooting.md). The [docs index](docs/) lists
all five pages.
all six pages.

## The Voca family

Directory: [vocahq.com](https://vocahq.com). [VocaPhone](https://vocaphone.vocahq.com)
is the live consumer. Embedding this gateway in a desktop app stays Planned.
is the live consumer. Embedding this gateway in a desktop app stays Planned;
see [docs/desktop-embed.md](docs/desktop-embed.md) for the spike contract.

| Product | Status | Website | Source |
| --- | --- | --- | --- |
Expand All @@ -947,8 +949,8 @@ is the live consumer. Embedding this gateway in a desktop app stays Planned.
| Project | How it uses this gateway |
| --- | --- |
| [vocaphone](https://github.com/VocaHQ/vocaphone) | Live consumer. Git submodule at `gateway/` for the iOS/Android clients |
| [vocalinux](https://github.com/VocaHQ/vocalinux) | `remote_api` can POST audio to `/v1/audio/transcriptions` on this host. Embedding the gateway in the app is still Planned. |
| [vocamac](https://github.com/VocaHQ/vocamac) / [vocawin](https://github.com/VocaHQ/vocawin) | Planned: ship and start the headless server from the desktop app |
| [vocalinux](https://github.com/VocaHQ/vocalinux) | `remote_api` can POST audio to `/v1/audio/transcriptions` on this host. Embedding the gateway in the app is still Planned ([docs/desktop-embed.md](docs/desktop-embed.md)). |
| [vocamac](https://github.com/VocaHQ/vocamac) / [vocawin](https://github.com/VocaHQ/vocawin) | Planned: ship and start the headless server from the desktop app ([docs/desktop-embed.md](docs/desktop-embed.md)) |

### VocaLinux `remote_api`

Expand Down
9 changes: 9 additions & 0 deletions app/admin_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from app.catalog import catalog_source_url, language_names, recommended_ids
from app.context import BOOTSTRAP_TOKEN_ID, TOKEN_FILE_HINT, VERSION, GatewayContext
from app.engine_state import active_model_path, available_engines, engine_id
from app.pairing import default_pairing_url, is_phone_reachable_gateway_url
from app.serializers import metrics_status, model_covers
from app.system import detect_system

Expand Down Expand Up @@ -290,6 +291,11 @@ async def status_payload(ctx: GatewayContext) -> schemas.AdminStatusResponse:
readiness_details = await ctx.readiness.details()
state = readiness_details.health
metrics = ctx.service.metrics.snapshot(sample=True)
pairing_url = default_pairing_url(
ctx.settings.port, saved_pairing_url=ctx.pairing_config.pairing_url
)
if pairing_url is not None and not is_phone_reachable_gateway_url(pairing_url):
pairing_url = None
return schemas.AdminStatusResponse(
version=VERSION,
commit=helper.build_commit_status(),
Expand Down Expand Up @@ -322,6 +328,9 @@ async def status_payload(ctx: GatewayContext) -> schemas.AdminStatusResponse:
warmup_state=readiness_details.warmup_state,
warmed_bytes=readiness_details.warmed_bytes,
),
pairable=pairing_url is not None,
pairing_url=pairing_url,
ready_for_dictation=bool(state.ready),
)


Expand Down
70 changes: 69 additions & 1 deletion app/pairing.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,69 @@ def _from_ifconfig(self) -> None:
self.found.update(_Ipv4Policy.parse_ifconfig(command_result.stdout))


def is_phone_reachable_gateway_url(url: str) -> bool:
"""True when a phone on LAN/VPN can open this gateway base URL.

Rejects empty hosts, ``localhost`` / ``localhost.*``, abbreviated IPv4
loopback (``127.1``), IPv4/IPv6 loopback, link-local, unspecified, and
multicast. Other non-IP hostnames (MagicDNS, custom DNS) pass without DNS
lookup.
"""
host = urlparse(url).hostname
if not host:
return False
lowered = host.lower()
if lowered == "localhost" or lowered.startswith("localhost."):
return False
ip_address = _parse_gateway_host_ip(host)
if ip_address is None:
return True
if isinstance(ip_address, ipaddress.IPv4Address):
return _Ipv4Policy.is_reachable(str(ip_address))
blocked = (
ip_address.is_loopback
or ip_address.is_link_local
or ip_address.is_unspecified
or ip_address.is_multicast
)
return not blocked


def _parse_gateway_host_ip(host: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
"""Parse a URL host as an IP, including short-form IPv4 that ``ip_address`` rejects."""
try:
return ipaddress.ip_address(host)
except ValueError:
return _parse_short_ipv4_host(host)


def _parse_short_ipv4_host(host: str) -> ipaddress.IPv4Address | None:
try:
return ipaddress.IPv4Address(socket.inet_aton(host))
except OSError:
return None


def unreachable_pairing_override() -> tuple[str, str] | None:
"""Return ``(env_key, raw_value)`` when a pairing override is set but unusable.

Checks ``VOCAGATEWAY_PUBLIC_URL`` then ``VOCAGATEWAY_PAIRING_URL``. A value
that fails to normalize is skipped; a value that normalizes to a
non-phone-reachable URL is returned so callers can explain the misconfig.
"""
for key in ("VOCAGATEWAY_PUBLIC_URL", "VOCAGATEWAY_PAIRING_URL"):
raw = os.environ.get(key, "").strip()
if not raw:
continue
try:
normalized = _GatewayUrls.normalize(raw)
except ValueError:
continue
if not is_phone_reachable_gateway_url(normalized):
return (key, raw)
return None


class _GatewayDiscovery:
@classmethod
def discover(cls, port: int) -> list[str]:
Expand All @@ -304,6 +367,8 @@ def default_url(cls, port: int, *, saved_pairing_url: str | None = None) -> str

@classmethod
def _keep_saved(cls, port: int, saved_pairing_url: str) -> bool:
if not is_phone_reachable_gateway_url(saved_pairing_url):
return False
if not is_ambient_lan_address(saved_pairing_url):
return True
return saved_pairing_url in cls.discover(port)
Expand All @@ -323,9 +388,12 @@ def _normalized_override(cls, configured_url: str) -> str | None:
if not configured_url:
return None
try:
return normalize_gateway_url(configured_url)
normalized = normalize_gateway_url(configured_url)
except ValueError:
return None
if not is_phone_reachable_gateway_url(normalized):
return None
return normalized

@classmethod
def _unique(cls, candidates: list[str]) -> list[str]:
Expand Down
22 changes: 19 additions & 3 deletions app/pairing_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
discover_gateway_base_urls,
encode_pairing_payload,
is_ambient_lan_address,
is_phone_reachable_gateway_url,
normalize_gateway_input,
primary_gateway_base_url,
qr_svg_for_payload,
unreachable_pairing_override,
)


Expand Down Expand Up @@ -186,14 +188,28 @@ def resolve_pairing_url(ctx: GatewayContext, url: str | None) -> str:
candidates = discover_gateway_base_urls(ctx.settings.port)
if url:
try:
return normalize_gateway_input(url, ctx.settings.port)
normalized = normalize_gateway_input(url, ctx.settings.port)
except ValueError as error:
raise APIProblem(HTTP_400_BAD_REQUEST, "invalid_pairing_url", str(error)) from error
if not is_phone_reachable_gateway_url(normalized):
raise APIProblem(
HTTP_400_BAD_REQUEST,
"invalid_pairing_url",
"Pairing URL must be phone-reachable (not loopback or link-local).",
)
return normalized
if not candidates:
detail = "No phone-reachable gateway address was detected."
unreachable = unreachable_pairing_override()
if unreachable is not None:
key, raw_value = unreachable
detail = (
f"{detail} {key}={raw_value} is loopback/link-local and cannot be used for pairing."
)
detail = f"{detail} Set a phone-reachable VOCAGATEWAY_PUBLIC_URL and retry."
raise APIProblem(
HTTP_503_SERVICE_UNAVAILABLE,
"pairing_unavailable",
"No phone-reachable gateway address was detected. "
"Set VOCAGATEWAY_PUBLIC_URL and retry.",
detail,
)
return candidates[0]
13 changes: 13 additions & 0 deletions app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ class EngineStatus(BaseModel):


class PathStatus(BaseModel):
"""On-disk locations the gateway owns.

``data_dir`` holds sessions, device tokens, and gateway-owned on-disk
data/logs. Desktop embedders must not place that tree under the host
app's Application Support (or equivalent) directory.
"""

data_dir: str
models_dir: str
config_file: str
Expand Down Expand Up @@ -183,6 +190,12 @@ class AdminStatusResponse(BaseModel):
setup: SetupChecklist
metrics: OperationalMetricsStatus
readiness: ReadinessStatus
# Desktop embed: Pairable when a phone-reachable pairing URL exists;
# Ready-for-dictation when the engine can transcribe. Never includes the
# bearer token; clients decode that from GET /v1/admin/pairing's payload.
pairable: bool
pairing_url: str | None = None
ready_for_dictation: bool


class AdminModelEntry(BaseModel):
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ full `VOCAGATEWAY_*` table. These pages go deeper on one topic each.
| --- | --- |
| [deployment.md](deployment.md) | Choosing between native macOS, native Linux, and Docker; running at login; backups; the portable CPU service and `cuda`/`vulkan` Compose profiles; where the phone reaches the host |
| [configuration.md](configuration.md) | You need the exact value of a path, an environment variable, or the pairing QR payload |
| [desktop-embed.md](desktop-embed.md) | Embedding the gateway in a desktop app (Planned); Pairable vs Ready; Compose image pin; platform launch notes |
| [tailscale.md](tailscale.md) | You want private HTTPS to the gateway without opening a port |
| [troubleshooting.md](troubleshooting.md) | Something is failing and you want the symptom, not the theory |
| [models.md](models.md) | Picking a model: all 58 in the catalog, what each speaks, and a reverse index from 108 languages back to the models that cover them |
Expand Down
7 changes: 6 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,16 @@ License: [AGPL-3.0](../LICENSE). Contact: [hello@vocahq.com](mailto:hello@vocahq
| --- | --- |
| `~/.config/vocagateway/token` | Bootstrap bearer token (mode `600` on first run) |
| `~/.config/vocagateway/config.json` | WebUI engine/model choice and saved pairing URLs |
| `~/.local/share/vocagateway/` | Application data (sessions DB and related files) |
| `~/.local/share/vocagateway/` | Application data (sessions DB, device tokens, and gateway-owned data/logs under `VOCAGATEWAY_DATA_DIR`) |
| `~/.local/share/vocagateway/models` | Downloaded models (`VOCAGATEWAY_MODELS_DIR` default) |

Docker Compose mounts the same layout under `/data` in the
`vocagateway_vocagateway-data` named volume (token via Compose secret).

Desktop embedders must keep gateway data/logs under `data_dir` (default
`~/.local/share/vocagateway`). Never relocate that tree into a host app's
Application Support directory. See [desktop-embed.md](desktop-embed.md).

## QR pairing payload

Version `1`. Fields are `url` (phone-reachable gateway base URL) and `token`
Expand Down Expand Up @@ -156,4 +160,5 @@ HTTPS needs a certificate the desktop OS trusts.

- [README](../README.md) — quick starts and full configuration table
- [deployment.md](deployment.md) — native vs Compose operations
- [desktop-embed.md](desktop-embed.md): Planned desktop embed contract (Pairable vs Ready, Compose pin)
- [troubleshooting.md](troubleshooting.md) — 401 and readiness failures
4 changes: 3 additions & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ portability.
- [Configuration paths and env vars](#configuration-paths-and-env-vars)

Quick starts live in the [README](../README.md). This page is the longer form:
what to choose, how to keep it running, and how the phone reaches it.
what to choose, how to keep it running, and how the phone reaches it. For the
Planned desktop-app embed contract (platform matrix, Pairable vs Ready, Compose
image pin), see [desktop-embed.md](desktop-embed.md).

## Which deployment should I choose?

Expand Down
Loading