diff --git a/apps/action2v/tests/test_application.py b/apps/action2v/tests/test_application.py index c767d5950..e1dbf23dc 100644 --- a/apps/action2v/tests/test_application.py +++ b/apps/action2v/tests/test_application.py @@ -22,6 +22,7 @@ from numpy import uint64 from flashdreams.api_v2.client_window import IClientWindow +from flashdreams.api_v2.user_input_event import UserInputEvent from flashdreams.runtime_v2.session_desc import SessionDesc from flashdreams.runtime_v2.session_runner import run_session from flashdreams.runtime_v2.step_result import StepResult @@ -138,6 +139,9 @@ def __init__(self) -> None: self.opened = False self.cursor_requests: list[tuple[str, bool]] = [] + def _put_user_input_event(self, event: UserInputEvent) -> None: + del event + def open(self, session_desc: SessionDesc) -> None: del session_desc self.opened = True diff --git a/apps/t2v/t2v/testing.py b/apps/t2v/t2v/testing.py index 648639a68..a20337d9c 100644 --- a/apps/t2v/t2v/testing.py +++ b/apps/t2v/t2v/testing.py @@ -21,6 +21,7 @@ import torch from flashdreams.api_v2.client_window import IClientWindow +from flashdreams.api_v2.user_input_event import UserInputEvent from flashdreams.runtime_v2.blit_model_output_to_screen_loop import ( BlitModelOutputToScreenLoop, ) @@ -410,6 +411,9 @@ class _DiscardingClientWindow(IClientWindow): def get_user_input_events(self) -> UserInputEvents: return UserInputEvents([]) + def _put_user_input_event(self, event: UserInputEvent) -> None: + del event + def open(self, session_desc: SessionDesc) -> None: del session_desc diff --git a/apps/t2v/tests/test_cli.py b/apps/t2v/tests/test_cli.py index 8521ddeea..0cadccec3 100644 --- a/apps/t2v/tests/test_cli.py +++ b/apps/t2v/tests/test_cli.py @@ -27,6 +27,7 @@ from flashdreams.api_v2.client_window import IClientWindow from flashdreams.api_v2.loop import IModelLoop from flashdreams.api_v2.session import ISession +from flashdreams.api_v2.user_input_event import UserInputEvent from flashdreams.runtime_v2 import cli from flashdreams.runtime_v2.application_registry import ( APPLICATION_ENTRY_POINT_GROUP, @@ -160,6 +161,9 @@ def __init__(self) -> None: def get_user_input_events(self) -> UserInputEvents: return UserInputEvents([]) + def _put_user_input_event(self, event: UserInputEvent) -> None: + del event + def open(self, session_desc: SessionDesc) -> None: self.session_desc = session_desc diff --git a/docs/source/documentation/demo_api/api_reference/application.md b/docs/source/documentation/demo_api/api_reference/application.md index 3ba277494..a8b6246a2 100644 --- a/docs/source/documentation/demo_api/api_reference/application.md +++ b/docs/source/documentation/demo_api/api_reference/application.md @@ -330,6 +330,70 @@ Request a positive downstream client-window width and height. - `TypeError`: The size is not a pair of integers. - `ValueError`: Either dimension is not positive. +#### `request_file_selector` + +```python +def request_file_selector(self, request_id: str, initial_path: str | None=None, *, accept: Sequence[str]=(), max_file_bytes: int | None=None, multiple: bool=False) -> None +``` + +Ask the client to show a file selector. The call only queues the request; the +result arrives on a later tick as one `SelectedFilesUserInputEvent` in +`user_events`, carrying `request_id`. The model loop receives the same event. + +Every call gets exactly one event unless its session is replaced or shut down +first. One selector is open at a time: a call made while one is open, including +a second call in the same tick, is answered `BUSY`. Use one stable +`request_id` per control. + +Read `status` (`SelectedFilesStatus`) before `files`; `files` is empty unless +the status is `OK`: + +- `OK`: each `SelectedFile` has a base `name` (no host path) and its `data` + bytes. +- `CANCELLED`: the user dismissed the selector. +- `BUSY`: another selector was open, so this one was not shown. +- `DISALLOWED_TYPE`: a file did not match `accept` (case-insensitive suffixes). +- `TOO_LARGE`: a file exceeded `max_file_bytes`, or the set exceeded + `MAX_SELECTED_FILE_BATCH_BYTES` (256 MiB) or `MAX_SELECTED_FILE_COUNT` (256). +- `UNAVAILABLE`: no picker (MP4, null), the picker was not shown within 30 + seconds, the viewer left, the chooser failed, or a file could not be read. + +`accept` checks filename suffixes only, so validate `data` as untrusted input. +`max_file_bytes` above `MAX_SELECTED_FILE_BYTES` (32 MiB) is clamped. +`initial_path` is a directory hint for the native Tk dialog and is ignored by +the browser. + +```python +from flashdreams.runtime_v2.selected_file import SelectedFilesStatus +from flashdreams.runtime_v2.user_input_event import SelectedFilesUserInputEvent + +if imgui.button("Open image"): + self.request_file_selector("open-image", accept=(".png",)) +for event in self.user_events.get_events(): + if not isinstance(event, SelectedFilesUserInputEvent): + continue + if event.request_id != "open-image": + continue + if event.status is SelectedFilesStatus.OK: + image_bytes = event.files[0].data + else: + self.state.message = f"No image: {event.status.value}" +``` + +**Parameters** + +- `request_id`: Names the control that asked; echoed on the result. +- `initial_path`: Native directory hint; `None` uses the host user's home. +- `accept`: Filename suffixes such as `.png`. Empty allows any type. +- `max_file_bytes`: Maximum size of each file, or `None` for the ceiling. +- `multiple`: Whether the selector may return more than one file. + +**Raises** + +- `TypeError`: An argument has the wrong type. +- `ValueError`: `request_id` or `initial_path` is empty, or `accept` / + `max_file_bytes` is invalid. + #### `get_or_create_ui_loop_requests` ```python diff --git a/flashdreams/flashdreams/api_v2/README.md b/flashdreams/flashdreams/api_v2/README.md index 3e85e27ca..89d530104 100644 --- a/flashdreams/flashdreams/api_v2/README.md +++ b/flashdreams/flashdreams/api_v2/README.md @@ -224,7 +224,14 @@ UI control that needs a fresh application session calls `request_new_session(session_desc)` with a fully resolved replacement description; the runtime cleans the current session and passes that description to `ApplicationRunner` unchanged. Remaining timeout and remaining model steps -still apply to the replacement. +still apply to the replacement. To let the user pick a client file, call +`request_file_selector(request_id, ...)` when they ask (for ImGui, when +`imgui.button(...)` returns `True`), with one stable `request_id` per control. +The result arrives on a later tick as one `SelectedFilesUserInputEvent` +carrying that `request_id`; read its `status` before its `files`. One picker +is open at a time, and a call made while one is open is answered `BUSY`. The +`request_file_selector` docstring is the full contract: timing, every status, +size and type limits, and what WebRTC shows. For SlangPy's smaller retained widget API, subclass `SlangPyUILoop` from `flashdreams.runtime_v2.slangpy_ui_loop`. The diff --git a/flashdreams/flashdreams/api_v2/client_window.py b/flashdreams/flashdreams/api_v2/client_window.py index 1a2dd1754..8d54fffa7 100644 --- a/flashdreams/flashdreams/api_v2/client_window.py +++ b/flashdreams/flashdreams/api_v2/client_window.py @@ -3,10 +3,31 @@ """Client window abstract interface.""" -from abc import ABC +import logging +import threading +import time +from abc import ABC, abstractmethod +from collections.abc import Callable +from dataclasses import replace +from typing import final + +from numpy import uint64 + +from flashdreams.runtime_v2.selected_file import ( + FILE_SELECTION_PRESENTATION_TIMEOUT_SECONDS, + FileSelectionRequest, + SelectedFile, + SelectedFilesStatus, + clamp_selected_file_max_bytes, + selected_files_policy_status, +) +from flashdreams.runtime_v2.user_input_event import SelectedFilesUserInputEvent from .input_source import InputSource from .output_sink import OutputSink +from .user_input_event import UserInputEvent + +_LOGGER = logging.getLogger(__name__) class IClientWindow(InputSource, OutputSink, ABC): @@ -20,8 +41,43 @@ class IClientWindow(InputSource, OutputSink, ABC): A window does not describe the output shape. The session does, and the window is given that description in :meth:`OutputSink.open`. - One thread makes every call on a window, so an implementation needs no - locking except when its backend delivers input from another thread. + The runtime makes every call on a window from one thread. A window still + needs locking where its backend or file picker delivers input from another + thread, such as :meth:`_put_user_input_event`. + + File selection lifecycle. This class holds the rules; a window only shows + a picker and reports what it returned. + + - **Request.** The runtime calls :meth:`request_file_selector` on the UI + thread, once for each call an app made, except in a tick that also + replaces the session (that session gets no results). The first request + opens a picker + with a new internal ``selector_id``. A request made while a picker is + open is answered at once as ``BUSY``. + - **Showing.** :meth:`_request_file_selector` runs on the UI thread and + must not block. The window calls :meth:`_file_selector_opened` from any + thread once the picker is on screen, which ends its deadline. + - **Deadline.** A picker not shown within + :data:`FILE_SELECTION_PRESENTATION_TIMEOUT_SECONDS` completes as + ``UNAVAILABLE``. :meth:`_expire_file_selectors` checks it; a window with a + picker calls it each time its input is read, or that answer waits for the + next request. + - **Result.** The window calls :meth:`_complete_file_selector` from any + thread. This class checks the result against the request (non-``OK`` + results carry no files; ``OK`` needs files that pass ``accept``, the + clamped size limits, and ``multiple``) and queues exactly one event + through :meth:`_put_user_input_event`. A result for a ``selector_id`` + that is no longer active is dropped. + - **Withdrawal.** :meth:`_cancel_active_file_selectors` runs on the UI + thread after a timeout or a flush, so the window can close what it shows. + - **Session end.** When a session ends, for replacement or shutdown, the + runtime calls :meth:`flush_file_selectors`: the open picker is withdrawn + and its result is dropped. :meth:`OutputSink.open` drops events still + queued for the previous session. An in-session reset + (``ResetUserInputEvent``) does not flush; the picker stays open and its + result arrives after the reset. + - **Wrappers.** A window that wraps another forwards + :meth:`request_file_selector` and :meth:`flush_file_selectors` to it. Created by the runtime, never by an application. """ @@ -46,3 +102,188 @@ def request_new_window_size(self, new_window_size: tuple[int, int]) -> None: new_window_size: Requested ``(width, height)`` in pixels. """ pass + + # Plain class defaults so subclasses need no constructor call; assigning + # one on an instance shadows it for that window only. + _active_file_selector: tuple[int, FileSelectionRequest] | None = None + _file_selector_deadline: float | None = None + _last_file_selector_id = 0 + _file_selector_clock: Callable[[], float] = staticmethod(time.monotonic) + + def request_file_selector(self, request: FileSelectionRequest) -> None: + """Show a file selector; its result arrives later as an input event. + + The result is one ``SelectedFilesUserInputEvent`` from + :meth:`InputSource.get_user_input_events` carrying + ``request.request_id``. A request made while a picker is open is + answered as ``BUSY``. See the class docstring for the lifecycle. + + Args: + request: Selector request queued by the UI loop. + """ + self._expire_file_selectors() + with _FILE_SELECTOR_LOCK: + if self._active_file_selector is not None: + self._put_user_input_event( + _selected_files_event(request, SelectedFilesStatus.BUSY) + ) + return + selector_id = self._last_file_selector_id + 1 + self._last_file_selector_id = selector_id + self._active_file_selector = (selector_id, request) + self._file_selector_deadline = ( + self._file_selector_clock() + + FILE_SELECTION_PRESENTATION_TIMEOUT_SECONDS + ) + try: + self._request_file_selector(selector_id, request) + except Exception: + _LOGGER.exception("File selector %r failed to start.", request.request_id) + self._complete_file_selector(selector_id, SelectedFilesStatus.UNAVAILABLE) + + def flush_file_selectors(self) -> None: + """Forget the open picker without a result, for a session that ended. + + The window withdraws a picker still on screen where it can; whatever + that picker returns is dropped. + """ + with _FILE_SELECTOR_LOCK: + active = self._active_file_selector + self._active_file_selector = None + self._file_selector_deadline = None + if active is not None: + self._cancel_active_file_selectors() + + # Optional to implement + def _request_file_selector( + self, selector_id: int, request: FileSelectionRequest + ) -> None: + """Show a picker for ``request`` without blocking. + + Call :meth:`_file_selector_opened` once it is shown, then + :meth:`_complete_file_selector` with ``selector_id`` when the user + finishes, from any thread. The default has no picker and completes as + ``UNAVAILABLE``. + """ + del request + self._complete_file_selector(selector_id, SelectedFilesStatus.UNAVAILABLE) + + # Optional to implement + def _cancel_active_file_selectors(self) -> None: + """Withdraw the picker that was flushed or that timed out.""" + pass + + @abstractmethod + def _put_user_input_event(self, event: UserInputEvent) -> None: + """Queue ``event`` for :meth:`InputSource.get_user_input_events`. + + Required even for a window without a picker, which answers every + request through it. Only this class calls it. It may run on any thread + and runs under the file-selector lock, so it must not call back into + file selection. + """ + + @final + def _file_selector_opened(self, selector_id: int) -> None: + """Stop the deadline because the picker for ``selector_id`` is shown.""" + with _FILE_SELECTOR_LOCK: + active = self._active_file_selector + if active is not None and active[0] == selector_id: + self._file_selector_deadline = None + + @final + def _complete_file_selector( + self, + selector_id: int, + status: SelectedFilesStatus, + files: tuple[SelectedFile, ...] = (), + ) -> None: + """Report the result for ``selector_id``; a stale id is dropped. + + A result that breaks the request's rules is reported by the status it + earns instead; see the class docstring. + """ + with _FILE_SELECTOR_LOCK: + active = self._active_file_selector + if active is None or active[0] != selector_id: + return + self._active_file_selector = None + self._file_selector_deadline = None + request = active[1] + status, files = _checked_result(request, status, files) + # Queued under the lock so flush_file_selectors, which runs before + # the next session's open(), cannot slip in before the put. + self._put_user_input_event(_selected_files_event(request, status, files)) + + @final + def _expire_file_selectors(self) -> None: + """Complete a picker not shown by its deadline as ``UNAVAILABLE``. + + Windows with a picker call this each time input is read. + """ + with _FILE_SELECTOR_LOCK: + active = self._active_file_selector + deadline = self._file_selector_deadline + if ( + active is None + or deadline is None + or self._file_selector_clock() < deadline + ): + return + _LOGGER.warning( + "File selector %r was not shown within %g seconds.", + active[1].request_id, + FILE_SELECTION_PRESENTATION_TIMEOUT_SECONDS, + ) + self._complete_file_selector(active[0], SelectedFilesStatus.UNAVAILABLE) + self._cancel_active_file_selectors() + + +def _selected_files_event( + request: FileSelectionRequest, + status: SelectedFilesStatus, + files: tuple[SelectedFile, ...] = (), +) -> SelectedFilesUserInputEvent: + """Return the event answering ``request``.""" + return SelectedFilesUserInputEvent( + timestamp=uint64(0), + request_id=request.request_id, + status=status, + files=files, + ) + + +def _checked_result( + request: FileSelectionRequest, + status: SelectedFilesStatus, + files: tuple[SelectedFile, ...], +) -> tuple[SelectedFilesStatus, tuple[SelectedFile, ...]]: + """Return the status and files a window's result earns under ``request``.""" + if status is SelectedFilesStatus.BUSY: + status = SelectedFilesStatus.UNAVAILABLE + if status is not SelectedFilesStatus.OK: + return status, () + if not files or (len(files) > 1 and not request.multiple): + _LOGGER.warning("File selector %r returned no valid set.", request.request_id) + return SelectedFilesStatus.UNAVAILABLE, () + files = tuple(replace(chosen, name=_base_name(chosen.name)) for chosen in files) + rejected = selected_files_policy_status( + [(chosen.name, len(chosen.data)) for chosen in files], + accept=request.accept, + max_bytes=clamp_selected_file_max_bytes(request.max_bytes), + ) + if rejected is not None: + return rejected, () + return status, files + + +def _base_name(name: str) -> str: + """Return ``name`` without any POSIX or Windows directory part.""" + base = name.replace("\\", "/").rsplit("/", 1)[-1] + return "upload" if base in ("", ".", "..") else base + + +# ponytail: one lock for every window keeps subclasses constructor-free; it only +# guards a few assignments and one event put. Upgrade path: a per-window lock +# set in ``__init__``. +_FILE_SELECTOR_LOCK = threading.Lock() diff --git a/flashdreams/flashdreams/api_v2/loop.py b/flashdreams/flashdreams/api_v2/loop.py index c4288433c..b16d241ba 100644 --- a/flashdreams/flashdreams/api_v2/loop.py +++ b/flashdreams/flashdreams/api_v2/loop.py @@ -9,9 +9,10 @@ import threading import time from abc import ABC, abstractmethod -from collections.abc import Callable -from dataclasses import dataclass +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field from enum import Enum +from pathlib import Path from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar, final import torch @@ -24,9 +25,14 @@ bind_inference_profiler, unbind_inference_profiler, ) +from flashdreams.runtime_v2.selected_file import ( + FileSelectionRequest, + normalize_selected_file_accept, +) from flashdreams.runtime_v2.step_result import StepResult from flashdreams.runtime_v2.user_input_event import ( CloseUserInputEvent, + SelectedFilesUserInputEvent, UserInputEvent, ) from flashdreams.runtime_v2.user_input_events import UserInputEvents @@ -97,6 +103,9 @@ class UILoopRequests: new_window_size: tuple[int, int] | None = None """Requested downstream window size, or ``None`` to leave it unchanged.""" + file_selections: list[FileSelectionRequest] = field(default_factory=list) + """File-selector requests in call order. The window answers each one.""" + class ILoop(ABC, Generic[StateT]): """Shared state, messaging, and lifecycle for a session loop. @@ -252,8 +261,16 @@ def _finish_run( """ if step_completed: self.latest_result = result + self._pending_user_events.clear() + else: + # Input that reaches a run with no step is dropped, except a file + # result: it answers a request and arrives once. + self._pending_user_events = [ + event + for event in self._pending_user_events + if isinstance(event, SelectedFilesUserInputEvent) + ] self._step_index += 1 - self._pending_user_events.clear() @final def _shutdown(self) -> None: @@ -581,6 +598,137 @@ def request_new_window_size(self, new_window_size: tuple[int, int]) -> None: raise ValueError("new_window_size dimensions must be > 0.") self.get_or_create_ui_loop_requests().new_window_size = new_window_size + @final + def request_file_selector( + self, + request_id: str, + initial_path: str | None = None, + *, + accept: Sequence[str] = (), + max_file_bytes: int | None = None, + multiple: bool = False, + ) -> None: + """Ask the client to show a file selector. + + The call only queues the request and returns nothing. The window sees + it after this step, so the result is never in :attr:`user_events` + during the step that asked. It arrives on a later tick as one + :class:`~flashdreams.runtime_v2.user_input_event.SelectedFilesUserInputEvent` + carrying ``request_id``. The model loop's ``step`` receives the same + event. + + Every call gets exactly one event, unless the session ends first: a + session replaced or shut down before the answer gets none. One + selector is open at a time, so a call made while one is open, including + a second call in the same tick, is answered ``BUSY``. Use one stable + ``request_id`` per control and reuse it. A control that is already + waiting for its own result can get a ``BUSY`` for a repeat click + before that result. + + For the full lifecycle, including session reset versus replacement, + see :class:`~flashdreams.api_v2.client_window.IClientWindow`. + + Read ``status`` first. ``files`` is empty unless it is ``OK``, and one + rejected file fails the whole set: + + - ``OK``: ``files`` holds each chosen file as its base ``name`` and its + ``data`` bytes. There is no host path. Empty files are allowed. + - ``CANCELLED``: the user dismissed the selector. + - ``BUSY``: another selector was open, so this one was not shown. + - ``DISALLOWED_TYPE``: a file did not match ``accept``. Suffixes + compare case-insensitively, and this wins over ``TOO_LARGE``. + - ``TOO_LARGE``: a file exceeded ``max_file_bytes``, or the set + exceeded + :data:`~flashdreams.runtime_v2.selected_file.MAX_SELECTED_FILE_BATCH_BYTES` + or :data:`~flashdreams.runtime_v2.selected_file.MAX_SELECTED_FILE_COUNT`. + - ``UNAVAILABLE``: the window has no picker (MP4, null), the picker + was not shown within + :data:`~flashdreams.runtime_v2.selected_file.FILE_SELECTION_PRESENTATION_TIMEOUT_SECONDS`, + the viewer left, the chooser failed, or a file could not be read. + + ``accept`` checks filename suffixes only, never contents, so validate + and decode ``data`` as untrusted input. + + Once the picker is shown there is no deadline. On WebRTC, shown means + the browser's "The app requested a file" prompt; its Select… button + opens the browser's file chooser. The native window uses a Tk dialog, + which needs a Python with Tk and a display; without them the result is + ``UNAVAILABLE``. Tk cannot close its dialog from another thread, so one + left open when its session ends stays on screen, and whatever it + returns is dropped. + + Example: + + .. code-block:: python + + from flashdreams.runtime_v2.selected_file import SelectedFilesStatus + from flashdreams.runtime_v2.user_input_event import ( + SelectedFilesUserInputEvent, + ) + + if imgui.button("Open image"): + self.request_file_selector("open-image", accept=(".png",)) + for event in self.user_events.get_events(): + if not isinstance(event, SelectedFilesUserInputEvent): + continue + if event.request_id != "open-image": + continue + if event.status is SelectedFilesStatus.OK: + image_bytes = event.files[0].data + else: + self.state.message = f"No image: {event.status.value}" + + Args: + request_id: Names the control that asked, so its result can be + told apart from another control's. Echoed on the result. + initial_path: Directory hint for the native selector; ``None`` + uses the host user's home directory. The browser ignores it. + accept: Filename suffixes such as ``.png``. Empty allows any type. + max_file_bytes: Maximum size of each file in bytes, or ``None`` for + the ceiling. Larger values are clamped to + :data:`~flashdreams.runtime_v2.selected_file.MAX_SELECTED_FILE_BYTES` + without notice. + multiple: Whether the selector may return more than one file. The + whole set is also capped by + :data:`~flashdreams.runtime_v2.selected_file.MAX_SELECTED_FILE_BATCH_BYTES`. + + Raises: + TypeError: ``request_id`` or ``initial_path`` is not a string, or + ``accept`` / ``max_file_bytes`` / ``multiple`` has the wrong + type. + ValueError: ``request_id`` or ``initial_path`` is empty, or + ``accept`` / ``max_file_bytes`` is invalid. + """ + if not isinstance(request_id, str): + raise TypeError("request_id must be a string.") + if not request_id: + raise ValueError("request_id must be a non-empty string.") + if initial_path is None: + resolved_initial_path = str(Path.home()) + else: + if not isinstance(initial_path, str): + raise TypeError("initial_path must be a string.") + if not initial_path: + raise ValueError("initial_path must be a non-empty string.") + resolved_initial_path = initial_path + suffixes = normalize_selected_file_accept(accept) + if max_file_bytes is not None: + if isinstance(max_file_bytes, bool) or not isinstance(max_file_bytes, int): + raise TypeError("max_file_bytes must be an integer.") + if max_file_bytes <= 0: + raise ValueError("max_file_bytes must be > 0.") + if not isinstance(multiple, bool): + raise TypeError("multiple must be a bool.") + self.get_or_create_ui_loop_requests().file_selections.append( + FileSelectionRequest( + request_id=request_id, + initial_path=resolved_initial_path, + accept=suffixes, + max_bytes=max_file_bytes, + multiple=multiple, + ) + ) + def get_or_create_ui_loop_requests(self) -> UILoopRequests: if self._ui_loop_requests is None: self._ui_loop_requests = UILoopRequests() @@ -676,6 +824,7 @@ def invoke_async(loop: ILoop[StateT], operation: Callable[[StateT], None]) -> No __all__ = [ + "FileSelectionRequest", "ILoop", "IModelLoop", "IUILoop", diff --git a/flashdreams/flashdreams/runtime_v2/README.md b/flashdreams/flashdreams/runtime_v2/README.md index 064974c7c..731fe2ca2 100644 --- a/flashdreams/flashdreams/runtime_v2/README.md +++ b/flashdreams/flashdreams/runtime_v2/README.md @@ -370,16 +370,25 @@ delegates over the thing that does the work — `Mp4ClientWindow` over the output shape, because the session already did. They differ in what they can do rather than in how they are driven. A session -chooses its UI independently of the client window. Browser keyboard, mouse and -focus events arrive on the server's own thread, so it queues them and hands them -over in batches when the session asks. +chooses its UI independently of the client window. Browser keyboard, mouse, +focus, and file-selector events arrive on the server's own thread, so it queues +them and hands them over in batches when the session asks. + +File selection rules live in `IClientWindow`, and its class docstring is the +full lifecycle: requests, the 30 second deadline, result checks, withdrawal, +session end versus in-session reset, threads, and wrappers. A window only shows +a picker and reports what it returned. Every window implements the abstract +`_put_user_input_event`; a window with a picker also implements +`_request_file_selector` and calls `_expire_file_selectors()` each time its +input is read. A UI loop may call `request_new_session(session_desc)` during its step with a fully resolved replacement description. `run_session` stops and cleans the current session, leaves the interactive window open, and returns that description unchanged. `ApplicationRunner` creates the replacement from it. A WebRTC browser disconnect releases only its peer connection, so refreshing the -page does not stop the session, server, or application. +page does not stop the session, server, or application. Leftover and in-flight +file picks complete as `unavailable`; a replacement viewer does not inherit them. The UI thread owns WebRTC cadence. Each `write` synchronously materializes one owned video frame and admits it to a two-frame FIFO of unsent frames. The WebRTC diff --git a/flashdreams/flashdreams/runtime_v2/mp4_client_window.py b/flashdreams/flashdreams/runtime_v2/mp4_client_window.py index 3e2c72ac6..40439ffda 100644 --- a/flashdreams/flashdreams/runtime_v2/mp4_client_window.py +++ b/flashdreams/flashdreams/runtime_v2/mp4_client_window.py @@ -6,6 +6,7 @@ from pathlib import Path from flashdreams.api_v2.client_window import IClientWindow +from flashdreams.api_v2.user_input_event import UserInputEvent from flashdreams.runtime_v2.mp4_output_sink import Mp4OutputSink from flashdreams.runtime_v2.session_desc import SessionDesc from flashdreams.runtime_v2.step_result import StepResult @@ -13,7 +14,7 @@ class Mp4ClientWindow(IClientWindow): - """Write UI frames to an MP4 file and report no input. + """Write UI frames to an MP4 file and report no input except file results. The session must finish on its own because this window never sends a close event. Use ``BackpressureMode.BLOCK`` with @@ -27,6 +28,7 @@ def __init__(self, path: str | Path) -> None: """ self._path = Path(path) self._video_sink = Mp4OutputSink(path) + self._input_events: list[UserInputEvent] = [] @property def path(self) -> Path: @@ -34,8 +36,12 @@ def path(self) -> Path: return self._path def get_user_input_events(self) -> UserInputEvents: - """Return an empty input batch.""" - return UserInputEvents([]) + """Return the file-selection results queued since the last call.""" + events, self._input_events = self._input_events, [] + return UserInputEvents(events) + + def _put_user_input_event(self, event: UserInputEvent) -> None: + self._input_events.append(event) def request_new_window_size(self, new_window_size: tuple[int, int]) -> None: """Change the MP4 presentation dimensions without replacing the session. @@ -52,6 +58,7 @@ def request_new_window_size(self, new_window_size: tuple[int, int]) -> None: def open(self, session_desc: SessionDesc) -> None: """Prepare to write a session's output.""" + self._input_events = [] self._video_sink.open(session_desc) def write(self, result: StepResult) -> None: diff --git a/flashdreams/flashdreams/runtime_v2/native_window_client_window.py b/flashdreams/flashdreams/runtime_v2/native_window_client_window.py index d179db154..5709b43bf 100644 --- a/flashdreams/flashdreams/runtime_v2/native_window_client_window.py +++ b/flashdreams/flashdreams/runtime_v2/native_window_client_window.py @@ -7,11 +7,13 @@ import logging import queue +import stat import threading import time from collections import deque -from collections.abc import Callable +from collections.abc import Callable, Sequence from dataclasses import replace +from pathlib import Path from typing import TYPE_CHECKING, Any, cast import numpy as np @@ -21,6 +23,14 @@ from flashdreams.api_v2.client_window import IClientWindow from flashdreams.api_v2.user_input_event import UserInputEvent +from flashdreams.runtime_v2.selected_file import ( + MAX_SELECTED_FILE_BYTES, + FileSelectionRequest, + SelectedFile, + SelectedFilesStatus, + clamp_selected_file_max_bytes, + selected_files_policy_status, +) from flashdreams.runtime_v2.session_desc import SessionDesc from flashdreams.runtime_v2.step_result import StepResult from flashdreams.runtime_v2.user_input_event import ( @@ -39,6 +49,107 @@ _LOGGER = logging.getLogger(__name__) + +def _ask_open_filename( + *, + initial_dir: str, + accept: tuple[str, ...] = (), + multiple: bool = False, + on_presented: Callable[[], None] | None = None, +) -> tuple[str, ...]: + """Return paths from the Tk file dialog, or an empty tuple when cancelled. + + Tk shows Explorer on Windows, the macOS panel, and its own dialog on Linux. + ``on_presented`` runs just before the dialog opens. It does not mean a + remote host made that dialog visible. + + Raises: + ImportError: This Python was built without Tk. + tkinter.TclError: Tk cannot open a dialog, such as without a display. + """ + # ponytail: this runs on a worker thread, which macOS may abort for Tk. + # Upgrade path: launch ``osascript`` "choose file" as a separate process. + import tkinter as tk + from tkinter import filedialog + + root = tk.Tk() + root.withdraw() + try: + root.attributes("-topmost", True) + except tk.TclError: + pass + # ponytail: calling the Tk dialog is the presentation signal. Tk does not + # report when the dialog is actually visible. Upgrade path: + # IFileDialogEvents on Windows or NSOpenPanel visibility on macOS. + if on_presented is not None: + on_presented() + try: + kwargs: dict[str, Any] = { + "initialdir": initial_dir, + "title": "Select a file", + } + if accept: + kwargs["filetypes"] = [ + ("Accepted", " ".join(f"*{suffix}" for suffix in accept)) + ] + if multiple: + selected = filedialog.askopenfilenames(**kwargs) + return tuple(str(path) for path in selected if path) + selected = filedialog.askopenfilename(**kwargs) + finally: + root.destroy() + return (str(selected),) if selected else () + + +def _selected_files_from_paths( + paths: Sequence[str], + *, + accept: tuple[str, ...] = (), + max_bytes: int | None = None, +) -> tuple[tuple[SelectedFile, ...], SelectedFilesStatus]: + """Load chosen files, or empty files and a failure status.""" + budget = MAX_SELECTED_FILE_BYTES if max_bytes is None else max_bytes + rejected = selected_files_policy_status( + [(Path(path).name, None) for path in paths], + accept=accept, + max_bytes=budget, + ) + if rejected is not None: + return (), rejected + sized: list[tuple[str, int]] = [] + for path in paths: + try: + info = Path(path).stat() + except OSError: + return (), SelectedFilesStatus.UNAVAILABLE + if not stat.S_ISREG(info.st_mode): + return (), SelectedFilesStatus.UNAVAILABLE + sized.append((path, info.st_size)) + rejected = selected_files_policy_status( + [(Path(path).name, size) for path, size in sized], + accept=accept, + max_bytes=budget, + ) + if rejected is not None: + return (), rejected + files: list[SelectedFile] = [] + for path, _size in sized: + try: + with open(path, "rb") as stream: + data = stream.read(budget + 1) + except OSError: + return (), SelectedFilesStatus.UNAVAILABLE + files.append(SelectedFile(name=Path(path).name, data=data)) + rejected = selected_files_policy_status( + [(chosen.name, len(chosen.data)) for chosen in files], + accept=accept, + max_bytes=budget, + ) + if rejected is not None: + return (), rejected + return tuple(files), SelectedFilesStatus.OK + + _PRINTABLE_KEY_NAMES = { "space": " ", "apostrophe": "'", @@ -171,6 +282,48 @@ def request_new_window_size(self, new_window_size: tuple[int, int]) -> None: ) self._window_size = presenter.resize(*new_window_size) + def _request_file_selector( + self, selector_id: int, request: FileSelectionRequest + ) -> None: + """Open the Tk file dialog on a worker thread so the window keeps presenting.""" + # ponytail: Tk cannot close this dialog from another thread, so one + # left open after its session ended can sit beside a newer one; its + # result is dropped. Upgrade path: run the dialog in a child process. + threading.Thread( + target=self._complete_file_selection, + args=(selector_id, request), + name="flashdreams-file-picker", + daemon=True, + ).start() + + def _complete_file_selection( + self, selector_id: int, request: FileSelectionRequest + ) -> None: + """Run the file dialog, then report it opened and its result.""" + files: tuple[SelectedFile, ...] = () + status = SelectedFilesStatus.UNAVAILABLE + try: + selected = _ask_open_filename( + initial_dir=request.initial_path or str(Path.home()), + accept=request.accept, + multiple=request.multiple, + on_presented=lambda: self._file_selector_opened(selector_id), + ) + except Exception: + _LOGGER.exception( + "Native file picker failed for request id %r.", request.request_id + ) + else: + if selected: + files, status = _selected_files_from_paths( + selected if request.multiple else selected[:1], + accept=request.accept, + max_bytes=clamp_selected_file_max_bytes(request.max_bytes), + ) + else: + status = SelectedFilesStatus.CANCELLED + self._complete_file_selector(selector_id, status, files) + def open(self, session_desc: SessionDesc) -> None: """Open a session in the GLFW window on the runtime's UI thread. @@ -233,6 +386,7 @@ def open(self, session_desc: SessionDesc) -> None: def get_user_input_events(self) -> UserInputEvents: """Pump GLFW and return native input events not yet read.""" + self._expire_file_selectors() presenter = self._presenter if presenter is not None: pending_before_poll = tuple(self._pending_printable_keys) @@ -399,6 +553,12 @@ def _on_window_closed(self) -> None: self._close_event_enqueued = True self._put_input(CloseUserInputEvent(timestamp=uint64(0))) + def _put_user_input_event(self, event: UserInputEvent) -> None: + """Queue one event from a picker thread, bypassing the poll-time buffer.""" + started_ns = self._session_started_ns + elapsed_ns = 0 if started_ns is None else max(0, self._clock_ns() - started_ns) + self._input_events.put(replace(event, timestamp=uint64(elapsed_ns // 1_000))) + class _SlangPyNativeWindowPresenter: """Own the SlangPy window and forward frames to its presentation context.""" diff --git a/flashdreams/flashdreams/runtime_v2/null_client_window.py b/flashdreams/flashdreams/runtime_v2/null_client_window.py index 76ff107fa..377889abe 100644 --- a/flashdreams/flashdreams/runtime_v2/null_client_window.py +++ b/flashdreams/flashdreams/runtime_v2/null_client_window.py @@ -4,21 +4,30 @@ """Client window that discards output and reports no input.""" from flashdreams.api_v2.client_window import IClientWindow +from flashdreams.api_v2.user_input_event import UserInputEvent from flashdreams.runtime_v2.session_desc import SessionDesc from flashdreams.runtime_v2.step_result import StepResult from flashdreams.runtime_v2.user_input_events import UserInputEvents class NullClientWindow(IClientWindow): - """Discard output and report no input.""" + """Discard output and report no input except file-selection results.""" + + def __init__(self) -> None: + self._input_events: list[UserInputEvent] = [] def get_user_input_events(self) -> UserInputEvents: - """Return an empty input batch.""" - return UserInputEvents([]) + """Return the file-selection results queued since the last call.""" + events, self._input_events = self._input_events, [] + return UserInputEvents(events) + + def _put_user_input_event(self, event: UserInputEvent) -> None: + self._input_events.append(event) def open(self, session_desc: SessionDesc) -> None: """Accept a session without opening an output.""" del session_desc + self._input_events = [] def write(self, result: StepResult) -> None: """Discard one result.""" diff --git a/flashdreams/flashdreams/runtime_v2/selected_file.py b/flashdreams/flashdreams/runtime_v2/selected_file.py new file mode 100644 index 000000000..ce100e183 --- /dev/null +++ b/flashdreams/flashdreams/runtime_v2/selected_file.py @@ -0,0 +1,183 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Selected-file types, request policy, and size/type checks.""" + +from collections.abc import Sequence +from dataclasses import dataclass +from enum import Enum + +MAX_SELECTED_FILE_BYTES = 32 * 1024 * 1024 +"""Hard ceiling for one selected file. Windows clamp requested budgets to this.""" + +# ponytail: 8× the per-file ceiling is the batch cap; raise it or +# make it per-request if an app needs a larger set in one pick. +MAX_SELECTED_FILE_BATCH_BYTES = 8 * MAX_SELECTED_FILE_BYTES +"""Hard ceiling for the total bytes of one selected-files request.""" + +MAX_SELECTED_FILE_COUNT = 256 +"""Hard ceiling for the number of files in one selected-files request.""" + +FILE_SELECTION_PRESENTATION_TIMEOUT_SECONDS = 30.0 +"""Seconds to wait for a client window to show a picker before ``UNAVAILABLE``. + +The deadline starts when a request is handed to the window. Once the window +reports the picker is shown, the user may take as long as they want. +""" + + +def clamp_selected_file_max_bytes(max_bytes: int | None) -> int: + """Return a file-size budget that cannot exceed ``MAX_SELECTED_FILE_BYTES``. + + Args: + max_bytes: Requested budget in bytes, or ``None`` for the ceiling. + + Returns: + The requested budget when it is below the ceiling, otherwise the ceiling. + + Raises: + TypeError: ``max_bytes`` is not ``None`` or an integer. + ValueError: ``max_bytes`` is not positive. + """ + if max_bytes is None: + return MAX_SELECTED_FILE_BYTES + if isinstance(max_bytes, bool) or not isinstance(max_bytes, int): + raise TypeError("max_bytes must be an integer.") + if max_bytes <= 0: + raise ValueError("max_bytes must be > 0.") + return min(max_bytes, MAX_SELECTED_FILE_BYTES) + + +def normalize_selected_file_accept(accept: Sequence[str] = ()) -> tuple[str, ...]: + """Return accepted filename suffixes such as ``.png``. + + Args: + accept: Suffixes the client may choose. Empty means any type. + + Returns: + The suffixes as a tuple, unchanged except for the sequence type. + + Raises: + TypeError: ``accept`` is not a sequence of strings. + ValueError: A suffix is missing the leading ``.`` or contains a path. + """ + if isinstance(accept, str) or not isinstance(accept, Sequence): + raise TypeError("accept must be a sequence of suffixes.") + suffixes: list[str] = [] + for suffix in accept: + if not isinstance(suffix, str): + raise TypeError("accept suffixes must be strings.") + if ( + not suffix.startswith(".") + or suffix == "." + or "/" in suffix + or "\\" in suffix + ): + raise ValueError( + "accept suffixes must look like ``.png`` and cannot contain a path." + ) + suffixes.append(suffix) + return tuple(suffixes) + + +def selected_file_suffix_allowed(name: str, accept: tuple[str, ...]) -> bool: + """Return whether ``name`` matches ``accept``. + + Empty ``accept`` allows any name. Comparison is case-insensitive. + """ + if not accept: + return True + lower = name.lower() + return any(lower.endswith(suffix.lower()) for suffix in accept) + + +class SelectedFilesStatus(Enum): + """Outcome of one client file-selector request.""" + + OK = "ok" + """The client chose files that passed type and size checks.""" + + CANCELLED = "cancelled" + """The user dismissed the selector.""" + + TOO_LARGE = "too_large" + """A file exceeded its size budget, or the set exceeded the batch size or + file-count ceiling.""" + + DISALLOWED_TYPE = "disallowed_type" + """A chosen file did not match ``accept``. Checked before size.""" + + BUSY = "busy" + """Another selector was already open, so this request was not shown. The + open selector keeps going and answers its own request.""" + + UNAVAILABLE = "unavailable" + """No pick was made: the client has no picker or left, the picker was not + shown in time, or a chosen file was unreadable.""" + + +def selected_files_policy_status( + files: Sequence[tuple[str, int | None]], + *, + accept: tuple[str, ...] = (), + max_bytes: int, +) -> SelectedFilesStatus | None: + """Return why a chosen set is rejected, or ``None`` if every file is allowed. + + Type is checked before size and count, across the whole set. + + Args: + files: ``(name, size)`` pairs. ``size`` of ``None`` skips that file's + size check and omits it from the batch total. + accept: Filename suffixes such as ``.png``. Empty allows any type. + max_bytes: Maximum allowed size of each file in bytes. + + Returns: + ``DISALLOWED_TYPE``, ``TOO_LARGE``, or ``None`` when the set passes. + """ + for name, _size in files: + if not selected_file_suffix_allowed(name, accept): + return SelectedFilesStatus.DISALLOWED_TYPE + if len(files) > MAX_SELECTED_FILE_COUNT: + return SelectedFilesStatus.TOO_LARGE + total = 0 + for _name, size in files: + if size is None: + continue + if size > max_bytes: + return SelectedFilesStatus.TOO_LARGE + total += size + if total > MAX_SELECTED_FILE_BATCH_BYTES: + return SelectedFilesStatus.TOO_LARGE + return None + + +@dataclass(frozen=True, slots=True) +class SelectedFile: + """One file chosen by a client file selector.""" + + name: str + """Base name of the chosen file, without any host path.""" + + data: bytes + """File contents.""" + + +@dataclass(frozen=True, slots=True) +class FileSelectionRequest: + """One client file-selector request queued by a UI loop.""" + + request_id: str + """Stable id for which button this request is for. Echoed on the matching event.""" + + initial_path: str + """Directory hint for the selector.""" + + accept: tuple[str, ...] = () + """Filename suffixes the client may choose, empty for any type.""" + + max_bytes: int | None = None + """Requested size budget per file in bytes, or ``None`` for the ceiling.""" + + multiple: bool = False + """Whether the selector may return more than one file.""" diff --git a/flashdreams/flashdreams/runtime_v2/serving/web/app.js b/flashdreams/flashdreams/runtime_v2/serving/web/app.js index d8e10c3e5..a8129779f 100644 --- a/flashdreams/flashdreams/runtime_v2/serving/web/app.js +++ b/flashdreams/flashdreams/runtime_v2/serving/web/app.js @@ -7,6 +7,12 @@ const pointerControls = peer.createDataChannel("pointer-controls"); peer.addTransceiver("video", {direction: "recvonly"}); const video = document.getElementById("video"); const status = document.getElementById("status"); +const promptDialog = document.getElementById("prompt-dialog"); +const promptBackdrop = document.getElementById("prompt-backdrop"); +const promptDialogTitle = document.getElementById("prompt-dialog-title"); +const promptDialogDetails = document.getElementById("prompt-dialog-details"); +const promptDialogConfirm = document.getElementById("prompt-dialog-confirm"); +const promptDialogCancel = document.getElementById("prompt-dialog-cancel"); const pressedKeys = new Map(); const pressedButtons = new Set(); const gamepadSnapshots = new Map(); @@ -74,6 +80,313 @@ const applyCursorOptions = options => { } }; +let promptSettled = false; +let promptOnConfirm = null; +let promptOnCancel = null; + +const setPromptBackdropVisible = visible => { + if (promptBackdrop !== null) { + promptBackdrop.hidden = !visible; + } +}; + +const closePromptDialog = () => { + setPromptBackdropVisible(false); + if (promptDialog !== null && promptDialog.open) { + promptDialog.close(); + } +}; + +const fillPromptDetails = details => { + if (promptDialogDetails === null) { + return; + } + promptDialogDetails.replaceChildren(); + if (!Array.isArray(details) || details.length === 0) { + promptDialogDetails.hidden = true; + return; + } + for (const section of details) { + const row = document.createElement("div"); + row.className = "prompt-dialog-row"; + const label = document.createElement("div"); + label.className = "prompt-dialog-detail-label"; + label.textContent = section.label; + const value = document.createElement("div"); + value.className = "prompt-dialog-row-value"; + const values = Array.isArray(section.items) ? section.items : []; + for (const item of values) { + const chip = document.createElement("span"); + chip.className = "prompt-dialog-chip"; + chip.textContent = item; + value.append(chip); + } + row.append(label, value); + promptDialogDetails.append(row); + } + promptDialogDetails.hidden = false; +}; + +const showPromptDialog = ({ + title, + details = [], + confirmLabel = "OK", + cancelLabel = "Cancel", + onConfirm = null, + onCancel = null, +} = {}) => { + if ( + promptDialog === null + || promptDialogTitle === null + || promptDialogConfirm === null + || promptDialogCancel === null + ) { + return; + } + if (promptDialog.open) { + promptSettled = true; + promptOnConfirm = null; + promptOnCancel = null; + promptDialog.close(); + } + promptDialogTitle.textContent = title; + fillPromptDetails(details); + promptDialogConfirm.textContent = confirmLabel; + promptDialogCancel.textContent = cancelLabel; + promptOnConfirm = onConfirm; + promptOnCancel = onCancel; + promptSettled = false; + setPromptBackdropVisible(true); + promptDialog.show(); + promptDialogConfirm.focus(); +}; + +if (promptDialog !== null) { + promptDialogConfirm?.addEventListener("click", () => { + if (promptSettled) { + return; + } + promptSettled = true; + const onConfirm = promptOnConfirm; + promptOnConfirm = null; + promptOnCancel = null; + closePromptDialog(); + onConfirm?.(); + }); + promptDialogCancel?.addEventListener("click", () => { + if (promptSettled) { + return; + } + promptSettled = true; + const onCancel = promptOnCancel; + promptOnConfirm = null; + promptOnCancel = null; + closePromptDialog(); + onCancel?.(); + }); + promptDialog.addEventListener("close", () => { + setPromptBackdropVisible(false); + if (promptSettled) { + return; + } + promptSettled = true; + const onCancel = promptOnCancel; + promptOnConfirm = null; + promptOnCancel = null; + onCancel?.(); + }); +} + +window.addEventListener("keydown", event => { + if (event.key !== "Escape" || promptDialog === null || !promptDialog.open) { + return; + } + event.preventDefault(); + event.stopImmediatePropagation(); + promptDialogCancel?.click(); +}); + +if (promptBackdrop !== null) { + promptBackdrop.addEventListener("click", () => { + promptDialogCancel?.click(); + }); +} + +let pendingFileRequest = null; + +const hideFilePicker = () => { + pendingFileRequest = null; + if (promptDialog !== null && promptDialog.open) { + promptSettled = true; + promptOnConfirm = null; + promptOnCancel = null; + closePromptDialog(); + } +}; + +const completeFileSelection = (requestId, status) => { + send({type: "file_selector_result", id: requestId, status}); +}; + +const formatByteBudget = bytes => { + if (!Number.isFinite(bytes) || bytes <= 0) { + return "the allowed size"; + } + const mib = bytes / (1024 * 1024); + if (mib >= 1) { + return Number.isInteger(mib) ? `${mib} MiB` : `${mib.toFixed(1)} MiB`; + } + const kib = bytes / 1024; + if (kib >= 1) { + return Number.isInteger(kib) ? `${kib} KiB` : `${kib.toFixed(1)} KiB`; + } + return `${bytes} bytes`; +}; + +const fileNameMatchesAccept = (name, accept) => { + if (!Array.isArray(accept) || accept.length === 0) { + return true; + } + const lower = name.toLowerCase(); + return accept.some( + suffix => typeof suffix === "string" && lower.endsWith(suffix.toLowerCase()), + ); +}; + +const startFileInput = pending => { + const requestId = pending.id; + const input = document.createElement("input"); + input.type = "file"; + if (pending.accept.length > 0) { + input.accept = pending.accept.join(","); + } + input.multiple = pending.multiple === true; + let settled = false; + const finishFileSelection = status => { + if (settled || pendingFileRequest === null || pendingFileRequest.id !== requestId) { + return; + } + settled = true; + completeFileSelection(requestId, status); + hideFilePicker(); + }; + input.addEventListener("change", () => { + if (pendingFileRequest === null || pendingFileRequest.id !== requestId) { + return; + } + const files = pending.multiple + ? Array.from(input.files || []) + : Array.from(input.files || []).slice(0, 1); + if (files.length === 0) { + finishFileSelection("cancelled"); + return; + } + let rejected = null; + for (const file of files) { + if (!fileNameMatchesAccept(file.name || "", pending.accept)) { + rejected = "disallowed_type"; + break; + } + } + if (rejected === null) { + for (const file of files) { + if (file.size > pending.maxBytes) { + rejected = "too_large"; + break; + } + } + } + if (rejected !== null) { + finishFileSelection(rejected); + return; + } + const body = new FormData(); + for (const file of files) { + body.append("file", file, file.name); + } + fetch(`/api/files?id=${encodeURIComponent(requestId)}`, { + method: "POST", + body, + }) + .then(response => { + if (!response.ok) { + // A proxy may refuse the upload before the server can answer it. + console.debug("File upload was rejected.", response.status); + finishFileSelection("unavailable"); + return; + } + if (settled || pendingFileRequest === null || pendingFileRequest.id !== requestId) { + return; + } + settled = true; + hideFilePicker(); + }) + .catch(error => { + console.debug("Unable to upload the selected file.", error); + finishFileSelection("unavailable"); + }); + }); + input.addEventListener("cancel", () => finishFileSelection("cancelled")); + input.click(); +}; + +const openFileSelector = options => { + const requestId = options?.id; + if (typeof requestId !== "string" || !requestId) { + return; + } + if (pendingFileRequest !== null) { + if (pendingFileRequest.id !== requestId) { + completeFileSelection(requestId, "unavailable"); + } + return; + } + const accept = Array.isArray(options.accept) ? options.accept : []; + const maxBytes = Number(options.max_bytes); + pendingFileRequest = { + id: requestId, + accept, + maxBytes: Number.isFinite(maxBytes) ? maxBytes : 0, + multiple: options.multiple === true, + }; + showPromptDialog({ + title: pendingFileRequest.multiple + ? "The app requested files" + : "The app requested a file", + details: [ + { + label: "Accepted types", + items: accept.length > 0 ? accept : ["Any"], + }, + { + label: "Max size per file", + items: [formatByteBudget(pendingFileRequest.maxBytes)], + }, + ], + confirmLabel: "Select…", + onConfirm: () => { + const pending = pendingFileRequest; + if (pending === null || pending.id !== requestId) { + return; + } + startFileInput(pending); + }, + onCancel: () => { + if (pendingFileRequest === null || pendingFileRequest.id !== requestId) { + return; + } + completeFileSelection(requestId, "cancelled"); + hideFilePicker(); + }, + }); + if (promptDialog === null || !promptDialog.open) { + completeFileSelection(requestId, "unavailable"); + hideFilePicker(); + return; + } + send({type: "file_selector_ack", id: requestId}); +}; + window.addEventListener("focus", updateCursorVisibility); window.addEventListener("blur", updateCursorVisibility); @@ -87,6 +400,14 @@ controls.addEventListener("message", event => { applyCursorOptions(payload); return; } + if (payload?.type === "file_selector") { + openFileSelector(payload); + return; + } + if (payload?.type === "file_selector_cancel") { + hideFilePicker(); + return; + } if (payload?.type === "error") { console.warn(`WebRTC server: ${payload.message ?? "unknown error"}`); } @@ -120,7 +441,7 @@ peer.addEventListener("connectionstatechange", () => { window.addEventListener("keydown", event => { const keyId = event.code || event.key; - if (pressedKeys.has(keyId)) { + if (pressedKeys.has(keyId) || (promptDialog !== null && promptDialog.open)) { return; } const wasSent = sendInput({ diff --git a/flashdreams/flashdreams/runtime_v2/serving/web/index.html b/flashdreams/flashdreams/runtime_v2/serving/web/index.html index 1b73e1dc8..eb0d4ad2c 100644 --- a/flashdreams/flashdreams/runtime_v2/serving/web/index.html +++ b/flashdreams/flashdreams/runtime_v2/serving/web/index.html @@ -48,11 +48,120 @@ color: #ff8a8a; } + #prompt-backdrop { + position: fixed; + inset: 0; + z-index: 2; + background: rgb(0 0 0 / 28%); + } + + #prompt-backdrop[hidden] { + display: none; + } + + #prompt-dialog { + position: fixed; + inset: 0; + z-index: 3; + margin: auto; + height: fit-content; + color-scheme: light dark; + max-width: 26rem; + width: min(26rem, calc(100% - 2rem)); + padding: 1.25rem 1.35rem 1rem; + border: 1px solid color-mix(in srgb, CanvasText 18%, Canvas); + border-radius: 12px; + background: Canvas; + color: CanvasText; + font: inherit; + box-shadow: 0 18px 40px rgb(0 0 0 / 45%); + } + + #prompt-dialog-title { + margin: 0 0 0.9rem; + font-size: 1.1rem; + font-weight: 650; + } + + #prompt-dialog-details { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + align-items: baseline; + column-gap: 0.85rem; + row-gap: 0.4rem; + margin: 0 0 1.15rem; + } + + #prompt-dialog-details[hidden] { + display: none; + } + + .prompt-dialog-row { + display: contents; + } + + .prompt-dialog-detail-label { + margin: 0; + font-size: 0.85rem; + color: color-mix(in srgb, CanvasText 62%, Canvas); + text-align: end; + white-space: nowrap; + } + + .prompt-dialog-detail-label::after { + content: ":"; + } + + .prompt-dialog-row-value { + display: flex; + flex-wrap: wrap; + column-gap: 0.55rem; + row-gap: 0.15rem; + } + + .prompt-dialog-chip { + font-size: 0.95rem; + } + + .prompt-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 0.55rem; + } + + .prompt-dialog-actions button { + font: inherit; + padding: 0.45rem 0.95rem; + border-radius: 0.45rem; + cursor: pointer; + } + + #prompt-dialog-cancel { + border: 1px solid color-mix(in srgb, CanvasText 28%, Canvas); + background: transparent; + color: CanvasText; + } + + #prompt-dialog-confirm { + border: 0; + background: CanvasText; + color: Canvas; + } +
Waiting for the server…
+ + +

+ +
+ + +
+
diff --git a/flashdreams/flashdreams/runtime_v2/serving/webrtc_server.py b/flashdreams/flashdreams/runtime_v2/serving/webrtc_server.py index 721959f85..e6b802386 100644 --- a/flashdreams/flashdreams/runtime_v2/serving/webrtc_server.py +++ b/flashdreams/flashdreams/runtime_v2/serving/webrtc_server.py @@ -8,6 +8,7 @@ import asyncio import json import math +import secrets import socket import threading import time @@ -21,6 +22,7 @@ import numpy as np import torch from aiohttp import web +from aiohttp.multipart import BodyPartReader, MultipartReader from aiortc import MediaStreamTrack, RTCPeerConnection, RTCSessionDescription from aiortc.mediastreams import MediaStreamError from av import VideoFrame @@ -28,6 +30,17 @@ from torch.nn import functional as F from flashdreams.runtime_v2.cuda_utils import resolve_cuda_device +from flashdreams.runtime_v2.selected_file import ( + MAX_SELECTED_FILE_BATCH_BYTES, + MAX_SELECTED_FILE_COUNT, + FileSelectionRequest, + SelectedFile, + SelectedFilesStatus, + clamp_selected_file_max_bytes, + normalize_selected_file_accept, + selected_file_suffix_allowed, + selected_files_policy_status, +) from flashdreams.runtime_v2.session_desc import SessionDesc from flashdreams.runtime_v2.step_result import StepResult from flashdreams.runtime_v2.user_input_event import ( @@ -294,6 +307,23 @@ async def _next_queued_frame(self) -> _QueuedRGBFrame: await self._frame_available.wait() +@dataclass(frozen=True, slots=True) +class _PendingFileSelection: + """Accept suffixes and size budget for one armed file-selector request.""" + + selector_id: str + """Window selector this upload answers. Never sent to the browser.""" + + accept: tuple[str, ...] + """Filename suffixes the client may upload, empty for any type.""" + + max_bytes: int + """Maximum file bytes, already clamped to the window ceiling.""" + + multiple: bool + """Whether this request may include more than one file.""" + + class WebRTCServer: """Own the HTTP, signaling, input buffering, and media transport.""" @@ -346,6 +376,16 @@ def __init__( self._client_connected = False self._hide_cursor = False self._lock_cursor_to_window = False + self._pending_file_requests: dict[str, _PendingFileSelection] = {} + self._uploading_file_requests: dict[str, _PendingFileSelection] = {} + self._file_selector_callbacks: ( + tuple[ + Callable[[str], None], + Callable[[str, SelectedFilesStatus, tuple[SelectedFile, ...]], None], + ] + | None + ) = None + self._file_selector_lock = threading.Lock() self._thread = threading.Thread( target=self._run_server, name="flashdreams-webrtc", @@ -436,6 +476,96 @@ def _send_cursor_options(self) -> None: self._sent_cursor_options = cursor_options + def request_file_selector( + self, selector_id: str, request: FileSelectionRequest + ) -> None: + """Ask the connected browser to open a file selector. + + The browser cannot use a host path, so ``initial_path`` is ignored. + Without a connected viewer the request completes as ``UNAVAILABLE``. + + Args: + selector_id: Window selector reported back with this result. + request: File selector to show. + """ + policy = _PendingFileSelection( + selector_id=selector_id, + accept=normalize_selected_file_accept(request.accept), + max_bytes=clamp_selected_file_max_bytes(request.max_bytes), + multiple=request.multiple, + ) + loop = self._loop + if loop is None: + raise RuntimeError("WebRTC server is not running.") + loop.call_soon_threadsafe(self._arm_file_selector, policy) + + def cancel_file_selectors(self) -> None: + """Forget every armed selector and close the viewer's prompt. + + A later upload for a forgotten selector is refused. + """ + loop = self._loop + if loop is None: + with self._file_selector_lock: + self._pending_file_requests.clear() + return + # On the event loop, after any arm still queued for a forgotten selector. + loop.call_soon_threadsafe(self._withdraw_file_selectors) + + def _withdraw_file_selectors(self) -> None: + """Forget armed selectors and tell the viewer to close its prompt.""" + with self._file_selector_lock: + self._pending_file_requests.clear() + channel = self._control_channel + if channel is not None and channel.readyState == "open": + channel.send(json.dumps({"type": "file_selector_cancel"})) + + def _arm_file_selector(self, policy: _PendingFileSelection) -> None: + """Send the selector to the browser and remember its upload policy. + + The browser gets an unguessable token instead of the selector id, so a + page that cannot read the data channel cannot answer the selector. + """ + channel = self._control_channel + if channel is None or channel.readyState != "open": + self._emit_selected_files( + policy.selector_id, status=SelectedFilesStatus.UNAVAILABLE + ) + return + token = secrets.token_urlsafe(16) + with self._file_selector_lock: + self._pending_file_requests[token] = policy + channel.send( + json.dumps( + { + "type": "file_selector", + "id": token, + "accept": list(policy.accept), + "max_bytes": policy.max_bytes, + "multiple": policy.multiple, + } + ) + ) + + def _take_pending_file_request(self, token: str) -> _PendingFileSelection | None: + """Return and consume the policy for ``token``, if it was pending.""" + with self._file_selector_lock: + return self._pending_file_requests.pop(token, None) + + def _complete_abandoned_file_selectors(self) -> None: + """Answer every armed pick as ``UNAVAILABLE`` because the viewer left.""" + with self._file_selector_lock: + abandoned = ( + *self._pending_file_requests.values(), + *self._uploading_file_requests.values(), + ) + self._pending_file_requests.clear() + self._uploading_file_requests.clear() + for policy in abandoned: + self._emit_selected_files( + policy.selector_id, status=SelectedFilesStatus.UNAVAILABLE + ) + def event_timestamp_us(self) -> np.uint64: """Return the current timestamp relative to this server's first session. @@ -498,6 +628,8 @@ def open(self, session_desc: SessionDesc) -> None: # Keep timestamps comparable across sessions so events buffered # during a handoff retain their real order. self._event_origin_ns = time.monotonic_ns() + with self._file_selector_lock: + self._pending_file_requests.clear() def request_new_window_size(self, new_window_size: tuple[int, int]) -> None: """Change the dimensions used to present subsequent UI frames. @@ -554,6 +686,22 @@ def register_input_callback( raise RuntimeError("An input callback is already registered.") self._input_callback = callback + def register_file_selector_callbacks( + self, + *, + opened: Callable[[str], None], + completed: Callable[[str, SelectedFilesStatus, tuple[SelectedFile, ...]], None], + ) -> None: + """Register how armed file selectors report back, by selector id. + + Both run on the server thread. + + Args: + opened: Called when the browser shows the selector. + completed: Called once with the selector's status and files. + """ + self._file_selector_callbacks = (opened, completed) + def write(self, result: StepResult) -> None: """Materialize and admit one generated result to the sender mailbox. @@ -720,11 +868,14 @@ def _run_server(self) -> None: async def _start_server(self) -> None: """Create and bind the standalone aiohttp application.""" self._offer_lock = asyncio.Lock() - app = web.Application() + app = web.Application( + client_max_size=MAX_SELECTED_FILE_BATCH_BYTES + 1024 * 1024 + ) app.router.add_get("/", self._serve_browser) app.router.add_get("/app.js", self._serve_browser_script) app.router.add_get("/healthz", self._health) app.router.add_post("/api/webrtc/offer", self._offer) + app.router.add_post("/api/files", self._upload_selected_file) runner = web.AppRunner(app) await runner.setup() address_family = socket.AF_INET6 if ":" in self._host else socket.AF_INET @@ -762,6 +913,120 @@ async def _health(self, _: web.Request) -> web.Response: } ) + async def _upload_selected_file(self, request: web.Request) -> web.Response: + """Turn a browser file upload into a selected-files input event.""" + if self._closed: + raise web.HTTPServiceUnavailable(reason="WebRTC server is closed.") + if self._timestamp_us() is None: + raise web.HTTPConflict(reason="WebRTC server is not open.") + token = request.query.get("id") + if not isinstance(token, str) or not token: + raise web.HTTPBadRequest(reason="File upload requires an id.") + policy = self._take_pending_file_request(token) + if policy is None: + raise web.HTTPConflict(reason="No pending file selection for this id.") + # Tracked until it ends so a viewer that leaves mid-upload is answered. + with self._file_selector_lock: + self._uploading_file_requests[token] = policy + + emitted = False + + def emit( + status: SelectedFilesStatus, + files: tuple[SelectedFile, ...] = (), + ) -> None: + nonlocal emitted + self._emit_selected_files(policy.selector_id, status=status, files=files) + emitted = True + + try: + if not request.content_type.startswith("multipart/"): + emit(SelectedFilesStatus.UNAVAILABLE) + raise web.HTTPBadRequest(reason="File upload requires a file.") + try: + reader = await request.multipart() + except web.HTTPRequestEntityTooLarge: + emit(SelectedFilesStatus.TOO_LARGE) + raise + chosen: list[tuple[str, bytes]] = [] + file_count = 0 + total_bytes = 0 + too_large = False + while True: + part = await reader.next() + if part is None: + break + if not isinstance(part, BodyPartReader): + continue + if part.name != "file": + await part.release() + continue + if not policy.multiple and (chosen or too_large): + await part.release() + continue + if not _identity_encoded(part): + emit(SelectedFilesStatus.UNAVAILABLE) + raise web.HTTPBadRequest(reason="File parts must not be encoded.") + file_count += 1 + if file_count > MAX_SELECTED_FILE_COUNT: + too_large = True + chosen.clear() + name = part.filename or "upload" + if not selected_file_suffix_allowed(name, policy.accept): + emit(SelectedFilesStatus.DISALLOWED_TYPE) + await _drain_multipart_reader(reader, part) + return web.Response(status=204) + if too_large: + # Keep reading names so a disallowed type still wins. + await part.release() + continue + data = await _read_upload_part_bounded(part, max_bytes=policy.max_bytes) + if data is not None: + total_bytes += len(data) + if data is None or total_bytes > MAX_SELECTED_FILE_BATCH_BYTES: + too_large = True + chosen.clear() + continue + chosen.append((name, data)) + if too_large: + emit(SelectedFilesStatus.TOO_LARGE) + raise web.HTTPRequestEntityTooLarge( + max_size=policy.max_bytes, actual_size=policy.max_bytes + 1 + ) + if not chosen: + emit(SelectedFilesStatus.UNAVAILABLE) + raise web.HTTPBadRequest(reason="File upload requires a file.") + rejected = selected_files_policy_status( + [(name, len(data)) for name, data in chosen], + accept=policy.accept, + max_bytes=policy.max_bytes, + ) + if rejected is SelectedFilesStatus.TOO_LARGE: + emit(rejected) + raise web.HTTPRequestEntityTooLarge( + max_size=policy.max_bytes, + actual_size=max(len(data) for _name, data in chosen), + ) + if rejected is not None: + emit(rejected) + return web.Response(status=204) + emit( + SelectedFilesStatus.OK, + files=tuple( + SelectedFile(name=name, data=data) for name, data in chosen + ), + ) + return web.Response(status=204) + except web.HTTPRequestEntityTooLarge: + if not emitted: + emit(SelectedFilesStatus.TOO_LARGE) + raise + finally: + with self._file_selector_lock: + self._uploading_file_requests.pop(token, None) + if not emitted: + emit(SelectedFilesStatus.UNAVAILABLE) + async def _offer(self, request: web.Request) -> web.Response: """Negotiate one browser peer connection.""" if self._closed: @@ -1080,6 +1345,36 @@ def _buffer_browser_message( event = ResetUserInputEvent(timestamp=timestamp_us) elif event_type == "close": event = CloseUserInputEvent(timestamp=timestamp_us) + elif event_type == "file_selector_ack": + token = payload.get("id") + if not isinstance(token, str) or not token: + raise ValueError( + "File selector acknowledgement requires a non-empty id." + ) + with self._file_selector_lock: + policy = self._pending_file_requests.get(token) + if policy is None: + return + self._file_selector_callbacks_or_raise()[0](policy.selector_id) + return + elif event_type == "file_selector_result": + token = payload.get("id") + if not isinstance(token, str) or not token: + raise ValueError("File selector result requires a non-empty id.") + try: + status = SelectedFilesStatus(payload.get("status")) + except ValueError as error: + raise ValueError("File selector result status is invalid.") from error + if status is SelectedFilesStatus.OK: + raise ValueError( + "File selector result must not set status ok; send files " + "with POST /api/files." + ) + policy = self._take_pending_file_request(token) + if policy is None: + return + self._emit_selected_files(policy.selector_id, status=status) + return else: raise ValueError("Unsupported browser event type.") self._append_event(event) @@ -1091,6 +1386,27 @@ def _append_event(self, event: UserInputEvent) -> None: raise RuntimeError("WebRTC input callback is not registered.") callback(event) + def _emit_selected_files( + self, + selector_id: str, + *, + status: SelectedFilesStatus, + files: tuple[SelectedFile, ...] = (), + ) -> None: + """Report the result of one armed file selector to the window.""" + self._file_selector_callbacks_or_raise()[1](selector_id, status, files) + + def _file_selector_callbacks_or_raise( + self, + ) -> tuple[ + Callable[[str], None], + Callable[[str, SelectedFilesStatus, tuple[SelectedFile, ...]], None], + ]: + callbacks = self._file_selector_callbacks + if callbacks is None: + raise RuntimeError("WebRTC file selector callbacks are not registered.") + return callbacks + async def _release_peer_connection( self, peer_connection: RTCPeerConnection, @@ -1109,6 +1425,7 @@ async def _release_peer_connection( self._control_channel = None self._video_track = None self._sent_cursor_options = None + self._complete_abandoned_file_selectors() else: track = detached_video_track failures: list[BaseException] = [] @@ -1185,6 +1502,59 @@ async def _shutdown(self) -> None: raise primary +_UPLOAD_READ_CHUNK_BYTES = 64 * 1024 +"""Bytes read per multipart chunk while enforcing ``max_bytes``.""" + + +def _identity_encoded(part: BodyPartReader) -> bool: + """Return whether ``part`` carries its bytes as-is, as browsers send them.""" + transfer = part.headers.get("Content-Transfer-Encoding", "").lower() + content = part.headers.get("Content-Encoding", "").lower() + return transfer in ("", "binary", "7bit", "8bit") and content in ("", "identity") + + +async def _read_upload_part_bounded( + part: BodyPartReader, *, max_bytes: int +) -> bytes | None: + """Return the decoded part body, or ``None`` when it exceeds ``max_bytes``. + + Args: + part: Multipart file part whose headers have already been read. + max_bytes: Maximum allowed size of the decoded file in bytes. + + Returns: + The decoded bytes, or ``None`` when they exceed ``max_bytes``. + """ + chunks: list[bytes] = [] + total = 0 + while True: + chunk = await part.read_chunk(_UPLOAD_READ_CHUNK_BYTES) + if not chunk: + break + async for decoded in part.decode_iter(chunk): + piece = bytes(decoded) + total += len(piece) + if total > max_bytes: + await part.release() + return None + chunks.append(piece) + return b"".join(chunks) + + +async def _drain_multipart_reader( + reader: MultipartReader, part: BodyPartReader | None = None +) -> None: + """Consume leftover multipart parts so the request can finish.""" + if part is not None: + await part.release() + while True: + leftover = await reader.next() + if leftover is None: + break + if isinstance(leftover, BodyPartReader): + await leftover.release() + + def _same_stream_format(first: SessionDesc, second: SessionDesc) -> bool: """Return whether two sessions can share one negotiated video track.""" return ( diff --git a/flashdreams/flashdreams/runtime_v2/session_runner.py b/flashdreams/flashdreams/runtime_v2/session_runner.py index 17918f64c..d5a331996 100644 --- a/flashdreams/flashdreams/runtime_v2/session_runner.py +++ b/flashdreams/flashdreams/runtime_v2/session_runner.py @@ -392,6 +392,8 @@ def should_end_ui() -> bool: cleanup(sink.close) # Both loops have stopped, so nothing will close an input wait still open. cleanup(profiler.reset_counts) + if window is not None: + cleanup(window.flush_file_selectors) if next_session_desc is None and window is not None: cleanup(window.close) cleanup(session.close) @@ -446,6 +448,10 @@ def _apply_window_requests(window: IClientWindow, request: UILoopRequests) -> No window.request_lock_cursor_to_window(request.lock_cursor_to_window) if request.new_window_size is not None: window.request_new_window_size(request.new_window_size) + # A replaced session gets no results, so its requests are not shown. + if request.new_session is None: + for file_selection in request.file_selections: + window.request_file_selector(file_selection) def _open_chunk_trace(path_value: object) -> _ChunkTraceLog: diff --git a/flashdreams/flashdreams/runtime_v2/user_input_event.py b/flashdreams/flashdreams/runtime_v2/user_input_event.py index 6f9c4ca50..7c3e2bacf 100644 --- a/flashdreams/flashdreams/runtime_v2/user_input_event.py +++ b/flashdreams/flashdreams/runtime_v2/user_input_event.py @@ -8,6 +8,7 @@ from typing import Literal from flashdreams.api_v2.user_input_event import UserInputEvent +from flashdreams.runtime_v2.selected_file import SelectedFile, SelectedFilesStatus class KeyboardInputState(Enum): @@ -245,6 +246,40 @@ def get_type_name(cls) -> str: """Optional controller quaternion in client XR space.""" +@dataclass(frozen=True, slots=True, eq=False) +class SelectedFilesUserInputEvent(UserInputEvent): + """Result of one file-selector request. + + The answer to :meth:`IUILoop.request_file_selector`, read from + ``user_events`` like any other input. + ``OK`` is the only status with files; every other status has empty ``files``. + """ + + @classmethod + def get_type_name(cls) -> str: + """Return the event type name.""" + return "selected_files" + + request_id: str + """``request_id`` of the control that asked for this result.""" + + status: SelectedFilesStatus + """Why this request completed. ``OK`` is the only value with files.""" + + files: tuple[SelectedFile, ...] = () + """Chosen files. Empty unless ``status`` is ``OK``.""" + + def __post_init__(self) -> None: + """Reject an ``ok`` event without files, or files on a failed pick.""" + has_files = bool(self.files) + if self.status is SelectedFilesStatus.OK: + if not has_files: + raise ValueError("ok selected-files events must include files.") + return + if has_files: + raise ValueError("failed selected-files events must not include files.") + + @dataclass(frozen=True, slots=True, eq=False) class UnknownUserInputEvent(UserInputEvent): """User input event for an unknown input modality.""" diff --git a/flashdreams/flashdreams/runtime_v2/webrtc_client_window.py b/flashdreams/flashdreams/runtime_v2/webrtc_client_window.py index 8eaa9b809..c98a58dc0 100644 --- a/flashdreams/flashdreams/runtime_v2/webrtc_client_window.py +++ b/flashdreams/flashdreams/runtime_v2/webrtc_client_window.py @@ -10,6 +10,7 @@ from numpy import uint64 from flashdreams.api_v2.client_window import IClientWindow +from flashdreams.runtime_v2.selected_file import FileSelectionRequest from flashdreams.runtime_v2.serving.webrtc_server import WebRTCServer from flashdreams.runtime_v2.session_desc import SessionDesc from flashdreams.runtime_v2.step_result import StepResult @@ -71,6 +72,12 @@ def handle_input(event: UserInputEvent) -> None: self._input_events.append(event) self.server.register_input_callback(handle_input) + self.server.register_file_selector_callbacks( + opened=lambda selector_id: self._file_selector_opened(int(selector_id)), + completed=lambda selector_id, status, files: self._complete_file_selector( + int(selector_id), status, files + ), + ) def request_hide_cursor(self, hide_cursor: bool) -> None: """Show or hide the cursor in the browser window.""" @@ -100,6 +107,22 @@ def request_new_window_size(self, new_window_size: tuple[int, int]) -> None: """ self.server.request_new_window_size(new_window_size) + def _request_file_selector( + self, selector_id: int, request: FileSelectionRequest + ) -> None: + """Ask the connected browser to open a file selector.""" + self.server.request_file_selector(str(selector_id), request) + + def _cancel_active_file_selectors(self) -> None: + """Refuse a later upload for the selector that was forgotten.""" + self.server.cancel_file_selectors() + + def _put_user_input_event(self, event: UserInputEvent) -> None: + """Queue a file-selection event on the server's clock, like browser input.""" + event = replace(event, timestamp=self.server.event_timestamp_us()) + with self._input_lock: + self._input_events.append(event) + def open(self, session_desc: SessionDesc) -> None: """Implement ``OutputSink.open`` by configuring WebRTC output. @@ -118,6 +141,7 @@ def get_user_input_events(self) -> UserInputEvents: Returns: Buffered browser events in timestamp order, each returned once. """ + self._expire_file_selectors() with self._input_lock: events = list(self._input_events) self._input_events.clear() diff --git a/flashdreams/test_v2/test_application_runner.py b/flashdreams/test_v2/test_application_runner.py index 606c3899d..89329e07a 100644 --- a/flashdreams/test_v2/test_application_runner.py +++ b/flashdreams/test_v2/test_application_runner.py @@ -16,6 +16,7 @@ from flashdreams.api_v2.client_window import IClientWindow from flashdreams.api_v2.loop import IModelLoop, IUILoop from flashdreams.api_v2.session import ISession +from flashdreams.api_v2.user_input_event import UserInputEvent from flashdreams.core.distributed.parallel import ParallelContext from flashdreams.runtime_v2.application_runner import ApplicationRunner from flashdreams.runtime_v2.metrics_output_sink import MetricsOutputSink @@ -158,6 +159,9 @@ def get_user_input_events(self) -> UserInputEvents: ) return UserInputEvents([]) + def _put_user_input_event(self, event: UserInputEvent) -> None: + del event + def open(self, session_desc: SessionDesc) -> None: del session_desc self._calls.append("window.open") diff --git a/flashdreams/test_v2/test_cli.py b/flashdreams/test_v2/test_cli.py index 1c8731244..839958e5c 100644 --- a/flashdreams/test_v2/test_cli.py +++ b/flashdreams/test_v2/test_cli.py @@ -18,6 +18,7 @@ from flashdreams.api_v2.application import IApplication from flashdreams.api_v2.client_window import IClientWindow from flashdreams.api_v2.session import ISession +from flashdreams.api_v2.user_input_event import UserInputEvent from flashdreams.runtime_v2 import cli, client_window_factory from flashdreams.runtime_v2.application_runner import ApplicationRunner, Unbound from flashdreams.runtime_v2.client_window_factory import ClientWindowMode @@ -42,6 +43,9 @@ class _StubWindow(IClientWindow): def get_user_input_events(self) -> UserInputEvents: return UserInputEvents([]) + def _put_user_input_event(self, event: UserInputEvent) -> None: + del event + def open(self, session_desc: SessionDesc) -> None: del session_desc diff --git a/flashdreams/test_v2/test_client_window.py b/flashdreams/test_v2/test_client_window.py index 720ab4acc..7e66a592f 100644 --- a/flashdreams/test_v2/test_client_window.py +++ b/flashdreams/test_v2/test_client_window.py @@ -11,6 +11,7 @@ from flashdreams.api_v2.client_window import IClientWindow from flashdreams.api_v2.input_source import InputSource from flashdreams.api_v2.output_sink import OutputSink +from flashdreams.api_v2.user_input_event import UserInputEvent from flashdreams.runtime_v2.session_desc import SessionDesc from flashdreams.runtime_v2.step_result import StepResult from flashdreams.runtime_v2.user_input_event import ( @@ -36,6 +37,9 @@ def __init__(self) -> None: def get_user_input_events(self) -> UserInputEvents: return self._input + def _put_user_input_event(self, event: UserInputEvent) -> None: + del event + def open(self, session_desc: SessionDesc) -> None: self.session_desc = session_desc self._is_open = True diff --git a/flashdreams/test_v2/test_native_window_client_window.py b/flashdreams/test_v2/test_native_window_client_window.py index c344e6e38..18ad9e763 100644 --- a/flashdreams/test_v2/test_native_window_client_window.py +++ b/flashdreams/test_v2/test_native_window_client_window.py @@ -8,7 +8,9 @@ import queue import sys import threading +import time from collections.abc import Callable, Sequence +from pathlib import Path from types import SimpleNamespace from typing import TYPE_CHECKING, Any, cast from unittest.mock import Mock @@ -20,6 +22,10 @@ from flashdreams.runtime_v2.native_window_client_window import ( NativeWindowClientWindow, ) +from flashdreams.runtime_v2.selected_file import ( + FileSelectionRequest, + SelectedFilesStatus, +) from flashdreams.runtime_v2.session_desc import SessionDesc from flashdreams.runtime_v2.step_result import StepResult from flashdreams.runtime_v2.user_input_event import ( @@ -29,6 +35,7 @@ KeyboardInputState, KeyboardUserInputEvent, MouseUserInputEvent, + SelectedFilesUserInputEvent, ) from flashdreams.runtime_v2.video_encoder import result_to_rgb24_tensor from flashdreams.runtime_v2.video_tensor import VideoTensorLayout @@ -39,6 +46,32 @@ pytestmark = pytest.mark.ci_cpu +def _file_request( + request_id: str, + initial_path: str, + *, + accept: tuple[str, ...] = (), + max_bytes: int | None = None, + multiple: bool = False, +) -> FileSelectionRequest: + return FileSelectionRequest( + request_id=request_id, + initial_path=initial_path, + accept=accept, + max_bytes=max_bytes, + multiple=multiple, + ) + + +def _wait_for_input_events(window: NativeWindowClientWindow) -> list[object]: + for _ in range(100): + events = window.get_user_input_events().get_events() + if events: + return list(events) + time.sleep(0.01) + return [] + + @pytest.mark.parametrize("fullscreen", [False, True]) def test_fullscreen_creation_reports_size_on_first_poll(fullscreen: bool) -> None: presenter = _Presenter() @@ -669,6 +702,208 @@ def create_presenter(**kwargs: object) -> _Presenter: assert (unlocked_mouse.x, unlocked_mouse.y) == (1.0, 0.0) +def test_native_window_reports_a_selected_file( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + chosen = tmp_path / "seed.png" + chosen.write_bytes(b"png-bytes") + monkeypatch.setattr( + native_window_module, + "_ask_open_filename", + lambda *, initial_dir, accept=(), multiple=False, on_presented=None: ( + str(chosen), + ), + ) + presenter = _Presenter() + window = NativeWindowClientWindow( + presenter_factory=_presenter_factory(presenter), + ) + window.open(_session_desc()) + window.request_file_selector(_file_request("open-1", str(tmp_path))) + events = _wait_for_input_events(window) + window.close() + + assert len(events) == 1 + event = events[0] + assert isinstance(event, SelectedFilesUserInputEvent) + assert event.request_id == "open-1" + assert event.status is SelectedFilesStatus.OK + assert event.files[0].name == "seed.png" + assert event.files[0].data == b"png-bytes" + + +def test_native_window_reports_cancelled_file_selection( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr( + native_window_module, + "_ask_open_filename", + lambda *, initial_dir, accept=(), multiple=False, on_presented=None: (), + ) + presenter = _Presenter() + window = NativeWindowClientWindow( + presenter_factory=_presenter_factory(presenter), + ) + window.open(_session_desc()) + window.request_file_selector(_file_request("open-1", str(tmp_path))) + events = _wait_for_input_events(window) + window.close() + + assert len(events) == 1 + event = events[0] + assert isinstance(event, SelectedFilesUserInputEvent) + assert event.request_id == "open-1" + assert event.status is SelectedFilesStatus.CANCELLED + assert event.files == () + + +def test_native_window_reports_unavailable_when_picker_raises( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + def fail_picker( + *, + initial_dir: str, + accept: tuple[str, ...] = (), + multiple: bool = False, + on_presented: object = None, + ) -> tuple[str, ...]: + del initial_dir, accept, multiple, on_presented + raise RuntimeError("Native file picker needs a desktop chooser.") + + monkeypatch.setattr(native_window_module, "_ask_open_filename", fail_picker) + presenter = _Presenter() + window = NativeWindowClientWindow( + presenter_factory=_presenter_factory(presenter), + ) + window.open(_session_desc()) + window.request_file_selector(_file_request("open-1", str(tmp_path))) + events = _wait_for_input_events(window) + window.close() + + assert len(events) == 1 + event = events[0] + assert isinstance(event, SelectedFilesUserInputEvent) + assert event.request_id == "open-1" + assert event.status is SelectedFilesStatus.UNAVAILABLE + assert event.files == () + + +def test_selected_files_from_paths_skips_unreadable_and_oversize( + tmp_path: Path, +) -> None: + missing = tmp_path / "gone.png" + files, status = native_window_module._selected_files_from_paths((str(missing),)) + assert files == () + assert status is SelectedFilesStatus.UNAVAILABLE + + too_big = tmp_path / "big.bin" + too_big.write_bytes(b"abcd") + files, status = native_window_module._selected_files_from_paths( + (str(too_big),), max_bytes=3 + ) + assert files == () + assert status is SelectedFilesStatus.TOO_LARGE + + wrong_type = tmp_path / "notes.txt" + wrong_type.write_bytes(b"hi") + files, status = native_window_module._selected_files_from_paths( + (str(wrong_type),), accept=(".png",) + ) + assert files == () + assert status is SelectedFilesStatus.DISALLOWED_TYPE + + too_big_wrong_type = tmp_path / "huge.txt" + too_big_wrong_type.write_bytes(b"abcd") + files, status = native_window_module._selected_files_from_paths( + (str(too_big_wrong_type),), accept=(".png",), max_bytes=1 + ) + assert files == () + assert status is SelectedFilesStatus.DISALLOWED_TYPE + + +@pytest.mark.skipif(not Path("/dev/zero").exists(), reason="needs /dev/zero") +def test_selected_files_from_paths_rejects_a_non_regular_file(tmp_path: Path) -> None: + endless = tmp_path / "endless.bin" + endless.symlink_to("/dev/zero") + files, status = native_window_module._selected_files_from_paths( + (str(endless),), accept=(".bin",) + ) + assert files == () + assert status is SelectedFilesStatus.UNAVAILABLE + + +def test_native_window_shown_picker_has_no_deadline( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + shown = threading.Event() + release = threading.Event() + + def picker( + *, + initial_dir: str, + accept: tuple[str, ...] = (), + multiple: bool = False, + on_presented: Callable[[], None] | None = None, + ) -> tuple[str, ...]: + del initial_dir, accept, multiple + assert on_presented is not None + on_presented() + shown.set() + release.wait(5) + return () + + monkeypatch.setattr(native_window_module, "_ask_open_filename", picker) + now = [0.0] + presenter = _Presenter() + window = NativeWindowClientWindow(presenter_factory=_presenter_factory(presenter)) + window._file_selector_clock = lambda: now[0] + window.open(_session_desc()) + window.request_file_selector(_file_request("open-1", str(tmp_path))) + assert shown.wait(5) + now[0] = 1_000.0 + assert window.get_user_input_events().get_events() == [] + release.set() + events = _wait_for_input_events(window) + window.close() + + assert len(events) == 1 + event = events[0] + assert isinstance(event, SelectedFilesUserInputEvent) + assert event.status is SelectedFilesStatus.CANCELLED + + +def test_native_window_reports_multiple_selected_files( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + first = tmp_path / "a.bin" + second = tmp_path / "b.bin" + first.write_bytes(b"one") + second.write_bytes(b"two") + monkeypatch.setattr( + native_window_module, + "_ask_open_filename", + lambda *, initial_dir, accept=(), multiple=False, on_presented=None: ( + str(first), + str(second), + ), + ) + presenter = _Presenter() + window = NativeWindowClientWindow( + presenter_factory=_presenter_factory(presenter), + ) + window.open(_session_desc()) + window.request_file_selector(_file_request("open-1", str(tmp_path), multiple=True)) + events = _wait_for_input_events(window) + window.close() + + assert len(events) == 1 + event = events[0] + assert isinstance(event, SelectedFilesUserInputEvent) + assert event.status is SelectedFilesStatus.OK + assert [chosen.name for chosen in event.files] == ["a.bin", "b.bin"] + assert [chosen.data for chosen in event.files] == [b"one", b"two"] + + def test_native_window_reports_standard_gamepad_events() -> None: presenter = _Presenter() clock_values = iter((1_000_000, 1_001_000, 1_002_000, 1_003_000)) diff --git a/flashdreams/test_v2/test_selected_files.py b/flashdreams/test_v2/test_selected_files.py new file mode 100644 index 000000000..46c24186d --- /dev/null +++ b/flashdreams/test_v2/test_selected_files.py @@ -0,0 +1,407 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""CPU tests for client file selection through the v2 window contract.""" + +import queue +import threading +from pathlib import Path + +import pytest +from numpy import uint64 + +from flashdreams.api_v2.client_window import IClientWindow +from flashdreams.api_v2.loop import IUILoop +from flashdreams.runtime_v2.mp4_client_window import Mp4ClientWindow +from flashdreams.runtime_v2.null_client_window import NullClientWindow +from flashdreams.runtime_v2.presentation_manager import PresentationManager +from flashdreams.runtime_v2.selected_file import ( + FILE_SELECTION_PRESENTATION_TIMEOUT_SECONDS, + MAX_SELECTED_FILE_BATCH_BYTES, + MAX_SELECTED_FILE_BYTES, + MAX_SELECTED_FILE_COUNT, + FileSelectionRequest, + SelectedFile, + SelectedFilesStatus, + clamp_selected_file_max_bytes, + normalize_selected_file_accept, + selected_file_suffix_allowed, + selected_files_policy_status, +) +from flashdreams.runtime_v2.session_desc import SessionDesc +from flashdreams.runtime_v2.step_result import StepResult +from flashdreams.runtime_v2.user_input_event import ( + SelectedFilesUserInputEvent, +) +from flashdreams.runtime_v2.user_input_events import UserInputEvents + +pytestmark = pytest.mark.ci_cpu + + +class _UILoop(IUILoop[None]): + def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: + del step_index, events + return [] + + def reset(self) -> None: + return + + +def _ui_loop() -> _UILoop: + loop = _UILoop() + loop.register_session_loop_objects( + state=None, + frequency=0, + shutdown_event=threading.Event(), + failure_queue=queue.Queue(), + ) + loop.register_session_ui_loop_objects( + session_desc=SessionDesc(), + presentation_manager=PresentationManager(), + ) + return loop + + +def test_ui_loop_queues_file_selection_policy_without_clamping() -> None: + loop = _ui_loop() + loop.request_file_selector( + "open-1", + "/tmp", + accept=(".png", ".JPG"), + max_file_bytes=MAX_SELECTED_FILE_BYTES * 2, + multiple=True, + ) + + requests = loop.flush_ui_loop_requests() + assert requests is not None + [selection] = requests.file_selections + assert selection.request_id == "open-1" + assert selection.initial_path == "/tmp" + assert selection.accept == (".png", ".JPG") + assert selection.max_bytes == MAX_SELECTED_FILE_BYTES * 2 + assert selection.multiple is True + + +def test_ui_loop_uses_home_when_initial_path_is_omitted() -> None: + loop = _ui_loop() + loop.request_file_selector("open-1") + + requests = loop.flush_ui_loop_requests() + assert requests is not None + assert requests.file_selections[0].initial_path == str(Path.home()) + + +def test_ui_loop_forwards_every_file_selection_of_a_tick() -> None: + loop = _ui_loop() + loop.request_file_selector("open-1", "/tmp") + loop.request_file_selector("open-2", "/var") + + requests = loop.flush_ui_loop_requests() + assert requests is not None + assert [item.request_id for item in requests.file_selections] == [ + "open-1", + "open-2", + ] + + +def test_ui_loop_keeps_file_results_until_a_step_runs() -> None: + loop = _ui_loop() + picked = SelectedFilesUserInputEvent( + timestamp=uint64(0), + request_id="open-1", + status=SelectedFilesStatus.CANCELLED, + ) + + loop._begin_run(UserInputEvents([picked]), generation=0) + loop._finish_run(None, step_completed=False) + loop._begin_run(UserInputEvents([]), generation=0) + assert loop.user_events.get_events() == [picked] + + loop._finish_run([], step_completed=True) + loop._begin_run(UserInputEvents([]), generation=0) + assert loop.user_events.get_events() == [] + + +class _PickerWindow(NullClientWindow): + def __init__(self) -> None: + super().__init__() + self.now = 0.0 + self._file_selector_clock = lambda: self.now + self.started: list[tuple[int, str]] = [] + self.cancelled = 0 + + def _request_file_selector( + self, selector_id: int, request: FileSelectionRequest + ) -> None: + self.started.append((selector_id, request.request_id)) + + def _cancel_active_file_selectors(self) -> None: + self.cancelled += 1 + + +def _request(request_id: str) -> FileSelectionRequest: + return FileSelectionRequest(request_id=request_id, initial_path="/tmp") + + +def _statuses(window: IClientWindow) -> list[tuple[str, SelectedFilesStatus]]: + return [ + (event.request_id, event.status) + for event in window.get_user_input_events().get_events() + if isinstance(event, SelectedFilesUserInputEvent) + ] + + +def test_window_shows_one_picker_at_a_time() -> None: + window = _PickerWindow() + window.request_file_selector(_request("open-1")) + window.request_file_selector(_request("open-1")) + window.request_file_selector(_request("open-2")) + assert [request_id for _, request_id in window.started] == ["open-1"] + assert _statuses(window) == [ + ("open-1", SelectedFilesStatus.BUSY), + ("open-2", SelectedFilesStatus.BUSY), + ] + + selector_id = window.started[0][0] + window._complete_file_selector(selector_id, SelectedFilesStatus.CANCELLED) + window._complete_file_selector(selector_id, SelectedFilesStatus.CANCELLED) + assert _statuses(window) == [("open-1", SelectedFilesStatus.CANCELLED)] + window.request_file_selector(_request("open-2")) + assert [request_id for _, request_id in window.started] == ["open-1", "open-2"] + + +def test_window_checks_a_result_against_its_request() -> None: + def complete( + request: FileSelectionRequest, + status: SelectedFilesStatus, + files: tuple[SelectedFile, ...], + ) -> list[SelectedFilesUserInputEvent]: + window = _PickerWindow() + window.request_file_selector(request) + window._complete_file_selector(window.started[0][0], status, files) + events = window.get_user_input_events().get_events() + selected = [e for e in events if isinstance(e, SelectedFilesUserInputEvent)] + assert selected == events + return selected + + png = FileSelectionRequest( + request_id="open-1", initial_path="/tmp", accept=(".png",), max_bytes=4 + ) + a = SelectedFile(name="a.png", data=b"1") + b = SelectedFile(name="b.png", data=b"2") + cases = [ + (png, SelectedFilesStatus.OK, (), SelectedFilesStatus.UNAVAILABLE), + (png, SelectedFilesStatus.OK, (a, b), SelectedFilesStatus.UNAVAILABLE), + ( + png, + SelectedFilesStatus.OK, + (SelectedFile(name="a.txt", data=b"1"),), + SelectedFilesStatus.DISALLOWED_TYPE, + ), + ( + png, + SelectedFilesStatus.OK, + (SelectedFile(name="a.png", data=b"12345"),), + SelectedFilesStatus.TOO_LARGE, + ), + (png, SelectedFilesStatus.CANCELLED, (a,), SelectedFilesStatus.CANCELLED), + (png, SelectedFilesStatus.BUSY, (), SelectedFilesStatus.UNAVAILABLE), + ] + for request, status, files, expected in cases: + [event] = complete(request, status, files) + assert (event.status, event.files) == (expected, ()) + + many = FileSelectionRequest(request_id="open-1", initial_path="/tmp", multiple=True) + [event] = complete(many, SelectedFilesStatus.OK, (a, b)) + assert (event.status, event.files) == (SelectedFilesStatus.OK, (a, b)) + + pathy = ( + SelectedFile(name="../outside.png", data=b"1"), + SelectedFile(name="C:\\dir\\in.png", data=b"2"), + SelectedFile(name="..", data=b"3"), + ) + [event] = complete(many, SelectedFilesStatus.OK, pathy) + assert [chosen.name for chosen in event.files] == [ + "outside.png", + "in.png", + "upload", + ] + + +def test_window_without_put_user_input_event_cannot_be_created() -> None: + class _Incomplete(IClientWindow): + def get_user_input_events(self) -> UserInputEvents: + return UserInputEvents([]) + + def open(self, session_desc: SessionDesc) -> None: + del session_desc + + def write(self, result: StepResult) -> None: + del result + + def close(self) -> None: + return + + with pytest.raises(TypeError, match="_put_user_input_event"): + _Incomplete() # type: ignore[abstract] + + +def test_window_times_out_unshown_picker_and_drops_its_late_result() -> None: + window = _PickerWindow() + window.request_file_selector(_request("open-1")) + window.now = FILE_SELECTION_PRESENTATION_TIMEOUT_SECONDS + window._expire_file_selectors() + assert _statuses(window) == [("open-1", SelectedFilesStatus.UNAVAILABLE)] + assert window.cancelled == 1 + + window._complete_file_selector(window.started[0][0], SelectedFilesStatus.CANCELLED) + assert _statuses(window) == [] + + +def test_null_window_open_drops_a_result_left_by_the_previous_session() -> None: + window = NullClientWindow() + window.request_file_selector(_request("open-1")) + window.open(SessionDesc()) + assert _statuses(window) == [] + + +def test_window_expires_a_stuck_picker_when_the_next_request_arrives() -> None: + window = _PickerWindow() + window.request_file_selector(_request("open-1")) + window.now = FILE_SELECTION_PRESENTATION_TIMEOUT_SECONDS + window.request_file_selector(_request("open-2")) + assert _statuses(window) == [("open-1", SelectedFilesStatus.UNAVAILABLE)] + assert [request_id for _, request_id in window.started] == ["open-1", "open-2"] + + +def test_window_shown_picker_waits_for_the_user_without_deadline() -> None: + window = _PickerWindow() + window.request_file_selector(_request("open-1")) + window._file_selector_opened(window.started[0][0]) + window.now = 1_000.0 + window._expire_file_selectors() + assert _statuses(window) == [] + + +def test_flush_forgets_the_open_picker_without_a_result() -> None: + window = _PickerWindow() + window.request_file_selector(_request("open-1")) + window.flush_file_selectors() + window._complete_file_selector(window.started[0][0], SelectedFilesStatus.CANCELLED) + assert _statuses(window) == [] + assert window.cancelled == 1 + + window.flush_file_selectors() + assert window.cancelled == 1 + window.request_file_selector(_request("open-1")) + assert len(window.started) == 2 + + +def test_window_answers_unavailable_when_its_picker_fails_to_start() -> None: + class _BrokenWindow(NullClientWindow): + def _request_file_selector( + self, selector_id: int, request: FileSelectionRequest + ) -> None: + raise RuntimeError("no picker") + + window = _BrokenWindow() + window.request_file_selector(_request("open-1")) + assert _statuses(window) == [("open-1", SelectedFilesStatus.UNAVAILABLE)] + + +def test_selected_file_policy_helpers() -> None: + assert clamp_selected_file_max_bytes(None) == MAX_SELECTED_FILE_BYTES + assert clamp_selected_file_max_bytes(16) == 16 + assert clamp_selected_file_max_bytes(MAX_SELECTED_FILE_BYTES * 2) == ( + MAX_SELECTED_FILE_BYTES + ) + assert normalize_selected_file_accept([".png", ".jpg"]) == (".png", ".jpg") + assert selected_file_suffix_allowed("seed.PNG", (".png",)) + assert not selected_file_suffix_allowed("seed.txt", (".png",)) + assert selected_file_suffix_allowed("seed.txt", ()) + assert ( + selected_files_policy_status( + [("a.bin", 0)] * (MAX_SELECTED_FILE_COUNT + 1), max_bytes=3 + ) + is SelectedFilesStatus.TOO_LARGE + ) + assert ( + selected_files_policy_status( + [("a.bin", 2), ("b.txt", 2)], + accept=(".bin",), + max_bytes=3, + ) + is SelectedFilesStatus.DISALLOWED_TYPE + ) + assert ( + selected_files_policy_status( + [("a.bin", 2), ("b.bin", 99)], + accept=(".bin",), + max_bytes=3, + ) + is SelectedFilesStatus.TOO_LARGE + ) + assert ( + selected_files_policy_status( + [("a.bin", MAX_SELECTED_FILE_BATCH_BYTES), ("b.bin", 1)], + accept=(".bin",), + max_bytes=MAX_SELECTED_FILE_BYTES, + ) + is SelectedFilesStatus.TOO_LARGE + ) + assert ( + selected_files_policy_status( + [("a.bin", 2), ("b.bin", 2)], + accept=(".bin",), + max_bytes=3, + ) + is None + ) + + +def test_ui_loop_rejects_empty_request_id() -> None: + loop = _ui_loop() + with pytest.raises(ValueError, match="non-empty string"): + loop.request_file_selector("") + + +def test_ui_loop_rejects_invalid_accept() -> None: + loop = _ui_loop() + with pytest.raises(ValueError, match="accept suffixes"): + loop.request_file_selector("open-1", accept=("png",)) + + +def test_ui_loop_rejects_invalid_multiple() -> None: + loop = _ui_loop() + with pytest.raises(TypeError, match="multiple must be a bool"): + loop.request_file_selector("open-1", multiple=1) # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + + +def test_selected_files_event_rejects_inconsistent_status() -> None: + with pytest.raises(ValueError, match="must include files"): + SelectedFilesUserInputEvent( + timestamp=uint64(1), + request_id="open-1", + status=SelectedFilesStatus.OK, + ) + with pytest.raises(ValueError, match="must not include files"): + SelectedFilesUserInputEvent( + timestamp=uint64(1), + request_id="open-1", + status=SelectedFilesStatus.CANCELLED, + files=(SelectedFile(name="seed.png", data=b"png"),), + ) + + +@pytest.mark.parametrize("kind", ["mp4", "null"]) +def test_window_without_a_selector_answers_unavailable( + kind: str, tmp_path: Path +) -> None: + window = ( + Mp4ClientWindow(tmp_path / "clip.mp4") if kind == "mp4" else NullClientWindow() + ) + window.request_file_selector( + FileSelectionRequest(request_id="open-1", initial_path="/tmp") + ) + + assert _statuses(window) == [("open-1", SelectedFilesStatus.UNAVAILABLE)] diff --git a/flashdreams/test_v2/test_session_runner.py b/flashdreams/test_v2/test_session_runner.py index e934afebc..a97d69d33 100644 --- a/flashdreams/test_v2/test_session_runner.py +++ b/flashdreams/test_v2/test_session_runner.py @@ -21,6 +21,7 @@ IModelLoop, IUILoop, ModelInferenceState, + UILoopRequests, invoke_async, ) from flashdreams.api_v2.session import ISession @@ -35,18 +36,23 @@ PresentationManager, _PresentationClock, ) +from flashdreams.runtime_v2.selected_file import ( + FileSelectionRequest, + SelectedFilesStatus, +) from flashdreams.runtime_v2.session_desc import ( BackpressureMode, PresentationMode, SessionDesc, ) -from flashdreams.runtime_v2.session_runner import run_session +from flashdreams.runtime_v2.session_runner import _apply_window_requests, run_session from flashdreams.runtime_v2.step_result import StepResult from flashdreams.runtime_v2.user_input_event import ( CloseUserInputEvent, KeyboardInputState, KeyboardUserInputEvent, ResetUserInputEvent, + SelectedFilesUserInputEvent, ) from flashdreams.runtime_v2.user_input_events import UserInputEvents from flashdreams.runtime_v2.video_tensor import VideoTensorLayout @@ -663,6 +669,8 @@ def __init__( self.session_desc: SessionDesc | None = None self.results: list[StepResult] = [] self.cursor_requests: list[tuple[str, bool]] = [] + self.file_selection_requests: list[FileSelectionRequest] = [] + self._file_events: list[UserInputEvent] = [] def request_hide_cursor(self, hide_cursor: bool) -> None: """Record one cursor visibility request.""" @@ -674,12 +682,25 @@ def request_lock_cursor_to_window(self, lock_cursor_to_window: bool) -> None: self._log.record("window.request_lock_cursor_to_window") self.cursor_requests.append(("lock", lock_cursor_to_window)) + def request_file_selector(self, request: FileSelectionRequest) -> None: + """Record one file-selector request and answer with the default.""" + self._log.record("window.request_file_selector") + self.file_selection_requests.append(request) + super().request_file_selector(request) + + def _put_user_input_event(self, event: UserInputEvent) -> None: + with self._lock: + self._file_events.append(event) + def get_user_input_events(self) -> UserInputEvents: self._log.record("window.get_user_input_events") with self._lock: + file_events, self._file_events = self._file_events, [] if self._scripted: - return self._scripted.pop(0) - return UserInputEvents([]) + return UserInputEvents( + [*file_events, *self._scripted.pop(0).get_events()] + ) + return UserInputEvents(file_events) def open(self, session_desc: SessionDesc) -> None: self._log.record("window.open") @@ -1370,6 +1391,77 @@ def init(self) -> None: assert "window.close" not in log.calls +def test_run_session_forwards_file_selection_requests_to_the_window() -> None: + log = CallLog() + + class RequestingSession(FakeSession): + def init(self) -> None: + super().init() + + def open_selector(_: object) -> None: + self.ui_loop.request_file_selector( + "open-1", + "/tmp", + accept=(".png",), + max_file_bytes=1024, + ) + + invoke_async(self.ui_loop, open_selector) + + window = RecordingClientWindow(log) + run_session(RequestingSession(_session_desc(), log), window, steps=1) + + assert window.file_selection_requests == [ + FileSelectionRequest( + request_id="open-1", + initial_path="/tmp", + accept=(".png",), + max_bytes=1024, + ) + ] + assert "window.request_file_selector" in log.calls + + +def test_window_requests_skip_a_file_selector_for_a_replaced_session() -> None: + window = RecordingClientWindow(CallLog()) + _apply_window_requests( + window, + UILoopRequests( + new_session=_session_desc(), + file_selections=[ + FileSelectionRequest(request_id="open-1", initial_path="/tmp") + ], + ), + ) + assert window.file_selection_requests == [] + + +def test_run_session_delivers_an_immediate_file_selection_result() -> None: + log = CallLog() + picks: list[SelectedFilesUserInputEvent] = [] + + class PickingSession(FakeSession): + def run_ui(self, step_index: int, events: UserInputEvents) -> list[StepResult]: + if self.ui_steps == 0: + self.ui_loop.request_file_selector("open-1") + self.ui_steps += 1 + picks.extend( + event + for event in self.ui_loop.user_events.get_events() + if isinstance(event, SelectedFilesUserInputEvent) + ) + return super().run_ui(step_index, events) + + session_desc = replace( + _session_desc(), presentation_mode=PresentationMode.CONTINUOUS + ) + run_session(PickingSession(session_desc, log), RecordingClientWindow(log), steps=4) + + assert [(pick.request_id, pick.status) for pick in picks] == [ + ("open-1", SelectedFilesStatus.UNAVAILABLE) + ] + + def test_timeout_wins_over_a_ui_requested_replacement() -> None: log = CallLog() resolved = _session_desc() diff --git a/flashdreams/test_v2/test_webrtc_client_window.py b/flashdreams/test_v2/test_webrtc_client_window.py index fb3435860..96aba7d3f 100644 --- a/flashdreams/test_v2/test_webrtc_client_window.py +++ b/flashdreams/test_v2/test_webrtc_client_window.py @@ -6,6 +6,7 @@ # ruff: noqa: E402 - optional WebRTC imports must follow importorskip. import asyncio +import base64 import json import time from dataclasses import replace @@ -20,7 +21,7 @@ pytest.importorskip("aiohttp") pytest.importorskip("aiortc") -from aiohttp import ClientSession +from aiohttp import ClientSession, FormData, MultipartWriter, web from aiortc import ( MediaStreamTrack, RTCDataChannel, @@ -31,6 +32,12 @@ from av import VideoFrame from yarl import URL +from flashdreams.runtime_v2.selected_file import ( + MAX_SELECTED_FILE_BYTES, + FileSelectionRequest, + SelectedFilesStatus, + clamp_selected_file_max_bytes, +) from flashdreams.runtime_v2.serving import webrtc_server from flashdreams.runtime_v2.serving.webrtc_server import _VideoTrack from flashdreams.runtime_v2.session_desc import PresentationMode, SessionDesc @@ -43,6 +50,7 @@ KeyboardUserInputEvent, MouseUserInputEvent, QueryStringUserInputEvent, + SelectedFilesUserInputEvent, TouchUserInputEvent, XRControllerUserInputEvent, ) @@ -50,6 +58,102 @@ from flashdreams.runtime_v2.webrtc_client_window import WebRTCClientWindow +def _file_request( + request_id: str, + initial_path: str = "/tmp", + *, + accept: tuple[str, ...] = (), + max_bytes: int | None = None, + multiple: bool = False, +) -> FileSelectionRequest: + return FileSelectionRequest( + request_id=request_id, + initial_path=initial_path, + accept=accept, + max_bytes=max_bytes, + multiple=multiple, + ) + + +def _files_url(window: WebRTCClientWindow, token: str) -> str: + return f"{window.server.url}api/files?id={token}" + + +async def _wait_file_armed(window: WebRTCClientWindow) -> str: + """Return the token the browser was sent once a selector is armed.""" + for _ in range(100): + if window.server._pending_file_requests: + return next(iter(window.server._pending_file_requests)) + await asyncio.sleep(0.01) + raise AssertionError("file selection was not armed") + + +def _arm(window: WebRTCClientWindow, request: FileSelectionRequest) -> str: + """Arm a selector as if the browser had been sent ``request``; return its token.""" + selector_id = window._last_file_selector_id + 1 + window._last_file_selector_id = selector_id + window._active_file_selector = (selector_id, request) + wire_id = f"token-{selector_id}" + window.server._pending_file_requests[wire_id] = webrtc_server._PendingFileSelection( + selector_id=str(selector_id), + accept=request.accept, + max_bytes=clamp_selected_file_max_bytes(request.max_bytes), + multiple=request.multiple, + ) + return wire_id + + +async def _post_selected_file( + window: WebRTCClientWindow, + request_id: str, + data: bytes, + filename: str, +) -> int: + form = FormData() + form.add_field( + "file", + data, + filename=filename, + content_type="application/octet-stream", + ) + async with ClientSession() as client: + async with client.post(_files_url(window, request_id), data=form) as response: + return response.status + + +@pytest.mark.asyncio +async def test_file_upload_accepts_multiple_files() -> None: + window = WebRTCClientWindow() + window.open(_session_desc()) + peer, _, _ = await _connect_browser(window) + try: + window.request_file_selector(_file_request("open-1", multiple=True)) + wire_id = await _wait_file_armed(window) + form = FormData() + form.add_field( + "file", + b"one", + filename="a.bin", + content_type="application/octet-stream", + ) + form.add_field( + "file", + b"two", + filename="b.bin", + content_type="application/octet-stream", + ) + async with ClientSession() as client: + async with client.post(_files_url(window, wire_id), data=form) as response: + assert response.status == 204 + event = await _wait_selected_files_event(window) + assert event.status is SelectedFilesStatus.OK + assert [chosen.name for chosen in event.files] == ["a.bin", "b.bin"] + assert [chosen.data for chosen in event.files] == [b"one", b"two"] + finally: + await peer.close() + window.close() + + def _session_desc( presentation_mode: PresentationMode = PresentationMode.ON_DEMAND, ) -> SessionDesc: @@ -141,6 +245,439 @@ async def test_browser_query_string_reaches_the_input_stream() -> None: window.close() +@pytest.mark.asyncio +async def test_file_selector_without_a_viewer_is_unavailable() -> None: + window = WebRTCClientWindow() + window.open(_session_desc()) + try: + window.request_file_selector(_file_request("open-1")) + event = await _wait_selected_files_event(window) + assert event.request_id == "open-1" + assert event.status is SelectedFilesStatus.UNAVAILABLE + assert window.server._pending_file_requests == {} + finally: + window.close() + + +@pytest.mark.asyncio +async def test_peer_loss_completes_armed_file_selections_as_unavailable() -> None: + window = WebRTCClientWindow() + window.open(_session_desc()) + peer: RTCPeerConnection | None = None + try: + peer, _, _ = await _connect_browser(window) + window.request_file_selector(_file_request("open-1")) + await _wait_file_armed(window) + await peer.close() + peer = None + + event = await _wait_selected_files_event(window) + assert event.request_id == "open-1" + assert event.status is SelectedFilesStatus.UNAVAILABLE + assert window.server._pending_file_requests == {} + finally: + if peer is not None: + await peer.close() + window.close() + + +@pytest.mark.asyncio +async def test_in_flight_upload_is_unavailable_after_peer_loss( + monkeypatch: pytest.MonkeyPatch, +) -> None: + window = WebRTCClientWindow() + window.open(_session_desc()) + peer: RTCPeerConnection | None = None + try: + peer, _, _ = await _connect_browser(window) + window.request_file_selector(_file_request("open-1")) + wire_id = await _wait_file_armed(window) + original_multipart = web.Request.multipart + disconnected = False + + async def multipart_after_disconnect(request: web.Request) -> Any: + nonlocal disconnected + if not disconnected: + disconnected = True + peer_connection = window.server._peer_connection + if peer_connection is not None: + await window.server._release_peer_connection(peer_connection) + return await original_multipart(request) + + monkeypatch.setattr(web.Request, "multipart", multipart_after_disconnect) + assert await _post_selected_file(window, wire_id, b"hello", "hello.txt") == 204 + event = await _wait_selected_files_event(window) + assert event.request_id == "open-1" + assert event.status is SelectedFilesStatus.UNAVAILABLE + await asyncio.sleep(0.05) + assert window.get_user_input_events().get_events() == [] + finally: + if peer is not None: + await peer.close() + window.close() + + +@pytest.mark.asyncio +async def test_encoded_file_part_is_unavailable() -> None: + window = WebRTCClientWindow() + window.open(_session_desc()) + try: + wire_id = _arm(window, _file_request("open-1")) + with MultipartWriter("form-data") as body: + part = body.append(b"abc=\n") + part.set_content_disposition("form-data", name="file", filename="a.bin") + part.headers["Content-Transfer-Encoding"] = "quoted-printable" + async with ClientSession() as client: + async with client.post(_files_url(window, wire_id), data=body) as response: + assert response.status == 400 + event = await _wait_selected_files_event(window) + assert event.status is SelectedFilesStatus.UNAVAILABLE + finally: + window.close() + + +@pytest.mark.asyncio +async def test_upload_without_the_armed_token_is_rejected() -> None: + window = WebRTCClientWindow() + window.open(_session_desc()) + try: + assert await _post_selected_file(window, "1", b"hello", "hello.txt") == 409 + _arm(window, _file_request("open-1")) + assert await _post_selected_file(window, "1", b"hello", "hello.txt") == 409 + assert window.get_user_input_events().get_events() == [] + assert len(window.server._pending_file_requests) == 1 + finally: + window.close() + + +@pytest.mark.asyncio +async def test_browser_file_selector_sends_clamped_policy_without_host_path() -> None: + window = WebRTCClientWindow() + window.open(_session_desc()) + peer: RTCPeerConnection | None = None + try: + peer, channel, _ = await _connect_browser(window) + messages: list[dict[str, object]] = [] + + @channel.on("message") + def on_message(message: object) -> None: + if isinstance(message, str): + messages.append(json.loads(message)) + + window.request_file_selector( + _file_request( + "open-1", + "/home/secret", + accept=(".png", ".jpg"), + max_bytes=MAX_SELECTED_FILE_BYTES * 2, + ) + ) + for _ in range(100): + if any(item.get("type") == "file_selector" for item in messages): + break + await asyncio.sleep(0.01) + payload = next(item for item in messages if item.get("type") == "file_selector") + token = payload.pop("id") + assert isinstance(token, str) and len(token) >= 16 + assert token in window.server._pending_file_requests + assert payload == { + "type": "file_selector", + "accept": [".png", ".jpg"], + "max_bytes": MAX_SELECTED_FILE_BYTES, + "multiple": False, + } + finally: + if peer is not None: + await peer.close() + window.close() + + +@pytest.mark.asyncio +async def test_flushed_file_selector_closes_the_browser_prompt() -> None: + window = WebRTCClientWindow() + window.open(_session_desc()) + peer: RTCPeerConnection | None = None + try: + peer, channel, _ = await _connect_browser(window) + messages: list[dict[str, object]] = [] + + @channel.on("message") + def on_message(message: object) -> None: + if isinstance(message, str): + messages.append(json.loads(message)) + + window.request_file_selector(_file_request("open-1")) + await _wait_file_armed(window) + window.flush_file_selectors() + for _ in range(100): + if any(item.get("type") == "file_selector_cancel" for item in messages): + break + await asyncio.sleep(0.01) + assert {"type": "file_selector_cancel"} in messages + assert window.server._pending_file_requests == {} + finally: + if peer is not None: + await peer.close() + window.close() + + +async def _wait_selected_files_event( + window: WebRTCClientWindow, +) -> SelectedFilesUserInputEvent: + events: list[object] = [] + for _ in range(100): + events.extend(window.get_user_input_events().get_events()) + if events: + break + await asyncio.sleep(0.01) + assert len(events) == 1 + event = events[0] + assert isinstance(event, SelectedFilesUserInputEvent) + return event + + +@pytest.mark.asyncio +async def test_file_upload_without_file_is_unavailable() -> None: + window = WebRTCClientWindow() + window.open(_session_desc()) + try: + wire_id = _arm(window, _file_request("open-1")) + form = FormData() + form.add_field("not_file", "hello") + async with ClientSession() as client: + async with client.post(_files_url(window, wire_id), data=form) as response: + assert response.status == 400 + event = await _wait_selected_files_event(window) + assert event.request_id == "open-1" + assert event.status is SelectedFilesStatus.UNAVAILABLE + assert event.files == () + finally: + window.close() + + +@pytest.mark.asyncio +async def test_file_upload_rejects_oversize() -> None: + window = WebRTCClientWindow() + window.open(_session_desc()) + try: + wire_id = _arm(window, _file_request("open-1", max_bytes=1)) + status = await _post_selected_file(window, wire_id, b"x" * 8192, "big.bin") + assert status == 413 + event = await _wait_selected_files_event(window) + assert event.request_id == "open-1" + assert event.status is SelectedFilesStatus.TOO_LARGE + assert event.files == () + finally: + window.close() + + +@pytest.mark.asyncio +async def test_file_upload_stops_once_the_batch_exceeds_its_cap( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(webrtc_server, "MAX_SELECTED_FILE_BATCH_BYTES", 5) + window = WebRTCClientWindow() + window.open(_session_desc()) + try: + wire_id = _arm(window, _file_request("open-1", multiple=True)) + form = FormData() + for name in ("a.bin", "b.bin", "c.bin"): + form.add_field( + "file", b"xyz", filename=name, content_type="application/octet-stream" + ) + async with ClientSession() as client: + async with client.post(_files_url(window, wire_id), data=form) as response: + assert response.status == 413 + event = await _wait_selected_files_event(window) + assert event.status is SelectedFilesStatus.TOO_LARGE + assert event.files == () + finally: + window.close() + + +@pytest.mark.asyncio +async def test_file_upload_over_the_batch_cap_still_reports_a_disallowed_type( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(webrtc_server, "MAX_SELECTED_FILE_BATCH_BYTES", 5) + window = WebRTCClientWindow() + window.open(_session_desc()) + try: + wire_id = _arm(window, _file_request("open-1", accept=(".bin",), multiple=True)) + form = FormData() + for name in ("a.bin", "b.bin", "c.exe"): + form.add_field( + "file", b"xyz", filename=name, content_type="application/octet-stream" + ) + async with ClientSession() as client: + async with client.post(_files_url(window, wire_id), data=form) as response: + assert response.status == 204 + event = await _wait_selected_files_event(window) + assert event.status is SelectedFilesStatus.DISALLOWED_TYPE + finally: + window.close() + + +@pytest.mark.asyncio +async def test_file_upload_prefers_disallowed_suffix_over_body_limit() -> None: + window = WebRTCClientWindow() + window.open(_session_desc()) + try: + wire_id = _arm(window, _file_request("open-1", accept=(".png",), max_bytes=1)) + assert ( + await _post_selected_file( + window, wire_id, b"x" * (2 * 1024 * 1024), "hello.txt" + ) + == 204 + ) + event = await _wait_selected_files_event(window) + assert event.request_id == "open-1" + assert event.status is SelectedFilesStatus.DISALLOWED_TYPE + assert event.files == () + finally: + window.close() + + +@pytest.mark.asyncio +async def test_browser_ack_reports_the_file_selector_opened() -> None: + window = WebRTCClientWindow() + window.open(_session_desc()) + peer: RTCPeerConnection | None = None + try: + peer, channel, _ = await _connect_browser(window) + window.request_file_selector(_file_request("open-1")) + wire_id = await _wait_file_armed(window) + channel.send(json.dumps({"type": "file_selector_ack", "id": "other"})) + channel.send(json.dumps({"type": "file_selector_ack", "id": wire_id})) + for _ in range(100): + if window._file_selector_deadline is None: + break + await asyncio.sleep(0.01) + assert window._file_selector_deadline is None + assert window.get_user_input_events().get_events() == [] + channel.send( + json.dumps( + { + "type": "file_selector_result", + "id": wire_id, + "status": "cancelled", + } + ) + ) + event = await _wait_selected_files_event(window) + assert event.status is SelectedFilesStatus.CANCELLED + assert event.files == () + finally: + if peer is not None: + await peer.close() + window.close() + + +@pytest.mark.asyncio +async def test_browser_file_selector_result_reports_status() -> None: + window = WebRTCClientWindow() + window.open(_session_desc()) + peer: RTCPeerConnection | None = None + try: + peer, channel, _ = await _connect_browser(window) + window.request_file_selector(_file_request("open-1")) + wire_id = await _wait_file_armed(window) + channel.send( + json.dumps( + { + "type": "file_selector_result", + "id": wire_id, + "status": "cancelled", + } + ) + ) + event = await _wait_selected_files_event(window) + assert event.request_id == "open-1" + assert event.status is SelectedFilesStatus.CANCELLED + assert event.files == () + finally: + if peer is not None: + await peer.close() + window.close() + + +@pytest.mark.asyncio +async def test_browser_file_selector_result_cannot_claim_ok() -> None: + window = WebRTCClientWindow() + window.open(_session_desc()) + peer: RTCPeerConnection | None = None + try: + peer, channel, _ = await _connect_browser(window) + errors: list[dict[str, object]] = [] + + @channel.on("message") + def on_message(message: object) -> None: + if isinstance(message, str): + payload = json.loads(message) + if payload.get("type") == "error": + errors.append(payload) + + window.request_file_selector(_file_request("open-1")) + wire_id = await _wait_file_armed(window) + channel.send( + json.dumps( + { + "type": "file_selector_result", + "id": wire_id, + "status": "ok", + } + ) + ) + for _ in range(100): + if errors: + break + await asyncio.sleep(0.01) + assert errors + assert "POST /api/files" in str(errors[0].get("message")) + assert window.get_user_input_events().get_events() == [] + assert await _post_selected_file(window, wire_id, b"hello", "hello.txt") == 204 + event = await _wait_selected_files_event(window) + assert event.status is SelectedFilesStatus.OK + assert event.files[0].data == b"hello" + finally: + if peer is not None: + await peer.close() + window.close() + + +@pytest.mark.asyncio +async def test_browser_script_checks_type_and_size_before_upload() -> None: + window = WebRTCClientWindow() + try: + async with ClientSession() as client: + async with client.get(f"{window.server.url}app.js") as response: + script = await response.text() + assert "fileNameMatchesAccept" in script + assert "file.size" in script + assert '"too_large"' in script + assert '"disallowed_type"' in script + assert "fetch(`/api/files?id=${encodeURIComponent(requestId)}`" in script + assert "input.multiple" in script + assert "file_selector_ack" in script + assert "event.stopImmediatePropagation();" in script + assert "(promptDialog !== null && promptDialog.open)" in script + finally: + window.close() + + +@pytest.mark.asyncio +async def test_session_handoff_clears_pending_file_selection() -> None: + window = WebRTCClientWindow() + window.open(_session_desc()) + try: + wire_id = _arm(window, _file_request("open-1")) + window.open(_session_desc()) + assert window.server._pending_file_requests == {} + assert await _post_selected_file(window, wire_id, b"hello", "hello.txt") == 409 + finally: + window.close() + + @pytest.mark.asyncio async def test_window_buffers_browser_events_until_drained() -> None: window = WebRTCClientWindow() diff --git a/flashdreams/tests/test_profiler.py b/flashdreams/tests/test_profiler.py index ffad1ff1e..935494a13 100644 --- a/flashdreams/tests/test_profiler.py +++ b/flashdreams/tests/test_profiler.py @@ -35,6 +35,7 @@ from flashdreams.api_v2.client_window import IClientWindow from flashdreams.api_v2.loop import IModelLoop from flashdreams.api_v2.profiler import IProfiler +from flashdreams.api_v2.user_input_event import UserInputEvent from flashdreams.infra import profiler as event_timer_module from flashdreams.runtime_v2 import profiler as profiler_module from flashdreams.runtime_v2.event_buffer import EventBuffer @@ -350,6 +351,9 @@ def get_user_input_events(self) -> UserInputEvents: self.batches += 1 return UserInputEvents([_key_press()]) + def _put_user_input_event(self, event: UserInputEvent) -> None: + del event + def open(self, session_desc: SessionDesc) -> None: pass diff --git a/integrations_v2/imgui_ui_demo/README.md b/integrations_v2/imgui_ui_demo/README.md index dcca7516a..3ca20bdb9 100644 --- a/integrations_v2/imgui_ui_demo/README.md +++ b/integrations_v2/imgui_ui_demo/README.md @@ -8,6 +8,13 @@ SPDX-License-Identifier: Apache-2.0 Small ImGui applications for the FlashDreams v2 loop runtime: - `imgui-ui-text-input` renders an editable text field over model output. +- `imgui-ui-file-picker` opens the client file selector and shows the chosen + name and size on one status line per button. Each Open button calls + `request_file_selector` with its own `request_id`, and the result event + carrying that id updates the button's line. One picker is open at a time; + a click on any button while it is open shows `BUSY` on that button's line. Dropping the WebRTC tab completes in-flight picks as unavailable. A picker + the host never shows within 30 seconds also completes as unavailable; a + picker that is shown stays open as long as the user wants. - `imgui-ui-query-string` sets background from a `?(r,g,b)` browser URL. - `imgui-ui-window-size` resizes native window or UI render target without resetting session. @@ -24,6 +31,19 @@ Run the text-input application: uv run --no-sync flashdreams-run-v2 imgui-ui-text-input --mode native-window ``` +Run the file-picker application. The native window uses the Tk file dialog +(on Linux, install Python's Tk package, such as `python3-tk`). WebRTC shows a +confirmation dialog in the viewer (the picker must open from a browser click). +The demo asks for `.bin` / `.raw` at the per-file ceiling +(`MAX_SELECTED_FILE_BYTES`). Controls are **Open file (A)**, **Open file (B)**, and **Open files** +(more than one file). Failed picks show in red with the +`SelectedFilesStatus` name and why. +```bash +uv run --no-sync flashdreams-run-v2 imgui-ui-file-picker --mode native-window +uv run --no-sync flashdreams-run-v2 imgui-ui-file-picker --mode webrtc \ + --host 127.0.0.1 --port 8080 +``` + Run the query-string application: ```bash uv run --no-sync flashdreams-run-v2 imgui-ui-query-string \ diff --git a/integrations_v2/imgui_ui_demo/imgui_ui_demo/__init__.py b/integrations_v2/imgui_ui_demo/imgui_ui_demo/__init__.py index b3fcbf18f..fa3acf920 100644 --- a/integrations_v2/imgui_ui_demo/imgui_ui_demo/__init__.py +++ b/integrations_v2/imgui_ui_demo/imgui_ui_demo/__init__.py @@ -3,10 +3,13 @@ """Interactive ImGui UI applications for the v2 loop runtime.""" +from .file_picker_app import FilePickerApplication, FilePickerSession from .text_input_app import TextInputApplication, TextInputSession, create_app from .window_size_app import WindowSizeApplication, WindowSizeSession __all__ = [ + "FilePickerApplication", + "FilePickerSession", "TextInputApplication", "TextInputSession", "WindowSizeApplication", diff --git a/integrations_v2/imgui_ui_demo/imgui_ui_demo/file_picker_app.py b/integrations_v2/imgui_ui_demo/imgui_ui_demo/file_picker_app.py new file mode 100644 index 000000000..c39767795 --- /dev/null +++ b/integrations_v2/imgui_ui_demo/imgui_ui_demo/file_picker_app.py @@ -0,0 +1,248 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""ImGui file-picker application for the v2 loop runtime.""" + +import math +from collections.abc import Sequence +from dataclasses import dataclass, field +from typing import Any + +import torch +from torch import Tensor + +from flashdreams.api_v2.application import IApplication +from flashdreams.api_v2.loop import IModelLoop +from flashdreams.api_v2.session import ISession +from flashdreams.runtime_v2.imgui_ui_loop import ImGuiUILoop +from flashdreams.runtime_v2.selected_file import ( + MAX_SELECTED_FILE_BYTES, + SelectedFilesStatus, +) +from flashdreams.runtime_v2.session_desc import SessionDesc +from flashdreams.runtime_v2.step_result import StepResult +from flashdreams.runtime_v2.user_input_event import SelectedFilesUserInputEvent +from flashdreams.runtime_v2.user_input_events import UserInputEvents +from flashdreams.runtime_v2.video_tensor import VideoTensorLayout + +_BREATHE_PERIOD_S = 3.0 +"""Seconds for one black → gray → black cycle in the demo background.""" + +_BREATHE_BLACK = -1.0 +"""Model-frame value for black in ``[-1, 1]``.""" + +_BREATHE_GRAY = -0.25 +"""Brightest gray in the cycle. Stops short of white.""" + +_FILE_SELECTION_STATUS_TEXT = { + SelectedFilesStatus.CANCELLED: ( + "SelectedFilesStatus.CANCELLED: user dismissed the selector." + ), + SelectedFilesStatus.BUSY: ( + "SelectedFilesStatus.BUSY: another picker was already open." + ), + SelectedFilesStatus.TOO_LARGE: ( + "SelectedFilesStatus.TOO_LARGE: a file exceeded this request's " + f"max_file_bytes={MAX_SELECTED_FILE_BYTES}, or the set exceeded the " + "batch limits." + ), + SelectedFilesStatus.DISALLOWED_TYPE: ( + "SelectedFilesStatus.DISALLOWED_TYPE: suffix not in accept=('.bin', '.raw')." + ), + SelectedFilesStatus.UNAVAILABLE: ( + "SelectedFilesStatus.UNAVAILABLE: picker could not complete " + "(no or gone client, picker not shown in time, failed chooser, or " + "unreadable file)." + ), +} +_FILE_SELECTION_ERROR_COLOR = (1.0, 0.25, 0.25, 1.0) +"""RGBA used for cancelled and other failed selector results.""" + +_OPEN_FILE_A_REQUEST_ID = "open-file-a" +"""Selector slot for Open file (A).""" + +_OPEN_FILE_B_REQUEST_ID = "open-file-b" +"""Selector slot for Open file (B).""" + +_OPEN_FILE_A_LABEL = "Open file (A)" +"""ImGui label for selector slot A.""" + +_OPEN_FILE_B_LABEL = "Open file (B)" +"""ImGui label for selector slot B.""" + +_OPEN_FILES_REQUEST_ID = "open-files" +"""Selector slot for Open files. Asks for more than one file.""" + +_OPEN_FILES_LABEL = "Open files" +"""ImGui label for a multi-file selector slot.""" + +_FILE_SELECTOR_SLOTS = ( + (_OPEN_FILE_A_LABEL, _OPEN_FILE_A_REQUEST_ID, False), + (_OPEN_FILE_B_LABEL, _OPEN_FILE_B_REQUEST_ID, False), + (_OPEN_FILES_LABEL, _OPEN_FILES_REQUEST_ID, True), +) +"""``(label, request_id, multiple)`` for each Open button, in display order.""" + +_NO_FILE_SELECTED = "No file selected." +"""Status shown for a button before its first result.""" + + +class BreathingBackgroundModelLoop(IModelLoop[tuple[SessionDesc, torch.device | str]]): + """Generate a black-to-gray breathing background for the file-picker demo.""" + + def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: + """Return one background frame along a 3s black-gray cycle.""" + del events + desc, device = self.state + phase = (step_index / desc.frames_per_second_for_step) * ( + 2.0 * math.pi / _BREATHE_PERIOD_S + ) + mix = 0.5 - 0.5 * math.cos(phase) + level = _BREATHE_BLACK + (_BREATHE_GRAY - _BREATHE_BLACK) * mix + return [ + StepResult( + step_index=step_index, + output=torch.full( + (1, 3, desc.video_height, desc.video_width), + level, + dtype=torch.float32, + device=device, + ), + frame_count=1, + output_layout=desc.output_layout, + ) + ] + + def reset(self) -> None: + return + + +@dataclass(slots=True) +class FilePickerState: + """Last file-selector result for each Open button.""" + + statuses: dict[str, str] = field(default_factory=dict) + """Status text by ``request_id``. A missing id has no result yet.""" + + +def _status_text(event: SelectedFilesUserInputEvent) -> str: + """Return the overlay text for one selector result.""" + if event.status is not SelectedFilesStatus.OK: + return _FILE_SELECTION_STATUS_TEXT[event.status] + return ", ".join( + f"{chosen.name} ({len(chosen.data)} bytes)" for chosen in event.files + ) + + +class FilePickerImGuiUILoop(ImGuiUILoop[FilePickerState]): + """Request ``.bin`` / ``.raw`` picks and show each button's own result.""" + + def step_ui( + self, + imgui: Any, + step_index: int, + events: UserInputEvents, + ) -> Tensor | None: + """Draw the Open buttons and one status line per button.""" + del step_index + for event in events.get_events(): + if isinstance(event, SelectedFilesUserInputEvent): + self.state.statuses[event.request_id] = _status_text(event) + imgui.set_next_window_pos(imgui.ImVec2(16.0, 16.0), imgui.Cond_.once) + imgui.set_next_window_size(imgui.ImVec2(520.0, 300.0), imgui.Cond_.once) + imgui.begin("File picker") + try: + for label, request_id, multiple in _FILE_SELECTOR_SLOTS: + if imgui.button(label): + self.request_file_selector( + request_id, + accept=(".bin", ".raw"), + max_file_bytes=MAX_SELECTED_FILE_BYTES, + multiple=multiple, + ) + for label, request_id, _ in _FILE_SELECTOR_SLOTS: + status = self.state.statuses.get(request_id, _NO_FILE_SELECTED) + line = f"{label}: {status}" + if status not in _FILE_SELECTION_STATUS_TEXT.values(): + imgui.text(line) + continue + imgui.push_style_color( + imgui.Col_.text, + imgui.ImVec4(*_FILE_SELECTION_ERROR_COLOR), + ) + try: + imgui.text_wrapped(line) + finally: + imgui.pop_style_color() + finally: + imgui.end() + return self.presented_model_frame() + + def reset(self) -> None: + """Clear every button's result for a new generation.""" + self.state.statuses.clear() + super().reset() + + +class FilePickerSession(ISession): + """Run an ImGui file picker over a generated background.""" + + def __init__( + self, + session_desc: SessionDesc, + *, + device: torch.device | str = "cuda", + ) -> None: + """Configure one file-picker session. + + Args: + session_desc: Output dimensions and loop frequencies. + device: Device used for the background model frame. + """ + if session_desc.output_layout is not VideoTensorLayout.tchw: + raise ValueError( + "The file-picker demo requires tchw output, got " + f"{session_desc.output_layout.value}." + ) + self._session_desc = session_desc + self._device = device + + @property + def session_desc(self) -> SessionDesc: + """Return the resolved session description.""" + return self._session_desc + + def init(self) -> None: + """Register the file-picker UI and background model loops.""" + self.register_ui_loop( + FilePickerImGuiUILoop, + state=FilePickerState(), + width=self._session_desc.video_width, + height=self._session_desc.video_height, + ) + self.register_model_loop( + BreathingBackgroundModelLoop, + state=(self._session_desc, self._device), + ) + + +class FilePickerApplication(IApplication): + """Create ImGui UI file-picker sessions.""" + + def init(self, commandline_args: Sequence[str]) -> None: + """Reject application-specific arguments.""" + if commandline_args: + raise ValueError("The file-picker demo takes no application arguments.") + + def session_desc(self) -> SessionDesc: + """Return the demo's established dimensions and rates.""" + return SessionDesc(video_width=640, video_height=480) + + def create_session(self, session_desc: SessionDesc) -> ISession: + """Create one uninitialized file-picker session.""" + return FilePickerSession(session_desc) + + +def create_app() -> IApplication: + """Return a new file-picker application.""" + return FilePickerApplication() diff --git a/integrations_v2/imgui_ui_demo/imgui_ui_demo/tests/test_imgui_ui_demo.py b/integrations_v2/imgui_ui_demo/imgui_ui_demo/tests/test_imgui_ui_demo.py index bc31faadf..ec0b2f5c1 100644 --- a/integrations_v2/imgui_ui_demo/imgui_ui_demo/tests/test_imgui_ui_demo.py +++ b/integrations_v2/imgui_ui_demo/imgui_ui_demo/tests/test_imgui_ui_demo.py @@ -9,10 +9,29 @@ from unittest.mock import Mock import pytest +from imgui_ui_demo.file_picker_app import ( + _FILE_SELECTION_ERROR_COLOR, + _FILE_SELECTION_STATUS_TEXT, + _OPEN_FILE_A_LABEL, + _OPEN_FILE_A_REQUEST_ID, + _OPEN_FILE_B_LABEL, + _OPEN_FILE_B_REQUEST_ID, + _OPEN_FILES_LABEL, + _OPEN_FILES_REQUEST_ID, + FilePickerImGuiUILoop, + FilePickerState, +) from imgui_ui_demo.text_input_app import TextInputImGuiUILoop, TextInputState +from numpy import uint64 from flashdreams.runtime_v2.presentation_manager import PresentationManager +from flashdreams.runtime_v2.selected_file import ( + MAX_SELECTED_FILE_BYTES, + SelectedFile, + SelectedFilesStatus, +) from flashdreams.runtime_v2.session_desc import SessionDesc +from flashdreams.runtime_v2.user_input_event import SelectedFilesUserInputEvent from flashdreams.runtime_v2.user_input_events import UserInputEvents pytestmark = pytest.mark.ci_cpu @@ -47,3 +66,184 @@ def test_text_input_updates_ui_owned_state() -> None: assert state.text == "hello world" imgui.text.assert_called_with("Value: hello world") imgui.end.assert_called_once_with() + + +def _file_picker_loop() -> tuple[FilePickerState, FilePickerImGuiUILoop]: + state = FilePickerState() + loop = FilePickerImGuiUILoop(renderer=Mock()) + loop.register_session_loop_objects( + state=state, + frequency=60, + shutdown_event=threading.Event(), + failure_queue=queue.Queue(), + ) + loop.register_session_ui_loop_objects( + session_desc=SessionDesc(), + presentation_manager=PresentationManager(), + ) + return state, loop + + +def _file_picker_imgui(*, pressed: str | tuple[str, ...] = ()) -> SimpleNamespace: + labels = (pressed,) if isinstance(pressed, str) else pressed + return SimpleNamespace( + ImVec2=lambda x, y: (x, y), + ImVec4=lambda x, y, z, w: (x, y, z, w), + Col_=SimpleNamespace(text="text"), + Cond_=SimpleNamespace(once="once"), + set_next_window_pos=Mock(), + set_next_window_size=Mock(), + begin=Mock(), + end=Mock(), + text=Mock(), + text_wrapped=Mock(), + push_style_color=Mock(), + pop_style_color=Mock(), + button=Mock(side_effect=lambda label: label in labels), + ) + + +@pytest.mark.parametrize( + ("label", "request_id"), + [ + (_OPEN_FILE_A_LABEL, _OPEN_FILE_A_REQUEST_ID), + (_OPEN_FILE_B_LABEL, _OPEN_FILE_B_REQUEST_ID), + ], +) +def test_open_file_button_requests_a_client_file(label: str, request_id: str) -> None: + _state, loop = _file_picker_loop() + imgui = _file_picker_imgui(pressed=label) + + loop.step_ui(imgui, 0, UserInputEvents([])) + requests = loop.flush_ui_loop_requests() + + assert requests is not None + [selection] = requests.file_selections + assert selection.request_id == request_id + assert selection.accept == (".bin", ".raw") + assert selection.max_bytes == MAX_SELECTED_FILE_BYTES + assert selection.multiple is False + + +def test_open_files_button_requests_multiple_client_files() -> None: + _state, loop = _file_picker_loop() + imgui = _file_picker_imgui(pressed=_OPEN_FILES_LABEL) + + loop.step_ui(imgui, 0, UserInputEvents([])) + requests = loop.flush_ui_loop_requests() + + assert requests is not None + [selection] = requests.file_selections + assert selection.request_id == _OPEN_FILES_REQUEST_ID + assert selection.accept == (".bin", ".raw") + assert selection.max_bytes == MAX_SELECTED_FILE_BYTES + assert selection.multiple is True + + +def test_selected_files_event_updates_file_picker_status() -> None: + state, loop = _file_picker_loop() + imgui = _file_picker_imgui() + + loop.user_events = UserInputEvents( + [ + SelectedFilesUserInputEvent( + timestamp=uint64(0), + request_id=_OPEN_FILE_A_REQUEST_ID, + status=SelectedFilesStatus.OK, + files=(SelectedFile(name="seed.png", data=b"xx"),), + ) + ] + ) + loop.step_ui(imgui, 0, loop.user_events) + + assert state.statuses == {_OPEN_FILE_A_REQUEST_ID: "seed.png (2 bytes)"} + assert [call.args[0] for call in imgui.text.call_args_list] == [ + f"{_OPEN_FILE_A_LABEL}: seed.png (2 bytes)", + f"{_OPEN_FILE_B_LABEL}: No file selected.", + f"{_OPEN_FILES_LABEL}: No file selected.", + ] + imgui.text_wrapped.assert_not_called() + imgui.push_style_color.assert_not_called() + + +def test_each_button_shows_only_its_own_result() -> None: + state, loop = _file_picker_loop() + loop.user_events = UserInputEvents( + [ + SelectedFilesUserInputEvent( + timestamp=uint64(0), + request_id=_OPEN_FILE_B_REQUEST_ID, + status=SelectedFilesStatus.OK, + files=(SelectedFile(name="b.bin", data=b"b"),), + ), + SelectedFilesUserInputEvent( + timestamp=uint64(0), + request_id=_OPEN_FILES_REQUEST_ID, + status=SelectedFilesStatus.UNAVAILABLE, + ), + ] + ) + loop.step_ui(_file_picker_imgui(), 0, loop.user_events) + + assert state.statuses == { + _OPEN_FILE_B_REQUEST_ID: "b.bin (1 bytes)", + _OPEN_FILES_REQUEST_ID: _FILE_SELECTION_STATUS_TEXT[ + SelectedFilesStatus.UNAVAILABLE + ], + } + + +def test_selected_files_event_lists_every_chosen_file() -> None: + state, loop = _file_picker_loop() + imgui = _file_picker_imgui() + + loop.user_events = UserInputEvents( + [ + SelectedFilesUserInputEvent( + timestamp=uint64(0), + request_id=_OPEN_FILES_REQUEST_ID, + status=SelectedFilesStatus.OK, + files=( + SelectedFile(name="a.bin", data=b"aa"), + SelectedFile(name="b.raw", data=b"bbb"), + ), + ) + ] + ) + loop.step_ui(imgui, 0, loop.user_events) + + assert state.statuses == { + _OPEN_FILES_REQUEST_ID: "a.bin (2 bytes), b.raw (3 bytes)" + } + imgui.text.assert_called_with( + f"{_OPEN_FILES_LABEL}: a.bin (2 bytes), b.raw (3 bytes)" + ) + imgui.text_wrapped.assert_not_called() + imgui.push_style_color.assert_not_called() + + +def test_file_picker_shows_error_status_in_red() -> None: + assert set(_FILE_SELECTION_STATUS_TEXT) == { + status for status in SelectedFilesStatus if status is not SelectedFilesStatus.OK + } + state, loop = _file_picker_loop() + imgui = _file_picker_imgui() + cancelled = SelectedFilesStatus.CANCELLED + + loop.user_events = UserInputEvents( + [ + SelectedFilesUserInputEvent( + timestamp=uint64(0), + request_id=_OPEN_FILE_A_REQUEST_ID, + status=cancelled, + ) + ] + ) + loop.step_ui(imgui, 0, loop.user_events) + + message = _FILE_SELECTION_STATUS_TEXT[cancelled] + assert state.statuses == {_OPEN_FILE_A_REQUEST_ID: message} + imgui.push_style_color.assert_called_once_with("text", _FILE_SELECTION_ERROR_COLOR) + imgui.text_wrapped.assert_called_once_with(f"{_OPEN_FILE_A_LABEL}: {message}") + imgui.pop_style_color.assert_called_once_with() + assert imgui.text.call_count == 2 diff --git a/integrations_v2/imgui_ui_demo/pyproject.toml b/integrations_v2/imgui_ui_demo/pyproject.toml index 5b1371922..3c1a73e25 100644 --- a/integrations_v2/imgui_ui_demo/pyproject.toml +++ b/integrations_v2/imgui_ui_demo/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ ] [project.entry-points."flashdreams.applications_v2"] +"imgui-ui-file-picker" = "imgui_ui_demo.file_picker_app:create_app" "imgui-ui-query-string" = "imgui_ui_demo.query_string_app:create_app" "imgui-ui-text-input" = "imgui_ui_demo.text_input_app:create_app" "imgui-ui-window-size" = "imgui_ui_demo.window_size_app:create_app" diff --git a/integrations_v2/red_screen/red_screen/tests/test_red_screen.py b/integrations_v2/red_screen/red_screen/tests/test_red_screen.py index 4dafd5952..382546640 100644 --- a/integrations_v2/red_screen/red_screen/tests/test_red_screen.py +++ b/integrations_v2/red_screen/red_screen/tests/test_red_screen.py @@ -12,6 +12,7 @@ from flashdreams.api_v2.client_window import IClientWindow from flashdreams.api_v2.session import ISession +from flashdreams.api_v2.user_input_event import UserInputEvent from flashdreams.runtime_v2.session_desc import ( BackpressureMode, PresentationMode, @@ -62,6 +63,9 @@ def get_user_input_events(self) -> UserInputEvents: return self._batches.pop(0) return UserInputEvents([]) + def _put_user_input_event(self, event: UserInputEvent) -> None: + del event + def open(self, session_desc: SessionDesc) -> None: self.session_desc = session_desc self._is_open = True diff --git a/tests/test_step_agreement.py b/tests/test_step_agreement.py index ba94c3b23..62ee09cff 100644 --- a/tests/test_step_agreement.py +++ b/tests/test_step_agreement.py @@ -164,6 +164,9 @@ def _main_thread(self): assert threading.get_ident() == self.session.main_thread assert self.session.ctx.is_main + def _put_user_input_event(self, event): + del event + def open(self, desc): self._main_thread()