diff --git a/.nx/version-plans/version-plan-1767776390185.md b/.nx/version-plans/version-plan-1767776390185.md new file mode 100644 index 00000000..19a663a6 --- /dev/null +++ b/.nx/version-plans/version-plan-1767776390185.md @@ -0,0 +1,5 @@ +--- +'@rozenite/plugin-bridge': minor +--- + +Add shared storage API for synchronizing state between DevTools panel and device. Plugins can now use `createRozeniteSharedStorage` and `useRozeniteSharedStorage` to maintain persistent, synchronized state across the host-device boundary with automatic localStorage persistence on the host side. diff --git a/packages/plugin-bridge/src/index.ts b/packages/plugin-bridge/src/index.ts index b4a094d6..895300dd 100644 --- a/packages/plugin-bridge/src/index.ts +++ b/packages/plugin-bridge/src/index.ts @@ -3,4 +3,7 @@ export type { RozeniteDevToolsClient } from './client'; export type { Subscription } from './types'; export type { UseRozeniteDevToolsClientOptions } from './useRozeniteDevToolsClient'; export { getRozeniteDevToolsClient } from './client'; +export { createRozeniteSharedStorage } from './storage/shared-storage'; +export type { RozeniteSharedStorage } from './storage/shared-storage'; +export { useRozeniteSharedStorage } from './storage/useSharedStorage'; export { UnsupportedPlatformError } from './errors'; diff --git a/packages/plugin-bridge/src/storage/shared-storage.ts b/packages/plugin-bridge/src/storage/shared-storage.ts new file mode 100644 index 00000000..a8911daf --- /dev/null +++ b/packages/plugin-bridge/src/storage/shared-storage.ts @@ -0,0 +1,197 @@ +import { RozeniteDevToolsClient } from '../client'; + +const isPanel = + typeof window !== 'undefined' && '__ROZENITE_PANEL__' in window; + +export type RozeniteSharedStorageEventMap = { + 'rozenite-storage-init': { defaults: T }; + 'rozenite-storage-sync': { data: T }; + 'rozenite-storage-update': { key: keyof T; value: T[keyof T] }; +}; + +export type RozeniteSharedStorage = { + connect: ( + client: RozeniteDevToolsClient> + ) => void; + get: (key: K) => T[K]; + set: (key: K, value: T[K]) => void; + getSnapshot: () => T; + subscribe: (listener: (data: T) => void) => () => void; + isSynchronized: () => boolean; +}; + +const createHostStorage = >( + pluginId: string, + defaults: T +): RozeniteSharedStorage => { + let data: T = { ...defaults }; + const listeners = new Set<(data: T) => void>(); + let client: RozeniteDevToolsClient< + RozeniteSharedStorageEventMap + > | null = null; + + const loadFromPersistence = () => { + try { + const stored = localStorage.getItem(`rozenite-storage-${pluginId}`); + if (stored) { + data = { ...defaults, ...JSON.parse(stored) }; + } + } catch (e) { + console.error('Failed to load storage', e); + } + }; + + const saveToPersistence = () => { + try { + localStorage.setItem( + `rozenite-storage-${pluginId}`, + JSON.stringify(data) + ); + } catch (e) { + console.error('Failed to save storage', e); + } + }; + + const notifyListeners = () => { + listeners.forEach((listener) => listener(data)); + }; + + const handleInit = (deviceDefaults: T) => { + let hasData = false; + try { + const stored = localStorage.getItem(`rozenite-storage-${pluginId}`); + if (stored) { + hasData = true; + } + } catch { + // ignore + } + + if (!hasData) { + // Adopt defaults + data = { ...deviceDefaults }; + saveToPersistence(); + } + + // Send Sync + client?.send('rozenite-storage-sync', { data: data }); + }; + + loadFromPersistence(); + + return { + connect: (newClient) => { + client = newClient; + client.onMessage( + 'rozenite-storage-init', + (payload: { defaults: T }) => { + handleInit(payload.defaults); + } + ); + }, + + get: (key: K): T[K] => { + return data[key]; + }, + + getSnapshot: (): T => { + return data; + }, + + set: (key: K, value: T[K]) => { + data[key] = value; + notifyListeners(); + saveToPersistence(); + client?.send('rozenite-storage-update', { key, value }); + }, + + subscribe: (listener: (data: T) => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + + isSynchronized: () => true, + }; +}; + +const createDeviceStorage = >( + defaults: T +): RozeniteSharedStorage => { + let data: T = { ...defaults }; + const listeners = new Set<(data: T) => void>(); + let client: RozeniteDevToolsClient< + RozeniteSharedStorageEventMap + > | null = null; + let synchronized = false; + + const notifyListeners = () => { + listeners.forEach((listener) => listener(data)); + }; + + const handleSync = (newData: T) => { + data = { ...newData }; + synchronized = true; + notifyListeners(); + }; + + const handleUpdate = (key: K, value: T[K]) => { + data[key] = value; + notifyListeners(); + }; + + return { + connect: (newClient) => { + client = newClient; + + client.onMessage('rozenite-storage-sync', (payload: { data: T }) => { + handleSync(payload.data); + }); + + client.onMessage( + 'rozenite-storage-update', + (payload: { key: keyof T; value: T[keyof T] }) => { + handleUpdate(payload.key, payload.value); + } + ); + + // Send Init + client.send('rozenite-storage-init', { defaults }); + }, + + get: (key: K): T[K] => { + return data[key]; + }, + + getSnapshot: (): T => { + return data; + }, + + set: (key: K, value: T[K]) => { + data[key] = value; + notifyListeners(); + // Device logic: only update local, do not sync back to host for now + }, + + subscribe: (listener: (data: T) => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + + isSynchronized: () => synchronized, + }; +}; + +export const createRozeniteSharedStorage = >( + pluginId: string, + defaults: T +): RozeniteSharedStorage => { + if (isPanel) { + return createHostStorage(pluginId, defaults); + } + + return createDeviceStorage(defaults); +}; diff --git a/packages/plugin-bridge/src/storage/useSharedStorage.ts b/packages/plugin-bridge/src/storage/useSharedStorage.ts new file mode 100644 index 00000000..b0134d0c --- /dev/null +++ b/packages/plugin-bridge/src/storage/useSharedStorage.ts @@ -0,0 +1,81 @@ +import { useState, useEffect } from 'react'; +import { RozeniteSharedStorage } from './shared-storage'; + +const useSynchronizedRozeniteSharedStorage = >( + storage: RozeniteSharedStorage +): T | null => { + const [data, setData] = useState(() => { + if (storage.isSynchronized()) { + return storage.getSnapshot(); + } + return null; + }); + + useEffect(() => { + if (storage.isSynchronized()) { + // It's possible it synced between init and effect + setData((prev) => { + // Optimization: don't update if already set (though reference comparison might fail if snapshot is new) + if (prev === null) return storage.getSnapshot(); + return prev; + }); + } + + return storage.subscribe((newData) => { + if (storage.isSynchronized()) { + setData({ ...newData }); + } else { + setData(null); + } + }); + }, [storage]); + + return data; +}; + +const useUnsynchronizedRozeniteSharedStorage = >( + storage: RozeniteSharedStorage +): T => { + const [data, setData] = useState(() => storage.getSnapshot()); + + useEffect(() => { + // In case storage ref changed or just to ensure freshness (though usually storage instance is stable) + setData(storage.getSnapshot()); + + return storage.subscribe((newData) => { + setData({ ...newData }); + }); + }, [storage]); + + return data; +}; + +export type UseRozeniteSharedStorageOptions = { + ensureSynchronized?: TEnsureSynchronized; +}; + +export function useRozeniteSharedStorage>( + storage: RozeniteSharedStorage, + options: UseRozeniteSharedStorageOptions +): T | null; + +export function useRozeniteSharedStorage>( + storage: RozeniteSharedStorage, + options?: UseRozeniteSharedStorageOptions +): T; + +export function useRozeniteSharedStorage>( + storage: RozeniteSharedStorage, + options?: UseRozeniteSharedStorageOptions +): T | null { + // Freeze the option on first render to prevent hook switching at runtime + const [ensureSynchronized] = useState( + () => options?.ensureSynchronized ?? false + ); + + if (ensureSynchronized) { + return useSynchronizedRozeniteSharedStorage(storage); + } + + return useUnsynchronizedRozeniteSharedStorage(storage); +} diff --git a/website/src/docs/plugin-development/_meta.json b/website/src/docs/plugin-development/_meta.json index 2233b01b..1a1bcd9a 100644 --- a/website/src/docs/plugin-development/_meta.json +++ b/website/src/docs/plugin-development/_meta.json @@ -8,5 +8,10 @@ "type": "file", "name": "plugin-development", "label": "Plugin Development Guide" + }, + { + "type": "file", + "name": "shared-storage", + "label": "Shared Storage" } -] \ No newline at end of file +] \ No newline at end of file diff --git a/website/src/docs/plugin-development/overview.md b/website/src/docs/plugin-development/overview.md index db76c9f7..a675c4d7 100644 --- a/website/src/docs/plugin-development/overview.md +++ b/website/src/docs/plugin-development/overview.md @@ -62,6 +62,7 @@ my-plugin/ - **React Native API Access**: Leverage React Native APIs and libraries - **Type Safety**: Full TypeScript support with compile-time checking +- **Shared Storage**: Synchronized key-value store shared between App and DevTools - **Hot Reloading**: See changes instantly during development - **Production Builds**: Optimized builds for distribution diff --git a/website/src/docs/plugin-development/plugin-development.md b/website/src/docs/plugin-development/plugin-development.md index 38b24c79..789c16c8 100644 --- a/website/src/docs/plugin-development/plugin-development.md +++ b/website/src/docs/plugin-development/plugin-development.md @@ -227,7 +227,13 @@ export default function setupPlugin( } ``` -## Step 5: Local Development Workflow +## Step 5: Shared Storage (Optional) + +If your plugin needs to persist settings or share a synchronized state between the app and the DevTools panels, you can use Rozenite's **Shared Storage**. + +Check out the [Shared Storage Guide](./shared-storage.md) to learn how to implement synchronous, persisted state for your plugin. + +## Step 6: Local Development Workflow ### Complete Development Setup diff --git a/website/src/docs/plugin-development/shared-storage.md b/website/src/docs/plugin-development/shared-storage.md new file mode 100644 index 00000000..3bcdf638 --- /dev/null +++ b/website/src/docs/plugin-development/shared-storage.md @@ -0,0 +1,148 @@ +# Shared Storage + +Rozenite provides a built-in mechanism for sharing a synchronized key-value store between your React Native app and the DevTools panels. This is useful for plugin settings, toggles, or any state that needs to be persisted on the developer's machine and immediately available on the device. + +## Key Features + +- **Synchronized Access**: Read values instantly on the device side. +- **Persistence**: Data is owned by the DevTools and persisted in the browser's local storage. +- **Automatic Sync**: Device starts with defaults and switches to saved values as soon as a connection is established. +- **Type Safety**: Full TypeScript support for your storage schema. + +## Basic Usage + +### 1. Define your Storage Schema + +First, define the structure of your storage: + +```typescript +interface MyPluginStorage { + isRecordingEnabled: boolean; + theme: 'light' | 'dark'; + refreshInterval: number; +} + +const defaults: MyPluginStorage = { + isRecordingEnabled: false, + theme: 'light', + refreshInterval: 5000, +}; +``` + +### 2. Create the Storage Instance + +Use `createRozeniteSharedStorage` to create an instance. This should typically be done in a shared file or at the entry point of your plugin. + +```typescript +import { createRozeniteSharedStorage } from '@rozenite/plugin-bridge'; + +export const storage = createRozeniteSharedStorage( + '@my-org/my-plugin', + defaults +); +``` + +### 3. Connect to the Client + +The storage needs to be connected to the `RozeniteDevToolsClient` on both the React Native and DevTools sides. + +#### In React Native + +```typescript title="react-native.ts" +import { useRozeniteDevToolsClient } from '@rozenite/plugin-bridge'; +import { storage } from './storage'; + +export function useMyPlugin() { + const client = useRozeniteDevToolsClient({ pluginId: '@my-org/my-plugin' }); + + useEffect(() => { + if (client) { + storage.connect(client); + } + }, [client]); +} +``` + +#### In DevTools Panel + +```typescript title="src/MyPanel.tsx" +import { useRozeniteDevToolsClient } from '@rozenite/plugin-bridge'; +import { storage } from './storage'; + +export default function MyPanel() { + const client = useRozeniteDevToolsClient({ pluginId: '@my-org/my-plugin' }); + + useEffect(() => { + if (client) { + storage.connect(client); + } + }, [client]); + + // ... +} +``` + +## Using in React Components + +Rozenite provides a `useRozeniteSharedStorage` hook to reactively use the storage values. + +```typescript +import { useRozeniteSharedStorage } from '@rozenite/plugin-bridge'; +import { storage } from './storage'; + +export function MyComponent() { + const data = useRozeniteSharedStorage(storage); + + return ( +
+

Theme: {data.theme}

+ +
+ ); +} +``` + +### Ensuring Synchronization + +Sometimes you don't want to render anything until the storage has been synchronized with the DevTools (to avoid flickering from defaults to saved values). You can use the `ensureSynchronized` option: + +```typescript +const data = useRozeniteSharedStorage(storage, { ensureSynchronized: true }); + +if (!data) { + return

Syncing...

; // data is null until synchronization is complete +} + +return

Theme: {data.theme}

; +``` + +## API Reference + +### `createRozeniteSharedStorage(pluginId, defaults)` + +Creates a new shared storage instance. + +- `pluginId`: A unique identifier for your plugin. +- `defaults`: Initial values to use when no persisted data is available. + +### `storage.get(key)` + +Returns the current synchronized value for a given key. + +### `storage.set(key, value)` + +Updates a value. If called from the DevTools side, it will be persisted and synced to the device. + +### `storage.subscribe(callback)` + +Subscribes to changes in the storage. Returns an unsubscribe function. + +### `useRozeniteSharedStorage(storage, options?)` + +A React hook that returns the current storage state and triggers re-renders on updates. + +| Option | Type | Description | +| --- | --- | --- | +| `ensureSynchronized` | `boolean` | If `true`, the hook returns `null` until the first sync with DevTools. Defaults to `false`. |