diff --git a/src/contexts/ConnectionProvider.tsx b/src/contexts/ConnectionProvider.tsx index 6851dfc6..ee3066bd 100644 --- a/src/contexts/ConnectionProvider.tsx +++ b/src/contexts/ConnectionProvider.tsx @@ -9,6 +9,7 @@ import { useState, useCallback, } from "react" +import { safeGetItem, safeSetItem } from "../utils/storage" type ConnectionStatus = "connecting" | "connected" | "disconnected" @@ -63,21 +64,12 @@ export function ConnectionProvider({ const urlParams = new URLSearchParams(window.location.search) const urlToken = urlParams.get("token") - let storedToken: string | null = null - try { - storedToken = localStorage.getItem("rein_auth_token") - } catch (e) { - // Restricted context (e.g. private mode) - } + const storedToken = safeGetItem("rein_auth_token") const token = urlToken || storedToken if (urlToken && urlToken !== storedToken) { - try { - localStorage.setItem("rein_auth_token", urlToken) - } catch (e) { - // Failed to store - } + safeSetItem("rein_auth_token", urlToken) } let wsUrl = `${protocol}//${host}/ws` diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index c9542781..cf87b5c8 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -13,6 +13,7 @@ import { useConnection, } from "../contexts/ConnectionProvider" import { useCaptureProvider } from "../hooks/useCaptureProvider" +import { safeGetItem } from "../utils/storage" export const Route = createRootRoute({ component: AppWithConnection, @@ -67,8 +68,7 @@ function DesktopCaptureProvider() { function ThemeInit() { useEffect(() => { - if (typeof localStorage === "undefined") return - const saved = localStorage.getItem(APP_CONFIG.THEME_STORAGE_KEY) + const saved = safeGetItem(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 a5f760ed..37239c95 100644 --- a/src/routes/settings.tsx +++ b/src/routes/settings.tsx @@ -3,6 +3,12 @@ import QRCode from "qrcode" import { useEffect, useState } from "react" import { APP_CONFIG, THEMES } from "../config" import serverConfig from "../server-config.json" +import { + getStoredBoolean, + getStoredNumber, + safeGetItem, + safeSetItem, +} from "../utils/storage" export const Route = createFileRoute("/settings")({ component: SettingsPage, @@ -19,40 +25,25 @@ function SettingsPage() { // Client Side Settings (LocalStorage) const [invertScroll, setInvertScroll] = useState(() => { - if (typeof window === "undefined") return false - try { - const saved = localStorage.getItem("rein_invert") - return saved === "true" - } catch { - return false - } + return getStoredBoolean("rein_invert", false) }) const [sensitivity, setSensitivity] = useState(() => { - if (typeof window === "undefined") return 1.0 - const saved = localStorage.getItem("rein_sensitivity") - const parsed = saved ? Number.parseFloat(saved) : Number.NaN - return Number.isFinite(parsed) ? parsed : 1.0 + return getStoredNumber("rein_sensitivity", 1.0) }) 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 = safeGetItem(APP_CONFIG.THEME_STORAGE_KEY) + return saved === THEMES.LIGHT || saved === THEMES.DARK + ? saved + : THEMES.DEFAULT }) const [qrData, setQrData] = useState("") // 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") || "" + return safeGetItem("rein_auth_token") || "" }) // Derive URLs once at the top @@ -92,7 +83,7 @@ function SettingsPage() { if (data.type === "token-generated" && data.token) { if (isMounted) { setAuthToken(data.token) - localStorage.setItem("rein_auth_token", data.token) + safeSetItem("rein_auth_token", data.token) } socket.close() } @@ -114,17 +105,17 @@ function SettingsPage() { // Effect: Update LocalStorage when settings change useEffect(() => { - localStorage.setItem("rein_sensitivity", String(sensitivity)) + safeSetItem("rein_sensitivity", String(sensitivity)) }, [sensitivity]) useEffect(() => { - localStorage.setItem("rein_invert", JSON.stringify(invertScroll)) + safeSetItem("rein_invert", JSON.stringify(invertScroll)) }, [invertScroll]) // Effect: Theme useEffect(() => { if (typeof window === "undefined") return - localStorage.setItem(APP_CONFIG.THEME_STORAGE_KEY, theme) + safeSetItem(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 f2f31461..03b65b5e 100644 --- a/src/routes/trackpad.tsx +++ b/src/routes/trackpad.tsx @@ -8,6 +8,7 @@ import { TouchArea } from "../components/Trackpad/TouchArea" import { useRemoteConnection } from "../hooks/useRemoteConnection" import { useTrackpadGesture } from "../hooks/useTrackpadGesture" import { ScreenMirror } from "../components/Trackpad/ScreenMirror" +import { getStoredBoolean, getStoredNumber } from "../utils/storage" export const Route = createFileRoute("/trackpad")({ component: TrackpadPage, @@ -25,15 +26,11 @@ function TrackpadPage() { // Load Client Settings const [sensitivity] = useState(() => { - if (typeof window === "undefined") return 1.0 - const s = localStorage.getItem("rein_sensitivity") - return s ? Number.parseFloat(s) : 1.0 + return getStoredNumber("rein_sensitivity", 1.0) }) const [invertScroll] = useState(() => { - if (typeof window === "undefined") return false - const s = localStorage.getItem("rein_invert") - return s ? JSON.parse(s) : false + return getStoredBoolean("rein_invert", false) }) const { send, sendCombo } = useRemoteConnection() diff --git a/src/utils/storage.test.ts b/src/utils/storage.test.ts new file mode 100644 index 00000000..a8ede8f9 --- /dev/null +++ b/src/utils/storage.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { + getStoredBoolean, + getStoredNumber, + safeGetItem, + safeSetItem, +} from "./storage" + +describe("storage utils", () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it("returns null when localStorage access throws on read", () => { + vi.stubGlobal( + "window", + { + localStorage: { + getItem: () => { + throw new Error("blocked") + }, + }, + } as unknown as Window, + ) + + expect(safeGetItem("rein_auth_token")).toBeNull() + }) + + it("returns false when localStorage access throws on write", () => { + vi.stubGlobal( + "window", + { + localStorage: { + setItem: () => { + throw new Error("blocked") + }, + }, + } as unknown as Window, + ) + + expect(safeSetItem("rein_auth_token", "token")).toBe(false) + }) + + it("falls back when stored number is invalid", () => { + vi.stubGlobal( + "window", + { + localStorage: { + getItem: () => "not-a-number", + }, + } as unknown as Window, + ) + + expect(getStoredNumber("rein_sensitivity", 1)).toBe(1) + }) + + it("falls back when stored boolean is malformed", () => { + vi.stubGlobal( + "window", + { + localStorage: { + getItem: () => "{oops", + }, + } as unknown as Window, + ) + + expect(getStoredBoolean("rein_invert", false)).toBe(false) + }) + + it("supports JSON encoded booleans", () => { + vi.stubGlobal( + "window", + { + localStorage: { + getItem: () => "true", + }, + } as unknown as Window, + ) + + expect(getStoredBoolean("rein_invert", false)).toBe(true) + }) +}) diff --git a/src/utils/storage.ts b/src/utils/storage.ts new file mode 100644 index 00000000..a42163a0 --- /dev/null +++ b/src/utils/storage.ts @@ -0,0 +1,42 @@ +export function safeGetItem(key: string): string | null { + if (typeof window === "undefined") return null + + try { + return window.localStorage.getItem(key) + } catch { + return null + } +} + +export function safeSetItem(key: string, value: string): boolean { + if (typeof window === "undefined") return false + + try { + window.localStorage.setItem(key, value) + return true + } catch { + return false + } +} + +export function getStoredNumber(key: string, fallback: number): number { + const saved = safeGetItem(key) + if (saved === null) return fallback + + const parsed = Number.parseFloat(saved) + return Number.isFinite(parsed) ? parsed : fallback +} + +export function getStoredBoolean(key: string, fallback: boolean): boolean { + const saved = safeGetItem(key) + if (saved === null) return fallback + if (saved === "true") return true + if (saved === "false") return false + + try { + const parsed = JSON.parse(saved) + return typeof parsed === "boolean" ? parsed : fallback + } catch { + return fallback + } +}