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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **Loopback-only by default**: the web server now binds `127.0.0.1` on launch
(`--host 127.0.0.1` is the default); use `--host 0.0.0.0` to pre-enable LAN
serving, or flip it at runtime with the new **LAN** toggle in the status
bar. Console banners only advertise `Network:` URLs when the bind actually
exposes the network.

## [0.11.0-rc.2] - 2026-11-02

### Added
Expand Down
6 changes: 4 additions & 2 deletions docs/network-server-guide.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,12 @@ New-NetFirewallRule -DisplayName "ProFiles Server" -Direction Inbound -Protocol

### Step 2: Start the server

In a terminal, run:
By default ProFiles is reachable **only on the machine it runs on**
(`127.0.0.1`). To serve the local network, pass `--host 0.0.0.0` (or use the
**LAN** toggle in the status bar of the desktop app):

```powershell
ProFiles --server-only
ProFiles --server-only --host 0.0.0.0
```

**Expected output**:
Expand Down
6 changes: 4 additions & 2 deletions docs/network-server-guide.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,12 @@ New-NetFirewallRule -DisplayName "ProFiles Server" -Direction Inbound -Protocol

### Étape 2: Démarrer le serveur

Dans un terminal, exécutez :
Par défaut, ProFiles n'est accessible que **depuis la machine qui l'exécute**
(`127.0.0.1`). Pour servir le réseau local, ajoutez `--host 0.0.0.0` (ou
utilisez l'interrupteur **LAN** de la barre d'état de l'application) :

```powershell
ProFiles --server-only
ProFiles --server-only --host 0.0.0.0
```

**Sortie attendue** :
Expand Down
8 changes: 8 additions & 0 deletions docs/operations/log-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ Every line follows this shape:
| `FILTER_REJECTED` | Right-click → Filter pre-check failed | `kind`, `reason`, `value` |
| `HASH_COMPUTED` | Right-click → Hash computed | `algorithm`, `path`, `status`, `duration_ms`, `error`, `reason` |
| `HASH_VERIFIED` | Right-click → Hash verified against clipboard | `algorithm`, `path`, `match`, `status`, `reason`, `error` |
| `WEB_CLIENT` | Web UI client connected / reconnected after an idle gap / disconnected after going silent (dedup per client IP; idle threshold 120 s) | `event` (`connected` / `reconnected` / `disconnected`), `ip`, `path`, `ua`, `idle_s` |
| `BIND_CHANGED` | Server rebound between LAN exposure and loopback (status-bar LAN toggle) | `host`, `lan` |
| `BIND_CHANGE_FAILED` | Rebind request failed; the server reverted to the previous address | `host`, `error` |

## Examples

Expand Down Expand Up @@ -106,6 +109,11 @@ grep 'SCAN_METRICS' profiles.log | awk -F'duration_ms=' '{print $2}' | sort -nr
grep 'FILE_LAUNCHED' profiles.log
```

**Client connections (who opened the web UI):**
```bash
grep 'WEB_CLIENT' profiles.log
```

**All right-click filter changes:**
```bash
grep 'FILTER_CHANGED' profiles.log
Expand Down
34 changes: 22 additions & 12 deletions docs/superpowers/specs/2026-11-03-lan-serving-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,34 @@
**Date:** 2026-11-03
**Status:** Approved (conversation: design for `--host` approved, then UI toggle
approved with build order "CLI first, then the pill toggle").
**Revised 2026-11-03 (same day, after the toggle shipped):** default bind
flipped from `0.0.0.0` to `127.0.0.1` — see Decisions.

## 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.
Keep a fresh ProFiles launch **loopback-only**; let the station operator expose
the machine to the local network with one click from the web UI status bar (or
pre-expose it at launch with `--host 0.0.0.0`), and flip it back just as
easily.

## 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.
- `ProFiles` (GUI and `--server-only`) serves on `127.0.0.1` by default —
loopback-only, nothing reachable from the network until the operator opts in.
- `--host` CLI option shared by GUI (desktop window) and `--server-only` mode;
`--host 0.0.0.0` pre-enables LAN serving for headless/automation launches.
- 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.
- Console shows `Local:` and `Network:` URLs only when the bind actually
exposes the network.
- StatusBar identity pill gains a LAN on/off switch (transient — not persisted
across restarts) with a in-app confirmation naming the exposed URLs.
across restarts) with an 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 persistence of the runtime LAN choice (restart = launch default, i.e.
loopback-only again).
- No IPv6 binding and no hostname bind values (socket stays IPv4; see
`lifecycle.py` `_bind_listener` constraint comment).

Expand All @@ -36,9 +42,13 @@ and let the station operator flip that access at runtime from the web UI.
- 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.
- **Revision (2026-11-03):** the launch default is `127.0.0.1`, reversing the
original "serve `0.0.0.0` by default" decision. Rationale: the status-bar
toggle makes LAN exposure a one-click opt-in at runtime, so serving the LAN
by default is no longer justified — secure-by-default wins. `--host 0.0.0.0`
remains available for headless/automation launches where nobody will click
the toggle. The original caveat ("a colleague cannot toggle LAN on a
loopback-bound app") is accepted: the operator toggles at the console.

## Constraints

Expand Down
27 changes: 16 additions & 11 deletions src/profiles/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def __init__(
server_only: bool = False,
no_config_propose: bool = False,
gui_backend: str = "web",
host: str = "0.0.0.0",
host: str = "127.0.0.1",
) -> None:
"""Initialize the application.

Expand Down Expand Up @@ -324,12 +324,16 @@ def _run_server_only(self, port: int = _DEFAULT_PORT, host: str | None = None) -
except ServerStartError as exc:
self._report_server_start_failure(fallback_port, exc)

# Display both local and network URLs
local_ip = get_local_ip()
print("ProFiles server running at:")
print(f" Local: http://127.0.0.1:{server.port}")
print(f" Network: http://{local_ip}:{server.port}")
print(" Access from other PCs on your network (press Ctrl+C to stop)")
# Advertise LAN URLs only when the bind actually exposes them.
if (host or self._host) == "0.0.0.0":
local_ip = get_local_ip()
print("ProFiles server running at:")
print(f" Local: http://127.0.0.1:{server.port}")
print(f" Network: http://{local_ip}:{server.port}")
print(" Access from other PCs on your network (press Ctrl+C to stop)")
else:
print("ProFiles server running at:")
print(f" Local: http://{server.host}:{server.port}")
try:
while server.is_running:
time.sleep(0.5)
Expand Down Expand Up @@ -617,16 +621,17 @@ 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)
profiles --host 0.0.0.0 Serve the local network (also: LAN toggle in the status bar)
""",
)
parser.add_argument(
"--host",
default="0.0.0.0",
default="127.0.0.1",
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)"
"Bind address for the web server (default: 127.0.0.1, this "
"machine only; 0.0.0.0 serves the local network — also "
"toggleable at runtime from the status bar)"
),
)
parser.add_argument(
Expand Down
20 changes: 20 additions & 0 deletions src/profiles/core/telemetry/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,26 @@ def web_client(logger: logging.Logger, *, ip: str, path: str, user_agent: str =
logger.info("WEB_CLIENT %s", " ".join(parts))


def web_client_reconnected(logger: logging.Logger, *, ip: str, path: str) -> None:
"""Emit WEB_CLIENT — a known client came back after the idle-disconnect window."""
logger.info('WEB_CLIENT event="reconnected" ip="%s" path=%s', ip, _quote(path))


def web_client_left(logger: logging.Logger, *, ip: str, idle_s: int) -> None:
"""Emit WEB_CLIENT — a client stopped polling and is considered gone."""
logger.info('WEB_CLIENT event="disconnected" ip="%s" idle_s=%d', ip, idle_s)


def bind_changed(logger: logging.Logger, *, host: str, lan: bool) -> None:
"""Emit BIND_CHANGED — the server rebound between LAN exposure and loopback."""
logger.info('BIND_CHANGED host="%s" lan=%s', host, "true" if lan else "false")


def bind_change_failed(logger: logging.Logger, *, host: str, error: str) -> None:
"""Emit BIND_CHANGE_FAILED — a rebind failed and the server reverted."""
logger.warning('BIND_CHANGE_FAILED host="%s" error="%s"', host, error)


def app_restarting(logger: logging.Logger) -> None:
"""Emit APP_RESTARTING."""
logger.info("APP_RESTARTING")
Expand Down
4 changes: 2 additions & 2 deletions src/profiles/desktop/launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,9 @@ def launch_desktop_window(
webview.create_window(
title=window_title,
url=url,
width=1000,
width=1200,
height=700,
min_size=(800, 500),
min_size=(1000, 500),
)
webview.start(debug=debug)
finally:
Expand Down
2 changes: 1 addition & 1 deletion src/profiles/desktop/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def find_available_port(start_port: int = 8088, max_attempts: int = 50) -> int:
class BackgroundServer:
"""Spawns and manages a local Uvicorn instance in a background thread."""

def __init__(self, config: AppConfig, host: str = "0.0.0.0", port: int = 8088) -> None:
def __init__(self, config: AppConfig, host: str = "127.0.0.1", port: int = 8088) -> None:
self.config = config
self.host = host
self.port = port if port != 0 else find_available_port(8088)
Expand Down
80 changes: 72 additions & 8 deletions src/profiles/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@

from __future__ import annotations

import asyncio
import logging
import time
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager, suppress
from pathlib import Path

from fastapi import FastAPI, Request
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware

from profiles.core.telemetry import events
Expand All @@ -22,6 +26,60 @@
# Server-side telemetry logger (the "profiles" logger writes profiles.log).
_LOGGER = logging.getLogger("profiles")

# A client counts as gone once it has not sent a request for this long. The
# web UI polls /api/notifications every 4s in the foreground, but browsers
# throttle background tabs down to ~60s, so the threshold must sit above
# that to avoid disconnect/reconnect churn for merely-hidden tabs.
CLIENT_IDLE_DISCONNECT_S = 120.0

# How often the lifespan watchdog looks for clients past the idle threshold.
CLIENT_SWEEP_INTERVAL_S = 15.0


def sweep_disconnected_clients(
last_seen: dict[str, float],
now: float,
logger: logging.Logger,
timeout_s: float = CLIENT_IDLE_DISCONNECT_S,
) -> None:
"""Log and drop clients idle for more than ``timeout_s``.

Pure bookkeeping over the ``ip -> last-seen`` map: mutates ``last_seen``
in place and emits one ``WEB_CLIENT event="disconnected"`` per stale
entry. Called periodically by the app lifespan and directly by tests.

Args:
last_seen: Client IP to last-request monotonic timestamp map.
now: Current ``time.monotonic()`` value.
logger: Application logger receiving the events.
timeout_s: Idle duration after which a client counts as gone.
"""
for ip, seen_at in list(last_seen.items()):
idle_s = now - seen_at
if idle_s > timeout_s:
del last_seen[ip]
events.web_client_left(logger, ip=ip, idle_s=int(idle_s))


async def _sweep_loop(app: FastAPI) -> None:
"""Periodically sweep the client map until the task is cancelled."""
last_seen: dict[str, float] = app.state.client_last_seen
while True:
await asyncio.sleep(CLIENT_SWEEP_INTERVAL_S)
sweep_disconnected_clients(last_seen, time.monotonic(), _LOGGER)


@asynccontextmanager
async def _client_watchdog_lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Run the disconnect watchdog for the server's lifetime."""
task = asyncio.create_task(_sweep_loop(app))
try:
yield
finally:
task.cancel()
with suppress(asyncio.CancelledError):
await task


def create_app(state: ServerState, static_dir: Path | None = None) -> FastAPI:
"""Create and configure the FastAPI application.
Expand All @@ -33,9 +91,9 @@ def create_app(state: ServerState, static_dir: Path | None = None) -> FastAPI:
Returns:
Configured FastAPI instance.
"""
app = FastAPI(title="ProFiles API", version="1.0.0")
app = FastAPI(title="ProFiles API", version="1.0.0", lifespan=_client_watchdog_lifespan)
app.state.server_state = state
app.state.seen_client_ips: set[str] = set()
app.state.client_last_seen: dict[str, float] = {}

app.add_middleware(
CORSMiddleware,
Expand All @@ -46,18 +104,24 @@ def create_app(state: ServerState, static_dir: Path | None = None) -> FastAPI:
)

@app.middleware("http")
async def log_web_client(request: Request, call_next): # type: ignore[no-untyped-def]
"""Emit WEB_CLIENT once per client IP (first request from it)."""
async def log_web_client(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
"""Emit WEB_CLIENT on first connect or reconnect after a long idle."""
client_ip = request.client.host if request.client else "unknown"
seen: set[str] = request.app.state.seen_client_ips
if client_ip not in seen:
seen.add(client_ip)
last_seen: dict[str, float] = request.app.state.client_last_seen
now = time.monotonic()
previous = last_seen.get(client_ip)
if previous is None:
events.web_client(
_LOGGER,
ip=client_ip,
path=request.url.path,
user_agent=request.headers.get("user-agent", "")[:120],
)
elif now - previous > CLIENT_IDLE_DISCONNECT_S:
events.web_client_reconnected(_LOGGER, ip=client_ip, path=request.url.path)
last_seen[client_ip] = now
return await call_next(request)

app.include_router(system_router)
Expand Down
Loading
Loading