Skip to content
Merged
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
292 changes: 292 additions & 0 deletions docs/superpowers/plans/2026-11-03-lan-serving.md

Large diffs are not rendered by default.

51 changes: 51 additions & 0 deletions docs/superpowers/specs/2026-11-03-lan-serving-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# LAN Serving Design — `--host` CLI + Status-bar LAN toggle

**Date:** 2026-11-03
**Status:** Approved (conversation: design for `--host` approved, then UI toggle
approved with build order "CLI first, then the pill toggle").

## Intent

Let any machine on the local network open `http://<hostname>:8088/` right after
a normal `ProFiles` launch, while keeping a one-flag way to stay loopback-only —
and let the station operator flip that access at runtime from the web UI.

## Goals

- `ProFiles` (GUI, default) serves on `0.0.0.0` by default — LAN reachable.
- `--host` CLI option shared by GUI (desktop window) and `--server-only` mode.
- `--host 127.0.0.1` restores the old loopback-only behavior.
- The PyWebView window always opens on a connectable URL (`127.0.0.1` when the
bind host is `0.0.0.0`).
- Console shows `Local:` and `Network:` URLs on GUI launches too.
- StatusBar identity pill gains a LAN on/off switch (transient — not persisted
across restarts) with a in-app confirmation naming the exposed URLs.
- Runtime rebind reverts the bind on failure; the app must never end up dead.

## Non-goals

- No persistence of the runtime LAN choice (restart = launch default).
- No IPv6 binding and no hostname bind values (socket stays IPv4; see
`lifecycle.py` `_bind_listener` constraint comment).

## Decisions (as made with the human partner)

- Build order: (1) CLI, (2) status-bar toggle on top of the same rebind path.
- Accepted bind values: `0.0.0.0`, `127.0.0.1`, or a literal IPv4 address.
Hostnames and IPv6 literals are rejected with an argparse error.
- Toggle state comes from `GET /api/system` (`lan_enabled`), changes via
`POST /api/system/bind` (`{"lan": bool}`). A confirm dialog mentions the
exposed hostname/IP URLs. Failure surfaces a toast; state stays unchanged.
- UI-first caveat acknowledged: a colleague on another machine cannot toggle
LAN on a loopback-bound app (they can't open the UI) — that is why the CLI
default matters more.

## Constraints

- Layering: `server/` must not import `profiles.desktop.*` at module level
(desktop imports server). Route access to the running server cross-layer
goes through a structural Protocol (`BindableServer`) in `server/state.py`,
registered on `app.state` by `BackgroundServer.start()`.
- `window.confirm`/`window.alert` are no-ops inside webviews — the project
uses in-app `ConfirmDialog` and `showToast`.
- Test suite mirrors package tree 1:1; suite gate 85% coverage.
1 change: 1 addition & 0 deletions makefile
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ install-system: ## Install system-wide (standard mode)

install-system-dev: ## Install system-wide (development mode)
@echo "📦 Installing ProFiles system-wide (development mode)..."
rm -rf src/profiles.egg-info
pip install -e ".[dev]"
@echo "✅ Development installation complete!"

Expand Down
65 changes: 59 additions & 6 deletions src/profiles/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import argparse
import ipaddress
import logging
import platform
import sys
Expand Down Expand Up @@ -45,6 +46,29 @@
)


def host_error(value: str) -> str | None:
"""Validate a ``--host`` bind value; return an error message or ``None``.

The server socket is IPv4 (see ``profiles.desktop.lifecycle``), so only
``0.0.0.0``, ``127.0.0.1`` and plain IPv4 addresses are accepted —
hostnames and IPv6 literals cannot be used as bind targets.

Args:
value: The bind address given on the command line.

Returns:
``None`` when the value can be bound, else a human-readable message.
"""
message = f"unsupported host '{value}' — use 0.0.0.0, 127.0.0.1, or an IPv4 address"
try:
ip = ipaddress.ip_address(value)
except ValueError:
return message
if ip.version != 4:
return message
return None


def _stop_port_owner_command(port: int) -> str:
"""Build the OS-appropriate command to free a busy local *port*.

Expand Down Expand Up @@ -91,6 +115,7 @@ def __init__(
server_only: bool = False,
no_config_propose: bool = False,
gui_backend: str = "web",
host: str = "0.0.0.0",
) -> None:
"""Initialize the application.

Expand All @@ -102,13 +127,15 @@ def __init__(
server_only: Run only the web server without opening desktop window.
no_config_propose: Do not offer to create a .profiles at startup.
gui_backend: GUI backend to use ('tk' or 'web'). Defaults to 'web'.
host: Bind address for the web server ('0.0.0.0' serves the LAN).
"""
self._config_path = Path(config_path) if config_path is not None else None
self._log_path = Path(log_path) if log_path is not None else default_log_path()
self._headless = headless
self._server_only = server_only
self._no_config_propose = no_config_propose
self._gui_backend = gui_backend.lower()
self._host = host
self._config: AppConfig | None = None
self._window: MainWindow | None = None
self._logger: logging.Logger | None = None
Expand Down Expand Up @@ -259,12 +286,16 @@ def run(self, file: str | Path | None = None) -> None:
else:
self._run_gui()

def _run_server_only(self, port: int = _DEFAULT_PORT, host: str = "0.0.0.0") -> None:
def _run_server_only(self, port: int = _DEFAULT_PORT, host: str | None = None) -> None:
"""Run only the FastAPI background server without opening PyWebView.

A busy port is never fatal: the config is handed off to a running
ProFiles instance when one owns the port, otherwise the server
retries on the next free port. Nothing is killed automatically.

Args:
port: TCP port to serve on.
host: Bind address; defaults to the ``--host`` CLI value.
"""
import time

Expand All @@ -276,7 +307,7 @@ def _run_server_only(self, port: int = _DEFAULT_PORT, host: str = "0.0.0.0") ->
from profiles.utils.network import get_local_ip

assert self._config is not None
server = BackgroundServer(config=self._config, host=host, port=port)
server = BackgroundServer(config=self._config, host=host or self._host, port=port)
try:
server.start()
except ServerStartError:
Expand All @@ -285,7 +316,9 @@ def _run_server_only(self, port: int = _DEFAULT_PORT, host: str = "0.0.0.0") ->
fallback_port = find_available_port(port)
if fallback_port != port:
self._announce_fallback_port(port, fallback_port)
server = BackgroundServer(config=self._config, host=host, port=fallback_port)
server = BackgroundServer(
config=self._config, host=host or self._host, port=fallback_port
)
try:
server.start()
except ServerStartError as exc:
Expand All @@ -305,21 +338,26 @@ def _run_server_only(self, port: int = _DEFAULT_PORT, host: str = "0.0.0.0") ->
finally:
server.stop()

def _run_web_gui(self, port: int = _DEFAULT_PORT, host: str = "127.0.0.1") -> None:
def _run_web_gui(self, port: int = _DEFAULT_PORT, host: str | None = None) -> None:
"""Launch web backend via PyWebView desktop shell.

A busy port is never fatal (issue #4): when a running ProFiles
instance owns the port the config is forwarded to it and this
launch exits 0; otherwise the server retries on the next free
port. The manual "free the port" hint only appears when even the
fallback port cannot be bound — nothing is killed automatically.

Args:
port: TCP port to serve on.
host: Bind address; defaults to the ``--host`` CLI value.
"""
from profiles.desktop.launcher import launch_desktop_window
from profiles.desktop.lifecycle import ServerStartError

assert self._config is not None
effective_host = host or self._host
try:
launch_desktop_window(self._config, port=port, host=host)
launch_desktop_window(self._config, port=port, host=effective_host)
return
except ServerStartError:
pass # busy port — try handoff or a free port below
Expand All @@ -333,7 +371,7 @@ def _run_web_gui(self, port: int = _DEFAULT_PORT, host: str = "127.0.0.1") -> No
if fallback_port != port:
self._announce_fallback_port(port, fallback_port)
try:
launch_desktop_window(self._config, port=fallback_port, host=host)
launch_desktop_window(self._config, port=fallback_port, host=effective_host)
except ServerStartError as exc:
self._report_server_start_failure(fallback_port, exc)

Expand Down Expand Up @@ -579,8 +617,18 @@ def main() -> None:
profiles --headless Headless: scan & launch all
profiles --headless file.mttl Headless: launch specific file
profiles --init Generate default .profiles in CWD
profiles --host 127.0.0.1 Restrict serving to this machine (loopback only)
""",
)
parser.add_argument(
"--host",
default="0.0.0.0",
type=str,
help=(
"Bind address for the web server (default: 0.0.0.0, reachable "
"from the local network; 127.0.0.1 restricts to this machine)"
),
)
parser.add_argument(
"--config",
default=None,
Expand Down Expand Up @@ -626,6 +674,10 @@ def main() -> None:

args = parser.parse_args()

host_error_message = host_error(args.host)
if host_error_message is not None:
parser.error(host_error_message)

if args.init:
init_default_config()
return
Expand All @@ -645,6 +697,7 @@ def main() -> None:
server_only=args.server_only,
no_config_propose=args.no_config_propose,
gui_backend=gui_backend,
host=args.host,
)
app.run(file=args.file)

Expand Down
16 changes: 15 additions & 1 deletion src/profiles/desktop/launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,29 @@ def launch_desktop_window(

The background server is stopped whenever the window closes, including
when startup fails, so no orphaned process is left listening.

The window always opens on a *connectable* URL: when the server is
bound to ``0.0.0.0`` (serve the LAN), that address is not routable in
a browser, so the window uses the loopback address instead.
"""
_configure_webview_permissions()
server = BackgroundServer(config=config, host=host, port=port)

window_title = f"ProFiles - {config.title}" if config.title else "ProFiles"
url = f"http://{host}:{server.port}"
webview_bind = "127.0.0.1" if host == "0.0.0.0" else host
url = f"http://{webview_bind}:{server.port}"

try:
server.start()
if host == "0.0.0.0":
from profiles.utils.network import get_local_ip

print("ProFiles web GUI running at:")
print(f" Local: http://127.0.0.1:{server.port}")
print(f" Network: http://{get_local_ip()}:{server.port}")
else:
print("ProFiles web GUI running at:")
print(f" Local: http://{host}:{server.port}")
webview.create_window(
title=window_title,
url=url,
Expand Down
33 changes: 33 additions & 0 deletions src/profiles/desktop/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ def start(self, timeout: float = _STARTUP_TIMEOUT_S) -> None:
msg = f"ProFiles server failed to start on {self.host}:{self.port}: {exc}"
raise ServerStartError(msg) from exc

# Route code reaches the running server through the app instance
# (structural BindableServer protocol in server/state.py).
self._app.state.background_server = self

uv_config = uvicorn.Config(
app=self._app,
host=self.host,
Expand Down Expand Up @@ -216,3 +220,32 @@ def stop(self) -> None:
if self._thread and self._thread.is_alive():
self._thread.join(timeout=3.0)
self._reset()

def rebind(self, host: str) -> None:
"""Restart the server bound to a new address, in place.

Used by the ``POST /api/system/bind`` route so the web UI can turn
LAN exposure on and off while a client is connected. When the new
bind fails, this server is brought back up on the *previous* host
and the original :class:`ServerStartError` is re-raised — the app
must never be left unreachable.

Args:
host: New bind address (e.g. ``"0.0.0.0"`` or ``"127.0.0.1"``).

Raises:
ServerStartError: The new bind could not be established. The
previous bind is still serving when this is raised.
"""
if host == self.host and self.is_running:
return
previous_host = self.host
self.stop()
self.host = host
try:
self.start()
except ServerStartError:
self.host = previous_host
with contextlib.suppress(ServerStartError):
self.start()
raise
51 changes: 47 additions & 4 deletions src/profiles/server/routes/system.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,72 @@
"""System and health check endpoints."""
"""System, health and bind-status endpoints for the local FastAPI server."""

from __future__ import annotations

import platform

from fastapi import APIRouter
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel

from profiles.core.environment import system
from profiles.server.state import BindableServer

router = APIRouter(prefix="/api", tags=["system"])


class BindRequest(BaseModel):
"""Body of ``POST /api/system/bind``."""

lan: bool


def _background_server(request: Request) -> BindableServer | None:
"""Return the registered running server, or ``None`` when unknown.

Registered by ``BackgroundServer.start()`` on ``app.state``; a bare
app factory (tests, static harnesses) has none.
"""
server: BindableServer | None = getattr(request.app.state, "background_server", None)
return server


@router.get("/health")
async def health_check() -> dict[str, str]:
"""Health check endpoint for process readiness."""
return {"status": "ok"}


@router.get("/system")
async def system_info() -> dict[str, str]:
"""Return local host and user diagnostic information."""
async def system_info(request: Request) -> dict[str, object]:
"""Return local host/user diagnostics and the current bind state."""
info = system.collect_system_info()
server = _background_server(request)
return {
"hostname": info.hostname,
"ip": info.ip,
"username": info.username,
"os_release": platform.platform(),
"lan_enabled": None if server is None else server.host == "0.0.0.0",
}


@router.post("/system/bind")
async def bind_server(payload: BindRequest, request: Request) -> dict[str, object]:
"""Rebind the running server between LAN exposure and loopback-only.

Transient on purpose: the choice resets to the launch default on the
next restart. The desktop lifecycle reverts a failed bind, so on error
the reported state is still the previous one.
"""
server = _background_server(request)
if server is None:
raise HTTPException(status_code=503, detail="no running server to rebind")
target_host = "0.0.0.0" if payload.lan else "127.0.0.1"
# Lazy import: desktop.lifecycle imports server.app at module level, so a
# top-level import here would create an import cycle.
from profiles.desktop.lifecycle import ServerStartError

try:
server.rebind(target_host)
except ServerStartError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"host": server.host, "lan_enabled": payload.lan}
Loading
Loading