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; + } +