diff --git a/packages/playground/devtools-extension/public/manifest.json b/packages/playground/devtools-extension/public/manifest.json index db60e3f80a..197ff8b884 100644 --- a/packages/playground/devtools-extension/public/manifest.json +++ b/packages/playground/devtools-extension/public/manifest.json @@ -10,14 +10,31 @@ }, "devtools_page": "devtools/index.html", "permissions": ["scripting", "activeTab", "webNavigation"], - "host_permissions": [""], + "host_permissions": [ + "*://playground.wordpress.net/*", + "*://developer.wordpress.org/*", + "*://developer.woocommerce.com/*", + "*://developer.wordpress.com/*", + "*://wordpress.org/*", + "*://localhost/*", + "*://127.0.0.1/*" + ], + "optional_host_permissions": [""], "background": { "service_worker": "background.js", "type": "module" }, "content_scripts": [ { - "matches": [""], + "matches": [ + "*://playground.wordpress.net/*", + "*://developer.wordpress.org/*", + "*://developer.woocommerce.com/*", + "*://developer.wordpress.com/*", + "*://wordpress.org/*", + "*://localhost/*", + "*://127.0.0.1/*" + ], "js": ["content-script.js"], "run_at": "document_idle", "all_frames": true diff --git a/packages/playground/devtools-extension/src/background.ts b/packages/playground/devtools-extension/src/background.ts index 2dbed553ce..44978ce58d 100644 --- a/packages/playground/devtools-extension/src/background.ts +++ b/packages/playground/devtools-extension/src/background.ts @@ -19,6 +19,30 @@ const playgroundFrames = new Map>(); // Store connections to DevTools panels const devToolsConnections = new Map(); +/** + * Inject content script into a tab/frame programmatically. + * Used when permission is granted for a non-allowlisted domain. + */ +async function injectContentScript(tabId: number, frameIds?: number[]) { + try { + const target: chrome.scripting.InjectionTarget = { tabId }; + if (frameIds && frameIds.length > 0) { + target.frameIds = frameIds; + } else { + target.allFrames = true; + } + + await chrome.scripting.executeScript({ + target, + files: ['content-script.js'], + }); + } catch (error) { + // Ignore errors - may fail if page doesn't allow script injection + // eslint-disable-next-line no-console + console.debug('Failed to inject content script:', error); + } +} + /** * Handle messages from content scripts. */ @@ -260,6 +284,14 @@ chrome.runtime.onConnect.addListener((port) => { }); }); } + + // Handle request to inject content script after permission is granted + if (message.type === 'INJECT_CONTENT_SCRIPT' && tabId !== null) { + injectContentScript(tabId).then(() => { + // After injection, trigger a refresh to detect playgrounds + port.postMessage({ type: 'INJECTION_COMPLETE' }); + }); + } }); port.onDisconnect.addListener(() => { diff --git a/packages/playground/devtools-extension/src/panel/panel.module.css b/packages/playground/devtools-extension/src/panel/panel.module.css index cf779ffea4..1e646a0b4c 100644 --- a/packages/playground/devtools-extension/src/panel/panel.module.css +++ b/packages/playground/devtools-extension/src/panel/panel.module.css @@ -103,3 +103,25 @@ opacity: 1; } } + +.permissionButton { + margin-top: 16px; + padding: 10px 20px; + font-size: 14px; + font-weight: 500; + color: #ffffff; + background-color: var(--wp-admin-theme-color, #3858e9); + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.permissionButton:hover:not(:disabled) { + background-color: var(--wp-admin-theme-color-darker-10, #2145e6); +} + +.permissionButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/packages/playground/devtools-extension/src/panel/panel.tsx b/packages/playground/devtools-extension/src/panel/panel.tsx index 94eff1157b..b714ac4dd9 100644 --- a/packages/playground/devtools-extension/src/panel/panel.tsx +++ b/packages/playground/devtools-extension/src/panel/panel.tsx @@ -1,7 +1,12 @@ -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useState, useRef, useCallback } from 'react'; import { createRoot } from 'react-dom/client'; import { PlaygroundFileEditor } from '@wp-playground/components'; import type { AsyncWritableFilesystem } from '@wp-playground/storage'; +import { + hasPermissionForUrl, + requestPermissionForUrl, + isAllowlistedUrl, +} from '../permissions'; import styles from './panel.module.css'; interface PlaygroundFrame { @@ -119,11 +124,70 @@ function PlaygroundPanel() { const [filesystem, setFilesystem] = useState(null); const [isConnected, setIsConnected] = useState(false); + const [tabUrl, setTabUrl] = useState(null); + const [hasPermission, setHasPermission] = useState(null); + const [isRequestingPermission, setIsRequestingPermission] = useState(false); const portRef = useRef(null); const refreshIntervalRef = useRef(null); + // Check permission for the current tab + const checkPermission = useCallback(async () => { + try { + // Get the inspected tab's URL via eval since we can't directly query it + chrome.devtools.inspectedWindow.eval( + 'window.location.href', + (result: unknown, error) => { + if (error || typeof result !== 'string') { + // Fallback: assume we need permission + setTabUrl(null); + setHasPermission(false); + return; + } + const url = result; + setTabUrl(url); + + // If URL is in allowlist, we have permission by default + if (isAllowlistedUrl(url)) { + setHasPermission(true); + return; + } + + // Check if we have permission for this URL + hasPermissionForUrl(url).then(setHasPermission); + } + ); + } catch { + setHasPermission(false); + } + }, []); + + // Request permission for the current tab + const handleRequestPermission = useCallback(async () => { + if (!tabUrl) return; + + setIsRequestingPermission(true); + try { + const granted = await requestPermissionForUrl(tabUrl); + setHasPermission(granted); + + if (granted && portRef.current) { + // Inject content script now that we have permission + portRef.current.postMessage({ type: 'INJECT_CONTENT_SCRIPT' }); + // Trigger a refresh after injection + setTimeout(() => { + portRef.current?.postMessage({ type: 'REFRESH_FRAMES' }); + }, 500); + } + } finally { + setIsRequestingPermission(false); + } + }, [tabUrl]); + // Connect to the background script and set up frame detection useEffect(() => { + // Check permission first + checkPermission(); + const port = chrome.runtime.connect({ name: 'playground-devtools' }); portRef.current = port; @@ -142,6 +206,10 @@ function PlaygroundPanel() { setSelectedFrame(message.frames[0]); } } + if (message.type === 'INJECTION_COMPLETE') { + // Re-check for frames after content script injection + port.postMessage({ type: 'REFRESH_FRAMES' }); + } }); // Request initial frame refresh @@ -165,7 +233,7 @@ function PlaygroundPanel() { } port.disconnect(); }; - }, []); + }, [checkPermission]); // Create filesystem when a frame is selected useEffect(() => { @@ -203,6 +271,35 @@ function PlaygroundPanel() { ); } + // If we don't have permission for this domain + if (hasPermission === false && !isAllowlistedUrl(tabUrl || '')) { + const hostname = tabUrl ? new URL(tabUrl).hostname : 'this site'; + return ( +
+
+

Permission Required

+

+ To inspect WordPress Playground on{' '} + {hostname}, this extension needs + permission to access the page. +

+ +

+ This permission only applies to {hostname} +

+
+
+ ); + } + // If no playground frames found if (frames.length === 0) { return ( diff --git a/packages/playground/devtools-extension/src/permissions.ts b/packages/playground/devtools-extension/src/permissions.ts new file mode 100644 index 0000000000..f6941e49e5 --- /dev/null +++ b/packages/playground/devtools-extension/src/permissions.ts @@ -0,0 +1,73 @@ +/** + * Permission utilities for the WordPress Playground DevTools extension. + * + * Handles checking and requesting host permissions for domains that aren't + * in the default allowlist. + */ + +/** + * Check if we have permission to access a given URL. + */ +export async function hasPermissionForUrl(url: string): Promise { + try { + return await chrome.permissions.contains({ + origins: [getOriginPattern(url)], + }); + } catch { + return false; + } +} + +/** + * Request permission to access a given URL. + * Returns true if permission was granted, false otherwise. + */ +export async function requestPermissionForUrl(url: string): Promise { + try { + return await chrome.permissions.request({ + origins: [getOriginPattern(url)], + }); + } catch { + return false; + } +} + +/** + * Convert a URL to an origin pattern suitable for the permissions API. + * e.g., "https://example.com/page" -> "https://example.com/*" + */ +function getOriginPattern(url: string): string { + try { + const urlObj = new URL(url); + return `${urlObj.protocol}//${urlObj.host}/*`; + } catch { + // If URL parsing fails, return a pattern that won't match anything + return url; + } +} + +/** + * Check if a URL matches the default allowlist in manifest.json. + */ +export function isAllowlistedUrl(url: string): boolean { + const allowlist = [ + 'playground.wordpress.net', + 'developer.wordpress.org', + 'developer.woocommerce.com', + 'developer.wordpress.com', + 'wordpress.org', + 'localhost', + '127.0.0.1', + ]; + + try { + const urlObj = new URL(url); + const hostname = urlObj.hostname; + return allowlist.some( + (allowed) => + hostname === allowed || hostname.endsWith('.' + allowed) + ); + } catch { + return false; + } +}