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
7 changes: 6 additions & 1 deletion client/src/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import TabBar from './components/TabBar.jsx';
import ScreensaverCountdown from './components/ScreensaverCountdown.jsx';
import UpdateIndicator from './components/UpdateIndicator.jsx';
import { API_BASE_URL } from './utils/apiConfig.js';
import { migrateLegacyPluginStorage } from './utils/pluginLocalStore.js';
import { getDeviceApiBase, getDeviceName } from './utils/deviceName.js';
import { refreshSubscription } from './utils/pushNotifications.js';
import { unlockAudio } from './utils/choreSound.js';
Expand Down Expand Up @@ -326,7 +327,11 @@ const App = () => {
const fetchInstalledPlugins = async () => {
try {
const response = await axios.get(`${API_BASE_URL}/api/widgets`);
setInstalledPlugins(Array.isArray(response.data) ? response.data : []);
const plugins = Array.isArray(response.data) ? response.data : [];
// Before any plugin frame is drawn: carry the plugins' old localStorage
// into their own stores, once per display (utils/pluginLocalStore.js).
migrateLegacyPluginStorage(plugins);
setInstalledPlugins(plugins);
} catch {
setInstalledPlugins([]);
}
Expand Down
6 changes: 5 additions & 1 deletion client/src/components/AdminPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ import { ADMIN_LAYOUT, ADMIN_TABS, normalizeAdminLocation, parseAdminHash, setAd
import SoundPicker from './SoundPicker';
import ControlsOnDisplay from './ControlsOnDisplay';
import ExpandWidgetsSettings from './ExpandWidgetsSettings';
import { frameNameFor } from '../utils/pluginLocalStore.js';
import useFetchTabs from '../hooks/useFetchTabs.js';
import { MAX_DOCK_HIDE_MINUTES, screensaverPreemptsDock } from '../utils/dockIdleHide.js';
import useIsMobile from '../hooks/useIsMobile.js';
Expand Down Expand Up @@ -2539,7 +2540,10 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
// it, so a plugin whose own UI saves one would fail here
// while working fine on the dashboard.
src={`${API_BASE_URL}/widgets/${encodeURIComponent(plugin.filename)}?theme=${document.documentElement.getAttribute('data-theme') || 'light'}&device=${encodeURIComponent(currentDeviceName)}&lang=${i18n.language || 'en'}`}
sandbox="allow-scripts allow-same-origin"
// Opaque, like the dashboard's frames (PluginWidgetWrapper):
// a plugin can't act as HomeGlow from here either.
sandbox="allow-scripts allow-forms"
name={frameNameFor(plugin.filename)}
style={{ width: '100%', height: '100%', border: 0, display: 'block', backgroundColor: 'var(--card-bg)' }}
/>
</Box>
Expand Down
49 changes: 33 additions & 16 deletions client/src/components/PluginWidgetWrapper.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useContext, useEffect, useId, useRef } from 'react';
import React, { useCallback, useContext, useEffect, useId, useMemo, useRef } from 'react';
import { Box } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { API_BASE_URL } from '../utils/apiConfig.js';
Expand All @@ -10,6 +10,16 @@ import { THEME_TOKENS_APPLIED_EVENT, buildPluginThemeMessage, readRoleTokens } f
import { ThemeContext } from '../themes/engine/ThemeContext.js';
import { acceptFrameTouchMessage, buildFrameTouchSettingsMessage, emitFrameTouch } from '../utils/twoFingerSwipe.js';
import { acceptFrameExpandMessage, buildFrameExpandMessage, emitFrameExpand } from '../utils/widgetExpand.js';
import { LOCAL_STORAGE_MESSAGE_TYPE, applyPluginStoreChange, frameNameFor } from '../utils/pluginLocalStore.js';

// Plugin frames are sandboxed without allow-same-origin (mrramam/HomeGlow#50):
// the plugin's page gets an opaque origin, so it can't read this page, its
// storage or its API as HomeGlow, wherever it was served from. Messages from
// it say they come from "null"; messages to it can't name its origin, so they
// go to "*". The source check (this wrapper's own frame) is the real gate.
export const PLUGIN_FRAME_SANDBOX = 'allow-scripts allow-forms allow-popups';
const FRAME_ORIGIN = 'null';
const TO_FRAME = '*';

const PluginWidgetWrapper = ({
filename,
Expand Down Expand Up @@ -44,12 +54,8 @@ const PluginWidgetWrapper = ({
// each gets its own swipe detector. The same plugin can be on the board twice.
const touchSourceKey = useId();

// The iframe's document comes from API_BASE_URL, which is NOT this page's
// origin in dev mode / split-origin deployments — postMessage must target
// the iframe's actual origin or the browser silently drops the message.
const iframeOrigin = API_BASE_URL
? new URL(API_BASE_URL, window.location.href).origin
: window.location.origin;
// The origin messages from the frame carry: opaque, so always "null".
const iframeOrigin = FRAME_ORIGIN;

// Forward core events this plugin declared in its manifest into the iframe
// (issue #105 Phase 3). Legacy widgets declare nothing and skip the stream.
Expand All @@ -65,10 +71,10 @@ const PluginWidgetWrapper = ({
if (!target) return;
target.postMessage(
{ type: 'homeglow:event', event: message.event, payload: message.payload, emittedAt: message.emittedAt },
iframeOrigin
TO_FRAME
);
});
}, [eventsKey, iframeOrigin]);
}, [eventsKey]);

// The reverse direction: a plugin that wrote core data through the REST API
// tells the host, so widgets showing that data can refetch instead of
Expand All @@ -80,6 +86,11 @@ const PluginWidgetWrapper = ({
useEffect(() => {
const onMessage = (event) => {
const frame = iframeRef.current?.contentWindow;
// The plugin's localStorage (its stand-in posts each change here).
if (frame && event.source === frame && event.origin === iframeOrigin && event.data?.type === LOCAL_STORAGE_MESSAGE_TYPE) {
applyPluginStoreChange(filename, event.data);
return;
}
// HomeGlow.celebrate(): the board's celebration, which CelebrationHost
// rate-limits per plugin.
if (acceptPluginCelebrateMessage(event, frame, iframeOrigin)) {
Expand Down Expand Up @@ -124,8 +135,8 @@ const PluginWidgetWrapper = ({
if (!target) return;
const { theme: mode, colors: picked, fonts } = sendRef.current;
const message = buildPluginThemeMessage(mode, picked, { tokens: readRoleTokens(document.documentElement), fonts });
target.postMessage(message, iframeOrigin);
}, [iframeOrigin]);
target.postMessage(message, TO_FRAME);
}, []);
useEffect(() => {
const frame = requestAnimationFrame(postTheme);
return () => cancelAnimationFrame(frame);
Expand All @@ -144,18 +155,18 @@ const PluginWidgetWrapper = ({
if (!target) return;
target.postMessage(
buildFrameTouchSettingsMessage({ swipe: swipeOn, blockPinchZoom: pinchBlocked, expand: expandMode }),
iframeOrigin
TO_FRAME
);
}, [iframeOrigin, swipeOn, pinchBlocked, expandMode]);
}, [swipeOn, pinchBlocked, expandMode]);
useEffect(() => {
postTouch();
}, [postTouch]);
// The plugin's size goes the same way: HomeGlow.onExpand in the SDK.
const postExpand = useCallback(() => {
const target = iframeRef.current?.contentWindow;
if (!target) return;
target.postMessage(buildFrameExpandMessage(expandSize), iframeOrigin);
}, [iframeOrigin, expandSize]);
target.postMessage(buildFrameExpandMessage(expandSize), TO_FRAME);
}, [expandSize]);
useEffect(() => {
postExpand();
}, [postExpand]);
Expand All @@ -170,6 +181,11 @@ const PluginWidgetWrapper = ({
// unchanged hidden set produces a byte-identical src — changing src navigates
// the frame, which is what makes a newly hidden control take effect, and is
// waste when a settings refetch merely handed us an equal array.
// The plugin's saved localStorage on this display, read when the frame is
// made (again on a refresh), for its stand-in to start from.
// eslint-disable-next-line react-hooks/exhaustive-deps
const frameName = useMemo(() => frameNameFor(filename), [filename, refreshNonce]);

const hideParam = (Array.isArray(hiddenControls) ? hiddenControls : [])
.map((id) => encodeURIComponent(id))
.join(',');
Expand All @@ -188,6 +204,7 @@ const PluginWidgetWrapper = ({
// ignore it are unaffected.
src={`${API_BASE_URL}/widgets/${filename}?theme=${theme}&device=${encodeURIComponent(deviceName)}&lang=${i18n.language || 'en'}${hideParam ? `&hide=${hideParam}` : ''}${transparentBackground ? '&transparent=true' : ''}`}
title={name}
name={frameName}
style={{
width: '100%',
height: '100%',
Expand All @@ -200,7 +217,7 @@ const PluginWidgetWrapper = ({
borderRadius: 'max(0px, calc(var(--hg-frame-radius) - var(--hg-frame-inset) - 3px))',
overflow: 'hidden',
}}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
sandbox={PLUGIN_FRAME_SANDBOX}
/>
</Box>
);
Expand Down
146 changes: 146 additions & 0 deletions client/src/utils/pluginLocalStore.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Each plugin's own localStorage, kept by the dashboard (mrramam/HomeGlow#50).
//
// Plugin frames have an opaque origin, where localStorage throws, so the
// script HomeGlow serves every plugin page with (server/utils/pluginFrameShims.js)
// puts a stand-in in its place. This is the other half: the dashboard keeps
// each plugin's keys under its own entry in this display's localStorage, hands
// them to the frame in its `name` when it loads, and saves the changes the
// frame posts back. One plugin never sees another's keys, or the dashboard's.
//
// Plugins used to share the dashboard's own localStorage, so their saved data
// is still there under the keys they used. The first time this version runs
// on a display, it copies the published plugins' keys (LEGACY_PLUGIN_KEYS)
// into their stores, once, for the plugins installed then. A plugin installed
// later gets nothing, so it can't collect what another plugin left behind.
// The old keys stay where they were, for a rollback; no plugin can read them.

export const PLUGIN_STORE_PREFIX = 'homeglow-plugin-local:';
export const PLUGIN_STORE_MIGRATED_KEY = 'homeglow-plugin-local-migrated';
// The same limit the frame's stand-in enforces, in characters.
export const PLUGIN_STORE_MAX_CHARS = 512 * 1024;
export const LOCAL_STORAGE_MESSAGE_TYPE = 'homeglow:local-storage';

// The published plugins' old keys (HomeGlowPlugins), by plugin id. A key the
// plugin saved under is taken if it starts with one of these.
export const LEGACY_PLUGIN_KEYS = Object.freeze({
'bills-reminder': ['billsReminder_'],
'family-bucket-list': ['homeglowBucketList'],
'calendar-agenda': ['homeglow.calendarAgenda.'],
'family-countdown': ['homeglowCountdowns'],
'daily-rotation': ['dailyRotation'],
'emoji-story': ['emojiStory_'],
'family-contacts': ['familyContacts_'],
'grill-card': ['grill-card:'],
'guest-wifi': ['guestWifi_'],
'healthy-habits': ['habitCompletions', 'habitsSelectedUser', 'healthyHabits'],
'family-messages': ['familyMessages', 'messageBoardCurrentUser'],
'rss-reader': ['homeglow.rssReader.'],
'pixel-reveal': ['pixelReveal_'],
'family-polls': ['pollWidget_'],
'routines': ['homeglow.routines.'],
'school-lunch': ['schoolLunch_'],
'family-scoreboard': ['homeglowActivities', 'homeglowScores'],
'ha-vacuum-card': ['homeglow.vacuum.'],
'wheel-of-choice': ['spinWheel_'],
'word-guess': ['wotd_'],
});

const sizeOf = (map) => Object.entries(map).reduce((total, [key, value]) => total + key.length + value.length, 0);

const browserStorage = () => {
try {
return window.localStorage;
} catch {
return null;
}
};

/** A plugin's saved keys on this display: { key: value }, all strings. */
export function readPluginStore(filename, storage = browserStorage()) {
if (!storage || !filename) return {};
try {
const parsed = JSON.parse(storage.getItem(PLUGIN_STORE_PREFIX + filename) || '{}');
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
return Object.fromEntries(Object.entries(parsed).filter(([, value]) => typeof value === 'string'));
} catch {
return {};
}
}

function writePluginStore(filename, map, storage) {
try {
storage.setItem(PLUGIN_STORE_PREFIX + filename, JSON.stringify(map));
return true;
} catch {
return false; // this display's storage is full or blocked: the plugin keeps it in memory
}
}

/** The frame's `name`: the plugin's saved keys, for its stand-in to start from. */
export function frameNameFor(filename, storage = browserStorage()) {
return JSON.stringify({ homeglowLocalStorage: readPluginStore(filename, storage) });
}

/**
* Apply one change a plugin's frame posted ({ op, key?, value? }). The
* caller has already checked it came from that plugin's own frame. Returns
* true when it was applied.
*/
export function applyPluginStoreChange(filename, change, storage = browserStorage()) {
if (!storage || !filename || !change || typeof change !== 'object') return false;
const map = readPluginStore(filename, storage);
if (change.op === 'clear') {
return writePluginStore(filename, {}, storage);
}
if (typeof change.key !== 'string' || change.key.length > 1024) return false;
if (change.op === 'remove') {
if (!Object.hasOwn(map, change.key)) return false;
delete map[change.key];
return writePluginStore(filename, map, storage);
}
if (change.op === 'set' && typeof change.value === 'string') {
map[change.key] = change.value;
if (sizeOf(map) > PLUGIN_STORE_MAX_CHARS) return false;
return writePluginStore(filename, map, storage);
}
return false;
}

/**
* Once per display: copy each installed published plugin's old keys from the
* dashboard's own localStorage into that plugin's store. `plugins` is the
* list from /api/widgets ({ filename, pluginId }). Keys a plugin's store
* already has are kept as they are.
*/
export function migrateLegacyPluginStorage(plugins, storage = browserStorage()) {
if (!storage || !Array.isArray(plugins)) return;
try {
if (storage.getItem(PLUGIN_STORE_MIGRATED_KEY)) return;
} catch {
return;
}
const keys = [];
for (let i = 0; i < storage.length; i += 1) {
const key = storage.key(i);
if (key && !key.startsWith(PLUGIN_STORE_PREFIX)) keys.push(key);
}
for (const plugin of plugins) {
const prefixes = plugin && LEGACY_PLUGIN_KEYS[plugin.pluginId];
if (!prefixes || typeof plugin.filename !== 'string') continue;
const map = readPluginStore(plugin.filename, storage);
let changed = false;
for (const key of keys) {
if (!prefixes.some((prefix) => key.startsWith(prefix)) || Object.hasOwn(map, key)) continue;
const value = storage.getItem(key);
if (typeof value !== 'string') continue;
map[key] = value;
changed = true;
}
if (changed && sizeOf(map) <= PLUGIN_STORE_MAX_CHARS) writePluginStore(plugin.filename, map, storage);
}
try {
storage.setItem(PLUGIN_STORE_MIGRATED_KEY, new Date().toISOString());
} catch {
// Not marked: it runs again next time, and keeps what it already copied.
}
}
Loading
Loading