mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
c8369bd14b
sessionStorage outlives a logout inside the same tab, so the keys stay readable after the next user signs in. They are namespaced by user id, which stops the new user's plugins from reading them, but the data is still sitting in the tab — the same reason logout already drops the appearance snapshot, the SW caches and the user-scoped offline DB. Purge them there too. Three things around it, from reading the handlers next to the new ones: - The inactive-plugin purge ran before the store was updated, so a sessionStorage failure cost us the plugin list we had just fetched. Commit first, purge after. - The session handlers answered without checking requestId, unlike confirm and geolocation — an answer the frame can never match is worth skipping. - The mock host threw plain Errors while the real bridge rejects with .code set, and checked the key before the trip scope where the host does the reverse, so a plugin's error handling could pass its tests and still break in the app.
106 lines
4.4 KiB
TypeScript
106 lines
4.4 KiB
TypeScript
import { create } from 'zustand'
|
|
import { pluginsApi } from '../api/client'
|
|
|
|
const PLUGIN_SESSION_NAMESPACE = 'trek:plugin-session:'
|
|
|
|
/**
|
|
* Purges state for plugins absent from a successful active-plugin response.
|
|
* A failed request never calls this, because plugin status is then unknown.
|
|
*/
|
|
function clearInactivePluginSessions(activePluginIds: Set<string>) {
|
|
const keysToRemove: string[] = []
|
|
for (let i = 0; i < sessionStorage.length; i += 1) {
|
|
const storageKey = sessionStorage.key(i)
|
|
if (!storageKey?.startsWith(PLUGIN_SESSION_NAMESPACE)) continue
|
|
const encodedPluginId = storageKey.slice(PLUGIN_SESSION_NAMESPACE.length).split(':')[1]
|
|
if (encodedPluginId === undefined) continue
|
|
try {
|
|
if (!activePluginIds.has(decodeURIComponent(encodedPluginId))) keysToRemove.push(storageKey)
|
|
} catch {
|
|
// Ignore malformed keys outside the host-owned format.
|
|
}
|
|
}
|
|
keysToRemove.forEach((storageKey) => sessionStorage.removeItem(storageKey))
|
|
}
|
|
|
|
/**
|
|
* Drops every plugin's session state, whatever user or trip it belonged to.
|
|
* Logout calls this so the next user on a shared browser starts clean — the
|
|
* same reason the appearance snapshot and the user-scoped offline DB go.
|
|
*/
|
|
export function clearAllPluginSessions() {
|
|
const keysToRemove: string[] = []
|
|
for (let i = 0; i < sessionStorage.length; i += 1) {
|
|
const storageKey = sessionStorage.key(i)
|
|
if (storageKey?.startsWith(PLUGIN_SESSION_NAMESPACE)) keysToRemove.push(storageKey)
|
|
}
|
|
keysToRemove.forEach((storageKey) => sessionStorage.removeItem(storageKey))
|
|
}
|
|
|
|
/**
|
|
* Active plugins the client renders (#plugins, M3). Page plugins become nav
|
|
* entries + a full-page iframe route; widget plugins mount on the dashboard.
|
|
* Cloned from addonStore — plugins have their own feed and lifecycle, so they
|
|
* don't overload the addon store.
|
|
*/
|
|
export interface ActivePlugin {
|
|
id: string
|
|
name: string
|
|
type: 'integration' | 'page' | 'widget' | 'trip-page'
|
|
icon: string | null
|
|
slot?: 'sidebar' | 'hero' | 'place-detail' | 'day-detail' | 'reservation-detail'
|
|
/** How a trip-page plugin sits in the planner tab bar: which core tabs it
|
|
* replaces while active ('plan' never — enforced server-side) and its
|
|
* preferred 0-based tab index. */
|
|
tripPage?: { replaces?: string[]; position?: number }
|
|
/** The plugin ships a settings.html the user-settings page frames. */
|
|
settingsUi?: true
|
|
/** Routing profiles the planner's route toggle offers (routeProvider hook;
|
|
* the server only sends these when the hook permission is granted). */
|
|
routeProfiles?: Array<{ id: string; label: string; icon?: string }>
|
|
/** The plugin holds the geolocation:read grant — its frames may ask the host
|
|
* for the browser position over the bridge. */
|
|
geolocation?: true
|
|
}
|
|
|
|
interface PluginState {
|
|
plugins: ActivePlugin[]
|
|
loaded: boolean
|
|
loadPlugins: () => Promise<void>
|
|
getById: (id: string) => ActivePlugin | undefined
|
|
pages: () => ActivePlugin[]
|
|
widgets: () => ActivePlugin[]
|
|
heroWidgets: () => ActivePlugin[]
|
|
tripPages: () => ActivePlugin[]
|
|
placeDetailWidgets: () => ActivePlugin[]
|
|
routeProviders: () => ActivePlugin[]
|
|
}
|
|
|
|
export const usePluginStore = create<PluginState>((set, get) => ({
|
|
plugins: [],
|
|
loaded: false,
|
|
|
|
loadPlugins: async () => {
|
|
try {
|
|
const data = await pluginsApi.active()
|
|
const plugins = (data.plugins as ActivePlugin[]) || []
|
|
set({ plugins, loaded: true })
|
|
// After the state is committed: a sessionStorage failure (Safari private
|
|
// mode, quota) must not cost us the plugin list we just fetched.
|
|
try {
|
|
clearInactivePluginSessions(new Set(plugins.map((plugin) => plugin.id)))
|
|
} catch { /* leaving stale plugin state behind beats losing the nav entries */ }
|
|
} catch {
|
|
set({ loaded: true })
|
|
}
|
|
},
|
|
|
|
getById: (id) => get().plugins.find((p) => p.id === id),
|
|
pages: () => get().plugins.filter((p) => p.type === 'page'),
|
|
widgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot !== 'hero' && p.slot !== 'place-detail' && p.slot !== 'day-detail' && p.slot !== 'reservation-detail'),
|
|
heroWidgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot === 'hero'),
|
|
tripPages: () => get().plugins.filter((p) => p.type === 'trip-page'),
|
|
placeDetailWidgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot === 'place-detail'),
|
|
routeProviders: () => get().plugins.filter((p) => !!p.routeProfiles?.length),
|
|
}))
|