Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/source/api/handles/client_handles.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ Client Handles
:undoc-members:
:inherited-members:

.. autoclass:: viser.LocalStorageHandle
:members:
:undoc-members:
:inherited-members:

.. autoclass:: viser.NotificationHandle
:members:
:undoc-members:
Expand Down
1 change: 1 addition & 0 deletions src/viser/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions src/viser/_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
104 changes: 104 additions & 0 deletions src/viser/_viser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__).
Expand Down Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions src/viser/client/src/MessageHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<THREE.IUniform, number>();

// 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);
Expand Down Expand Up @@ -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.
Expand Down
50 changes: 49 additions & 1 deletion src/viser/client/src/WebsocketMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2238,7 +2281,12 @@ export type Message =
| RegisterCommandMessage
| CommandUpdateMessage
| RemoveCommandMessage
| CommandTriggerMessage;
| CommandTriggerMessage
| LocalStorageSetItemMessage
| LocalStorageRemoveItemMessage
| LocalStorageClearMessage
| LocalStorageGetItemRequestMessage
| LocalStorageGetItemResponseMessage;
export type SceneNodeMessage =
| CameraFrustumMessage
| GlbMessage
Expand Down
60 changes: 60 additions & 0 deletions tests/e2e/test_local_storage.py
Original file line number Diff line number Diff line change
@@ -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"
)
Loading