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:
2026-10-02 07:14:51 -07:00
co-authored by Claude Opus 5.5
parent 1883a63ccd
commit 9b80284519
8 changed files with 192 additions and 8 deletions
+29 -1
View File
@@ -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) ? `<span class="text-[10px] font-mono text-slate-500">+${s.matches.length - 3}</span>` : ''}
</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>
`).join('') : `
<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));
});
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();
+48 -2
View File
@@ -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];
+1 -1
View File
@@ -1 +1 @@
export const VERSION = '1.0.5';
export const VERSION = '1.0.6';