Files
OpenScript/src/utils/userScripts.js
T
multipleof4andClaude Opus 5.5 9b80284519 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>
2026-10-02 07:14:51 -07:00

217 lines
8.4 KiB
JavaScript

import { getScripts, saveScripts, getSecrets } from './storage.js';
import { normalizeMatch, parseMeta, getMetaRunAt } from './parser.js';
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;
try {
await chrome.userScripts.getScripts();
return true;
} catch {
return false;
}
};
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';
${code}
})(...(() => {
const env = Object.freeze(${JSON.stringify(secrets)});
const call = async (operation, key, value) => {
const response = await chrome.runtime.sendMessage({
type: '${STORAGE_MESSAGE}', token: ${JSON.stringify(storageToken)}, operation, key, value
});
if (!response?.ok) throw new Error(response?.error || 'OpenScript storage request failed');
return response;
};
const storage = Object.freeze({
set: (key, value) => call('set', key, value).then(() => undefined),
get: key => call('get', key).then(result => result.found ? result.value : undefined),
delete: key => call('delete', key).then(() => undefined),
list: () => call('list').then(result => result.keys),
});
const fetch = async (url, options = {}) => {
let { headers, body, ...rest } = options;
if (headers instanceof Headers) headers = Object.fromEntries(headers.entries());
const response = await chrome.runtime.sendMessage({
type: '${FETCH_MESSAGE}', url: url.toString(), options: { ...rest, headers, body },
});
if (!response?.ok) throw new TypeError(response?.error || 'OpenScript fetch failed');
let resBody = null;
if (![101, 204, 205, 304].includes(response.status)) {
if (response.base64 !== undefined) {
const bin = atob(response.base64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
resBody = bytes.buffer;
} else {
resBody = response.body ?? null;
}
}
const res = new Response(resBody, {
status: response.status, statusText: response.statusText, headers: response.headers,
});
Object.defineProperty(res, 'url', { value: response.url || url.toString() });
return res;
};
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];
})()).catch(error => console.error('[OpenScript] Script failed:', error));
`;
export const buildScriptCode = (script, secrets = {}) => [
...(script.requires || []).map(url =>
`// [OpenScript @require ${url}]\n${script.requireCache?.[url] || ''}`
),
wrapScriptCode(script.code, secrets, script.storageToken),
].join('\n\n');
const download = async url => {
const parsed = new URL(url);
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('only HTTP(S) URLs are supported');
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.text();
};
export const refreshRequireCaches = async scripts => {
const urls = [...new Set(scripts.flatMap(s => s.requires || []))];
const downloads = new Map(await Promise.all(urls.map(async url => {
try {
return [url, { code: await download(url) }];
} catch (error) {
return [url, { error: error.message }];
}
})));
const warnings = [];
const updated = scripts.map(script => {
const cache = Object.fromEntries((script.requires || []).flatMap(url => {
const result = downloads.get(url);
if (result?.code !== undefined) return [[url, result.code]];
if (script.requireCache?.[url] !== undefined) {
warnings.push(`${script.name}: using cached ${url} (${result.error})`);
return [[url, script.requireCache[url]]];
}
warnings.push(`${script.name}: could not download ${url} (${result.error})`);
return [];
}));
return { ...script, requireCache: cache };
});
return { scripts: updated, warnings };
};
export const syncUserScripts = async ({ refreshRequires = false } = {}) => {
let [available, scripts, secrets] = await Promise.all([
isUserScriptsAvailable(), getScripts(), getSecrets(),
]);
let changed = false;
let missingCache = false;
scripts = scripts.map(script => {
const requires = parseMeta(script.code || '').requires;
const storageToken = script.storageToken || crypto.randomUUID();
const runAt = getMetaRunAt(script.code || '') || script.runAt || 'document_idle';
if (requires.some(url => script.requireCache?.[url] === undefined)) missingCache = true;
if (storageToken === script.storageToken &&
runAt === script.runAt &&
JSON.stringify(requires) === JSON.stringify(script.requires || [])) return script;
changed = true;
const requireCache = Object.fromEntries(requires.flatMap(url =>
script.requireCache && Object.hasOwn(script.requireCache, url) ? [[url, script.requireCache[url]]] : []
));
return { ...script, runAt, requires, requireCache, storageToken };
});
let warnings = [];
if (refreshRequires || missingCache) {
({ scripts, warnings } = await refreshRequireCaches(scripts));
changed = true;
}
if (changed) await saveScripts(scripts);
if (!available)
return { success: false, errors: ['Allow User Scripts is disabled'], warnings };
const errors = [];
const activeScripts = scripts.filter(script => {
if (!script.enabled) return false;
const missing = (script.requires || []).filter(url => script.requireCache?.[url] === undefined);
if (!missing.length) return true;
errors.push(`${script.name}: missing @require cache for ${missing.join(', ')}`);
return false;
});
try {
const existing = await chrome.userScripts.getScripts();
if (existing.length) await chrome.userScripts.unregister({ ids: existing.map(s => s.id) });
if (!activeScripts.length) return { success: !errors.length, errors, warnings };
await Promise.all(activeScripts.map(script =>
chrome.userScripts.configureWorld({ worldId: script.id, messaging: true })
));
await chrome.userScripts.register(activeScripts.map(script => ({
id: script.id,
matches: (script.matches?.length ? script.matches : ['*://*/*']).map(normalizeMatch),
runAt: script.runAt || 'document_idle',
world: 'USER_SCRIPT',
worldId: script.id,
js: [{ code: buildScriptCode(script, secrets) }],
})));
return { success: !errors.length, errors, warnings };
} catch (error) {
console.error('[OpenScript] sync failed:', error);
return { success: false, errors: [...errors, error.message], warnings };
}
};