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;
+ }
+});