diff --git a/README.md b/README.md index 068b658..c009089 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,22 @@ const data = await response.json(); `OpenScript.fetch(url, options)` returns a native `Response` instance supporting `.json()`, `.text()`, `.arrayBuffer()`, `.blob()`, `.status`, `.ok`, and `.headers`. See the [Cross-origin fetch guide](docs/fetch.md) for full options and examples. +### Popup buttons + +Scripts can add buttons under their entry in the popup's Scripts tab, for example to open a settings panel or trigger an action on the current page: + +```javascript +OpenScript.button('Settings', () => openSettingsPanel()); + +if (location.pathname.startsWith('/issues')) + OpenScript.button('Export issues', exportIssues); + +const remove = OpenScript.button('One-time action', doThing); +remove(); // takes the button away again +``` + +Buttons are registered at runtime, so they show only for scripts running on the active tab. A script that matches `*://*/*` therefore shows its buttons on any regular web page, but not on pages where Chrome blocks user scripts, such as `chrome://` pages and the Chrome Web Store. Clicking a button runs its handler inside the page in the script's world, then closes the popup. + ### External libraries Use `@require` to cache libraries when a script is saved: diff --git a/manifest.json b/manifest.json index 0f989c2..def74a6 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "minimum_chrome_version": "138", "name": "OpenScript", - "version": "1.0.5", + "version": "1.0.6", "description": "A lightweight user script manager for modern browsers", "action": { "default_popup": "src/popup.html", diff --git a/package-lock.json b/package-lock.json index de3cbc9..aebbf5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscript", - "version": "1.0.5", + "version": "1.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscript", - "version": "1.0.5", + "version": "1.0.6", "dependencies": { "lucide": "^0.475.0" }, diff --git a/package.json b/package.json index 96336bf..0f3f898 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openscript", - "version": "1.0.5", + "version": "1.0.6", "private": true, "type": "module", "scripts": { diff --git a/src/popup.js b/src/popup.js index 2e96710..4ba80de 100644 --- a/src/popup.js +++ b/src/popup.js @@ -2,7 +2,7 @@ import { getScripts, saveScripts, getSecrets, saveSecrets, garbageCollectScriptStorage, } from './utils/storage.js'; import { parseMeta, getBoilerplate, getMetaRunAt } from './utils/parser.js'; -import { isUserScriptsAvailable } from './utils/userScripts.js'; +import { isUserScriptsAvailable, getTabButtons, clickTabButton } from './utils/userScripts.js'; import { renderIcons, icon } from './utils/icons.js'; import { VERSION } from './version.js'; @@ -15,9 +15,12 @@ const state = { revealedSecrets: new Set(), userScriptsReady: true, search: '', + tabId: null, + buttons: {}, // scriptId -> OpenScript.button registrations in the active tab }; const $ = sel => document.querySelector(sel); +const esc = s => String(s).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`); const app = $('#app'); const syncScripts = refreshRequires => chrome.runtime.sendMessage({ type: 'SYNC_SCRIPTS', refreshRequires }); @@ -47,6 +50,9 @@ const init = async () => { runAt: getMetaRunAt(s.code || '') || s.runAt || 'document_idle', })); state.secrets = secrets; + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + state.tabId = tab?.id ?? null; + if (state.tabId !== null) state.buttons = await getTabButtons(state.tabId, state.scripts); render(); }; @@ -67,6 +73,15 @@ const toggleScript = async id => { showToast(`Script ${s.enabled ? 'enabled' : 'disabled'}`); }; +const runButton = async (scriptId, id) => { + const button = state.buttons[scriptId]?.find(b => b.id === id); + if (!button) return; + try { + if (await clickTabButton(state.tabId, scriptId, button)) return window.close(); + } catch {} + showToast('Button is no longer available on this page', true); +}; + const deleteScript = async id => { if (!confirm('Delete this user script?')) return; state.scripts = state.scripts.filter(s => s.id !== id); @@ -230,6 +245,15 @@ const renderScriptList = () => { `).join('')} ${(s.matches?.length > 3) ? `+${s.matches.length - 3}` : ''} + ${s.enabled && state.buttons[s.id]?.length ? ` +
+ ${state.buttons[s.id].map(b => ` + + `).join('')} +
+ ` : ''} `).join('') : `
@@ -447,6 +471,10 @@ const bindEvents = () => { btn.addEventListener('click', () => setTab('editor', btn.dataset.id)); }); + app.querySelectorAll('[data-action="script-button"]').forEach(btn => { + btn.addEventListener('click', () => runButton(btn.dataset.id, +btn.dataset.button)); + }); + app.querySelectorAll('[data-action="delete"]').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); diff --git a/src/utils/userScripts.js b/src/utils/userScripts.js index d647ce5..d505fe9 100644 --- a/src/utils/userScripts.js +++ b/src/utils/userScripts.js @@ -4,6 +4,7 @@ import { VERSION } from '../version.js'; const STORAGE_MESSAGE = 'OPEN_SCRIPT_STORAGE'; const FETCH_MESSAGE = 'OPEN_SCRIPT_FETCH'; +const BUTTONS_GLOBAL = '__OpenScriptButtons'; export const isUserScriptsAvailable = async () => { if (!chrome.userScripts) return false; @@ -15,7 +16,30 @@ export const isUserScriptsAvailable = async () => { } }; -export const wrapScriptCode = (code, secrets = {}, storageToken = '') => ` +const runInScriptWorld = (target, worldId, code) => + chrome.userScripts.execute({ target, worldId, js: [{ code }] }); + +// Collect OpenScript.button registrations from each enabled script running in the tab's top frame. +export const getTabButtons = async (tabId, scripts) => Object.fromEntries((await Promise.all( + scripts.filter(s => s.enabled).map(async s => { + try { + const [frame] = await runInScriptWorld({ tabId }, s.id, `globalThis.${BUTTONS_GLOBAL}?.list()`); + if (!frame?.result?.length) return []; + return [[s.id, frame.result.map(b => ({ ...b, documentId: frame.documentId }))]]; + } catch { + return []; + } + }), +)).flat()); + +export const clickTabButton = async (tabId, scriptId, { id, documentId }) => { + const [frame] = await runInScriptWorld( + { tabId, documentIds: [documentId] }, scriptId, `globalThis.${BUTTONS_GLOBAL}?.click(${JSON.stringify(id)})`, + ); + return frame?.result === true; +}; + +export const wrapScriptCode =(code, secrets = {}, storageToken = '') => ` // [OpenScript runtime] (async function(OpenScript, env) { 'use strict'; @@ -59,7 +83,29 @@ ${code} Object.defineProperty(res, 'url', { value: response.url || url.toString() }); return res; }; - const OpenScript = Object.freeze({ version: '${VERSION}', env, storage, fetch }); + const buttons = new Map(); + let buttonId = 0; + const button = (label, onClick) => { + if (typeof onClick !== 'function') throw new TypeError('OpenScript.button requires a click handler'); + const id = ++buttonId; + buttons.set(id, { label: String(label), onClick }); + return () => void buttons.delete(id); + }; + globalThis.${BUTTONS_GLOBAL} = Object.freeze({ + list: () => [...buttons].map(([id, { label }]) => ({ id, label })), + click: id => { + const target = buttons.get(id); + if (target) setTimeout(async () => { + try { + await target.onClick(); + } catch (error) { + console.error('[OpenScript] Button failed:', error); + } + }); + return !!target; + }, + }); + const OpenScript = Object.freeze({ version: '${VERSION}', env, storage, fetch, button }); globalThis.OpenScript = OpenScript; globalThis.env = env; return [OpenScript, env]; diff --git a/src/version.js b/src/version.js index 6cec2cb..3f6f127 100644 --- a/src/version.js +++ b/src/version.js @@ -1 +1 @@ -export const VERSION = '1.0.5'; +export const VERSION = '1.0.6'; diff --git a/tests/buttons.test.js b/tests/buttons.test.js new file mode 100644 index 0000000..a9eee15 --- /dev/null +++ b/tests/buttons.test.js @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { wrapScriptCode, getTabButtons, clickTabButton } from '../src/utils/userScripts.js'; + +const runWrapped = async code => { + let resolveDone; + const done = new Promise(resolve => { resolveDone = resolve; }); + globalThis.__resolve_done = resolveDone; + new Function(wrapScriptCode(`${code}\nglobalThis.__resolve_done();`))(); + await done; + delete globalThis.__resolve_done; + return globalThis.__OpenScriptButtons; +}; + +test('OpenScript.button registers, clicks, and removes popup buttons', async () => { + const clicks = []; + globalThis.__clicks = clicks; + try { + const registry = await runWrapped(` + OpenScript.button('Settings', () => globalThis.__clicks.push('settings')); + const remove = OpenScript.button('Temp', () => globalThis.__clicks.push('temp')); + globalThis.__remove = remove; + `); + assert.deepEqual(registry.list(), [{ id: 1, label: 'Settings' }, { id: 2, label: 'Temp' }]); + + assert.equal(registry.click(1), true); + await new Promise(resolve => setTimeout(resolve, 5)); + assert.deepEqual(clicks, ['settings']); + + globalThis.__remove(); + assert.deepEqual(registry.list(), [{ id: 1, label: 'Settings' }]); + assert.equal(registry.click(2), false); + } finally { + delete globalThis.__clicks; + delete globalThis.__remove; + delete globalThis.__OpenScriptButtons; + delete globalThis.OpenScript; + delete globalThis.env; + } +}); + +test('OpenScript.button rejects missing click handlers', async () => { + let resolveDone; + const done = new Promise(resolve => { resolveDone = resolve; }); + globalThis.__resolve_done = resolveDone; + try { + new Function(wrapScriptCode(` + try { OpenScript.button('Broken'); } catch (error) { globalThis.__resolve_done(error); } + `))(); + const error = await done; + assert.equal(error.name, 'TypeError'); + } finally { + delete globalThis.__resolve_done; + delete globalThis.__OpenScriptButtons; + delete globalThis.OpenScript; + delete globalThis.env; + } +}); + +test('getTabButtons queries enabled script worlds and clickTabButton targets the document', async () => { + const originalChrome = globalThis.chrome; + const calls = []; + globalThis.chrome = { + userScripts: { + execute: async injection => { + calls.push(injection); + if (injection.worldId === 'broken') throw new Error('Cannot access a chrome:// URL'); + if (injection.js[0].code.includes('.click(')) return [{ documentId: 'doc1', result: true }]; + return injection.worldId === 'a' + ? [{ documentId: 'doc1', result: [{ id: 1, label: 'Settings' }] }] + : [{ documentId: 'doc1', result: undefined }]; + }, + }, + }; + + try { + const buttons = await getTabButtons(7, [ + { id: 'a', enabled: true }, + { id: 'b', enabled: true }, + { id: 'off', enabled: false }, + { id: 'broken', enabled: true }, + ]); + assert.deepEqual(buttons, { a: [{ id: 1, label: 'Settings', documentId: 'doc1' }] }); + assert.deepEqual(calls.map(c => c.worldId), ['a', 'b', 'broken']); + assert.deepEqual(calls[0].target, { tabId: 7 }); + + assert.equal(await clickTabButton(7, 'a', buttons.a[0]), true); + assert.deepEqual(calls.at(-1).target, { tabId: 7, documentIds: ['doc1'] }); + assert.equal(calls.at(-1).worldId, 'a'); + assert.match(calls.at(-1).js[0].code, /__OpenScriptButtons\?\.click\(1\)/); + } finally { + globalThis.chrome = originalChrome; + } +});