diff --git a/docs/source/api/handles/client_handles.rst b/docs/source/api/handles/client_handles.rst index 7393b3c3e..20ef166da 100644 --- a/docs/source/api/handles/client_handles.rst +++ b/docs/source/api/handles/client_handles.rst @@ -6,6 +6,11 @@ Client Handles :undoc-members: :inherited-members: +.. autoclass:: viser.LocalStorageHandle + :members: + :undoc-members: + :inherited-members: + .. autoclass:: viser.NotificationHandle :members: :undoc-members: diff --git a/src/viser/__init__.py b/src/viser/__init__.py index 3f1cff0af..9f75b915c 100644 --- a/src/viser/__init__.py +++ b/src/viser/__init__.py @@ -74,6 +74,7 @@ from ._viser import CameraHandle as CameraHandle from ._viser import ClientHandle as ClientHandle from ._viser import InitialCameraConfig as InitialCameraConfig +from ._viser import LocalStorageHandle as LocalStorageHandle from ._viser import ViserServer as ViserServer # Legacy alias for ``ScenePointerEvent``: importable at runtime but diff --git a/src/viser/_messages.py b/src/viser/_messages.py index bbf5e06c6..955e88d05 100644 --- a/src/viser/_messages.py +++ b/src/viser/_messages.py @@ -2594,3 +2594,52 @@ class CommandTriggerMessage(Message, include_in_scene_serialization=False): """Message from client->server when a command is triggered from the command palette.""" uuid: str + + +@dataclasses.dataclass +class LocalStorageSetItemMessage(Message, include_in_scene_serialization=False): + """Set a key in the client's localStorage.""" + + key: str + value: str + + @override + def redundancy_key(self) -> str: + return "LocalStorageItem-" + self.key + + +@dataclasses.dataclass +class LocalStorageRemoveItemMessage(Message, include_in_scene_serialization=False): + """Remove a key from the client's localStorage.""" + + key: str + + @override + def redundancy_key(self) -> str: + return "LocalStorageItem-" + self.key + + +@dataclasses.dataclass +class LocalStorageClearMessage(Message, include_in_scene_serialization=False): + """Clear all viser-written keys from the client's localStorage.""" + + +@dataclasses.dataclass +class LocalStorageGetItemRequestMessage(Message, include_in_scene_serialization=False): + """Message from server->client requesting a value from localStorage.""" + + key: str + request_uuid: str + + @override + def redundancy_key(self) -> str: + return type(self).__name__ + "-" + self.request_uuid + + +@dataclasses.dataclass +class LocalStorageGetItemResponseMessage(Message, include_in_scene_serialization=False): + """Message from client->server carrying the requested localStorage value.""" + + value: Optional[str] + error: Optional[str] + request_uuid: str diff --git a/src/viser/_viser.py b/src/viser/_viser.py index ecc3ad4fd..59a18e1ff 100644 --- a/src/viser/_viser.py +++ b/src/viser/_viser.py @@ -555,6 +555,108 @@ def get_render( NoneOrCoroutine = TypeVar("NoneOrCoroutine", None, Coroutine) +class LocalStorageHandle: + """A handle for reading and writing this client's browser localStorage. + + Keys are namespaced in the browser under a viser-specific prefix, so + values written here can't collide with — and :meth:`clear` can't wipe — + localStorage state that other applications keep on the same origin. + The prefix is an implementation detail: keys passed to these methods + should be the bare, unprefixed names.""" + + def __init__(self, client: ClientHandle) -> None: + self._client = client + + def set_item(self, key: str, value: str) -> None: + """Set a key.""" + self._client._websock_connection.queue_message( + _messages.LocalStorageSetItemMessage(key=key, value=value) + ) + + def remove_item(self, key: str) -> None: + """Remove a key.""" + self._client._websock_connection.queue_message( + _messages.LocalStorageRemoveItemMessage(key=key) + ) + + def clear(self) -> None: + """Clear all keys that were written through this API. Other + localStorage state on the client's origin is left untouched.""" + self._client._websock_connection.queue_message( + _messages.LocalStorageClearMessage() + ) + + def get_item(self, key: str, timeout: float | None = None) -> str | None: + """Return a value, or ``None`` if the key is absent. + + Failure semantics match :meth:`ClientHandle.get_render`. + + Args: + key: Key to read. + timeout: Optional maximum seconds to wait for the value. ``None`` + (default) waits indefinitely; a disconnect still raises + promptly either way. Set this to bound a client that stays + connected but never returns a response (raises + ``TimeoutError``). + + Raises: + RuntimeError: If the client disconnects before responding, or if + the browser blocks localStorage access. + TimeoutError: If ``timeout`` is set and exceeded. + """ + request_uuid = _make_uuid() + response: dict[str, str | None] = {"value": None, "error": None} + ready_event = threading.Event() + + def got_response( + client_id: int, message: _messages.LocalStorageGetItemResponseMessage + ) -> None: + del client_id + if message.request_uuid != request_uuid: + return + response["value"] = message.value + response["error"] = message.error + ready_event.set() + + self._client._websock_connection.register_handler( + _messages.LocalStorageGetItemResponseMessage, got_response + ) + try: + self._client._websock_connection.queue_message( + _messages.LocalStorageGetItemRequestMessage( + key=key, request_uuid=request_uuid + ) + ) + self._client.flush() + # Poll rather than wait unbounded: a client that DISCONNECTS (tab + # closed, network drop) never sends a response, so this raises as + # soon as it leaves _connected_clients instead of hanging the + # caller (same rationale as get_render()). + deadline = None if timeout is None else time.time() + timeout + while not ready_event.wait(timeout=0.1): + if ( + self._client.client_id + not in self._client._viser_server._connected_clients + ): + raise RuntimeError( + "localStorage request failed: the client disconnected " + "before returning a response." + ) + if deadline is not None and time.time() > deadline: + raise TimeoutError( + f"localStorage request timed out after {timeout}s: " + "the client did not return a response." + ) + finally: + self._client._websock_connection.unregister_handler( + _messages.LocalStorageGetItemResponseMessage, got_response + ) + + if response["error"] is not None: + raise RuntimeError(f"Failed to read localStorage: {response['error']}") + return response["value"] + + # Don't inherit from RenamedAttributeCompatShim during type checking, because # this will unnecessarily suppress type errors. (from the overriding of # __getattr__). @@ -624,6 +726,8 @@ def __init__( """Handle for interacting with the GUI.""" self.camera: CameraHandle = CameraHandle(self) """Handle for reading from and manipulating the client's viewport camera.""" + self.local_storage: LocalStorageHandle = LocalStorageHandle(self) + """Handle for reading and writing the client's browser localStorage.""" def flush(self) -> None: """Flush the outgoing message buffer. Any buffered messages will immediately be diff --git a/src/viser/client/src/MessageHandler.tsx b/src/viser/client/src/MessageHandler.tsx index f457e2bbe..5f1e04652 100644 --- a/src/viser/client/src/MessageHandler.tsx +++ b/src/viser/client/src/MessageHandler.tsx @@ -42,6 +42,12 @@ function swapBackgroundTexture( // Every new load and every synchronous clear bumps the uniform's token; an // async callback installs its result only if the token is still current. const backgroundTextureSeq = new WeakMap(); + +// Namespace for keys written via the server-side localStorage API. Keys are +// prefixed on write/read and clear() only touches prefixed keys, so a viser +// server can't read or clobber unrelated data on a shared browser origin. +const LOCAL_STORAGE_PREFIX = "viser-user:"; + function bumpBackgroundTextureSeq(uniform: THREE.IUniform): number { const next = (backgroundTextureSeq.get(uniform) ?? 0) + 1; backgroundTextureSeq.set(uniform, next); @@ -336,6 +342,61 @@ function useMessageHandler() { return; } + // Set a key in localStorage. + case "LocalStorageSetItemMessage": { + try { + localStorage.setItem( + LOCAL_STORAGE_PREFIX + message.key, + message.value, + ); + } catch (error) { + console.error("Failed to set localStorage item:", error); + } + return; + } + // Remove a key from localStorage. + case "LocalStorageRemoveItemMessage": { + try { + localStorage.removeItem(LOCAL_STORAGE_PREFIX + message.key); + } catch (error) { + console.error("Failed to remove localStorage item:", error); + } + return; + } + // Clear all keys written through the viser localStorage API. + case "LocalStorageClearMessage": { + try { + // Deliberately scoped to our prefix: the page's origin may be + // shared with other applications (e.g. statically-hosted clients), + // whose keys a viser server should never be able to wipe. + for (const key of Object.keys(localStorage)) { + if (key.startsWith(LOCAL_STORAGE_PREFIX)) { + localStorage.removeItem(key); + } + } + } catch (error) { + console.error("Failed to clear localStorage:", error); + } + return; + } + // Request the value of a key from localStorage. + case "LocalStorageGetItemRequestMessage": { + let value: string | null = null; + let error: string | null = null; + try { + value = localStorage.getItem(LOCAL_STORAGE_PREFIX + message.key); + } catch (caught) { + error = caught instanceof Error ? caught.message : String(caught); + } + viewerMutable.sendMessage({ + type: "LocalStorageGetItemResponseMessage", + value, + error, + request_uuid: message.request_uuid, + }); + return; + } + // Run some arbitrary Javascript. // This is used for plotting, where the Python server will send over a // copy of plotly.min.js for the currently-installed version of plotly. diff --git a/src/viser/client/src/WebsocketMessages.ts b/src/viser/client/src/WebsocketMessages.ts index a7303ec73..dab4e04b3 100644 --- a/src/viser/client/src/WebsocketMessages.ts +++ b/src/viser/client/src/WebsocketMessages.ts @@ -2123,6 +2123,49 @@ export interface CommandTriggerMessage { type: "CommandTriggerMessage"; uuid: string; } +/** Set a key in the client's localStorage. + * + * (automatically generated) + */ +export interface LocalStorageSetItemMessage { + type: "LocalStorageSetItemMessage"; + key: string; + value: string; +} +/** Remove a key from the client's localStorage. + * + * (automatically generated) + */ +export interface LocalStorageRemoveItemMessage { + type: "LocalStorageRemoveItemMessage"; + key: string; +} +/** Clear all viser-written keys from the client's localStorage. + * + * (automatically generated) + */ +export interface LocalStorageClearMessage { + type: "LocalStorageClearMessage"; +} +/** Message from server->client requesting a value from localStorage. + * + * (automatically generated) + */ +export interface LocalStorageGetItemRequestMessage { + type: "LocalStorageGetItemRequestMessage"; + key: string; + request_uuid: string; +} +/** Message from client->server carrying the requested localStorage value. + * + * (automatically generated) + */ +export interface LocalStorageGetItemResponseMessage { + type: "LocalStorageGetItemResponseMessage"; + value: string | null; + error: string | null; + request_uuid: string; +} export type Message = | CameraFrustumMessage @@ -2238,7 +2281,12 @@ export type Message = | RegisterCommandMessage | CommandUpdateMessage | RemoveCommandMessage - | CommandTriggerMessage; + | CommandTriggerMessage + | LocalStorageSetItemMessage + | LocalStorageRemoveItemMessage + | LocalStorageClearMessage + | LocalStorageGetItemRequestMessage + | LocalStorageGetItemResponseMessage; export type SceneNodeMessage = | CameraFrustumMessage | GlbMessage diff --git a/tests/e2e/test_local_storage.py b/tests/e2e/test_local_storage.py new file mode 100644 index 000000000..1bf28afc7 --- /dev/null +++ b/tests/e2e/test_local_storage.py @@ -0,0 +1,60 @@ +"""E2E tests for per-client browser localStorage access.""" + +import time + +from playwright.sync_api import Page + +import viser + + +def test_local_storage_round_trip( + viser_server: viser.ViserServer, viser_page: Page +) -> None: + """Read, write, remove, and clear values through a real browser client.""" + deadline = time.monotonic() + 5.0 + while not (clients := viser_server.get_clients()): + assert time.monotonic() < deadline, "client never connected" + time.sleep(0.05) + client = next(iter(clients.values())) + key_a = "viser-e2e-local-storage-a" + key_b = "viser-e2e-local-storage-b" + + assert client.local_storage.get_item(key_a) is None + + client.local_storage.set_item(key_a, "value-a") + assert client.local_storage.get_item(key_a) == "value-a" + + client.local_storage.remove_item(key_a) + assert client.local_storage.get_item(key_a) is None + + client.local_storage.set_item(key_a, "value-a") + client.local_storage.set_item(key_b, "value-b") + client.local_storage.clear() + assert client.local_storage.get_item(key_a) is None + assert client.local_storage.get_item(key_b) is None + + +def test_local_storage_scoped_to_viser_prefix( + viser_server: viser.ViserServer, viser_page: Page +) -> None: + """The API must not read or clobber unprefixed keys on the same origin.""" + deadline = time.monotonic() + 5.0 + while not (clients := viser_server.get_clients()): + assert time.monotonic() < deadline, "client never connected" + time.sleep(0.05) + client = next(iter(clients.values())) + foreign_key = "some-other-apps-key" + + # A key written directly by the page (i.e. by another application on the + # same origin) is invisible to the server-side API... + viser_page.evaluate(f"() => localStorage.setItem({foreign_key!r}, 'foreign-value')") + assert client.local_storage.get_item(foreign_key) is None + + # ...and survives a server-side clear(). + client.local_storage.set_item("viser-owned-key", "viser-value") + client.local_storage.clear() + assert client.local_storage.get_item("viser-owned-key") is None + assert ( + viser_page.evaluate(f"() => localStorage.getItem({foreign_key!r})") + == "foreign-value" + )