mirror of
https://github.com/GetOpenScript/OpenScript.git
synced 2026-10-09 01:55:20 +00:00
Add OpenScript.button popup actions and release 1.0.6
Scripts can register buttons at runtime with OpenScript.button(label, onClick). The popup lists buttons from scripts running in the active tab via chrome.userScripts.execute in each script's world, and clicking one runs the handler in the page before closing the popup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
`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
|
### External libraries
|
||||||
|
|
||||||
Use `@require` to cache libraries when a script is saved:
|
Use `@require` to cache libraries when a script is saved:
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"minimum_chrome_version": "138",
|
"minimum_chrome_version": "138",
|
||||||
"name": "OpenScript",
|
"name": "OpenScript",
|
||||||
"version": "1.0.5",
|
"version": "1.0.6",
|
||||||
"description": "A lightweight user script manager for modern browsers",
|
"description": "A lightweight user script manager for modern browsers",
|
||||||
"action": {
|
"action": {
|
||||||
"default_popup": "src/popup.html",
|
"default_popup": "src/popup.html",
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "openscript",
|
"name": "openscript",
|
||||||
"version": "1.0.5",
|
"version": "1.0.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "openscript",
|
"name": "openscript",
|
||||||
"version": "1.0.5",
|
"version": "1.0.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide": "^0.475.0"
|
"lucide": "^0.475.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openscript",
|
"name": "openscript",
|
||||||
"version": "1.0.5",
|
"version": "1.0.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+29
-1
@@ -2,7 +2,7 @@ import {
|
|||||||
getScripts, saveScripts, getSecrets, saveSecrets, garbageCollectScriptStorage,
|
getScripts, saveScripts, getSecrets, saveSecrets, garbageCollectScriptStorage,
|
||||||
} from './utils/storage.js';
|
} from './utils/storage.js';
|
||||||
import { parseMeta, getBoilerplate, getMetaRunAt } from './utils/parser.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 { renderIcons, icon } from './utils/icons.js';
|
||||||
import { VERSION } from './version.js';
|
import { VERSION } from './version.js';
|
||||||
|
|
||||||
@@ -15,9 +15,12 @@ const state = {
|
|||||||
revealedSecrets: new Set(),
|
revealedSecrets: new Set(),
|
||||||
userScriptsReady: true,
|
userScriptsReady: true,
|
||||||
search: '',
|
search: '',
|
||||||
|
tabId: null,
|
||||||
|
buttons: {}, // scriptId -> OpenScript.button registrations in the active tab
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = sel => document.querySelector(sel);
|
const $ = sel => document.querySelector(sel);
|
||||||
|
const esc = s => String(s).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
|
||||||
const app = $('#app');
|
const app = $('#app');
|
||||||
const syncScripts = refreshRequires =>
|
const syncScripts = refreshRequires =>
|
||||||
chrome.runtime.sendMessage({ type: 'SYNC_SCRIPTS', refreshRequires });
|
chrome.runtime.sendMessage({ type: 'SYNC_SCRIPTS', refreshRequires });
|
||||||
@@ -47,6 +50,9 @@ const init = async () => {
|
|||||||
runAt: getMetaRunAt(s.code || '') || s.runAt || 'document_idle',
|
runAt: getMetaRunAt(s.code || '') || s.runAt || 'document_idle',
|
||||||
}));
|
}));
|
||||||
state.secrets = secrets;
|
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();
|
render();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,6 +73,15 @@ const toggleScript = async id => {
|
|||||||
showToast(`Script ${s.enabled ? 'enabled' : 'disabled'}`);
|
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 => {
|
const deleteScript = async id => {
|
||||||
if (!confirm('Delete this user script?')) return;
|
if (!confirm('Delete this user script?')) return;
|
||||||
state.scripts = state.scripts.filter(s => s.id !== id);
|
state.scripts = state.scripts.filter(s => s.id !== id);
|
||||||
@@ -230,6 +245,15 @@ const renderScriptList = () => {
|
|||||||
`).join('')}
|
`).join('')}
|
||||||
${(s.matches?.length > 3) ? `<span class="text-[10px] font-mono text-slate-500">+${s.matches.length - 3}</span>` : ''}
|
${(s.matches?.length > 3) ? `<span class="text-[10px] font-mono text-slate-500">+${s.matches.length - 3}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
${s.enabled && state.buttons[s.id]?.length ? `
|
||||||
|
<div class="flex flex-wrap gap-1 pt-1.5 border-t border-slate-100">
|
||||||
|
${state.buttons[s.id].map(b => `
|
||||||
|
<button data-action="script-button" data-id="${s.id}" data-button="${b.id}" class="text-[11px] font-medium px-2 py-0.5 rounded border border-sky-200 bg-sky-50 text-sky-800 hover:bg-sky-100 hover:border-sky-300 cursor-pointer transition-colors max-w-full truncate">
|
||||||
|
${esc(b.label)}
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
`).join('') : `
|
`).join('') : `
|
||||||
<div class="h-full flex flex-col items-center justify-center text-center p-6 text-slate-500">
|
<div class="h-full flex flex-col items-center justify-center text-center p-6 text-slate-500">
|
||||||
@@ -447,6 +471,10 @@ const bindEvents = () => {
|
|||||||
btn.addEventListener('click', () => setTab('editor', btn.dataset.id));
|
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 => {
|
app.querySelectorAll('[data-action="delete"]').forEach(btn => {
|
||||||
btn.addEventListener('click', e => {
|
btn.addEventListener('click', e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { VERSION } from '../version.js';
|
|||||||
|
|
||||||
const STORAGE_MESSAGE = 'OPEN_SCRIPT_STORAGE';
|
const STORAGE_MESSAGE = 'OPEN_SCRIPT_STORAGE';
|
||||||
const FETCH_MESSAGE = 'OPEN_SCRIPT_FETCH';
|
const FETCH_MESSAGE = 'OPEN_SCRIPT_FETCH';
|
||||||
|
const BUTTONS_GLOBAL = '__OpenScriptButtons';
|
||||||
|
|
||||||
export const isUserScriptsAvailable = async () => {
|
export const isUserScriptsAvailable = async () => {
|
||||||
if (!chrome.userScripts) return false;
|
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]
|
// [OpenScript runtime]
|
||||||
(async function(OpenScript, env) {
|
(async function(OpenScript, env) {
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -59,7 +83,29 @@ ${code}
|
|||||||
Object.defineProperty(res, 'url', { value: response.url || url.toString() });
|
Object.defineProperty(res, 'url', { value: response.url || url.toString() });
|
||||||
return res;
|
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.OpenScript = OpenScript;
|
||||||
globalThis.env = env;
|
globalThis.env = env;
|
||||||
return [OpenScript, env];
|
return [OpenScript, env];
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export const VERSION = '1.0.5';
|
export const VERSION = '1.0.6';
|
||||||
|
|||||||
@@ -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('<b>Temp</b>', () => globalThis.__clicks.push('temp'));
|
||||||
|
globalThis.__remove = remove;
|
||||||
|
`);
|
||||||
|
assert.deepEqual(registry.list(), [{ id: 1, label: 'Settings' }, { id: 2, label: '<b>Temp</b>' }]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user