Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions packages/playground/devtools-extension/public/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,31 @@
},
"devtools_page": "devtools/index.html",
"permissions": ["scripting", "activeTab", "webNavigation"],
"host_permissions": ["<all_urls>"],
"host_permissions": [
"*://playground.wordpress.net/*",
"*://developer.wordpress.org/*",
"*://developer.woocommerce.com/*",
"*://developer.wordpress.com/*",
"*://wordpress.org/*",
"*://localhost/*",
"*://127.0.0.1/*"
],
"optional_host_permissions": ["<all_urls>"],
"background": {
"service_worker": "background.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"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
Expand Down
32 changes: 32 additions & 0 deletions packages/playground/devtools-extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,30 @@ const playgroundFrames = new Map<number, Map<number, PlaygroundFrame>>();
// Store connections to DevTools panels
const devToolsConnections = new Map<number, chrome.runtime.Port>();

/**
* 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.
*/
Expand Down Expand Up @@ -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(() => {
Expand Down
22 changes: 22 additions & 0 deletions packages/playground/devtools-extension/src/panel/panel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
101 changes: 99 additions & 2 deletions packages/playground/devtools-extension/src/panel/panel.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -119,11 +124,70 @@ function PlaygroundPanel() {
const [filesystem, setFilesystem] =
useState<AsyncWritableFilesystem | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [tabUrl, setTabUrl] = useState<string | null>(null);
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
const [isRequestingPermission, setIsRequestingPermission] = useState(false);
const portRef = useRef<chrome.runtime.Port | null>(null);
const refreshIntervalRef = useRef<number | null>(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;

Expand All @@ -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
Expand All @@ -165,7 +233,7 @@ function PlaygroundPanel() {
}
port.disconnect();
};
}, []);
}, [checkPermission]);

// Create filesystem when a frame is selected
useEffect(() => {
Expand Down Expand Up @@ -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 (
<div className={styles.container}>
<div className={styles.message}>
<h2>Permission Required</h2>
<p>
To inspect WordPress Playground on{' '}
<strong>{hostname}</strong>, this extension needs
permission to access the page.
</p>
<button
className={styles.permissionButton}
onClick={handleRequestPermission}
disabled={isRequestingPermission}
>
{isRequestingPermission
? 'Requesting...'
: 'Grant Permission'}
</button>
<p className={styles.hint}>
This permission only applies to {hostname}
</p>
</div>
</div>
);
}

// If no playground frames found
if (frames.length === 0) {
return (
Expand Down
73 changes: 73 additions & 0 deletions packages/playground/devtools-extension/src/permissions.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<boolean> {
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;
}
}