-
- {
- await containerRef.current?.requestFullscreen();
- }}
- onMouseDown={Events.preventFocus}
- size="xs"
- variant="text"
- >
-
-
-
+ {hasFullscreen && (
+
+ {
+ await containerRef.current?.requestFullscreen();
+ }}
+ onMouseDown={Events.preventFocus}
+ size="xs"
+ variant="text"
+ >
+
+
+
+ )}
{(isOverflowing || isExpanded) && !forceExpand && (
): JSX.Element => {
const el = React.useRef(null);
const [isFullscreen, setIsFullscreen] = React.useState(false);
+ const { hasFullscreen } = useIframeCapabilities();
useEventListener(document, "fullscreenchange", () => {
if (document.fullscreenElement) {
@@ -103,28 +105,30 @@ const SlidesComponent = ({
);
})}
- {
- if (!el.current) {
- return;
- }
- const domEl = el.current as unknown as HTMLElement;
+ {hasFullscreen && (
+ {
+ if (!el.current) {
+ return;
+ }
+ const domEl = el.current as unknown as HTMLElement;
- if (document.fullscreenElement) {
- await document.exitFullscreen();
- setIsFullscreen(false);
- } else {
- await domEl.requestFullscreen();
- setIsFullscreen(true);
- }
- }}
- className="absolute bottom-0 right-0 z-10 mx-1 mb-0"
- >
- {isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
-
+ if (document.fullscreenElement) {
+ await document.exitFullscreen();
+ setIsFullscreen(false);
+ } else {
+ await domEl.requestFullscreen();
+ setIsFullscreen(true);
+ }
+ }}
+ className="absolute bottom-0 right-0 z-10 mx-1 mb-0"
+ >
+ {isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
+
+ )}
);
};
diff --git a/frontend/src/hooks/useIframeCapabilities.ts b/frontend/src/hooks/useIframeCapabilities.ts
new file mode 100644
index 00000000000..d5a0618e46d
--- /dev/null
+++ b/frontend/src/hooks/useIframeCapabilities.ts
@@ -0,0 +1,14 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+
+import { useMemo } from "react";
+import {
+ getIframeCapabilities,
+ type IframeCapabilities,
+} from "@/utils/capabilities";
+
+/**
+ * React hook to access iframe capabilities
+ */
+export function useIframeCapabilities(): IframeCapabilities {
+ return useMemo(() => getIframeCapabilities(), []);
+}
diff --git a/frontend/src/plugins/core/sanitize.ts b/frontend/src/plugins/core/sanitize.ts
index 4a589ab0730..e7c12a0c4dd 100644
--- a/frontend/src/plugins/core/sanitize.ts
+++ b/frontend/src/plugins/core/sanitize.ts
@@ -1,3 +1,4 @@
+/* Copyright 2024 Marimo. All rights reserved. */
import DOMPurify, { type Config } from "dompurify";
import { atom, useAtomValue } from "jotai";
import { hasRunAnyCellAtom } from "@/components/editor/cell/useRunCells";
diff --git a/frontend/src/utils/__tests__/capabilities.test.ts b/frontend/src/utils/__tests__/capabilities.test.ts
new file mode 100644
index 00000000000..1d44007aa49
--- /dev/null
+++ b/frontend/src/utils/__tests__/capabilities.test.ts
@@ -0,0 +1,453 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+
+import { beforeEach, describe, expect, it, type Mock, vi } from "vitest";
+import type { IframeCapabilities } from "../capabilities";
+
+describe("capabilities", () => {
+ let Logger: { log: Mock; warn: Mock };
+ let getIframeCapabilities: () => IframeCapabilities;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ vi.resetModules();
+ vi.unstubAllGlobals();
+
+ // Mock Logger before importing capabilities
+ vi.doMock("../Logger", () => ({
+ Logger: {
+ log: vi.fn(),
+ warn: vi.fn(),
+ },
+ }));
+
+ // Re-import the modules after mocking
+ const loggerModule = await import("../Logger");
+ Logger = loggerModule.Logger as unknown as { log: Mock; warn: Mock };
+
+ const capabilitiesModule = await import("../capabilities");
+ getIframeCapabilities = capabilitiesModule.getIframeCapabilities;
+ });
+
+ describe("testStorage", () => {
+ it("should detect available localStorage", async () => {
+ const mockStorage: Partial = {
+ setItem: vi.fn(),
+ getItem: vi.fn((key) => (key === "__storage_test__" ? "test" : null)),
+ removeItem: vi.fn(),
+ };
+
+ vi.stubGlobal("localStorage", mockStorage);
+
+ const capabilities = getIframeCapabilities();
+
+ expect(capabilities.hasLocalStorage).toBe(true);
+ expect(mockStorage.setItem).toHaveBeenCalledWith(
+ "__storage_test__",
+ "test",
+ );
+ expect(mockStorage.getItem).toHaveBeenCalledWith("__storage_test__");
+ expect(mockStorage.removeItem).toHaveBeenCalledWith("__storage_test__");
+ });
+
+ it("should detect unavailable localStorage when getItem returns wrong value", async () => {
+ const mockStorage: Partial = {
+ setItem: vi.fn(),
+ getItem: vi.fn(() => "wrong-value"),
+ removeItem: vi.fn(),
+ };
+
+ vi.stubGlobal("localStorage", mockStorage);
+
+ const capabilities = getIframeCapabilities();
+
+ expect(capabilities.hasLocalStorage).toBe(false);
+ });
+
+ it("should detect available sessionStorage", async () => {
+ const mockStorage: Partial = {
+ setItem: vi.fn(),
+ getItem: vi.fn((key) => (key === "__storage_test__" ? "test" : null)),
+ removeItem: vi.fn(),
+ };
+
+ vi.stubGlobal("sessionStorage", mockStorage);
+
+ const capabilities = getIframeCapabilities();
+
+ expect(capabilities.hasSessionStorage).toBe(true);
+ });
+ });
+
+ describe("testDownloadCapability", () => {
+ it("should detect download capability when anchor supports download attribute", async () => {
+ const mockAnchor = {
+ download: "",
+ };
+
+ const mockDocument = {
+ ...document,
+ createElement: vi.fn(() => mockAnchor as unknown as HTMLElement),
+ fullscreenEnabled: true,
+ };
+
+ vi.stubGlobal("document", mockDocument);
+
+ const capabilities = getIframeCapabilities();
+
+ expect(capabilities.hasDownloads).toBe(true);
+ expect(mockDocument.createElement).toHaveBeenCalledWith("a");
+ });
+
+ it("should detect no download capability when anchor doesn't support download", async () => {
+ const mockAnchor = {};
+
+ const mockDocument = {
+ ...document,
+ createElement: vi.fn(() => mockAnchor as unknown as HTMLElement),
+ fullscreenEnabled: false,
+ };
+
+ vi.stubGlobal("document", mockDocument);
+
+ const capabilities = getIframeCapabilities();
+
+ expect(capabilities.hasDownloads).toBe(false);
+ });
+ });
+
+ describe("detectIframeCapabilities", () => {
+ it("should detect not embedded when window.parent === window", async () => {
+ // In test environment, window.parent === window by default
+ const capabilities = getIframeCapabilities();
+
+ expect(capabilities.isEmbedded).toBe(false);
+ expect(Logger.log).not.toHaveBeenCalled();
+ });
+
+ it("should detect embedded when window.parent !== window", async () => {
+ const mockWindow = {
+ ...window,
+ parent: {} as Window,
+ };
+
+ vi.stubGlobal("window", mockWindow);
+
+ const capabilities = getIframeCapabilities();
+
+ expect(capabilities.isEmbedded).toBe(true);
+ expect(Logger.log).toHaveBeenCalledWith(
+ "[iframe] Running in embedded context",
+ );
+ });
+
+ it("should detect clipboard availability", async () => {
+ vi.stubGlobal("navigator", {
+ ...navigator,
+ clipboard: {},
+ });
+
+ const capabilities = getIframeCapabilities();
+ expect(capabilities.hasClipboard).toBe(true);
+ });
+
+ it("should detect no clipboard when undefined", async () => {
+ vi.stubGlobal("navigator", {
+ ...navigator,
+ clipboard: undefined,
+ });
+
+ const capabilities = getIframeCapabilities();
+ expect(capabilities.hasClipboard).toBe(false);
+ });
+
+ it("should detect fullscreen availability", async () => {
+ vi.stubGlobal("document", {
+ ...document,
+ fullscreenEnabled: true,
+ createElement: vi.fn(
+ () => ({ download: "" }) as unknown as HTMLElement,
+ ),
+ });
+
+ const capabilities = getIframeCapabilities();
+ expect(capabilities.hasFullscreen).toBe(true);
+ });
+
+ it("should detect no fullscreen when disabled", async () => {
+ vi.stubGlobal("document", {
+ ...document,
+ fullscreenEnabled: false,
+ createElement: vi.fn(
+ () => ({ download: "" }) as unknown as HTMLElement,
+ ),
+ });
+
+ const capabilities = getIframeCapabilities();
+ expect(capabilities.hasFullscreen).toBe(false);
+ });
+
+ it("should detect media devices availability", async () => {
+ vi.stubGlobal("navigator", {
+ ...navigator,
+ mediaDevices: { getUserMedia: vi.fn() },
+ });
+
+ const capabilities = getIframeCapabilities();
+ expect(capabilities.hasMediaDevices).toBe(true);
+ });
+
+ it("should detect no media devices when undefined", async () => {
+ vi.stubGlobal("navigator", {
+ ...navigator,
+ mediaDevices: undefined,
+ });
+
+ const capabilities = getIframeCapabilities();
+ expect(capabilities.hasMediaDevices).toBe(false);
+ });
+
+ it("should detect no media devices when getUserMedia is not a function", async () => {
+ vi.stubGlobal("navigator", {
+ ...navigator,
+ mediaDevices: {},
+ });
+
+ const capabilities = getIframeCapabilities();
+ expect(capabilities.hasMediaDevices).toBe(false);
+ });
+
+ it("should log warnings for missing capabilities when embedded", async () => {
+ const mockWindow = {
+ ...window,
+ parent: {} as Window,
+ };
+
+ const mockStorage: Partial = {
+ setItem: vi.fn(() => {
+ throw new Error("blocked");
+ }),
+ getItem: vi.fn(),
+ removeItem: vi.fn(),
+ };
+
+ vi.stubGlobal("window", mockWindow);
+ vi.stubGlobal("localStorage", mockStorage);
+ vi.stubGlobal("sessionStorage", mockStorage);
+ vi.stubGlobal("navigator", {
+ ...navigator,
+ clipboard: undefined,
+ mediaDevices: undefined,
+ });
+ vi.stubGlobal("document", {
+ ...document,
+ fullscreenEnabled: false,
+ createElement: vi.fn(() => {
+ throw new Error("blocked");
+ }),
+ });
+
+ const capabilities = getIframeCapabilities();
+
+ expect(capabilities.isEmbedded).toBe(true);
+ expect(Logger.warn).toHaveBeenCalledWith(
+ "[iframe] localStorage unavailable - using fallback storage",
+ );
+ expect(Logger.warn).toHaveBeenCalledWith(
+ "[iframe] Clipboard API unavailable",
+ );
+ expect(Logger.warn).toHaveBeenCalledWith(
+ "[iframe] Download capability may be restricted",
+ );
+ expect(Logger.warn).toHaveBeenCalledWith(
+ "[iframe] Fullscreen API unavailable",
+ );
+ expect(Logger.warn).toHaveBeenCalledWith(
+ "[iframe] Media devices API unavailable",
+ );
+ });
+
+ it("should not log warnings when not embedded even if capabilities missing", async () => {
+ const mockStorage: Partial = {
+ setItem: vi.fn(() => {
+ throw new Error("blocked");
+ }),
+ getItem: vi.fn(),
+ removeItem: vi.fn(),
+ };
+
+ vi.stubGlobal("localStorage", mockStorage);
+ vi.stubGlobal("navigator", {
+ ...navigator,
+ clipboard: undefined,
+ });
+
+ const capabilities = getIframeCapabilities();
+
+ expect(capabilities.isEmbedded).toBe(false);
+ expect(Logger.warn).not.toHaveBeenCalled();
+ });
+
+ it("should return all capabilities as expected structure", async () => {
+ const capabilities = getIframeCapabilities();
+
+ expect(capabilities).toMatchObject({
+ isEmbedded: expect.any(Boolean),
+ hasLocalStorage: expect.any(Boolean),
+ hasSessionStorage: expect.any(Boolean),
+ hasClipboard: expect.any(Boolean),
+ hasDownloads: expect.any(Boolean),
+ hasFullscreen: expect.any(Boolean),
+ hasMediaDevices: expect.any(Boolean),
+ });
+ });
+ });
+
+ describe("getIframeCapabilities caching", () => {
+ it("should cache capabilities after first call", async () => {
+ const mockDocument = {
+ ...document,
+ createElement: vi.fn(
+ () => ({ download: "" }) as unknown as HTMLElement,
+ ),
+ fullscreenEnabled: true,
+ };
+
+ vi.stubGlobal("document", mockDocument);
+
+ const capabilities1 = getIframeCapabilities();
+ const capabilities2 = getIframeCapabilities();
+
+ expect(capabilities1).toBe(capabilities2);
+ // Should only call detection functions once due to once wrapper
+ expect(mockDocument.createElement).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("complete capability detection scenarios", () => {
+ it("should handle fully sandboxed iframe", async () => {
+ const mockWindow = {
+ ...window,
+ parent: {} as Window,
+ };
+
+ const mockStorage: Partial = {
+ setItem: vi.fn(() => {
+ throw new Error("blocked");
+ }),
+ getItem: vi.fn(),
+ removeItem: vi.fn(),
+ };
+
+ vi.stubGlobal("window", mockWindow);
+ vi.stubGlobal("localStorage", mockStorage);
+ vi.stubGlobal("sessionStorage", mockStorage);
+ vi.stubGlobal("navigator", {
+ ...navigator,
+ clipboard: undefined,
+ mediaDevices: undefined,
+ });
+ vi.stubGlobal("document", {
+ ...document,
+ fullscreenEnabled: false,
+ createElement: vi.fn(() => {
+ throw new Error("blocked");
+ }),
+ });
+
+ const capabilities = getIframeCapabilities();
+
+ expect(capabilities).toMatchObject({
+ isEmbedded: true,
+ hasLocalStorage: false,
+ hasSessionStorage: false,
+ hasClipboard: false,
+ hasDownloads: false,
+ hasFullscreen: false,
+ hasMediaDevices: false,
+ });
+ });
+
+ it("should handle fully capable standalone context", async () => {
+ const mockStorage: Partial = {
+ setItem: vi.fn(),
+ getItem: vi.fn((key) => (key === "__storage_test__" ? "test" : null)),
+ removeItem: vi.fn(),
+ };
+
+ vi.stubGlobal("localStorage", mockStorage);
+ vi.stubGlobal("sessionStorage", mockStorage);
+ vi.stubGlobal("navigator", {
+ ...navigator,
+ clipboard: {},
+ mediaDevices: { getUserMedia: vi.fn() },
+ });
+ vi.stubGlobal("document", {
+ ...document,
+ fullscreenEnabled: true,
+ createElement: vi.fn(
+ () => ({ download: "" }) as unknown as HTMLElement,
+ ),
+ });
+
+ const capabilities = getIframeCapabilities();
+
+ expect(capabilities).toMatchObject({
+ isEmbedded: false,
+ hasLocalStorage: true,
+ hasSessionStorage: true,
+ hasClipboard: true,
+ hasDownloads: true,
+ hasFullscreen: true,
+ hasMediaDevices: true,
+ });
+ });
+ });
+
+ describe("edge cases", () => {
+ it("should handle localStorage that returns null on getItem", async () => {
+ const mockStorage: Partial = {
+ setItem: vi.fn(),
+ getItem: vi.fn(() => null),
+ removeItem: vi.fn(),
+ };
+
+ vi.stubGlobal("localStorage", mockStorage);
+
+ const capabilities = getIframeCapabilities();
+
+ expect(capabilities.hasLocalStorage).toBe(false);
+ });
+
+ it("should handle document.createElement returning null", async () => {
+ const mockDocument = {
+ ...document,
+ createElement: vi.fn(() => null as unknown as HTMLElement),
+ fullscreenEnabled: false,
+ };
+
+ vi.stubGlobal("document", mockDocument);
+
+ const capabilities = getIframeCapabilities();
+
+ // Should handle gracefully
+ expect(capabilities.hasDownloads).toBe(false);
+ });
+
+ it("should handle storage.removeItem throwing", async () => {
+ const mockStorage: Partial = {
+ setItem: vi.fn(),
+ getItem: vi.fn((key) => (key === "__storage_test__" ? "test" : null)),
+ removeItem: vi.fn(() => {
+ throw new Error("Cannot remove");
+ }),
+ };
+
+ vi.stubGlobal("localStorage", mockStorage);
+
+ // Should not throw, should handle error gracefully
+ const capabilities = getIframeCapabilities();
+
+ expect(capabilities.hasLocalStorage).toBe(false);
+ });
+ });
+});
diff --git a/frontend/src/utils/capabilities.ts b/frontend/src/utils/capabilities.ts
new file mode 100644
index 00000000000..c1607653bfc
--- /dev/null
+++ b/frontend/src/utils/capabilities.ts
@@ -0,0 +1,114 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+
+import { Logger } from "./Logger";
+import { once } from "./once";
+
+/**
+ * Capabilities that may be restricted in sandboxed iframes
+ */
+export interface IframeCapabilities {
+ /** Whether the app is running inside an iframe */
+ isEmbedded: boolean;
+ /** Whether localStorage is available */
+ hasLocalStorage: boolean;
+ /** Whether sessionStorage is available */
+ hasSessionStorage: boolean;
+ /** Whether the Clipboard API is available */
+ hasClipboard: boolean;
+ /** Whether downloads are likely to work */
+ hasDownloads: boolean;
+ /** Whether fullscreen API is available */
+ hasFullscreen: boolean;
+ /** Whether media devices (microphone/camera) may be accessible */
+ hasMediaDevices: boolean;
+}
+
+/**
+ * Test if a specific storage type is available and working
+ */
+function testStorage(type: "localStorage" | "sessionStorage"): boolean {
+ try {
+ const storage: Storage = window[type];
+ const testKey = "__storage_test__";
+ const testValue = "test";
+
+ if (!storage) {
+ return false;
+ }
+
+ storage.setItem(testKey, testValue);
+ const retrieved = storage.getItem(testKey);
+ storage.removeItem(testKey);
+
+ return retrieved === testValue;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Test if downloads are likely to work
+ * This is a heuristic check - actual downloads may still fail
+ */
+function testDownloadCapability(): boolean {
+ try {
+ // Check if we can create anchor elements with download attribute
+ const a = document.createElement("a");
+ return "download" in a;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Detect all iframe capabilities at once
+ * This should be called once at startup and cached
+ */
+function detectIframeCapabilities(): IframeCapabilities {
+ const isEmbedded = window.parent !== window;
+
+ const capabilities: IframeCapabilities = {
+ isEmbedded,
+ hasLocalStorage: testStorage("localStorage"),
+ hasSessionStorage: testStorage("sessionStorage"),
+ hasClipboard: navigator.clipboard !== undefined,
+ hasDownloads: testDownloadCapability(),
+ hasFullscreen:
+ document.fullscreenEnabled !== undefined && document.fullscreenEnabled,
+ hasMediaDevices:
+ navigator.mediaDevices !== undefined &&
+ typeof navigator.mediaDevices.getUserMedia === "function",
+ };
+
+ // Log warnings for missing capabilities when embedded
+ if (isEmbedded) {
+ Logger.log("[iframe] Running in embedded context");
+
+ if (!capabilities.hasLocalStorage) {
+ Logger.warn("[iframe] localStorage unavailable - using fallback storage");
+ }
+
+ if (!capabilities.hasClipboard) {
+ Logger.warn("[iframe] Clipboard API unavailable");
+ }
+
+ if (!capabilities.hasDownloads) {
+ Logger.warn("[iframe] Download capability may be restricted");
+ }
+
+ if (!capabilities.hasFullscreen) {
+ Logger.warn("[iframe] Fullscreen API unavailable");
+ }
+
+ if (!capabilities.hasMediaDevices) {
+ Logger.warn("[iframe] Media devices API unavailable");
+ }
+ }
+
+ return capabilities;
+}
+
+/**
+ * Get the current iframe capabilities (cached after first call)
+ */
+export const getIframeCapabilities = once(() => detectIframeCapabilities());
diff --git a/frontend/src/utils/storage/storage.ts b/frontend/src/utils/storage/storage.ts
index 2d687d9bf20..f0182f0debe 100644
--- a/frontend/src/utils/storage/storage.ts
+++ b/frontend/src/utils/storage/storage.ts
@@ -1,12 +1,12 @@
/* Copyright 2024 Marimo. All rights reserved. */
-import { Logger } from "../Logger";
+import { getIframeCapabilities } from "../capabilities";
/**
* In-memory storage implementation of the Storage interface.
*/
class InMemoryStorage implements Storage {
- private store: Map = new Map();
+ private store = new Map();
get length(): number {
return this.store.size;
@@ -21,7 +21,7 @@ class InMemoryStorage implements Storage {
}
key(index: number): string | null {
- const keys = Array.from(this.store.keys());
+ const keys = [...this.store.keys()];
return keys[index] || null;
}
@@ -34,48 +34,20 @@ class InMemoryStorage implements Storage {
}
}
-/**
- * Tests if a specific storage type is available and working
- */
-function isStorageAvailable(type: "localStorage" | "sessionStorage"): boolean {
- try {
- const storage: Storage = window[type];
- const testKey = "__storage_test__";
- const testValue = "test";
-
- // Check if storage exists
- if (!storage) {
- return false;
- }
-
- // Try to use the storage
- storage.setItem(testKey, testValue);
- const retrieved = storage.getItem(testKey);
- storage.removeItem(testKey);
-
- return retrieved === testValue;
- } catch (error) {
- // Storage might be disabled or full
- return false;
- }
-}
-
/**
* Gets the best available storage. Should only be called once.
*/
function getAvailableStorage(): Storage {
- if (isStorageAvailable("localStorage")) {
+ const { hasLocalStorage, hasSessionStorage } = getIframeCapabilities();
+
+ if (hasLocalStorage) {
return window.localStorage;
}
- Logger.warn("localStorage is not available, using sessionStorage");
-
- if (isStorageAvailable("sessionStorage")) {
+ if (hasSessionStorage) {
return window.sessionStorage;
}
- Logger.warn("sessionStorage is not available, using in-memory storage");
-
return new InMemoryStorage();
}