From 980b571a76bf9a95f64402c9ce72645dc6f27f46 Mon Sep 17 00:00:00 2001 From: eimexdev Date: Wed, 15 Jul 2026 20:24:36 -0700 Subject: [PATCH 1/2] Harden extension for store launch --- .gitignore | 7 +- README.md | 140 ++++++----------- background.js | 305 +++++++++++++++++++++++++++++--------- content.js | 38 +++++ docs/PRIVACY.md | 31 ++++ docs/RELEASE_CHECKLIST.md | 36 +++++ docs/STORE_SUBMISSION.md | 64 ++++++++ manifest.json | 77 ++++------ manifests/advanced.json | 59 ++++++++ onboarding.html | 63 ++++++++ onboarding.js | 47 ++++++ options.html | 39 ++++- options.js | 26 +++- package.json | 13 ++ popup.html | 39 ++++- privacy.html | 38 +++++ rules/chatgpt-embed.json | 113 -------------- scripts/build.mjs | 48 ++++++ scripts/package.mjs | 20 +++ scripts/validate.mjs | 60 ++++++++ sidechat.html | 7 +- sidechat.js | 13 ++ t3-bridge.js | 6 + t3-frame-bootstrap.js | 23 +++ tests/manifest.test.mjs | 28 ++++ 25 files changed, 1009 insertions(+), 331 deletions(-) create mode 100644 docs/PRIVACY.md create mode 100644 docs/RELEASE_CHECKLIST.md create mode 100644 docs/STORE_SUBMISSION.md create mode 100644 manifests/advanced.json create mode 100644 onboarding.html create mode 100644 onboarding.js create mode 100644 package.json create mode 100644 privacy.html delete mode 100644 rules/chatgpt-embed.json create mode 100644 scripts/build.mjs create mode 100644 scripts/package.mjs create mode 100644 scripts/validate.mjs create mode 100644 t3-frame-bootstrap.js create mode 100644 tests/manifest.test.mjs diff --git a/.gitignore b/.gitignore index 716b97f..0e93946 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ +dist/ +output/ +.playwright-cli/ _metadata/generated_indexed_rulesets/ -output/playwright/sidechat-debug/ +*.pem +*.crx +.DS_Store diff --git a/README.md b/README.md index 199808a..28972ce 100644 --- a/README.md +++ b/README.md @@ -1,119 +1,75 @@ # RightClickGPT -RightClickGPT is a Chrome extension for sending selected webpage text to ChatGPT or t3.chat from the right-click menu. You can keep multiple prompt presets, choose where each prompt opens, and optionally reuse an existing chat tab or conversation. +RightClickGPT is an independent Chrome extension for sending selected webpage text to ChatGPT or t3.chat from the right-click menu. It is not affiliated with, endorsed by, or produced by OpenAI or t3.chat. ## Features -- Right-click context menu for selected text. -- ChatGPT and t3.chat provider support. -- Multiple named prompt presets with custom prompt formats. -- Collapsed preset editor with chevrons, per-preset enable toggles, and delete confirmation. -- Global default behavior for opening prompts in a new tab or in Chrome's side panel. -- Per-preset behavior overrides: Default, New tab, or Sidechat. -- Optional conversation URL or legacy chat ID for sending prompts to a specific conversation. -- Optional reuse of an existing ChatGPT or t3.chat tab. -- Sidechat panel with embedded chat, reload control, and an "Open tab" fallback. -- Popup and full options page for configuration. -- Autosaved settings stored in Chrome local extension storage. -- Prompt URL support for fresh chats, with content-script automation for existing tabs, configured conversations, long ChatGPT prompts, and submission fallback. -- t3.chat session bridge for smoother prompt submission in t3.chat. - -## Installation - -1. Clone or download this repository. -2. Open Chrome and go to `chrome://extensions/`. -3. Turn on **Developer mode**. -4. Click **Load unpacked**. -5. Select the `RightClickGPT` folder. -6. Pin RightClickGPT to the Chrome toolbar if you want quick access to the popup settings. +- Right-click delivery of selected text to ChatGPT or t3.chat. +- Multiple named prompt presets with enable toggles and per-preset behavior. +- New-chat, existing-tab, and configured-conversation delivery. +- First-use disclosure and affirmative consent before any prompt can be sent. +- Local-only preset and preference storage; no analytics, ads, tracking, or developer server. +- Least-privilege Store package with access limited to the supported provider domains. +- Separate Advanced GitHub package with experimental embedded side-panel support. -## Usage - -### Send Selected Text - -1. Select text on a webpage. -2. Right-click the selection. -3. Choose a RightClickGPT preset. - - If one enabled preset is available, it appears directly in the context menu. - - If multiple enabled presets are available, they appear under the provider menu, such as **Ask ChatGPT** or **Ask t3.chat**. -4. RightClickGPT formats the preset by replacing `` with the selected text, then opens or updates the configured chat target. - -### Configure Presets - -Open the toolbar popup or the extension options page. - -Click **Add preset** to create a new preset. New presets open expanded so you can name them and edit the prompt format immediately. +## Release variants -For each preset: +### Store -- Click the chevron to expand or collapse the preset details. -- Edit the preset name shown in the context menu. -- Write a prompt format that includes ``. -- Choose a behavior: - - **Default** uses the global default behavior. - - **New tab** opens the prompt in a browser tab. - - **Sidechat** opens the prompt in Chrome's side panel. -- Use the on/off toggle to keep a preset without showing it in the context menu. -- Expand a preset and click the trash icon to delete it. +`dist/store` is the default and Chrome Web Store-targeted build. It includes the core context-menu, preset, ChatGPT, and t3.chat features. It does not include provider framing, response-header modification, or the T3 WebSocket session workaround. -Settings autosave shortly after changes. Enabled presets must have a name and include `` to be usable. +### Advanced -### Choose a Provider +`dist/advanced` is for informed users who deliberately install the extension unpacked from GitHub. It adds the embedded side panel. The Advanced build narrowly removes CSP/X-Frame-Options only for provider subframes initiated by this extension and injects the T3 session workaround only into this extension's own embedded T3 frame. -Use **Chat provider** to choose where prompts are sent: +The Advanced build is not intended for Chrome Web Store submission. Provider changes, authentication challenges, and third-party cookie restrictions can still break embedded pages. -- **ChatGPT** sends prompts to `chatgpt.com`. -- **t3.chat (beta)** sends prompts to `t3.chat`. +## Build and validate -You may need to be signed in to the selected provider before prompts can submit successfully. +Requires Node.js 20 or later. -### Choose Opening Behavior +```sh +npm run check +``` -Use **Default behavior** to choose what presets do when their behavior is set to **Default**: +This creates both unpacked variants in `dist/`, validates their manifests and package allowlists, and runs automated tests. -- **New tab** opens a provider tab. -- **Sidechat** opens Chrome's side panel and loads the provider inside it. +To install locally: -Each preset can override this default. +1. Open `chrome://extensions/`. +2. Enable **Developer mode**. +3. Choose **Load unpacked**. +4. Select `dist/store` for the launch-safe build or `dist/advanced` for the experimental build. +5. Review the disclosure that opens after installation and explicitly enable the extension. -### Reuse an Existing Chat Tab - -Turn on **Use existing ChatGPT tab** or **Use existing t3.chat tab** to send prompts to the first matching open provider tab. If no matching tab exists, RightClickGPT opens the configured target instead. - -### Send to a Specific Conversation - -Use **Conversation URL** to target an existing conversation. You can enter: - -- A full ChatGPT or t3.chat conversation URL. -- A legacy bare chat ID. +## Usage -Leave it blank to start a new chat each time. +1. Select text on a webpage. +2. Right-click the selection. +3. Choose a preset. The menu identifies the destination provider. +4. The extension formats the preset and sends it only after this explicit action. -### Use Sidechat +Use the toolbar popup or full options page to manage presets, provider, conversation target, existing-tab reuse, and—on the Advanced build only—side-panel behavior. -When a prompt uses **Sidechat**, RightClickGPT opens Chrome's side panel for the current tab and loads the prompt there. +For normal-length T3 prompts, the extension uses t3.chat's native new-chat query flow. Longer prompts, existing tabs, configured conversations, and ChatGPT use the provider composer automation. -The Sidechat panel includes: +## Privacy -- An embedded provider frame. -- A reload button. -- An **Open tab** link for opening the same prompt in a normal browser tab. -- A status message if Chrome cannot open the side panel for the current page. +Read the [privacy notice](docs/PRIVACY.md). Store publishers must host this notice at a stable public HTTPS URL and enter that URL in the Chrome Web Store Privacy tab. -## How Prompt Delivery Works +## Release documentation -For fresh chats, RightClickGPT builds provider URLs with the prompt in the `q` query parameter. For configured conversations, existing tabs, long ChatGPT prompts, or cases where the provider does not submit automatically, the extension injects `content.js` to fill and submit the provider composer. +- [Store listing and privacy answers](docs/STORE_SUBMISSION.md) +- [Release checklist](docs/RELEASE_CHECKLIST.md) -## Files +## Source map -- `manifest.json`: Chrome extension manifest, permissions, popup, options page, side panel, and content script registration. -- `background.js`: Context menu setup, preset filtering, prompt formatting, tab routing, side panel routing, and chat URL normalization. -- `options.html`: Full options page. -- `popup.html`: Toolbar popup settings UI. -- `options.js`: Settings UI behavior, preset editing, autosave, validation, provider selection, and conversation URL normalization. -- `content.js`: ChatGPT and t3.chat composer detection, prompt insertion, and submission fallback. -- `sidechat.html`: Chrome side panel UI. -- `sidechat.js`: Sidechat frame loading, reload handling, open-tab fallback, and status updates. -- `t3-bridge.js`: t3.chat WebSocket session patch used by the t3 integration. -- `rules/chatgpt-embed.json`: Header rules that allow supported chat providers to load in the side panel frame. -- `logo16.png`, `logo48.png`, `logo128.png`: Extension icons. +- `manifest.json`: least-privilege Store manifest. +- `manifests/advanced.json`: Advanced GitHub manifest. +- `background.js`: consent gating, context menus, provider routing, and Advanced frame-rule setup. +- `content.js`: provider composer insertion and submission. +- `onboarding.*`: prominent data disclosure and affirmative consent. +- `options.*` / `popup.html`: local settings UI. +- `sidechat.*`: Advanced-only embedded panel. +- `t3-frame-bootstrap.js` / `t3-bridge.js`: Advanced-only, frame-scoped T3 compatibility workaround. +- `scripts/`: deterministic build and validation scripts. diff --git a/background.js b/background.js index 6bc77b3..04f0d67 100644 --- a/background.js +++ b/background.js @@ -1,7 +1,7 @@ const CHATGPT_HOME_URL = 'https://chatgpt.com/'; const T3_HOME_URL = 'https://t3.chat/'; const T3_NEW_CHAT_URL = 'https://t3.chat/new'; -const CHATGPT_QUERY_URL_MAX_LENGTH = 1800; +const T3_QUERY_URL_MAX_LENGTH = 1800; const CHATGPT_TAB_URLS = [ 'https://chatgpt.com/*', 'https://chat.openai.com/*', @@ -12,16 +12,24 @@ const DEFAULT_CHAT_PROVIDER = 'chatgpt'; const DEFAULT_PROMPT_BEHAVIOR = 'newTab'; const PROMPT_BEHAVIORS = ['default', 'newTab', 'sidechat']; const GLOBAL_PROMPT_BEHAVIORS = ['newTab', 'sidechat']; +const CONSENT_VERSION = 1; +const MANIFEST = chrome.runtime.getManifest(); +const SUPPORTS_SIDECHAT = Boolean(MANIFEST.side_panel && chrome.sidePanel); +const ADVANCED_FRAME_RULE_IDS = [1001, 1002, 1003]; +const PENDING_PROMPT_PREFIX = 'pendingPrompt:'; +const PENDING_PROMPT_MAX_AGE_MS = 5 * 60 * 1000; const CHAT_PROVIDERS = { chatgpt: { homeURL: CHATGPT_HOME_URL, tabURLs: CHATGPT_TAB_URLS, + label: 'ChatGPT', menuTitle: 'Ask ChatGPT', existingTabLabel: 'Use existing ChatGPT tab', }, t3: { homeURL: T3_HOME_URL, tabURLs: T3_TAB_URLS, + label: 't3.chat', menuTitle: 'Ask t3.chat', existingTabLabel: 'Use existing t3.chat tab', }, @@ -32,33 +40,103 @@ const DEFAULT_PROMPT_PRESET = { promptFormat: 'Explain ', behavior: 'default', }; -let menuItemBehaviorById = {}; -chrome.runtime.onInstalled.addListener(updateContextMenus); -chrome.runtime.onStartup.addListener(updateContextMenus); +chrome.runtime.onInstalled.addListener(async () => { + await clearExpiredPendingPrompts(); + await configureAdvancedFrameRules(); + const consent = await chrome.storage.local.get(['dataConsentVersion']); + if (consent.dataConsentVersion !== CONSENT_VERSION) { + await chrome.contextMenus.removeAll(); + await chrome.tabs.create({ url: chrome.runtime.getURL('onboarding.html') }); + return; + } + + updateContextMenus(); +}); + +chrome.runtime.onStartup.addListener(async () => { + await clearExpiredPendingPrompts(); + await configureAdvancedFrameRules(); + updateContextMenus(); +}); chrome.storage.onChanged.addListener((changes, areaName) => { - if (areaName === 'local' && (changes.promptPresets || changes.promptFormat || changes.chatProvider || changes.defaultPromptBehavior)) { + if (areaName === 'local' && ( + changes.promptPresets || + changes.promptFormat || + changes.chatProvider || + changes.defaultPromptBehavior || + changes.dataConsentVersion + )) { updateContextMenus(); } }); -chrome.runtime.onMessage.addListener((request) => { +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.action === 'logToBackground') { console.log.apply(null, request.data); + return false; } + + if (request.action === 'enableT3SidePanelBridge') { + if (!SUPPORTS_SIDECHAT || !chrome.scripting || !isValidT3SidePanelFrame(sender)) { + sendResponse({ ok: false }); + return false; + } + + chrome.scripting.executeScript({ + target: { + tabId: sender.tab.id, + frameIds: [sender.frameId], + }, + files: ['t3-bridge.js'], + world: 'MAIN', + }).then( + () => sendResponse({ ok: true }), + (error) => { + console.error('Unable to enable the T3 side-panel bridge.', error); + sendResponse({ ok: false }); + }, + ); + return true; + } + + if (request.action === 'consumePendingPrompt') { + getPendingPromptForTab(sender).then(sendResponse); + return true; + } + + if (request.action === 'acknowledgePendingPrompt') { + acknowledgePendingPrompt(sender).then(sendResponse); + return true; + } + + return false; }); +function isValidT3SidePanelFrame(sender) { + if (!sender.tab || !Number.isInteger(sender.tab.id) || !Number.isInteger(sender.frameId) || sender.frameId <= 0) { + return false; + } + + try { + return new URL(sender.url).hostname === 't3.chat'; + } catch (error) { + return false; + } +} + chrome.contextMenus.onClicked.addListener((info, tab) => { if (!info.selectionText) { return; } - if (menuItemBehaviorById[info.menuItemId] === 'sidechat') { - openSidechatPanel(tab); - } + chrome.storage.local.get(['dataConsentVersion', 'promptPresets', 'promptFormat', 'chatID', 'chatURL', 'chatProvider', 'focusExistingTab', 'defaultPromptBehavior'], (data) => { + if (data.dataConsentVersion !== CONSENT_VERSION) { + chrome.tabs.create({ url: chrome.runtime.getURL('onboarding.html') }); + return; + } - chrome.storage.local.get(['promptPresets', 'promptFormat', 'chatID', 'chatURL', 'chatProvider', 'focusExistingTab', 'defaultPromptBehavior'], (data) => { const preset = findPresetForMenuItem(info.menuItemId, data); if (!preset) { return; @@ -70,6 +148,7 @@ chrome.contextMenus.onClicked.addListener((info, tab) => { const configuredChatURL = normalizeChatURL(data.chatURL || data.chatID || '', provider.id); if (getEffectivePromptBehavior(preset, data.defaultPromptBehavior) === 'sidechat') { + openSidechatPanel(tab); updateSidechatPrompt(formattedPrompt, tab, provider.id, configuredChatURL); return; } @@ -91,23 +170,23 @@ chrome.contextMenus.onClicked.addListener((info, tab) => { function updateContextMenus() { chrome.contextMenus.removeAll(() => { - chrome.storage.local.get(['promptPresets', 'promptFormat', 'chatProvider', 'defaultPromptBehavior'], (data) => { + chrome.storage.local.get(['dataConsentVersion', 'promptPresets', 'promptFormat', 'chatProvider', 'defaultPromptBehavior'], (data) => { + if (data.dataConsentVersion !== CONSENT_VERSION) { + return; + } + const presets = getPromptPresets(data); const provider = getChatProvider(data.chatProvider); - const nextMenuItemBehaviorById = {}; if (!presets.length) { - menuItemBehaviorById = nextMenuItemBehaviorById; return; } if (presets.length <= 1) { - nextMenuItemBehaviorById[getMenuItemId(presets[0])] = getEffectivePromptBehavior(presets[0], data.defaultPromptBehavior); chrome.contextMenus.create({ id: getMenuItemId(presets[0]), - title: presets[0].name || provider.menuTitle, + title: `${presets[0].name || 'Send selection'} → ${provider.label}`, contexts: ['selection'], }); - menuItemBehaviorById = nextMenuItemBehaviorById; return; } @@ -118,7 +197,6 @@ function updateContextMenus() { }); presets.forEach((preset) => { - nextMenuItemBehaviorById[getMenuItemId(preset)] = getEffectivePromptBehavior(preset, data.defaultPromptBehavior); chrome.contextMenus.create({ id: getMenuItemId(preset), parentId: ROOT_MENU_ID, @@ -126,7 +204,6 @@ function updateContextMenus() { contexts: ['selection'], }); }); - menuItemBehaviorById = nextMenuItemBehaviorById; }); }); } @@ -178,13 +255,17 @@ function getPresetIdFromMenuItem(menuItemId) { function getPresetBehavior(preset) { if (PROMPT_BEHAVIORS.includes(preset && preset.behavior)) { - return preset.behavior; + return preset.behavior === 'sidechat' && !SUPPORTS_SIDECHAT ? 'newTab' : preset.behavior; } - return preset && preset.sidechat ? 'sidechat' : 'default'; + return preset && preset.sidechat && SUPPORTS_SIDECHAT ? 'sidechat' : 'default'; } function getGlobalPromptBehavior(behavior) { + if (behavior === 'sidechat' && !SUPPORTS_SIDECHAT) { + return DEFAULT_PROMPT_BEHAVIOR; + } + return GLOBAL_PROMPT_BEHAVIORS.includes(behavior) ? behavior : DEFAULT_PROMPT_BEHAVIOR; } @@ -207,7 +288,7 @@ function sendToExistingChatTab(prompt, providerId, callback) { const existingTab = tabs[0]; chrome.tabs.update(existingTab.id, { active: true }, () => { - injectPromptIntoTab(existingTab.id, { action: 'sendToChatProvider', provider: provider.id, prompt }); + sendPromptMessage(existingTab.id, { action: 'sendToChatProvider', provider: provider.id, prompt }); callback(true); }); }); @@ -216,26 +297,28 @@ function sendToExistingChatTab(prompt, providerId, callback) { function openPromptTarget(configuredChatURL, prompt, providerId) { const provider = getChatProvider(providerId); if (configuredChatURL) { - createNewTabAndInject(configuredChatURL, prompt, provider.id); + openTabWithPendingPrompt(configuredChatURL, prompt, provider.id); return; } - const queryURL = buildPromptQueryURL(prompt, provider.id); if (provider.id === 't3') { - chrome.tabs.create({ url: queryURL }); - return; - } + const queryURL = buildPromptQueryURL(prompt, provider.id); + if (queryURL.length <= T3_QUERY_URL_MAX_LENGTH) { + chrome.tabs.create({ url: queryURL }); + return; + } - if (queryURL.length <= CHATGPT_QUERY_URL_MAX_LENGTH) { - createNewTabAndEnsureQueryPromptSubmitted(queryURL, prompt, provider.id); + // T3 redirects /new during hydration. Open the stable home route for the + // in-memory handoff used by long prompts. + openTabWithPendingPrompt(T3_HOME_URL, prompt, provider.id); return; } - createNewTabAndInject(provider.homeURL, prompt, provider.id); + openTabWithPendingPrompt(provider.homeURL, prompt, provider.id); } function openSidechatPanel(tab) { - if (!chrome.sidePanel || !tab || !tab.id) { + if (!chrome.sidePanel || !tab || !Number.isInteger(tab.id)) { setSidechatError('Chrome side panel is not available for this page.'); return; } @@ -262,7 +345,7 @@ function updateSidechatPrompt(prompt, tab, providerId, configuredChatURL = '') { sidechatPrompt: prompt, sidechatURL: chatURL, sidechatProvider: provider.id, - sidechatError: chrome.sidePanel && tab && tab.id ? '' : 'Chrome side panel is not available for this page.', + sidechatError: chrome.sidePanel && tab && Number.isInteger(tab.id) ? '' : 'Chrome side panel is not available for this page.', sidechatUpdatedAt: Date.now(), }); } @@ -321,51 +404,137 @@ function isProviderHost(hostname, providerId) { return hostname === 'chatgpt.com' || hostname === 'chat.openai.com'; } -function createNewTabAndInject(url, prompt, providerId) { - createNewTabAndSendMessage(url, { action: 'sendToChatProvider', provider: getChatProvider(providerId).id, prompt }); +function sendPromptMessage(tabId, message) { + chrome.tabs.sendMessage(tabId, message, (response) => { + if (chrome.runtime.lastError) { + console.error(chrome.runtime.lastError.message); + return; + } + + if (!response || !response.ok) { + console.error(response && response.error ? response.error : 'Unable to send prompt to the selected provider.'); + } + }); } -function createNewTabAndEnsureQueryPromptSubmitted(url, prompt, providerId) { - createNewTabAndSendMessage(url, { action: 'ensureChatProviderPromptSubmitted', provider: getChatProvider(providerId).id, prompt }); +async function openTabWithPendingPrompt(rawURL, prompt, providerId) { + await clearExpiredPendingPrompts(); + const provider = getChatProvider(providerId); + const tab = await chrome.tabs.create({ url: 'about:blank', active: true }); + const storageKey = `${PENDING_PROMPT_PREFIX}${tab.id}`; + + await chrome.storage.session.set({ + [storageKey]: { + prompt, + providerId: provider.id, + createdAt: Date.now(), + }, + }); + await chrome.tabs.update(tab.id, { url: rawURL }); } -function createNewTabAndSendMessage(url, message) { - chrome.tabs.create({ url }, (newTab) => { - const listener = (tabId, changeInfo) => { - if (tabId !== newTab.id || changeInfo.status !== 'complete') { - return; - } +async function getPendingPromptForTab(sender) { + if (!isValidTopLevelProviderFrame(sender)) { + return { ok: false }; + } - chrome.tabs.onUpdated.removeListener(listener); - injectPromptIntoTab(tabId, message); - }; + const storageKey = `${PENDING_PROMPT_PREFIX}${sender.tab.id}`; + const stored = await chrome.storage.session.get(storageKey); + const pending = stored[storageKey]; + if (!pending || Date.now() - pending.createdAt > PENDING_PROMPT_MAX_AGE_MS) { + await chrome.storage.session.remove(storageKey); + return { ok: false }; + } - chrome.tabs.onUpdated.addListener(listener); - }); + const senderURL = new URL(sender.url); + const senderHost = senderURL.hostname; + if (!isProviderHost(senderHost, pending.providerId)) { + return { ok: false }; + } + + if ( + pending.providerId === 't3' && + (senderURL.pathname === '/new' || Date.now() - pending.createdAt < 5000) + ) { + return { ok: false, retry: true }; + } + + return { + ok: true, + prompt: pending.prompt, + provider: pending.providerId, + }; } -function injectPromptIntoTab(tabId, message) { - chrome.scripting.executeScript( - { - target: { tabId }, - files: ['content.js'], - }, - () => { - if (chrome.runtime.lastError) { - console.error(chrome.runtime.lastError.message); - return; - } +async function acknowledgePendingPrompt(sender) { + if (!isValidTopLevelProviderFrame(sender)) { + return { ok: false }; + } - chrome.tabs.sendMessage(tabId, message, (response) => { - if (chrome.runtime.lastError) { - console.error(chrome.runtime.lastError.message); - return; - } + const storageKey = `${PENDING_PROMPT_PREFIX}${sender.tab.id}`; + await chrome.storage.session.remove(storageKey); + return { ok: true }; +} - if (!response || !response.ok) { - console.error(response && response.error ? response.error : 'Unable to send prompt to ChatGPT.'); - } - }); - } - ); +function isValidTopLevelProviderFrame(sender) { + if (!sender.tab || !Number.isInteger(sender.tab.id) || sender.frameId !== 0 || !sender.url) { + return false; + } + + try { + const hostname = new URL(sender.url).hostname; + return hostname === 'chatgpt.com' || hostname === 'chat.openai.com' || hostname === 't3.chat'; + } catch (error) { + return false; + } +} + +async function clearExpiredPendingPrompts() { + const stored = await chrome.storage.session.get(); + const now = Date.now(); + const expiredKeys = Object.entries(stored) + .filter(([key, value]) => ( + key.startsWith(PENDING_PROMPT_PREFIX) && + (!value || !value.createdAt || now - value.createdAt > PENDING_PROMPT_MAX_AGE_MS) + )) + .map(([key]) => key); + + if (expiredKeys.length) { + await chrome.storage.session.remove(expiredKeys); + } +} + +async function configureAdvancedFrameRules() { + if (!SUPPORTS_SIDECHAT || !chrome.declarativeNetRequest) { + return; + } + + const responseHeaders = [ + { header: 'content-security-policy', operation: 'remove' }, + { header: 'content-security-policy-report-only', operation: 'remove' }, + { header: 'x-frame-options', operation: 'remove' }, + ]; + const providerDomains = ['chatgpt.com', 'chat.openai.com', 't3.chat']; + const addRules = providerDomains.map((requestDomain, index) => ({ + id: ADVANCED_FRAME_RULE_IDS[index], + priority: 1, + action: { + type: 'modifyHeaders', + responseHeaders, + }, + condition: { + initiatorDomains: [chrome.runtime.id], + requestDomains: [requestDomain], + resourceTypes: ['sub_frame'], + }, + })); + + try { + await chrome.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: ADVANCED_FRAME_RULE_IDS, + addRules, + }); + } catch (error) { + console.error('Unable to configure Advanced side-panel frame rules.', error); + } } diff --git a/content.js b/content.js index 2aba182..e4729e2 100644 --- a/content.js +++ b/content.js @@ -45,6 +45,11 @@ return false; } + if (typeof request.prompt !== 'string' || !request.prompt.trim()) { + sendResponse({ ok: false, error: 'The prompt was empty.' }); + return false; + } + const provider = request.provider || 'chatgpt'; const task = request.action === 'sendToChatGPT' || request.action === 'sendToChatProvider' ? sendPromptToProvider(request.prompt, provider) @@ -61,6 +66,15 @@ return true; }); + if (window.top === window && isSupportedProviderURL()) { + setTimeout(() => { + deliverPendingPromptForTab().catch((error) => { + const message = error && error.message ? error.message : String(error); + logToBackground('RightClickGPT pending prompt failed:', message); + }); + }, 250); + } + const framedPrompt = window.top !== window && isSupportedProviderURL() ? getPromptParamFromCurrentURL() : ''; if (framedPrompt) { ensurePromptSubmitted(framedPrompt, getCurrentProviderId()).catch((error) => { @@ -76,6 +90,30 @@ await submitPrompt(composer, provider); } + async function deliverPendingPromptForTab() { + const response = await chrome.runtime.sendMessage({ + action: 'consumePendingPrompt', + }); + if (!response || !response.ok) { + if (response && response.retry) { + setTimeout(() => { + deliverPendingPromptForTab().catch((error) => { + const message = error && error.message ? error.message : String(error); + logToBackground('RightClickGPT pending prompt retry failed:', message); + }); + }, 500); + } + return; + } + + if (typeof response.prompt !== 'string') { + throw new Error('The pending prompt was invalid.'); + } + + await sendPromptToProvider(response.prompt, response.provider); + await chrome.runtime.sendMessage({ action: 'acknowledgePendingPrompt' }); + } + async function ensurePromptSubmitted(prompt, provider) { if (await waitForPromptInConversation(prompt, 2500, provider)) { return; diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md new file mode 100644 index 0000000..b63aa4f --- /dev/null +++ b/docs/PRIVACY.md @@ -0,0 +1,31 @@ +# RightClickGPT Privacy Notice + +Effective July 15, 2026. + +RightClickGPT is an independent browser extension and is not affiliated with OpenAI or t3.chat. + +## Data handled + +The extension handles webpage text that you explicitly select and send, your prompt preset text, your selected provider, an optional provider conversation URL, your existing-tab preference, and your privacy-consent choice. + +## How data is used and shared + +When you invoke a context-menu action, the selected text and preset instructions are transferred directly to the provider you chose—ChatGPT or t3.chat—solely to submit your prompt. Those providers process the content under their own terms and privacy policies. The extension developer does not receive, sell, or use this content for advertising. + +For normal-length T3 prompts, the extension uses t3.chat's native prompt URL. That URL, including the prompt, may appear in your local browser history. Longer T3 prompts and ChatGPT prompts are inserted into the provider composer instead. + +## Local storage and retention + +Preferences and presets are stored locally through Chrome's extension storage and remain until you change them, clear extension data, or uninstall the extension. A prompt may be held for up to five minutes in Chrome's in-memory extension session storage while a provider tab finishes loading; it is deleted as soon as that tab retrieves it and is never written to disk by the extension. The Advanced GitHub build may also temporarily store the current side-panel prompt locally so the embedded panel can load it. + +## Advanced GitHub build + +The separately packaged Advanced build can embed provider pages. It narrowly removes framing headers only for subframes initiated by the extension and applies a T3 session compatibility workaround only inside the extension's embedded t3.chat frame. Session identifiers are not sent to the extension developer or stored by the extension. + +## Security and limited use + +Provider transfers use HTTPS. RightClickGPT's use of information is limited to its user-facing purpose of sending text you choose to your selected chat provider. It does not use analytics, advertising, tracking, or a developer-operated server. + +## Contact and changes + +Questions can be filed through the project's GitHub issue tracker. Material changes to these practices will require an updated in-product disclosure and consent. diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..a98bd9e --- /dev/null +++ b/docs/RELEASE_CHECKLIST.md @@ -0,0 +1,36 @@ +# Release checklist + +## Code and package + +- [ ] Replace the temporary product name everywhere after the final brand is chosen. +- [ ] Run `npm run check` on a clean checkout. +- [ ] Confirm `dist/store/manifest.json` contains no ``, DNR, `scripting`, `sidePanel`, T3 bridge, or sidechat files. +- [ ] Test the exact `dist/store` directory unpacked in stable Chrome. +- [ ] Zip the *contents* of `dist/store` so `manifest.json` is at the ZIP root. +- [ ] Inspect the ZIP file list before upload. + +## Functional testing + +- [ ] Fresh install: disclosure opens and context menu stays disabled until consent. +- [ ] Consent, clear-data, reinstall, and upgrade paths work. +- [ ] ChatGPT: signed out, signed in, new chat, existing tab, configured conversation, and long prompt. +- [ ] T3: signed out, signed in, native query flow, existing tab, configured conversation, long prompt, and hCaptcha/error path. +- [ ] Popup and options page: presets, provider selection, autosave, validation, enable/disable, deletion, and keyboard navigation. +- [ ] No extension errors appear in `chrome://extensions`. + +## Policy and listing + +- [ ] Host `docs/PRIVACY.md` at a stable public HTTPS URL. +- [ ] Complete the Privacy tab using `docs/STORE_SUBMISSION.md`. +- [ ] Add accurate screenshots and promotional tile from the Store build only. +- [ ] Include the independent/unaffiliated statement in the listing. +- [ ] Provide reviewer instructions and restricted test credentials if required. +- [ ] Confirm the final name and listing comply with third-party trademark rules. +- [ ] Verify developer email, 2-Step Verification, account registration, category, language, regions, and support URL. + +## Advanced GitHub release + +- [ ] Label the Advanced artifact experimental and not for Web Store submission. +- [ ] Confirm dynamic frame rules match only extension-initiated provider subframes. +- [ ] Confirm the T3 bridge is absent from top-level T3 and unrelated frames. +- [ ] Document known provider, cookie, authentication, and anti-bot limitations. diff --git a/docs/STORE_SUBMISSION.md b/docs/STORE_SUBMISSION.md new file mode 100644 index 0000000..720b159 --- /dev/null +++ b/docs/STORE_SUBMISSION.md @@ -0,0 +1,64 @@ +# Chrome Web Store submission copy + +Replace the product name after choosing the final non-infringing brand. Keep all claims aligned with the submitted `dist/store` ZIP. + +## Single purpose + +Send webpage text that the user explicitly selects to the user's chosen supported chat provider using configurable prompt presets. + +## Short description + +Send selected webpage text to your chosen chat provider from the right-click menu. + +## Detailed description + +Select text on a webpage, right-click, and choose a preset to send a formatted prompt to ChatGPT or t3.chat. Configure multiple local prompt presets, reuse an existing provider tab, or target a conversation URL. + +The extension sends text only after you invoke a context-menu action. Presets and preferences stay in Chrome local extension storage. The extension has no analytics, ads, tracking, or developer-operated server. + +This independent extension is not affiliated with, endorsed by, or produced by OpenAI or t3.chat. An account with the selected provider may be required. + +## Permission justifications + +- `contextMenus`: Adds the user-invoked action for selected webpage text. +- `storage`: Stores privacy consent, presets, provider choice, conversation target, and tab preference locally. +- `https://chatgpt.com/*` and `https://chat.openai.com/*`: Inserts and submits the user-approved prompt in ChatGPT and can locate an already-open ChatGPT tab. +- `https://t3.chat/*`: Inserts and submits the user-approved prompt in T3 and can locate an already-open T3 tab. + +The Store package does not request ``, `tabs`, `scripting`, `sidePanel`, web-request, DNR, cookies, history, identity, or remote-code permissions. + +## Privacy practices + +- Single purpose: use the text a user explicitly selects to create and submit a prompt to the provider they chose. +- Website content: **Yes**—only the selected text the user explicitly invokes the extension on. +- User-provided/user-generated content: **Yes**—prompt presets and an optional provider conversation target. +- Authentication information: **No** in the Store build. +- Web history: **No collection by the extension.** Note that a normal-length T3 prompt is placed in a t3.chat URL and Chrome may retain that URL in local browser history. +- Transfers: selected text and formatted prompt are sent only to OpenAI/ChatGPT or t3.chat as necessary for the user-facing purpose. +- Developer access: none. +- Advertising, analytics, tracking, sale, or unrelated use: none. +- Remote hosted code: **No.** All extension logic is packaged locally. +- Limited Use certification: **Yes.** Data use is limited to the disclosed single purpose. + +Host `docs/PRIVACY.md` at a stable public HTTPS URL and enter it in the Privacy policy field. + +## Reviewer test instructions + +1. Install the submitted Store ZIP and confirm the disclosure page opens. +2. Confirm no context-menu item is available before consent. +3. Check the consent box and click **Agree and enable**. +4. Open a normal webpage, select visible text, right-click, and choose **Explain command → ChatGPT**. +5. Confirm ChatGPT opens and the formatted prompt is submitted. A free ChatGPT account may be required. +6. In the popup, switch to **t3.chat (beta)**. +7. Select text on a webpage and invoke the preset again. Confirm a T3 new-chat URL opens with the prompt. T3 may present hCaptcha or require an account; do not bypass it. +8. Verify presets autosave, an existing provider tab can be reused, and the conversation target accepts only the selected provider's host. +9. Open **Review consent**, click **Clear local data**, and confirm the context menu is disabled until consent is granted again. + +If reviewers need authenticated features, provide dedicated restricted test credentials in the dashboard Test instructions tab. Never place credentials in this repository. + +## Listing assets still required + +- At least one accurate 1280×800 or 640×400 screenshot of the Store build. +- 440×280 small promotional tile. +- Final category, language, support URL, distribution regions, and privacy-policy URL. +- Verified developer email, 2-Step Verification, and developer account registration. diff --git a/manifest.json b/manifest.json index fc8138c..6ff5251 100644 --- a/manifest.json +++ b/manifest.json @@ -1,55 +1,40 @@ -{ -"update_url": "https://clients2.google.com/service/update2/crx", - - "manifest_version": 3, - "name": "RightClickGPT", - "version": "1.2", - "description": "Send selected text to ChatGPT", - "permissions": ["contextMenus", "storage", "scripting", "sidePanel", "declarativeNetRequestWithHostAccess"], - "host_permissions": [""], - "action": { - "default_icon": { - "128": "logo128.png" - }, - "default_popup": "popup.html" - - }, +{ + "manifest_version": 3, + "name": "RightClickGPT", + "version": "1.3.0", + "minimum_chrome_version": "114", + "description": "Send selected text to your chosen chat provider from the context menu.", + "permissions": ["contextMenus", "storage"], + "host_permissions": [ + "https://chatgpt.com/*", + "https://chat.openai.com/*", + "https://t3.chat/*" + ], + "action": { + "default_icon": { + "16": "logo16.png", + "48": "logo48.png", + "128": "logo128.png" + }, + "default_popup": "popup.html" + }, "background": { "service_worker": "background.js" }, - "side_panel": { - "default_path": "sidechat.html" - }, - "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self'; frame-src https://chatgpt.com https://chat.openai.com https://t3.chat;" - }, - "declarative_net_request": { - "rule_resources": [ - { - "id": "chatgpt_embed_headers", - "enabled": true, - "path": "rules/chatgpt-embed.json" - } - ] - }, "options_page": "options.html", - "icons": { - "128": "logo128.png", - "48": "logo48.png", - "16": "logo16.png" - }, + "icons": { + "16": "logo16.png", + "48": "logo48.png", + "128": "logo128.png" + }, "content_scripts": [ { - "matches": ["https://t3.chat/*"], - "js": ["t3-bridge.js"], - "all_frames": true, - "run_at": "document_start", - "world": "MAIN" - }, - { - "matches": [""], - "js": ["content.js"], - "all_frames": true + "matches": [ + "https://chatgpt.com/*", + "https://chat.openai.com/*", + "https://t3.chat/*" + ], + "js": ["content.js"] } ] } diff --git a/manifests/advanced.json b/manifests/advanced.json new file mode 100644 index 0000000..aa283d5 --- /dev/null +++ b/manifests/advanced.json @@ -0,0 +1,59 @@ +{ + "manifest_version": 3, + "name": "RightClickGPT", + "version": "1.3.0", + "minimum_chrome_version": "114", + "description": "Send selected text to your chosen chat provider, with advanced embedded side-panel support.", + "permissions": [ + "contextMenus", + "storage", + "sidePanel", + "scripting", + "declarativeNetRequestWithHostAccess" + ], + "host_permissions": [ + "https://chatgpt.com/*", + "https://chat.openai.com/*", + "https://t3.chat/*" + ], + "action": { + "default_icon": { + "16": "logo16.png", + "48": "logo48.png", + "128": "logo128.png" + }, + "default_popup": "popup.html" + }, + "background": { + "service_worker": "background.js" + }, + "side_panel": { + "default_path": "sidechat.html" + }, + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'; frame-src https://chatgpt.com https://chat.openai.com https://t3.chat;" + }, + "options_page": "options.html", + "icons": { + "16": "logo16.png", + "48": "logo48.png", + "128": "logo128.png" + }, + "content_scripts": [ + { + "matches": ["https://t3.chat/*"], + "js": ["t3-frame-bootstrap.js"], + "all_frames": true, + "run_at": "document_start" + }, + { + "matches": [ + "https://chatgpt.com/*", + "https://chat.openai.com/*", + "https://t3.chat/*" + ], + "js": ["content.js"], + "all_frames": true + } + ] +} diff --git a/onboarding.html b/onboarding.html new file mode 100644 index 0000000..e6d29b0 --- /dev/null +++ b/onboarding.html @@ -0,0 +1,63 @@ + + + + + + Enable RightClickGPT + + + +
+

Before you enable RightClickGPT

+

RightClickGPT is an independent extension and is not affiliated with, endorsed by, or produced by OpenAI or t3.chat.

+ +
+ When you choose a RightClickGPT context-menu action, the selected webpage text and your preset instructions are sent directly to the chat provider you selected: ChatGPT or t3.chat. +

The extension developer does not receive this content. Your provider processes it under its own terms and privacy policy.

+
+ +

What stays on this device

+
    +
  • Your prompt presets, provider choice, target conversation, and tab preference are stored in Chrome's local extension storage.
  • +
  • A prompt may be held briefly in Chrome's in-memory session storage while its provider tab finishes loading, then it is deleted.
  • +
  • Neither build uses analytics, advertising, tracking, or a developer-operated server.
  • +
  • The Advanced GitHub build can embed provider pages in a side panel and applies a T3 session compatibility workaround only inside that embedded frame.
  • +
+ + + +
+ + Read the full privacy notice + +

+
+
+ + + diff --git a/onboarding.js b/onboarding.js new file mode 100644 index 0000000..c8a44f2 --- /dev/null +++ b/onboarding.js @@ -0,0 +1,47 @@ +const CONSENT_VERSION = 1; +const consent = document.getElementById('consent'); +const enable = document.getElementById('enable'); +const clearData = document.getElementById('clearData'); +const status = document.getElementById('status'); +const manifest = chrome.runtime.getManifest(); + +document.body.dataset.advanced = String(Boolean(manifest.side_panel)); + +chrome.storage.local.get(['dataConsentVersion'], (data) => { + if (data.dataConsentVersion === CONSENT_VERSION) { + consent.checked = true; + enable.disabled = false; + enable.innerText = 'Enabled'; + status.innerText = 'Privacy choice saved on this device.'; + } +}); + +consent.addEventListener('change', () => { + enable.disabled = !consent.checked; +}); + +enable.addEventListener('click', () => { + if (!consent.checked) { + return; + } + + chrome.storage.local.set({ dataConsentVersion: CONSENT_VERSION }, () => { + enable.innerText = 'Enabled'; + status.innerText = 'RightClickGPT is ready. You can close this tab.'; + }); +}); + +clearData.addEventListener('click', () => { + if (!confirm('Clear all RightClickGPT presets, preferences, side-panel data, and consent from this device?')) { + return; + } + + chrome.storage.local.clear(() => { + chrome.storage.session.clear(() => { + consent.checked = false; + enable.disabled = true; + enable.innerText = 'Agree and enable'; + status.innerText = 'Local extension data cleared. RightClickGPT is disabled until you agree again.'; + }); + }); +}); diff --git a/options.html b/options.html index 1f0f52d..fd7930f 100644 --- a/options.html +++ b/options.html @@ -1,6 +1,9 @@ - - + + + + + RightClickGPT Options + + +

RightClickGPT Privacy Notice

+

Effective July 15, 2026. RightClickGPT is an independent browser extension and is not affiliated with OpenAI or t3.chat.

+ +

Data handled

+

The extension handles webpage text that you explicitly select and send, your prompt preset text, your selected provider, an optional provider conversation URL, your existing-tab preference, and your privacy-consent choice.

+ +

How data is used and shared

+

When you invoke a context-menu action, the selected text and preset instructions are transferred directly to the provider you chose—ChatGPT or t3.chat—solely to submit your prompt. Those providers process the content under their own terms and privacy policies. The extension developer does not receive, sell, or use this content for advertising.

+

For normal-length T3 prompts, the extension uses t3.chat's native prompt URL. That URL, including the prompt, may appear in your local browser history. Longer T3 prompts and ChatGPT prompts are inserted into the provider composer instead.

+ +

Local storage and retention

+

Preferences and presets are stored locally through Chrome's extension storage and remain until you change them, clear extension data, or uninstall the extension. A prompt may be held for up to five minutes in Chrome's in-memory extension session storage while a provider tab finishes loading; it is deleted as soon as that tab retrieves it and is never written to disk by the extension. The Advanced GitHub build may also temporarily store the current side-panel prompt locally so the embedded panel can load it.

+ +

Advanced GitHub build

+

The separately packaged Advanced build can embed provider pages. It narrowly removes framing headers only for subframes initiated by the extension and applies a T3 session compatibility workaround only inside the extension's embedded t3.chat frame. Session identifiers are not sent to the extension developer or stored by the extension.

+ +

Security and limited use

+

Provider transfers use HTTPS. RightClickGPT's use of information is limited to its user-facing purpose of sending text you choose to your selected chat provider. It does not use analytics, advertising, tracking, or a developer-operated server.

+ +

Contact and changes

+

Questions can be filed through the project's GitHub issue tracker. Material changes to these practices will require an updated in-product disclosure and consent.

+ + diff --git a/rules/chatgpt-embed.json b/rules/chatgpt-embed.json deleted file mode 100644 index 42b6806..0000000 --- a/rules/chatgpt-embed.json +++ /dev/null @@ -1,113 +0,0 @@ -[ - { - "id": 1, - "priority": 1, - "action": { - "type": "modifyHeaders", - "responseHeaders": [ - { - "header": "content-security-policy", - "operation": "remove" - }, - { - "header": "content-security-policy-report-only", - "operation": "remove" - }, - { - "header": "x-frame-options", - "operation": "remove" - }, - { - "header": "cross-origin-embedder-policy", - "operation": "remove" - }, - { - "header": "cross-origin-opener-policy", - "operation": "remove" - }, - { - "header": "cross-origin-resource-policy", - "operation": "remove" - } - ] - }, - "condition": { - "urlFilter": "||chatgpt.com/", - "resourceTypes": ["sub_frame"] - } - }, - { - "id": 2, - "priority": 1, - "action": { - "type": "modifyHeaders", - "responseHeaders": [ - { - "header": "content-security-policy", - "operation": "remove" - }, - { - "header": "content-security-policy-report-only", - "operation": "remove" - }, - { - "header": "x-frame-options", - "operation": "remove" - }, - { - "header": "cross-origin-embedder-policy", - "operation": "remove" - }, - { - "header": "cross-origin-opener-policy", - "operation": "remove" - }, - { - "header": "cross-origin-resource-policy", - "operation": "remove" - } - ] - }, - "condition": { - "urlFilter": "||chat.openai.com/", - "resourceTypes": ["sub_frame"] - } - }, - { - "id": 3, - "priority": 1, - "action": { - "type": "modifyHeaders", - "responseHeaders": [ - { - "header": "content-security-policy", - "operation": "remove" - }, - { - "header": "content-security-policy-report-only", - "operation": "remove" - }, - { - "header": "x-frame-options", - "operation": "remove" - }, - { - "header": "cross-origin-embedder-policy", - "operation": "remove" - }, - { - "header": "cross-origin-opener-policy", - "operation": "remove" - }, - { - "header": "cross-origin-resource-policy", - "operation": "remove" - } - ] - }, - "condition": { - "urlFilter": "||t3.chat/", - "resourceTypes": ["sub_frame"] - } - } -] diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..9255b6d --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,48 @@ +import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const dist = path.join(root, 'dist'); + +const commonFiles = [ + 'background.js', + 'content.js', + 'logo16.png', + 'logo48.png', + 'logo128.png', + 'onboarding.html', + 'onboarding.js', + 'options.html', + 'options.js', + 'popup.html', + 'privacy.html', +]; + +const variants = { + store: { + manifest: 'manifest.json', + extraFiles: [], + }, + advanced: { + manifest: 'manifests/advanced.json', + extraFiles: ['sidechat.html', 'sidechat.js', 't3-frame-bootstrap.js', 't3-bridge.js'], + }, +}; + +await rm(dist, { recursive: true, force: true }); +await mkdir(dist, { recursive: true }); + +for (const [name, variant] of Object.entries(variants)) { + const destination = path.join(dist, name); + await mkdir(destination, { recursive: true }); + + for (const file of [...commonFiles, ...variant.extraFiles]) { + await cp(path.join(root, file), path.join(destination, file)); + } + + const manifest = JSON.parse(await readFile(path.join(root, variant.manifest), 'utf8')); + await writeFile(path.join(destination, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); +} + +console.log('Built dist/store and dist/advanced.'); diff --git a/scripts/package.mjs b/scripts/package.mjs new file mode 100644 index 0000000..05f6151 --- /dev/null +++ b/scripts/package.mjs @@ -0,0 +1,20 @@ +import { execFile } from 'node:child_process'; +import { readFile, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +const execFileAsync = promisify(execFile); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const dist = path.join(root, 'dist'); +const manifest = JSON.parse(await readFile(path.join(dist, 'store', 'manifest.json'), 'utf8')); + +for (const variant of ['store', 'advanced']) { + const output = path.join(dist, `rightclickgpt-${variant}-${manifest.version}.zip`); + await rm(output, { force: true }); + await execFileAsync('zip', ['-q', '-r', output, '.'], { + cwd: path.join(dist, variant), + }); + await execFileAsync('unzip', ['-t', output]); + console.log(`Created ${path.relative(root, output)}.`); +} diff --git a/scripts/validate.mjs b/scripts/validate.mjs new file mode 100644 index 0000000..6db40bd --- /dev/null +++ b/scripts/validate.mjs @@ -0,0 +1,60 @@ +import { access, readFile, readdir } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const execFileAsync = promisify(execFile); + +for (const variant of ['store', 'advanced']) { + const directory = path.join(root, 'dist', variant); + const manifest = JSON.parse(await readFile(path.join(directory, 'manifest.json'), 'utf8')); + const files = await readdir(directory); + + assert(manifest.manifest_version === 3, `${variant}: Manifest V3 is required.`); + assert(!JSON.stringify(manifest).includes(''), `${variant}: is forbidden.`); + assert(!files.some((file) => file.startsWith('.')), `${variant}: hidden files must not be packaged.`); + + const referencedFiles = [ + manifest.action?.default_popup, + manifest.background?.service_worker, + manifest.options_page, + manifest.side_panel?.default_path, + ...Object.values(manifest.icons || {}), + ...(manifest.content_scripts || []).flatMap((entry) => entry.js || []), + ].filter(Boolean); + + for (const file of referencedFiles) { + await access(path.join(directory, file)); + } + + if (variant === 'store') { + assert(!manifest.permissions.includes('sidePanel'), 'store: sidePanel permission is forbidden.'); + assert(!manifest.permissions.some((permission) => permission.startsWith('declarativeNetRequest')), 'store: DNR permissions are forbidden.'); + assert(!files.includes('t3-bridge.js'), 'store: the T3 session bridge must not ship.'); + assert(!files.includes('t3-frame-bootstrap.js'), 'store: the T3 frame bootstrap must not ship.'); + assert(!files.includes('sidechat.html'), 'store: embedded sidechat must not ship.'); + } +} + +for (const file of ['background.js', 'content.js', 'onboarding.js', 'options.js', 'sidechat.js', 't3-frame-bootstrap.js', 't3-bridge.js']) { + const source = await readFile(path.join(root, file), 'utf8'); + assert(!/\beval\s*\(/.test(source), `${file}: eval is forbidden.`); + assert(!/new\s+Function\s*\(/.test(source), `${file}: Function constructor is forbidden.`); + assert(!/]+src=["']https?:/i.test(source), `${file}: remote scripts are forbidden.`); + await execFileAsync(process.execPath, ['--check', path.join(root, file)]); +} + +for (const file of ['onboarding.html', 'options.html', 'popup.html', 'privacy.html', 'sidechat.html']) { + const source = await readFile(path.join(root, file), 'utf8'); + assert(!/]+src=["']https?:/i.test(source), `${file}: remote scripts are forbidden.`); +} + +console.log('Store and advanced packages satisfy launch invariants.'); + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} diff --git a/sidechat.html b/sidechat.html index 6809125..b9ef36b 100644 --- a/sidechat.html +++ b/sidechat.html @@ -1,6 +1,9 @@ - - + + + + + RightClickGPT Sidechat