Files
OpenScript/src/utils/userScripts.js

171 lines
6.7 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';
export const isUserScriptsAvailable = async () => {
if (!chrome.userScripts) return false;
try {
await chrome.userScripts.getScripts();
return true;
} catch {
return false;
}
};
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 OpenScript = Object.freeze({ version: '${VERSION}', env, storage, fetch });
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 };
}
};