diff --git a/webapp/_webapp/package.json b/webapp/_webapp/package.json index 36700900..5630d99b 100644 --- a/webapp/_webapp/package.json +++ b/webapp/_webapp/package.json @@ -6,11 +6,13 @@ "scripts": { "dev": "nodemon --watch src --ext ts,js,tsx,jsx,json --exec 'npm run build'", "dev:chat": "vite dev --config vite.config.dev.ts", - "build": "tsc -b && npm run _build:default && npm run _build:background && npm run _build:intermediate", + "build": "tsc -b && npm run _build:default && npm run _build:background && npm run _build:intermediate && npm run _build:settings && npm run _build:popup", "_build": "vite build", "_build:default": "VITE_CONFIG=default npm run _build", "_build:background": "VITE_CONFIG=background npm run _build", "_build:intermediate": "VITE_CONFIG=intermediate npm run _build", + "_build:settings": "VITE_CONFIG=settings npm run _build", + "_build:popup": "VITE_CONFIG=popup npm run _build", "lint": "eslint .", "format": "prettier --write .", "build:local:chrome": "bash -c 'source ./scripts/build-local-chrome.sh'", diff --git a/webapp/_webapp/public/images/locator.png b/webapp/_webapp/public/images/locator.png new file mode 100644 index 00000000..3fabbdcd Binary files /dev/null and b/webapp/_webapp/public/images/locator.png differ diff --git a/webapp/_webapp/public/popup.html b/webapp/_webapp/public/popup.html index 68a152fe..3ec37553 100644 --- a/webapp/_webapp/public/popup.html +++ b/webapp/_webapp/public/popup.html @@ -3,112 +3,10 @@ - PaperDebugger - - + PaperDebugger Popup - -
PaperDebugger
-
How to use
-
-
- 1.In - overleaf.com, open any of - your projects. -
-
2.PaperDebugger is in the top left of the project page.
-
- + +
+ diff --git a/webapp/_webapp/public/settings.html b/webapp/_webapp/public/settings.html new file mode 100644 index 00000000..d491382c --- /dev/null +++ b/webapp/_webapp/public/settings.html @@ -0,0 +1,12 @@ + + + + + + PaperDebugger Settings + + +
+ + + diff --git a/webapp/_webapp/src/background.ts b/webapp/_webapp/src/background.ts index cda95898..74847df2 100644 --- a/webapp/_webapp/src/background.ts +++ b/webapp/_webapp/src/background.ts @@ -17,6 +17,7 @@ import { getAllCookies } from "./libs/browser"; import { HANDLER_NAMES } from "./shared/constants"; import { blobToBase64 } from "./libs/helpers"; +import { registerContentScripts } from "./libs/permissions"; export type Handler = { name: string; @@ -78,12 +79,50 @@ export const fetchImageHandler: Handler = { }, }; +const registerContentScriptsIfPermitted = async () => { + try { + const { origins = [] } = await chrome.permissions.getAll(); + if (!origins.length) { + console.log("[PaperDebugger] No origins found, skipping content script registration"); + return; + } + await registerContentScripts(origins); + } catch (error) { + console.error("[PaperDebugger] Unable to register content scripts", error); + } +}; + +export const requestHostPermissionHandler: Handler = { + name: HANDLER_NAMES.REQUEST_HOST_PERMISSION, + handler: async (origin, sendResponse) => { + const granted = await chrome.permissions.request({ origins: [origin] }); + if (granted) { + // chrome.permissions.request requires a user gesture context, the requestHostPermissionHandler is in the background script + // and called via async messaging from the settings page. + // Here we must register content scripts because when a message is sent through chrome.runtime.sendMessage, + // the user gesture context is not preserved in the background script handler, + // causing the permission request to fail with "This function must be called during a user gesture." + // The permission request needs to be called directly from the settings page where the user click occurs, + // not delegated to the background script. + // Therefore, we must register content scripts here. + await registerContentScriptsIfPermitted(); + } + sendResponse(granted); + }, +}; + // @ts-expect-error: browser may not be defined in all environments const browserAPI = typeof browser !== "undefined" ? browser : chrome; browserAPI.runtime?.onMessage?.addListener( (request: { action: string; args: unknown }, _: unknown, sendResponse: (response: unknown) => void) => { - const handlers = [getCookiesHandler, getUrlHandler, getOrCreateSessionIdHandler, fetchImageHandler]; + const handlers = [ + getCookiesHandler, + getUrlHandler, + getOrCreateSessionIdHandler, + fetchImageHandler, + requestHostPermissionHandler, + ]; const handler = handlers.find((h) => h.name === request.action) as HandlerAny; if (!handler) { @@ -100,3 +139,5 @@ browserAPI.runtime?.onMessage?.addListener( return true; }, ); + +registerContentScriptsIfPermitted(); diff --git a/webapp/_webapp/src/intermediate.ts b/webapp/_webapp/src/intermediate.ts index 5c3c5afd..70d2b617 100644 --- a/webapp/_webapp/src/intermediate.ts +++ b/webapp/_webapp/src/intermediate.ts @@ -104,3 +104,4 @@ export { getCookies }; export const getUrl = makeFunction(HANDLER_NAMES.GET_URL); export const getOrCreateSessionId = makeFunction(HANDLER_NAMES.GET_OR_CREATE_SESSION_ID); export const fetchImage = makeFunction(HANDLER_NAMES.FETCH_IMAGE); +export const requestHostPermission = makeFunction(HANDLER_NAMES.REQUEST_HOST_PERMISSION); diff --git a/webapp/_webapp/src/libs/manifest.ts b/webapp/_webapp/src/libs/manifest.ts index e472aad8..2965ecd0 100644 --- a/webapp/_webapp/src/libs/manifest.ts +++ b/webapp/_webapp/src/libs/manifest.ts @@ -10,6 +10,9 @@ export function getManifest() { // This is the version on github tag. manifestJSON.version = semver.clean(version || "") || "0.0.0"; + // @ts-expect-error we don't use this variable permissions_explanation + delete manifestJSON.permissions_explanation; + if (betaBuild === "true") { manifestJSON.version_name = `v${manifestJSON.version}-${monorepoRevision}-beta`; manifestJSON.name = "PaperDebugger BETA"; diff --git a/webapp/_webapp/src/libs/permissions.ts b/webapp/_webapp/src/libs/permissions.ts new file mode 100644 index 00000000..fc2d5eeb --- /dev/null +++ b/webapp/_webapp/src/libs/permissions.ts @@ -0,0 +1,37 @@ +// can not running in content_script. registerContentScripts can only be called in service_worker. +export async function registerContentScripts(origins?: string[]) { + try { + const resolvedOrigins = origins ?? (await chrome.permissions.getAll()).origins ?? []; + if (resolvedOrigins.length === 0) { + console.log("[PaperDebugger] No origins found, skipping content script registration"); + return; + } + + const scriptIds = (await chrome.scripting.getRegisteredContentScripts()).map((script) => script.id); + if (scriptIds.length > 0) { + console.log("[PaperDebugger] Unregistering dynamic content scripts", scriptIds); + await chrome.scripting.unregisterContentScripts({ ids: scriptIds }); + } + + await chrome.scripting.registerContentScripts([ + { + id: "content-script-main", + js: ["paperdebugger.js"], + persistAcrossSessions: true, + matches: resolvedOrigins, + world: "MAIN", + }, + { + id: "content-script-intermediate", + js: ["intermediate.js"], + persistAcrossSessions: true, + matches: resolvedOrigins, + runAt: "document_start", + }, + ]); + + console.log("[PaperDebugger] Registration complete", resolvedOrigins); + } catch (error) { + console.error("[PaperDebugger] Failed to register content scripts", error); + } +} diff --git a/webapp/_webapp/src/main.tsx b/webapp/_webapp/src/main.tsx index f8678532..f699ded1 100644 --- a/webapp/_webapp/src/main.tsx +++ b/webapp/_webapp/src/main.tsx @@ -156,6 +156,8 @@ export const Main = () => { ); }; +console.log("[PaperDebugger] PaperDebugger injected, find toolbar-left or ide-redesign-toolbar-menu-bar to add button"); + if (!import.meta.env.DEV) { onElementAppeared(".toolbar-left .toolbar-item, .ide-redesign-toolbar-menu-bar", () => { logInfo("initializing"); diff --git a/webapp/_webapp/src/manifest.json b/webapp/_webapp/src/manifest.json index 7ca2c9d1..4d060a4f 100644 --- a/webapp/_webapp/src/manifest.json +++ b/webapp/_webapp/src/manifest.json @@ -11,29 +11,20 @@ "48": "images/logo-1024.png" }, "host_permissions": ["*://*.overleaf.com/"], - "permissions": ["cookies", "storage"], + "optional_host_permissions": ["*://*/*"], + "permissions_explanation": "The optional_host_permissions pattern '*://*/*' allows the extension to request access to any website. This is necessary to support self-hosted Overleaf instances and similar use cases. Users will be prompted to grant access only when needed. Please review the extension documentation for details on security and privacy implications.", + "permissions": ["cookies", "storage", "scripting", "activeTab"], + "options_page": "settings.html", "action": { "default_popup": "popup.html" }, "background": { "service_worker": "background.js" }, - "content_scripts": [ - { - "js": ["paperdebugger.js"], - "matches": ["https://*.overleaf.com/project/*"], - "world": "MAIN" - }, - { - "js": ["intermediate.js"], - "matches": ["https://*.overleaf.com/project/*"], - "run_at": "document_start" - } - ], "web_accessible_resources": [ { "resources": ["images/*"], - "matches": ["https://*.overleaf.com/*"] + "matches": ["*://*/*"] } ], "key": "[AUTO-GENERATED]" diff --git a/webapp/_webapp/src/shared/constants.ts b/webapp/_webapp/src/shared/constants.ts index 279cda07..37e8ed9c 100644 --- a/webapp/_webapp/src/shared/constants.ts +++ b/webapp/_webapp/src/shared/constants.ts @@ -3,4 +3,5 @@ export const HANDLER_NAMES = { GET_URL: "getUrl", GET_OR_CREATE_SESSION_ID: "getOrCreateSessionId", FETCH_IMAGE: "fetchImage", + REQUEST_HOST_PERMISSION: "requestHostPermission", } as const; diff --git a/webapp/_webapp/src/views/extension-popup/app.css b/webapp/_webapp/src/views/extension-popup/app.css new file mode 100644 index 00000000..cbaf285f --- /dev/null +++ b/webapp/_webapp/src/views/extension-popup/app.css @@ -0,0 +1,105 @@ +.popup-shell { + font-family: + "Inter", + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + color: #212529; + background-color: #ffffff; + padding: 14px 12px; + max-width: 700px; + line-height: 1.2; +} + +.title { + margin: 0 0 6px; + font-size: 24px; + font-weight: 800; +} + +.subtitle { + margin: 0 0 18px; + font-size: 14px; + font-weight: 700; + color: #1f2933; +} + +.steps { + display: flex; + flex-direction: column; + gap: 14px; +} + +.step { + display: flex; + align-items: flex-start; + gap: 12px; + padding-left: 14px; + border-left: 4px solid #e5e7eb; +} + +.step-number { + color: #1f7aec; + font-weight: 800; + font-size: 14px; + min-width: 14px; +} + +.step-text { + margin: 0; + font-size: 14px; + color: #1f2933; +} + +.step-link { + color: #1f7aec; + text-decoration: none; +} + +.step-link:hover { + text-decoration: underline; +} + +.cta-button { + margin: 16px 0; + width: 100%; + padding: 12px 14px; + border: none; + border-radius: 10px; + background-color: #1f7aec; + color: #ffffff; + font-size: 16px; + font-weight: 700; + cursor: pointer; + transition: + background-color 0.15s ease, + transform 0.1s ease; +} + +.cta-button:hover { + background-color: #1664c2; + transform: translateY(-1px); +} + +.footnote { + font-size: 12px; + color: #6c757d; + margin-top: 12px; +} + +.noselect { + -webkit-touch-callout: none; + /* iOS Safari */ + -webkit-user-select: none; + /* Safari */ + -khtml-user-select: none; + /* Konqueror HTML */ + -moz-user-select: none; + /* Firefox */ + -ms-user-select: none; + /* Internet Explorer/Edge */ + user-select: none; + /* Non-prefixed version, currently supported by Chrome and Opera */ +} diff --git a/webapp/_webapp/src/views/extension-popup/app.tsx b/webapp/_webapp/src/views/extension-popup/app.tsx new file mode 100644 index 00000000..63fb185d --- /dev/null +++ b/webapp/_webapp/src/views/extension-popup/app.tsx @@ -0,0 +1,25 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { Providers } from "../../providers"; +import { ExtensionPopup } from "./components/ExtensionPopup"; +import "./app.css"; + +const rootElement = document.getElementById("root"); +if (!rootElement) { + throw new Error("Root element not found"); +} + +const root = createRoot(rootElement); +root.render( + import.meta.env.DEV ? ( + + + + + + ) : ( + + + + ), +); diff --git a/webapp/_webapp/src/views/extension-popup/components/ExtensionPopup.tsx b/webapp/_webapp/src/views/extension-popup/components/ExtensionPopup.tsx new file mode 100644 index 00000000..11a25638 --- /dev/null +++ b/webapp/_webapp/src/views/extension-popup/components/ExtensionPopup.tsx @@ -0,0 +1,69 @@ +import { Steps, Step } from "./Steps"; + +const steps: Step[] = [ + { + number: 1, + content: ( + <> + In{" "} + + overleaf.com + + , open any of your projects. + + ), + }, + { + number: 2, + content: <>PaperDebugger is in the "top left" of the project page., + }, +]; + +export const ExtensionPopup = () => { + const openSettingsPage = () => { + const runtime = typeof chrome !== "undefined" ? chrome.runtime : undefined; + const url = runtime?.getURL?.("settings.html") ?? "/settings.html"; + + if (runtime?.openOptionsPage) { + runtime.openOptionsPage(); + return; + } + + window.open(url, "_blank", "noopener,noreferrer"); + }; + + const settingsUrl = + (typeof chrome !== "undefined" ? chrome.runtime?.getURL?.("settings.html") : undefined) ?? "/settings.html"; + + return ( +
+

PaperDebugger

+

How to use

+ + + PaperDebugger Location + +

+ Self-hosted Overleaf?{" "} + { + e.preventDefault(); + openSettingsPage(); + }} + > + Allow PaperDebugger access here. + +

+
+ ); +}; diff --git a/webapp/_webapp/src/views/extension-popup/components/StepItem.tsx b/webapp/_webapp/src/views/extension-popup/components/StepItem.tsx new file mode 100644 index 00000000..2bf7ec68 --- /dev/null +++ b/webapp/_webapp/src/views/extension-popup/components/StepItem.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from "react"; + +type StepItemProps = { + number: number; + children: ReactNode; +}; + +export const StepItem = ({ number, children }: StepItemProps) => ( +
+ {number}. +

{children}

+
+); diff --git a/webapp/_webapp/src/views/extension-popup/components/Steps.tsx b/webapp/_webapp/src/views/extension-popup/components/Steps.tsx new file mode 100644 index 00000000..df60b53f --- /dev/null +++ b/webapp/_webapp/src/views/extension-popup/components/Steps.tsx @@ -0,0 +1,21 @@ +import { ReactNode } from "react"; +import { StepItem } from "./StepItem"; + +export type Step = { + number: number; + content: ReactNode; +}; + +type StepsProps = { + steps: Step[]; +}; + +export const Steps = ({ steps }: StepsProps) => ( +
+ {steps.map((step) => ( + + {step.content} + + ))} +
+); diff --git a/webapp/_webapp/src/views/extension-settings/app.tsx b/webapp/_webapp/src/views/extension-settings/app.tsx new file mode 100644 index 00000000..9deeb968 --- /dev/null +++ b/webapp/_webapp/src/views/extension-settings/app.tsx @@ -0,0 +1,25 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { Providers } from "../../providers"; +import { ExtensionSettings } from "./components/ExtensionSettings"; +import "../../index.css"; + +const rootElement = document.getElementById("root"); +if (!rootElement) { + throw new Error("Root element not found"); +} + +const root = createRoot(rootElement); +root.render( + import.meta.env.DEV ? ( + + + + + + ) : ( + + + + ), +); diff --git a/webapp/_webapp/src/views/extension-settings/components/ExtensionSettings.tsx b/webapp/_webapp/src/views/extension-settings/components/ExtensionSettings.tsx new file mode 100644 index 00000000..7db58afb --- /dev/null +++ b/webapp/_webapp/src/views/extension-settings/components/ExtensionSettings.tsx @@ -0,0 +1,12 @@ +import { HostPermissionWidget } from "./HostPermissionWidget/HostPermissionWidget"; + +export const ExtensionSettings = () => { + return ( +
+
+

PaperDebugger Settings

+ +
+
+ ); +}; diff --git a/webapp/_webapp/src/views/extension-settings/components/HostPermissionWidget/HostPermissionInput.tsx b/webapp/_webapp/src/views/extension-settings/components/HostPermissionWidget/HostPermissionInput.tsx new file mode 100644 index 00000000..7986d078 --- /dev/null +++ b/webapp/_webapp/src/views/extension-settings/components/HostPermissionWidget/HostPermissionInput.tsx @@ -0,0 +1,52 @@ +import { Button, Input } from "@heroui/react"; +import React, { useCallback } from "react"; +import { getMessageClassName, useHostPermissionStore } from "./useHostPermissionStore"; + +export const HostPermissionInput = () => { + const { permissionUrl, setPermissionUrl, submitPermissionRequest, isSubmitting, message } = useHostPermissionStore(); + + const handleKeyPress = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !isSubmitting) { + submitPermissionRequest(); + } + }, + [isSubmitting, submitPermissionRequest], + ); + + return ( + <> +
+ setPermissionUrl(e.target.value)} + onKeyDown={handleKeyPress} + classNames={{ + input: "font-mono", + }} + /> +

+ Example: *://*.overleaf.com/*{" "} +

+

+ Example: *://sharelatex.gwdg.de/*{" "} +

+
+ +
+ +
+ + {message && ( +
{message.text}
+ )} + + ); +}; diff --git a/webapp/_webapp/src/views/extension-settings/components/HostPermissionWidget/HostPermissionList.tsx b/webapp/_webapp/src/views/extension-settings/components/HostPermissionWidget/HostPermissionList.tsx new file mode 100644 index 00000000..9a7ee55d --- /dev/null +++ b/webapp/_webapp/src/views/extension-settings/components/HostPermissionWidget/HostPermissionList.tsx @@ -0,0 +1,27 @@ +import { HostPermissionListItem } from "./HostPermissionListItem"; +import { useHostPermissionStore } from "./useHostPermissionStore"; + +export const HostPermissionList = () => { + const { permissions, isLoadingPermissions } = useHostPermissionStore(); + + if (isLoadingPermissions) { + return

Loading permissions...

; + } + + if (permissions.length === 0) { + return ( +
+

No permissions granted yet.

+

Please request permission for the website you want to use.

+
+ ); + } + + return ( +
+ {permissions.map((item) => ( + + ))} +
+ ); +}; diff --git a/webapp/_webapp/src/views/extension-settings/components/HostPermissionWidget/HostPermissionListItem.tsx b/webapp/_webapp/src/views/extension-settings/components/HostPermissionWidget/HostPermissionListItem.tsx new file mode 100644 index 00000000..c053a53f --- /dev/null +++ b/webapp/_webapp/src/views/extension-settings/components/HostPermissionWidget/HostPermissionListItem.tsx @@ -0,0 +1,17 @@ +import { PermissionItem } from "./hostPermissionTypes"; + +interface HostPermissionItemProps { + item: PermissionItem; +} + +export const HostPermissionListItem = ({ item }: HostPermissionItemProps) => { + return ( +
+
+
Host Permission
+
{item.origin}
+
+
Granted
+
+ ); +}; diff --git a/webapp/_webapp/src/views/extension-settings/components/HostPermissionWidget/HostPermissionWidget.tsx b/webapp/_webapp/src/views/extension-settings/components/HostPermissionWidget/HostPermissionWidget.tsx new file mode 100644 index 00000000..8e962086 --- /dev/null +++ b/webapp/_webapp/src/views/extension-settings/components/HostPermissionWidget/HostPermissionWidget.tsx @@ -0,0 +1,32 @@ +import { useEffect } from "react"; +import { HostPermissionInput } from "./HostPermissionInput"; +import { HostPermissionList } from "./HostPermissionList"; +import { useHostPermissionStore } from "./useHostPermissionStore"; + +export const HostPermissionWidget = () => { + const { message, loadPermissions, clearMessage } = useHostPermissionStore(); + + useEffect(() => { + loadPermissions(); + }, [loadPermissions]); + + useEffect(() => { + if (!message) return; + const timer = setTimeout(() => clearMessage(), 5000); + return () => clearTimeout(timer); + }, [message, clearMessage]); + + return ( +
+
Host Permissions
+

+ Add your self-hosted Overleaf domain so PaperDebugger can interact with it. +

+ + +
+ +
+
+ ); +}; diff --git a/webapp/_webapp/src/views/extension-settings/components/HostPermissionWidget/hostPermissionTypes.ts b/webapp/_webapp/src/views/extension-settings/components/HostPermissionWidget/hostPermissionTypes.ts new file mode 100644 index 00000000..7a9ea52c --- /dev/null +++ b/webapp/_webapp/src/views/extension-settings/components/HostPermissionWidget/hostPermissionTypes.ts @@ -0,0 +1,11 @@ +export type MessageType = "success" | "error" | "info"; + +export interface PermissionMessage { + text: string; + type: MessageType; +} + +export interface PermissionItem { + origin: string; + granted: boolean; +} diff --git a/webapp/_webapp/src/views/extension-settings/components/HostPermissionWidget/useHostPermissionStore.ts b/webapp/_webapp/src/views/extension-settings/components/HostPermissionWidget/useHostPermissionStore.ts new file mode 100644 index 00000000..780e91e9 --- /dev/null +++ b/webapp/_webapp/src/views/extension-settings/components/HostPermissionWidget/useHostPermissionStore.ts @@ -0,0 +1,139 @@ +import { create } from "zustand"; +import { requestHostPermission } from "../../../../intermediate"; +import { PermissionItem, PermissionMessage } from "./hostPermissionTypes"; + +const normalizeWildcardPattern = (url: string) => { + const trimmed = url.trim(); + if (!trimmed) { + return { valid: false as const, error: "Please enter a URL" }; + } + + // Chrome host permission pattern: :// + // scheme: *, http, https + // host: can include wildcard like *.example.com or specific domain + // path: must include at least /, typically /* + const hostPermissionPattern = /^(\*|https?):\/\/((?:\*\.)?[^/\s]+)(\/.*)?$/i; + const match = trimmed.match(hostPermissionPattern); + + if (match) { + const scheme = match[1].toLowerCase(); + const host = match[2]; + const path = match[3] || "/*"; + + // Normalize scheme (keep * as is, normalize http/https) + const normalizedScheme = scheme === "*" ? "*" : scheme; + // Ensure path ends with /* if it's just / + const normalizedPath = path === "/" ? "/*" : path.endsWith("/*") ? path : `${path}/*`; + + return { valid: true as const, origin: `${normalizedScheme}://${host}${normalizedPath}` }; + } + + // Try parsing as regular URL if pattern doesn't match + try { + const urlObj = new URL(trimmed); + if (!["http:", "https:"].includes(urlObj.protocol)) { + return { valid: false as const, error: "URL must start with http://, https://, or *://" }; + } + return { valid: true as const, origin: `${urlObj.protocol}//${urlObj.host}/*` }; + } catch (e) { + return { + valid: false as const, + error: + "Invalid URL. Use a full URL (e.g., https://example.com) or a wildcard pattern (e.g., https://*.example.com/*, *://*.example.com/*)", + }; + } +}; + +interface HostPermissionState { + permissionUrl: string; + permissions: PermissionItem[]; + isSubmitting: boolean; + isLoadingPermissions: boolean; + message: PermissionMessage | null; + setPermissionUrl: (value: string) => void; + clearMessage: () => void; + loadPermissions: () => Promise; + submitPermissionRequest: () => Promise; +} + +const handleError = (error: unknown, defaultMessage: string): string => { + console.error(defaultMessage, error); + return error instanceof Error ? error.message : defaultMessage; +}; + +export const useHostPermissionStore = create((set, get) => ({ + permissionUrl: "", + permissions: [], + isSubmitting: false, + isLoadingPermissions: true, + message: null, + setPermissionUrl: (value) => set({ permissionUrl: value }), + clearMessage: () => set({ message: null }), + loadPermissions: async () => { + set({ isLoadingPermissions: true }); + + const chromePermissions = await chrome.permissions.getAll().catch((error) => { + const errorMessage = handleError(error, "Error loading permissions."); + set({ message: { text: errorMessage, type: "error" } }); + return null; + }); + + const origins = chromePermissions?.origins || []; + const permissions: PermissionItem[] = origins.map((origin) => ({ origin, granted: true })); + + set({ permissions, isLoadingPermissions: false }); + }, + submitPermissionRequest: async () => { + const { permissionUrl } = get(); + + if (!permissionUrl) { + set({ message: { text: "Please enter a URL", type: "error" } }); + return; + } + + const validation = normalizeWildcardPattern(permissionUrl); + if (!validation.valid) { + set({ message: { text: validation.error, type: "error" } }); + return; + } + + set({ message: null, isSubmitting: true }); + const origin = validation.origin; + + const alreadyGranted = await chrome.permissions.contains({ origins: [origin] }).catch(() => false); + if (alreadyGranted) { + set({ message: { text: `Permission for ${origin} is already granted.`, type: "info" }, isSubmitting: false }); + await get().loadPermissions(); + return; + } + + const granted = await requestHostPermission(origin).catch((error) => { + const errorMessage = handleError(error, "Error requesting permission"); + set({ message: { text: `Error: ${errorMessage}`, type: "error" }, isSubmitting: false }); + return false; + }); + + if (granted) { + set({ message: { text: `Permission granted for ${origin}`, type: "success" } }); + await get().loadPermissions(); + } else { + set({ message: { text: `Permission denied for ${origin}`, type: "error" } }); + } + + set({ isSubmitting: false }); + }, +})); + +export const getMessageClassName = (type: PermissionMessage["type"]): string => { + switch (type) { + case "success": + return "bg-green-100 text-green-800 border border-green-300"; + case "error": + return "bg-red-100 text-red-800 border border-red-300"; + case "info": + return "bg-blue-100 text-blue-800 border border-blue-300"; + default: + return ""; + } +}; + diff --git a/webapp/_webapp/vite.config.ts b/webapp/_webapp/vite.config.ts index c398aea8..eb0b189f 100644 --- a/webapp/_webapp/vite.config.ts +++ b/webapp/_webapp/vite.config.ts @@ -67,6 +67,8 @@ const configs: Record = { }), background: generateConfig("./src/background.ts", "background"), intermediate: generateConfig("./src/intermediate.ts", "intermediate"), + settings: generateConfig("./src/views/extension-settings/app.tsx", "settings"), + popup: generateConfig("./src/views/extension-popup/app.tsx", "popup"), }; const viteConfig = process.env.VITE_CONFIG || "default";