From 18457e2434e4183ee4ccc58ff98bb68f29d867fe Mon Sep 17 00:00:00 2001 From: "f.albany" Date: Tue, 3 Nov 2026 12:29:42 +0100 Subject: [PATCH 01/10] build: strip stale src/profiles.egg-info before editable install The legacy egg-info could shadow profiles-app metadata in importlib.metadata and report an old version (e.g. 2026.8.0) even after a fresh pip install. install-system-dev now removes it so the editable metadata snapshot always matches pyproject.toml. --- makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/makefile b/makefile index 58047a4..2e43678 100644 --- a/makefile +++ b/makefile @@ -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!" From f1361fb2d9ad337097cb97738d918f94b30753a1 Mon Sep 17 00:00:00 2001 From: "f.albany" Date: Tue, 3 Nov 2026 14:07:40 +0100 Subject: [PATCH 02/10] feat: add --host CLI option (default 0.0.0.0, LAN serving) host_error() validates IPv4 bind targets (0.0.0.0/127.0.0.1/IPv4 literal); hostname and IPv6 args exit 2 with a clear message. ProFileApp carries host into both GUI and server-only launches. Also unblocks the suite on Windows: tests/utils/test_file_utils_errors.py evaluated os.geteuid() at decoration time (AttributeError, POSIX-only), now guarded. --- src/profiles/app.py | 65 ++++++++++++++++++++++--- tests/AGENTS.md | 1 + tests/README.md | 2 +- tests/test_app.py | 2 + tests/test_app_backend_selection.py | 2 +- tests/test_app_host.py | 68 +++++++++++++++++++++++++++ tests/utils/test_file_utils_errors.py | 20 ++++++-- 7 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 tests/test_app_host.py diff --git a/src/profiles/app.py b/src/profiles/app.py index 81a16b1..9450a75 100644 --- a/src/profiles/app.py +++ b/src/profiles/app.py @@ -7,6 +7,7 @@ from __future__ import annotations import argparse +import ipaddress import logging import platform import sys @@ -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*. @@ -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. @@ -102,6 +127,7 @@ 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() @@ -109,6 +135,7 @@ def __init__( 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 @@ -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 @@ -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: @@ -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: @@ -305,7 +338,7 @@ 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 @@ -313,13 +346,18 @@ def _run_web_gui(self, port: int = _DEFAULT_PORT, host: str = "127.0.0.1") -> No 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 @@ -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) @@ -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, @@ -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 @@ -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) diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 8439a4d..9cd233a 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -32,6 +32,7 @@ tests/ β”œβ”€β”€ conftest.py # os.startfile polyfill (root) β”œβ”€β”€ test_app.py # ← app.py β”œβ”€β”€ test_app_backend_selection.py # ← app.py (supplement: --tkinter/web backend routing) +β”œβ”€β”€ test_app_host.py # ← app.py (supplement: --host CLI option) β”œβ”€β”€ test_app_launch_resolution.py # ← app.py (supplement: launch config resolution) β”œβ”€β”€ test_app_log_path.py # ← app.py (supplement: CWD-independent log path) β”œβ”€β”€ test_app_no_tkinter.py # ← app.py (supplement: web/headless run with tkinter blocked) diff --git a/tests/README.md b/tests/README.md index 935603d..14648c6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -61,7 +61,7 @@ Highlights: | File | Source Module | Focus Areas | |---|---|---| | tests/test_app.py | profiles.app | ProFileApp, main(), init_default_config | -| tests/test_app_*.py | profiles.app | supplements: backend selection, launch resolution, log path, no-tkinter run, deprecated tk lifecycle | +| tests/test_app_*.py | profiles.app | supplements: backend selection, --host option, launch resolution, log path, no-tkinter run, deprecated tk lifecycle | | tests/test_config.py | profiles.config | shim re-exports (ColumnConfiguration, …) | | tests/core/… | profiles.core.* | actions, config (models/reader/schema/service/loader/matcher/validator/template/inheritance/yaml_io), environment (system/execution/matcher/render/interactions/message_dialog/workflow), processing (scanner/file_classifier/file_metadata/column_extractor), telemetry (diagnostics/events/metrics) | | tests/desktop/test_lifecycle.py | profiles.desktop.lifecycle | port discovery, background server lifecycle | diff --git a/tests/test_app.py b/tests/test_app.py index f81c59f..736e24b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -526,6 +526,7 @@ def test_headless_flag(self, mocker) -> None: # type: ignore[no-untyped-def] server_only=False, no_config_propose=False, gui_backend="web", + host="0.0.0.0", ) mock_app.return_value.run.assert_called_once_with(file=None) @@ -554,6 +555,7 @@ def test_default_no_args(self, mocker) -> None: # type: ignore[no-untyped-def] server_only=False, no_config_propose=False, gui_backend="web", + host="0.0.0.0", ) diff --git a/tests/test_app_backend_selection.py b/tests/test_app_backend_selection.py index 79eb074..722827a 100644 --- a/tests/test_app_backend_selection.py +++ b/tests/test_app_backend_selection.py @@ -34,4 +34,4 @@ def test_launch_web_backend_calls_desktop() -> None: app._config = AppConfig(title="Test Bench") with patch("profiles.desktop.launcher.launch_desktop_window") as mock_launch: app._run_web_gui() - mock_launch.assert_called_once_with(app._config, port=8088, host="127.0.0.1") + mock_launch.assert_called_once_with(app._config, port=8088, host="0.0.0.0") diff --git a/tests/test_app_host.py b/tests/test_app_host.py new file mode 100644 index 0000000..4e15f94 --- /dev/null +++ b/tests/test_app_host.py @@ -0,0 +1,68 @@ +"""Test the ``--host`` CLI option and its validation.""" + +from __future__ import annotations + +import sys +from unittest.mock import patch + +import pytest + +from profiles.app import ProFileApp, host_error, main + + +def test_host_error_accepts_wildcard_loopback_and_ipv4() -> None: + assert host_error("0.0.0.0") is None + assert host_error("127.0.0.1") is None + assert host_error("172.16.41.65") is None + + +def test_host_error_rejects_hostname_and_ipv6() -> None: + assert host_error("st-465") is not None + assert host_error("::1") is not None + + +def test_default_host_is_lan_wildcard() -> None: + app = ProFileApp(headless=False) + assert app._host == "0.0.0.0" + + +def test_explicit_loopback_host_is_kept() -> None: + app = ProFileApp(headless=False, host="127.0.0.1") + assert app._host == "127.0.0.1" + + +def test_main_rejects_hostname() -> None: + with ( + patch.object(sys, "argv", ["profiles", "--host", "st-465"]), + pytest.raises(SystemExit) as excinfo, + ): + main() + assert excinfo.value.code == 2 + + +def test_main_rejects_ipv6() -> None: + with ( + patch.object(sys, "argv", ["profiles", "--host", "::1"]), + pytest.raises(SystemExit) as excinfo, + ): + main() + assert excinfo.value.code == 2 + + +def test_main_passes_host_argument() -> None: + """``--host`` flows from argv into the constructed ProFileApp.""" + argv = ["profiles", "--host", "127.0.0.1"] + with patch.object(sys, "argv", argv), patch("profiles.app.ProFileApp") as mock_app: + main() + call = mock_app.call_args + assert call is not None, "main() must construct ProFileApp" + assert call.kwargs["host"] == "127.0.0.1" + + +def test_main_default_host_is_wildcard() -> None: + argv = ["profiles"] + with patch.object(sys, "argv", argv), patch("profiles.app.ProFileApp") as mock_app: + main() + call = mock_app.call_args + assert call is not None + assert call.kwargs["host"] == "0.0.0.0" diff --git a/tests/utils/test_file_utils_errors.py b/tests/utils/test_file_utils_errors.py index 224cb3c..59af1bf 100644 --- a/tests/utils/test_file_utils_errors.py +++ b/tests/utils/test_file_utils_errors.py @@ -15,11 +15,19 @@ from profiles.utils.file_utils import _scan_subtree, scan_directory +# ``os.geteuid`` exists only on POSIX, and POSIX chmod semantics are assumed +# by every test below; evaluating it unconditionally broke collection on +# Windows (AttributeError at decoration time). Skip everywhere non-POSIX. +_SKIP_POSIX_PERMISSION_TESTS = not hasattr(os, "geteuid") or os.geteuid() == 0 + class TestPermissionSkipReporting: """Unreadable subtrees are skipped (other folders still scanned) and reported.""" - @pytest.mark.skipif(os.geteuid() == 0, reason="root ignores permission bits") + @pytest.mark.skipif( + _SKIP_POSIX_PERMISSION_TESTS, + reason="POSIX permission bits required (and never ignored by root)", + ) def test_locked_directory_recursive_scan_directory(self, tmp_path: Path) -> None: for rel in ("locked/a.pdf", "open/b.pdf"): p = tmp_path / rel @@ -42,7 +50,10 @@ def test_locked_directory_recursive_scan_directory(self, tmp_path: Path) -> None assert any("locked" in Path(p).name for p in locked_paths) assert all(isinstance(e, str) and e for _p, e in skips) - @pytest.mark.skipif(os.geteuid() == 0, reason="root ignores permission bits") + @pytest.mark.skipif( + _SKIP_POSIX_PERMISSION_TESTS, + reason="POSIX permission bits required (and never ignored by root)", + ) def test_locked_directory_scan_subtree(self, tmp_path: Path) -> None: for rel in ("locked/a.pdf", "open/b.pdf"): p = tmp_path / rel @@ -59,7 +70,10 @@ def test_locked_directory_scan_subtree(self, tmp_path: Path) -> None: assert skips @pytest.mark.skipif(sys.platform == "win32", reason="POSIX chmod semantics") - @pytest.mark.skipif(os.geteuid() == 0, reason="root ignores permission bits") + @pytest.mark.skipif( + _SKIP_POSIX_PERMISSION_TESTS, + reason="POSIX permission bits required (and never ignored by root)", + ) def test_no_callback_keeps_silent_behavior(self, tmp_path: Path) -> None: """Default on_skip=None: locked dirs are still silently skipped.""" for rel in ("locked/a.pdf", "open/b.pdf"): From aa27afe67cb4f84306fdf1b1dbdbdc945b799d30 Mon Sep 17 00:00:00 2001 From: "f.albany" Date: Tue, 3 Nov 2026 14:10:52 +0100 Subject: [PATCH 03/10] feat: connectable PyWebView URL and URL banner for LAN bind --- src/profiles/desktop/launcher.py | 16 ++++++++- tests/AGENTS.md | 6 ++-- tests/README.md | 1 + tests/desktop/test_launcher.py | 59 ++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 tests/desktop/test_launcher.py diff --git a/src/profiles/desktop/launcher.py b/src/profiles/desktop/launcher.py index ce6edf1..83f7ceb 100644 --- a/src/profiles/desktop/launcher.py +++ b/src/profiles/desktop/launcher.py @@ -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, diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 9cd233a..4d92bf9 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -78,7 +78,9 @@ tests/ β”‚ β”œβ”€β”€ test_events.py # ← core/telemetry/events.py β”‚ └── test_metrics.py # ← core/telemetry/metrics.py β”œβ”€β”€ desktop/ -β”‚ └── test_lifecycle.py # ← desktop/lifecycle.py (launcher.py: no test yet) +β”‚ β”œβ”€β”€ test_lifecycle.py # ← desktop/lifecycle.py +β”‚ β”œβ”€β”€ test_launcher.py # ← desktop/launcher.py +β”‚ └── test_instance.py # ← desktop/instance.py β”œβ”€β”€ gui/ # DEPRECATED β€” conftest.py skip-marks everything here β”‚ β”œβ”€β”€ conftest.py # skip mark: tkinter deprecation β”‚ β”œβ”€β”€ test_context_menu.py # ← gui/context_menu.py @@ -130,7 +132,7 @@ tests/ - `tests/server/test_barcode_integration.py` β€” end-to-end server integration (app + state + routes). **Known coverage gaps (mirrored test file does not exist yet):** -`core/telemetry/log_viewer.py`, `desktop/launcher.py`, `server/state.py` β€” `server/state.py` is partially exercised through `tests/server/test_app.py`. +`core/telemetry/log_viewer.py`, `server/state.py` β€” `server/state.py` is partially exercised through `tests/server/test_app.py`. **Deprecated Tkinter isolation:** everything under `tests/gui/` is skip-marked by `tests/gui/conftest.py` with a deprecation reason, and `tests/test_app_tk_lifecycle.py` carries the same module-level skip. These files are deleted together with `src/profiles/gui` in phase 2. diff --git a/tests/README.md b/tests/README.md index 14648c6..cfb3634 100644 --- a/tests/README.md +++ b/tests/README.md @@ -65,6 +65,7 @@ Highlights: | tests/test_config.py | profiles.config | shim re-exports (ColumnConfiguration, …) | | tests/core/… | profiles.core.* | actions, config (models/reader/schema/service/loader/matcher/validator/template/inheritance/yaml_io), environment (system/execution/matcher/render/interactions/message_dialog/workflow), processing (scanner/file_classifier/file_metadata/column_extractor), telemetry (diagnostics/events/metrics) | | tests/desktop/test_lifecycle.py | profiles.desktop.lifecycle | port discovery, background server lifecycle | +| tests/desktop/test_launcher.py | profiles.desktop.launcher | connectable window URL rule, URL banner, window-bound lifecycle | | tests/gui/** | profiles.gui.* | **skipped** β€” deprecated Tkinter frontend (see `tests/gui/conftest.py`) | | tests/launch/test_{context,creation,resolution}.py | profiles.launch.* | launch context detection, shortcut creation, resolution | | tests/packaging/ | (cross-cutting) | entry-point stub, packaging dependency rules, ProFiles.pyw stub | diff --git a/tests/desktop/test_launcher.py b/tests/desktop/test_launcher.py new file mode 100644 index 0000000..71b9377 --- /dev/null +++ b/tests/desktop/test_launcher.py @@ -0,0 +1,59 @@ +"""Test the desktop window launcher (server-owned window lifecycle).""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from profiles.core.config.models import AppConfig +from profiles.desktop.launcher import launch_desktop_window +from profiles.desktop.lifecycle import find_available_port + +_WILDCARD_HOST = "0.0.0.0" +_LOOPBACK_HOST = "127.0.0.1" + + +@pytest.fixture +def webview_mock(mocker) -> MagicMock: + """Replace the pywebview module import so no window system is touched.""" + return mocker.patch("profiles.desktop.launcher.webview") + + +@pytest.fixture +def local_ip_mock(mocker) -> MagicMock: + """Pin get_local_ip so the Network banner asserts deterministically.""" + return mocker.patch("profiles.utils.network.get_local_ip", return_value="192.0.2.7") + + +def test_webview_url_uses_loopback_for_wildcard_bind( + webview_mock: MagicMock, local_ip_mock: MagicMock, capsys +) -> None: + """A 0.0.0.0 bind opens the window at 127.0.0.1 and advertises the LAN URL.""" + launch_desktop_window( + AppConfig(title="t"), + port=find_available_port(8000), + host=_WILDCARD_HOST, + ) + url = webview_mock.create_window.call_args.kwargs["url"] + assert url.startswith(f"http://{_LOOPBACK_HOST}:"), url + assert not url.startswith("http://0.0.0.0:") + out = capsys.readouterr().out + assert "Network: http://192.0.2.7:" in out + assert f"Local: http://{_LOOPBACK_HOST}:" in out + + +def test_webview_url_and_single_line_for_loopback_bind( + webview_mock: MagicMock, local_ip_mock: MagicMock, capsys +) -> None: + """A loopback bind keeps the URL as-is and prints only the local line.""" + launch_desktop_window( + AppConfig(title="t"), + port=find_available_port(8000), + host=_LOOPBACK_HOST, + ) + url = webview_mock.create_window.call_args.kwargs["url"] + assert url.startswith(f"http://{_LOOPBACK_HOST}:") + out = capsys.readouterr().out + assert "Local:" in out + assert "Network:" not in out From 1bdda65e9de1d2375168a3892053ce480826ca7c Mon Sep 17 00:00:00 2001 From: "f.albany" Date: Tue, 3 Nov 2026 14:12:57 +0100 Subject: [PATCH 04/10] feat: BackgroundServer.rebind with revert-on-failure --- src/profiles/desktop/lifecycle.py | 33 ++++++++++++++++++++++ tests/desktop/test_lifecycle.py | 46 +++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/src/profiles/desktop/lifecycle.py b/src/profiles/desktop/lifecycle.py index 4617485..b393ed9 100644 --- a/src/profiles/desktop/lifecycle.py +++ b/src/profiles/desktop/lifecycle.py @@ -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, @@ -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 diff --git a/tests/desktop/test_lifecycle.py b/tests/desktop/test_lifecycle.py index 8b06435..7754131 100644 --- a/tests/desktop/test_lifecycle.py +++ b/tests/desktop/test_lifecycle.py @@ -105,3 +105,49 @@ def test_background_server_lifecycle() -> None: server.stop() time.sleep(0.5) assert not server.is_running + + +def test_rebind_changes_host_and_reverses(tmp_path: Path) -> None: + """rebind switches the live bind host; app-state registration exposes it.""" + port = find_available_port(start_port=8000) + server = BackgroundServer(config=AppConfig(title="rebind"), host="127.0.0.1", port=port) + server.start() + try: + server.rebind("0.0.0.0") + assert server.host == "0.0.0.0" + response = httpx.get(f"http://127.0.0.1:{port}/api/health", timeout=2.0) + assert response.status_code == 200 + # The registration the route layer relies on: + assert server._app.state.background_server is server + server.rebind("127.0.0.1") + response = httpx.get(f"http://127.0.0.1:{port}/api/health", timeout=2.0) + assert response.status_code == 200 + finally: + server.stop() + + +_original_socket_bind = socket.socket.bind + + +def _bind_rejects_wildcard(sock: socket.socket, addr: tuple[str, int]) -> None: + """Bind normally except for 0.0.0.0, which is forced to fail (firewall sim).""" + if addr[0] == "0.0.0.0": + raise OSError("simulated bind failure for the wildcard address") + _original_socket_bind(sock, addr) + + +def test_rebind_failure_reverts_to_previous_host(mocker, tmp_path: Path) -> None: + """A denied wildcard bind leaves the server serving the original address.""" + mocker.patch.object(socket.socket, "bind", _bind_rejects_wildcard) + port = find_available_port(start_port=8000) + server = BackgroundServer(config=AppConfig(title="revert"), host="127.0.0.1", port=port) + server.start() + try: + with pytest.raises(ServerStartError): + server.rebind("0.0.0.0") + assert server.host == "127.0.0.1" + assert server.is_running + response = httpx.get(f"http://127.0.0.1:{port}/api/health", timeout=2.0) + assert response.status_code == 200 + finally: + server.stop() From 3d30dbf98382c7bf6f531657e2831df6397b34c5 Mon Sep 17 00:00:00 2001 From: "f.albany" Date: Tue, 3 Nov 2026 14:15:19 +0100 Subject: [PATCH 05/10] feat: system bind-status endpoint and LAN rebind API --- src/profiles/server/routes/system.py | 51 +++++++++++++++-- src/profiles/server/state.py | 19 ++++++- tests/server/routes/test_system.py | 82 ++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 5 deletions(-) diff --git a/src/profiles/server/routes/system.py b/src/profiles/server/routes/system.py index befca5b..3f99cd0 100644 --- a/src/profiles/server/routes/system.py +++ b/src/profiles/server/routes/system.py @@ -1,16 +1,34 @@ -"""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.""" @@ -18,12 +36,37 @@ async def health_check() -> dict[str, str]: @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} diff --git a/src/profiles/server/state.py b/src/profiles/server/state.py index 5f4bf62..e521705 100644 --- a/src/profiles/server/state.py +++ b/src/profiles/server/state.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from typing import Any, Protocol from profiles.core.config.io.yaml_io import PRIMARY_CONFIG_NAME, find_config_file from profiles.core.config.models import AppConfig @@ -12,6 +12,23 @@ _MAX_EXTERNAL_EVENTS = 50 +class BindableServer(Protocol): + """The subset of ``BackgroundServer`` the route layer touches. + + Declared here so ``server/routes`` can type the registered server + without importing ``profiles.desktop`` (desktop imports server; the + reverse would be a layering cycle). + """ + + @property + def is_running(self) -> bool: ... + + @property + def host(self) -> str: ... + + def rebind(self, host: str) -> None: ... + + @dataclass class ServerState: """Holds active application configuration and cached scan results.""" diff --git a/tests/server/routes/test_system.py b/tests/server/routes/test_system.py index 8290b93..2aed723 100644 --- a/tests/server/routes/test_system.py +++ b/tests/server/routes/test_system.py @@ -16,6 +16,19 @@ def test_state() -> ServerState: return ServerState(config=config) +class _StubServer: + """BindableServer stand-in recording rebind calls.""" + + host = "127.0.0.1" + + @property + def is_running(self) -> bool: + return True + + def rebind(self, host: str) -> None: + raise AssertionError("stub must be renamed before a test rebinds") + + @pytest.mark.anyio async def test_health_check(test_state: ServerState) -> None: app = create_app(test_state) @@ -37,3 +50,72 @@ async def test_system_info(test_state: ServerState) -> None: assert "ip" in data assert "username" in data assert "os_release" in data + assert "lan_enabled" in data + + +@pytest.mark.anyio +async def test_system_info_reports_unknown_bind_state( + test_state: ServerState, +) -> None: + """No registered server (bare app factory) leaves the state unknown.""" + app = create_app(test_state) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/api/system") + assert response.status_code == 200 + assert response.json()["lan_enabled"] is None + + +@pytest.mark.anyio +async def test_bind_without_server_returns_503(test_state: ServerState) -> None: + app = create_app(test_state) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post("/api/system/bind", json={"lan": True}) + assert response.status_code == 503 + + +@pytest.mark.anyio +async def test_bind_runs_rebind_on_registered_server( + test_state: ServerState, +) -> None: + """lan=True rebinds to 0.0.0.0 and reflects the new state.""" + calls: list[str] = [] + + class _RecordingServer(_StubServer): + @property + def is_running(self) -> bool: # noqa: F811 + return True + + def rebind(self, host: str) -> None: # noqa: F811 + calls.append(host) + self.host = host + + stub = _RecordingServer() + app = create_app(test_state) + app.state.background_server = stub + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post("/api/system/bind", json={"lan": True}) + assert response.status_code == 200 + assert response.json() == {"host": "0.0.0.0", "lan_enabled": True} + assert calls == ["0.0.0.0"] + + +@pytest.mark.anyio +async def test_bind_failure_returns_500_and_state_is_unchanged( + test_state: ServerState, +) -> None: + """A failed rebind reports the error; the bind state stays on the server.""" + from profiles.desktop.lifecycle import ServerStartError + + class _FailingServer(_StubServer): + def rebind(self, host: str) -> None: # noqa: F811 + raise ServerStartError("bind denied") + + stub = _FailingServer() + app = create_app(test_state) + app.state.background_server = stub + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post("/api/system/bind", json={"lan": True}) + assert response.status_code == 500 + assert "bind denied" in response.json()["detail"] + status = await client.get("/api/system") + assert status.json()["lan_enabled"] is False From a4e4a54f8cb3553560aa55112900ae8c8f48a6b3 Mon Sep 17 00:00:00 2001 From: "f.albany" Date: Tue, 3 Nov 2026 14:48:32 +0100 Subject: [PATCH 06/10] feat(test): add @testing-library/jest-dom for better DOM assertions - Add @testing-library/jest-dom dev dependency to package.json - Update package-lock.json with jest-dom and its dependencies (@adobe/css-tools, css.escape, dom-accessibility-api, redent, etc.) - Create src/setup-tests.ts to import jest-dom matchers - Create src/vitest.d.ts with type references for jest-dom/vitest - Configure vitest.config.ts to use setup-tests.ts as setup file - Update build.py to use npm install instead of npm ci for lock file sync handling - Update tsconfig.tsbuildinfo with new test files --- src/profiles/web/build.py | 8 +-- src/profiles/web/package-lock.json | 89 +++++++++++++++++++++++++++ src/profiles/web/package.json | 1 + src/profiles/web/src/setup-tests.ts | 1 + src/profiles/web/src/vitest.d.ts | 4 ++ src/profiles/web/tsconfig.tsbuildinfo | 2 +- src/profiles/web/vitest.config.ts | 1 + 7 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 src/profiles/web/src/setup-tests.ts create mode 100644 src/profiles/web/src/vitest.d.ts diff --git a/src/profiles/web/build.py b/src/profiles/web/build.py index b622035..db9966b 100644 --- a/src/profiles/web/build.py +++ b/src/profiles/web/build.py @@ -138,10 +138,10 @@ def main() -> int: print("Building web frontend...") try: # Install dependencies - if not (WEB_ROOT / "node_modules").is_dir(): - command = "npm ci" - print(f"> {command}", flush=True) - subprocess.run(command, cwd=WEB_ROOT, check=True, shell=True, env=env) + # Use npm install instead of npm ci to handle lock file sync issues + command = "npm install" + print(f"> {command}", flush=True) + subprocess.run(command, cwd=WEB_ROOT, check=True, shell=True, env=env) # Build command = "npm run build" diff --git a/src/profiles/web/package-lock.json b/src/profiles/web/package-lock.json index c154ccf..b845bc1 100644 --- a/src/profiles/web/package-lock.json +++ b/src/profiles/web/package-lock.json @@ -19,6 +19,7 @@ "devDependencies": { "@tailwindcss/vite": "^4.0.9", "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.8", "@types/node": "^22.13.9", @@ -33,6 +34,13 @@ "vitest": "^5.0.5" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@apideck/better-ajv-errors": { "version": "0.3.7", "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz", @@ -3244,6 +3252,33 @@ "node": ">=18" } }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/react": { "version": "16.3.3", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", @@ -4030,6 +4065,13 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -5073,6 +5115,16 @@ "dev": true, "license": "ISC" }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inline-style-parser": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.9.tgz", @@ -6968,6 +7020,16 @@ ], "license": "MIT" }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -7367,6 +7429,20 @@ "node": ">=0.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -8092,6 +8168,19 @@ "node": ">=10" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/style-to-object": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-2.0.2.tgz", diff --git a/src/profiles/web/package.json b/src/profiles/web/package.json index 825ff77..ce86e57 100644 --- a/src/profiles/web/package.json +++ b/src/profiles/web/package.json @@ -22,6 +22,7 @@ "devDependencies": { "@tailwindcss/vite": "^4.0.9", "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.8", "@types/node": "^22.13.9", diff --git a/src/profiles/web/src/setup-tests.ts b/src/profiles/web/src/setup-tests.ts new file mode 100644 index 0000000..d0de870 --- /dev/null +++ b/src/profiles/web/src/setup-tests.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom"; diff --git a/src/profiles/web/src/vitest.d.ts b/src/profiles/web/src/vitest.d.ts new file mode 100644 index 0000000..ebbb8d2 --- /dev/null +++ b/src/profiles/web/src/vitest.d.ts @@ -0,0 +1,4 @@ +/// +/// + +import "@testing-library/jest-dom"; diff --git a/src/profiles/web/tsconfig.tsbuildinfo b/src/profiles/web/tsconfig.tsbuildinfo index f2925d6..ddf4c49 100644 --- a/src/profiles/web/tsconfig.tsbuildinfo +++ b/src/profiles/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.test.ts","./src/api/client.ts","./src/api/types.ts","./src/components/actionbar.tsx","./src/components/barcodemodal.tsx","./src/components/camerabarcodemodal.tsx","./src/components/confirmdialog.tsx","./src/components/contextmenu.tsx","./src/components/directorypickermodal.tsx","./src/components/dropconfigoverlay.tsx","./src/components/editablecombobox.tsx","./src/components/errorboundary.tsx","./src/components/filelisttable.tsx","./src/components/filepropertiesmodal.tsx","./src/components/filtersyntaxhelper.tsx","./src/components/headerbar.tsx","./src/components/initerrorpanel.tsx","./src/components/loadconfigmodal.tsx","./src/components/logviewermodal.tsx","./src/components/markdownmessage.test.tsx","./src/components/markdownmessage.tsx","./src/components/scanstatusstrip.tsx","./src/components/searchbar.tsx","./src/components/shortcutsmodal.tsx","./src/components/singlematchpromptmodal.tsx","./src/components/statusbar.tsx","./src/components/timermodal.tsx","./src/components/tooltip.tsx","./src/components/workflowrunnermodal.test.tsx","./src/components/workflowrunnermodal.tsx","./src/utils/barcodedecoder.test.ts","./src/utils/barcodedecoder.ts","./src/utils/barcodescanner.ts","./src/utils/configdrop.test.ts","./src/utils/configdrop.ts","./src/utils/exportcsv.ts","./src/utils/fileproperties.test.ts","./src/utils/fileproperties.ts","./src/utils/searchsyntax.test.ts","./src/utils/searchsyntax.ts","./src/utils/theme.test.ts","./src/utils/theme.ts","./src/utils/usemodala11y.test.tsx","./src/utils/usemodala11y.ts"],"version":"5.7.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/setup-tests.ts","./src/vite-env.d.ts","./src/vitest.d.ts","./src/api/client.test.ts","./src/api/client.ts","./src/api/types.ts","./src/components/actionbar.tsx","./src/components/barcodemodal.tsx","./src/components/camerabarcodemodal.tsx","./src/components/confirmdialog.tsx","./src/components/contextmenu.tsx","./src/components/directorypickermodal.tsx","./src/components/dropconfigoverlay.tsx","./src/components/editablecombobox.tsx","./src/components/errorboundary.tsx","./src/components/filelisttable.tsx","./src/components/filepropertiesmodal.tsx","./src/components/filtersyntaxhelper.tsx","./src/components/headerbar.tsx","./src/components/initerrorpanel.tsx","./src/components/loadconfigmodal.tsx","./src/components/logviewermodal.tsx","./src/components/markdownmessage.test.tsx","./src/components/markdownmessage.tsx","./src/components/scanstatusstrip.tsx","./src/components/searchbar.tsx","./src/components/shortcutsmodal.tsx","./src/components/singlematchpromptmodal.tsx","./src/components/statusbar.test.tsx","./src/components/statusbar.tsx","./src/components/timermodal.tsx","./src/components/tooltip.tsx","./src/components/workflowrunnermodal.test.tsx","./src/components/workflowrunnermodal.tsx","./src/utils/barcodedecoder.test.ts","./src/utils/barcodedecoder.ts","./src/utils/barcodescanner.ts","./src/utils/configdrop.test.ts","./src/utils/configdrop.ts","./src/utils/exportcsv.ts","./src/utils/fileproperties.test.ts","./src/utils/fileproperties.ts","./src/utils/searchsyntax.test.ts","./src/utils/searchsyntax.ts","./src/utils/theme.test.ts","./src/utils/theme.ts","./src/utils/usemodala11y.test.tsx","./src/utils/usemodala11y.ts"],"version":"5.7.3"} \ No newline at end of file diff --git a/src/profiles/web/vitest.config.ts b/src/profiles/web/vitest.config.ts index 241df71..760275b 100644 --- a/src/profiles/web/vitest.config.ts +++ b/src/profiles/web/vitest.config.ts @@ -6,5 +6,6 @@ export default defineConfig({ }, test: { include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + setupFiles: ["./src/setup-tests.ts"], }, }); From 7acc6dda5e0a1a1fe6a0c8907f46a8b06598dd81 Mon Sep 17 00:00:00 2001 From: "f.albany" Date: Tue, 3 Nov 2026 15:27:07 +0100 Subject: [PATCH 07/10] feat(web): LAN binding toggle in the status bar --- src/profiles/web/src/App.tsx | 44 ++++++++++++++ src/profiles/web/src/api/client.ts | 22 +++++++ src/profiles/web/src/api/types.ts | 2 + .../web/src/components/StatusBar.test.tsx | 58 +++++++++++++++++++ src/profiles/web/src/components/StatusBar.tsx | 30 +++++++++- src/profiles/web/vitest.config.ts | 1 + 6 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 src/profiles/web/src/components/StatusBar.test.tsx diff --git a/src/profiles/web/src/App.tsx b/src/profiles/web/src/App.tsx index c3674e5..7c1f848 100644 --- a/src/profiles/web/src/App.tsx +++ b/src/profiles/web/src/App.tsx @@ -17,6 +17,7 @@ import { loadConfigContent, reloadConfig, exportCsvToDisk, + setLanEnabled, } from "./api/client"; import type { AppConfigData, ExternalEvent, ScanSort, ScannedFileItem, SystemInfo } from "./api/types"; import { @@ -104,6 +105,8 @@ export default function App() { const [propertiesFile, setPropertiesFile] = useState(null); const [showLogViewer, setShowLogViewer] = useState(false); const [showTimerModal, setShowTimerModal] = useState(false); + /** Pending LAN toggle (true/false) awaiting user confirmation. */ + const [lanPending, setLanPending] = useState(null); // Workflow Modal state const [workflowLaunchParams, setWorkflowLaunchParams] = useState<{ @@ -151,6 +154,11 @@ export default function App() { setToastMessage(null); }; + /** Open the in-app confirmation dialog for a LAN toggle request. */ + const toggleLan = (next: boolean) => { + setLanPending(next); + }; + const beginScan = () => { // Collision rule: any explicit scan (Enter, Scan button, programmatic // filter change, barcode apply…) supersedes a pending deferred @@ -1553,6 +1561,8 @@ export default function App() { language={language} onLanguageChange={setLanguage} activeConfigName={config?.config_path ? config.config_path.split(/[\\/]/).pop() || config.config_path : undefined} + lanEnabled={systemInfo?.lan_enabled ?? null} + onToggleLan={toggleLan} /> @@ -1678,6 +1688,40 @@ export default function App() { onCancel={() => setPendingDeleteFile(null)} /> + {/* LAN toggle confirmation */} + { + const next = lanPending!; + setLanPending(null); + try { + await setLanEnabled(next); + const sys = await fetchSystemInfo(); + setSystemInfo(sys); + showToast( + next + ? "ProFiles is now reachable on the local network." + : "ProFiles is restricted to this machine.", + "info" + ); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to update LAN binding"; + showToast(`Failed to update LAN binding: ${msg}`, "error"); + // Refetch to restore the actual state + const sys = await fetchSystemInfo(); + setSystemInfo(sys); + } + }} + onCancel={() => setLanPending(null)} + /> + {/* Single Match Launch Confirmation */} { return res.json(); } +/** Flip the running server between LAN exposure and loopback-only. */ +export async function setLanEnabled( + lan: boolean +): Promise<{ host: string; lan_enabled: boolean }> { + const res = await fetch("/api/system/bind", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ lan }), + }); + if (!res.ok) { + let detail = "Failed to update LAN binding"; + try { + const body = await res.json(); + if (body?.detail) detail = body.detail; + } catch { + // keep the fallback message + } + throw new Error(detail); + } + return res.json(); +} + export async function fetchConfig(): Promise { const res = await fetch("/api/config"); if (!res.ok) throw new Error("Failed to fetch app configuration"); diff --git a/src/profiles/web/src/api/types.ts b/src/profiles/web/src/api/types.ts index 0b8c069..5a81b4b 100644 --- a/src/profiles/web/src/api/types.ts +++ b/src/profiles/web/src/api/types.ts @@ -3,6 +3,8 @@ export interface SystemInfo { ip: string; username: string; os_release: string; + /** Current bind state; null when the server object is unknown. */ + lan_enabled: boolean | null; } export interface AppConfigData { diff --git a/src/profiles/web/src/components/StatusBar.test.tsx b/src/profiles/web/src/components/StatusBar.test.tsx new file mode 100644 index 0000000..fd3bc37 --- /dev/null +++ b/src/profiles/web/src/components/StatusBar.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { StatusBar } from "./StatusBar"; +import type { SystemInfo } from "../api/types"; + +const systemInfo: SystemInfo = { + hostname: "st-465", + ip: "172.16.41.65", + username: "operator", + os_release: "Windows 11", + lan_enabled: false, +}; + +/** Build minimal StatusBar props for each scenario. */ +function baseProps() { + return { + systemInfo, + fileCount: 1, + theme: "dark" as const, + onToggleTheme: vi.fn(), + onOpenLog: vi.fn(), + onOpenConfig: vi.fn(), + onShowShortcuts: vi.fn(), + }; +} + +afterEach(() => cleanup()); + +describe("StatusBar LAN toggle", () => { + it("shows the LAN switch and forwards the requested next state", async () => { + const onToggleLan = vi.fn(); + render(); + const sw = screen.getByRole("button", { name: /lan/i }); + expect(sw).not.toBeDisabled(); + await userEvent.click(sw); + expect(onToggleLan).toHaveBeenCalledWith(true); + }); + + it("reflects the enabled state via aria-pressed", () => { + render(); + expect(screen.getByRole("button", { name: /lan/i })).toHaveAttribute( + "aria-pressed", + "true" + ); + }); + + it("disables the switch while the bind state is unknown", () => { + render(); + expect(screen.getByRole("button", { name: /lan/i })).toBeDisabled(); + }); + + it("renders no LAN switch without a toggle handler", () => { + render(); + expect(screen.queryByRole("button", { name: /lan/i })).toBeNull(); + }); +}); diff --git a/src/profiles/web/src/components/StatusBar.tsx b/src/profiles/web/src/components/StatusBar.tsx index 20a9a97..0617bdd 100644 --- a/src/profiles/web/src/components/StatusBar.tsx +++ b/src/profiles/web/src/components/StatusBar.tsx @@ -1,5 +1,5 @@ import Tooltip from "./Tooltip"; -import { Activity, Download, FileCog, FileText, Globe, Keyboard, Moon, RefreshCw, Settings, Sun, User } from "lucide-react"; +import { Activity, Download, FileCog, FileText, Globe, Keyboard, Moon, Network, RefreshCw, Settings, Sun, User } from "lucide-react"; import type { SystemInfo } from "../api/types"; type Language = "en" | "fr"; @@ -21,6 +21,10 @@ interface StatusBarProps { language?: Language; onLanguageChange?: (lang: Language) => void; activeConfigName?: string; + /** Current bind state from /api/system; null while unknown. */ + lanEnabled?: boolean | null; + /** Request a bind change; renders the LAN switch only when set. */ + onToggleLan?: (next: boolean) => void; } export function StatusBar({ @@ -38,6 +42,8 @@ export function StatusBar({ language = "en", onLanguageChange, activeConfigName, + lanEnabled, + onToggleLan, }: StatusBarProps) { return (