From 14563b39cd1cf2d25f206c5979ac197d215cf76e Mon Sep 17 00:00:00 2001 From: Aryan Date: Sun, 16 Aug 2026 11:07:10 +0530 Subject: [PATCH 1/2] fix: guard client localStorage in restricted contexts Trackpad, settings, theme init, and connection status read/write localStorage without handling SecurityError when storage is blocked. Centralize get/set behind safe helpers and fall back to defaults. --- src/contexts/ConnectionProvider.tsx | 3 +- src/routes/__root.tsx | 4 +-- src/routes/settings.tsx | 54 +++++++++++++---------------- src/routes/trackpad.tsx | 12 +++---- src/utils/safeLocalStorage.test.ts | 41 ++++++++++++++++++++++ src/utils/safeLocalStorage.ts | 24 +++++++++++++ 6 files changed, 99 insertions(+), 39 deletions(-) create mode 100644 src/utils/safeLocalStorage.test.ts create mode 100644 src/utils/safeLocalStorage.ts diff --git a/src/contexts/ConnectionProvider.tsx b/src/contexts/ConnectionProvider.tsx index 7dba01d..00095fd 100644 --- a/src/contexts/ConnectionProvider.tsx +++ b/src/contexts/ConnectionProvider.tsx @@ -9,6 +9,7 @@ import { useCallback, useEffect, } from "react" +import { getLocalStorageItem } from "../utils/safeLocalStorage" type ConnectionStatus = "connecting" | "connected" | "disconnected" @@ -105,7 +106,7 @@ export function ConnectionProvider({ const token = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("token") || - localStorage.getItem("rein_auth_token") + getLocalStorageItem("rein_auth_token") : null fetch("/api/debug/report-latency", { method: "POST", diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 4b8d4a9..f61c3c5 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -7,6 +7,7 @@ import { } from "@tanstack/react-router" import { useEffect } from "react" import { APP_CONFIG, THEMES } from "../config" +import { getLocalStorageItem } from "../utils/safeLocalStorage" import "../styles.css" import { ConnectionProvider, @@ -46,8 +47,7 @@ function RootComponent() { function ThemeInit() { useEffect(() => { - if (typeof localStorage === "undefined") return - const saved = localStorage.getItem(APP_CONFIG.THEME_STORAGE_KEY) + const saved = getLocalStorageItem(APP_CONFIG.THEME_STORAGE_KEY) const theme = saved === THEMES.LIGHT || saved === THEMES.DARK ? saved : THEMES.DEFAULT document.documentElement.setAttribute("data-theme", theme) diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx index f00e3da..0fb6088 100644 --- a/src/routes/settings.tsx +++ b/src/routes/settings.tsx @@ -5,6 +5,10 @@ import { APP_CONFIG, THEMES } from "../config" import serverConfig from "../server-config.json" import pkg from "../../package.json" import { t } from "../utils/i18n" +import { + getLocalStorageItem, + setLocalStorageItem, +} from "../utils/safeLocalStorage" export const Route = createFileRoute("/settings")({ component: SettingsPage, }) @@ -46,43 +50,34 @@ function SettingsPage() { useEffect(() => { setFrontendPort(window.location.port) }, []) - // Client Side Settings (LocalStorage) + // Client Side Settings (LocalStorage) — safe when storage is blocked const [initialSensitivity, initialInvert] = (() => { - try { - const savedSensitivity = localStorage.getItem("rein_sensitivity") - const parsed = savedSensitivity - ? Number.parseFloat(savedSensitivity) - : Number.NaN - return [ - Number.isFinite(parsed) ? parsed : 1.0, - localStorage.getItem("rein_invert") === "true", - ] as const - } catch { - return [1.0, false] as const - } + const savedSensitivity = getLocalStorageItem("rein_sensitivity") + const parsed = savedSensitivity + ? Number.parseFloat(savedSensitivity) + : Number.NaN + return [ + Number.isFinite(parsed) ? parsed : 1.0, + getLocalStorageItem("rein_invert") === "true", + ] as const })() const sensitivity = useRef(initialSensitivity) const invertScroll = useRef(initialInvert) const [theme, setTheme] = useState(() => { - if (typeof window === "undefined") return THEMES.DEFAULT - try { - const saved = localStorage.getItem(APP_CONFIG.THEME_STORAGE_KEY) - return saved === THEMES.LIGHT || saved === THEMES.DARK - ? saved - : THEMES.DEFAULT - } catch { - return THEMES.DEFAULT - } + const saved = getLocalStorageItem(APP_CONFIG.THEME_STORAGE_KEY) + return saved === THEMES.LIGHT || saved === THEMES.DARK + ? saved + : THEMES.DEFAULT }) const [qrData, setQrData] = useState("") const setConfig = (sensitivity_val: number, invertedScroll_val: boolean) => { sensitivity.current = sensitivity_val invertScroll.current = invertedScroll_val - localStorage.setItem("rein_sensitivity", String(sensitivity_val)) - localStorage.setItem("rein_invert", JSON.stringify(invertedScroll_val)) + setLocalStorageItem("rein_sensitivity", String(sensitivity_val)) + setLocalStorageItem("rein_invert", JSON.stringify(invertedScroll_val)) const timer = setTimeout(() => { fetch("/api/config", { method: "POST", @@ -107,10 +102,9 @@ function SettingsPage() { } // Load initial state (IP is not stored in localStorage; only sensitivity, invert, theme are client settings) - const [authToken, setAuthToken] = useState(() => { - if (typeof window === "undefined") return "" - return localStorage.getItem("rein_auth_token") || "" - }) + const [authToken, setAuthToken] = useState( + () => getLocalStorageItem("rein_auth_token") || "", + ) // Derive URLs once at the top const protocol = @@ -142,7 +136,7 @@ function SettingsPage() { .then((data) => { if (isMounted && data.token) { setAuthToken(data.token) - localStorage.setItem("rein_auth_token", data.token) + setLocalStorageItem("rein_auth_token", data.token) } }) .catch((e) => console.error("Token fetch error:", e)) @@ -155,7 +149,7 @@ function SettingsPage() { // Effect: Theme useEffect(() => { if (typeof window === "undefined") return - localStorage.setItem(APP_CONFIG.THEME_STORAGE_KEY, theme) + setLocalStorageItem(APP_CONFIG.THEME_STORAGE_KEY, theme) document.documentElement.setAttribute("data-theme", theme) }, [theme]) diff --git a/src/routes/trackpad.tsx b/src/routes/trackpad.tsx index 86665ce..245d639 100644 --- a/src/routes/trackpad.tsx +++ b/src/routes/trackpad.tsx @@ -10,6 +10,10 @@ import { useTrackpadGesture } from "../hooks/useTrackpadGesture" import { ScreenMirror } from "../components/Trackpad/ScreenMirror" import { ErrorComponent } from "../components/Trackpad/ErrorComponent" import { useWebRtcStream } from "../hooks/useWebRtcStream" +import { + getLocalStorageItem, + setLocalStorageItem, +} from "../utils/safeLocalStorage" export const Route = createFileRoute("/trackpad")({ component: TrackpadPage, @@ -22,16 +26,12 @@ function TrackpadPage() { // Scan standard URL parameter fields to locate token strings passed from settings QR codes const urlToken = searchParams.get("token") - const token = - urlToken || - (typeof window !== "undefined" - ? localStorage.getItem("rein_auth_token") - : null) + const token = urlToken || getLocalStorageItem("rein_auth_token") // Save token internally if extracted directly from the URL scan pass useEffect(() => { if (urlToken) { - localStorage.setItem("rein_auth_token", urlToken) + setLocalStorageItem("rein_auth_token", urlToken) } }, [urlToken]) const [scrollMode, setScrollMode] = useState(false) diff --git a/src/utils/safeLocalStorage.test.ts b/src/utils/safeLocalStorage.test.ts new file mode 100644 index 0000000..c478af2 --- /dev/null +++ b/src/utils/safeLocalStorage.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { getLocalStorageItem, setLocalStorageItem } from "./safeLocalStorage" + +describe("safeLocalStorage", () => { + const store = new Map() + const mockStorage = { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => { + store.set(k, v) + }, + } + + beforeEach(() => { + store.clear() + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: mockStorage, + }) + }) + + it("reads and writes when storage works", () => { + expect(setLocalStorageItem("k", "v")).toBe(true) + expect(getLocalStorageItem("k")).toBe("v") + }) + + it("returns null/false when getItem/setItem throw", () => { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: () => { + throw new Error("blocked") + }, + setItem: () => { + throw new Error("blocked") + }, + }, + }) + expect(getLocalStorageItem("k")).toBeNull() + expect(setLocalStorageItem("k", "v")).toBe(false) + }) +}) diff --git a/src/utils/safeLocalStorage.ts b/src/utils/safeLocalStorage.ts new file mode 100644 index 0000000..2039bb5 --- /dev/null +++ b/src/utils/safeLocalStorage.ts @@ -0,0 +1,24 @@ +/** + * Safe localStorage accessors for restricted contexts (private browsing, + * embedded webviews, blocked storage). Never throw to the UI. + */ + +export function getLocalStorageItem(key: string): string | null { + try { + if (typeof localStorage === "undefined") return null + return localStorage.getItem(key) + } catch { + return null + } +} + +/** @returns false when storage is unavailable or the write failed */ +export function setLocalStorageItem(key: string, value: string): boolean { + try { + if (typeof localStorage === "undefined") return false + localStorage.setItem(key, value) + return true + } catch { + return false + } +} From a7c2580ee6b55308c5848a0be9afddb71152f0fe Mon Sep 17 00:00:00 2001 From: Aryan Date: Sun, 16 Aug 2026 11:12:12 +0530 Subject: [PATCH 2/2] test: cover unavailable localStorage fallback Assert helpers return null/false when localStorage is undefined, matching restricted environments where the Storage API is missing. --- src/utils/safeLocalStorage.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/utils/safeLocalStorage.test.ts b/src/utils/safeLocalStorage.test.ts index c478af2..4dcf024 100644 --- a/src/utils/safeLocalStorage.test.ts +++ b/src/utils/safeLocalStorage.test.ts @@ -38,4 +38,13 @@ describe("safeLocalStorage", () => { expect(getLocalStorageItem("k")).toBeNull() expect(setLocalStorageItem("k", "v")).toBe(false) }) + + it("returns null/false when localStorage is unavailable", () => { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: undefined, + }) + expect(getLocalStorageItem("k")).toBeNull() + expect(setLocalStorageItem("k", "v")).toBe(false) + }) })