Complete OpenScript runtime roadmap

This commit is contained in:
2026-09-10 19:42:48 -07:00
parent 6fb12f2ea7
commit f5738ba2b8
11 changed files with 441 additions and 326 deletions
+25 -3
View File
@@ -1,16 +1,38 @@
import { syncUserScripts } from './utils/userScripts.js';
import { runScriptStorageOperation } from './utils/storage.js';
let syncQueue = Promise.resolve();
const safelySync = async options => {
try {
return await syncUserScripts(options);
} catch (error) {
console.error('[OpenScript] sync failed:', error);
return { success: false, errors: [error.message], warnings: [] };
}
};
const queueSync = options => syncQueue = syncQueue.then(
() => safelySync(options), () => safelySync(options),
);
chrome.runtime.onInstalled.addListener(() => {
syncUserScripts();
queueSync();
});
chrome.runtime.onStartup.addListener(() => {
syncUserScripts();
queueSync();
});
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'SYNC_SCRIPTS') {
syncUserScripts().then(success => sendResponse({ success }));
queueSync({ refreshRequires: !!msg.refreshRequires }).then(sendResponse);
return true;
}
});
chrome.runtime.onUserScriptMessage.addListener((msg, sender, sendResponse) => {
if (msg?.type !== 'OPEN_SCRIPT_STORAGE') return;
runScriptStorageOperation(msg.token, msg.operation, msg.key, msg.value)
.then(result => sendResponse({ ok: true, ...result }))
.catch(error => sendResponse({ ok: false, error: error.message }));
return true;
});
+24 -16
View File
@@ -1,6 +1,8 @@
import { getScripts, saveScripts, getSecrets, saveSecrets } from './utils/storage.js';
import {
getScripts, saveScripts, getSecrets, saveSecrets, garbageCollectScriptStorage,
} from './utils/storage.js';
import { parseMeta, getBoilerplate } from './utils/parser.js';
import { isUserScriptsAvailable, syncUserScripts } from './utils/userScripts.js';
import { isUserScriptsAvailable } from './utils/userScripts.js';
import { renderIcons, icon } from './utils/icons.js';
// Application State
@@ -16,6 +18,8 @@ const state = {
const $ = sel => document.querySelector(sel);
const app = $('#app');
const syncScripts = refreshRequires =>
chrome.runtime.sendMessage({ type: 'SYNC_SCRIPTS', refreshRequires });
// Toast feedback helper
let toastTimeout;
@@ -33,7 +37,9 @@ const showToast = (msg, isErr = false) => {
// Initialize & Load
const init = async () => {
state.userScriptsReady = await isUserScriptsAvailable();
await syncScripts(false);
const [scripts, secrets] = await Promise.all([getScripts(), getSecrets()]);
await garbageCollectScriptStorage(scripts.map(s => s.id));
state.scripts = scripts;
state.secrets = secrets;
render();
@@ -51,7 +57,7 @@ const toggleScript = async id => {
if (!s) return;
s.enabled = !s.enabled;
await saveScripts(state.scripts);
await syncUserScripts();
await syncScripts(false);
render();
showToast(`Script ${s.enabled ? 'enabled' : 'disabled'}`);
};
@@ -60,7 +66,7 @@ const deleteScript = async id => {
if (!confirm('Delete this user script?')) return;
state.scripts = state.scripts.filter(s => s.id !== id);
await saveScripts(state.scripts);
await syncUserScripts();
await Promise.all([syncScripts(false), garbageCollectScriptStorage(state.scripts.map(s => s.id))]);
render();
showToast('Script deleted');
};
@@ -76,9 +82,11 @@ const saveCurrentScript = async () => {
const scriptObj = {
id: state.editingId || `script_${Date.now()}`,
name: meta.name,
version: meta.version,
description: meta.description,
matches: meta.matches,
requires: meta.requires,
requireCache: existing?.requireCache || {},
storageToken: existing?.storageToken,
runAt: $('#run-at-select')?.value || meta.runAt || 'document_idle',
code,
enabled: existing ? existing.enabled : true,
@@ -90,9 +98,12 @@ const saveCurrentScript = async () => {
: [scriptObj, ...state.scripts];
await saveScripts(state.scripts);
await syncUserScripts();
const result = await syncScripts(true);
state.scripts = await getScripts();
setTab('list');
showToast('Script saved & synced!');
if (!result.success) showToast(`Saved, but not synced: ${result.errors[0]}`, true);
else if (result.warnings.length) showToast('Saved using cached dependencies');
else showToast('Script saved & synced!');
};
const addSecret = async (key, val) => {
@@ -101,7 +112,7 @@ const addSecret = async (key, val) => {
if (!k) return showToast('Enter variable name', true);
state.secrets[k] = v;
await saveSecrets(state.secrets);
await syncUserScripts();
await syncScripts(false);
render();
showToast(`Saved secret: ${k}`);
};
@@ -110,7 +121,7 @@ const removeSecret = async k => {
delete state.secrets[k];
state.revealedSecrets.delete(k);
await saveSecrets(state.secrets);
await syncUserScripts();
await syncScripts(false);
render();
showToast(`Removed secret: ${k}`);
};
@@ -193,9 +204,6 @@ const renderScriptList = () => {
<span class="font-semibold text-xs text-slate-900 truncate cursor-pointer hover:text-sky-600" data-action="edit" data-id="${s.id}">
${s.name}
</span>
<span class="text-[10px] font-mono text-slate-600 bg-slate-100 px-1 rounded border border-slate-200 shrink-0">
v${s.version || '1.0'}
</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button data-action="edit" data-id="${s.id}" class="p-1 hover:bg-slate-100 rounded text-slate-500 hover:text-sky-600 cursor-pointer" title="Edit Script">
@@ -220,7 +228,7 @@ const renderScriptList = () => {
<div class="h-full flex flex-col items-center justify-center text-center p-6 text-slate-500">
${icon('FileCode', 'w-10 h-10 text-slate-300 mb-2')}
<p class="text-xs font-medium text-slate-700">No user scripts found</p>
<p class="text-[11px] text-slate-500 mt-1 max-w-[240px]">Create a new script or import a Tampermonkey script to get started.</p>
<p class="text-[11px] text-slate-500 mt-1 max-w-[240px]">Create a new OpenScript to get started.</p>
<button id="btn-empty-new" class="mt-4 bg-slate-900 text-white hover:bg-slate-800 font-semibold text-xs px-3.5 py-1.5 rounded shadow-sm cursor-pointer transition-colors">
+ New Script
</button>
@@ -278,7 +286,7 @@ const renderEditor = () => {
<!-- Notice Bar -->
<footer class="bg-slate-100 border-t border-slate-200 px-3 py-1 text-[10px] text-slate-500 font-mono shrink-0">
notice: scripts run on matched URLs with synced secrets injected.
notice: scripts run asynchronously with env, storage, and cached @require libraries.
</footer>
</div>
`;
@@ -355,7 +363,7 @@ const renderSecrets = () => {
<p class="text-[11px] text-slate-600 font-semibold mb-1">Code usage in scripts:</p>
<pre class="bg-slate-100 p-2 rounded text-[10px] font-mono text-emerald-800 border border-slate-200/80 overflow-x-auto">const token = OpenScript.env.GH_PAT;
// or: const token = env.GH_PAT;
// or: const token = GM_getValue('GH_PAT');</pre>
const cache = await OpenScript.storage.get('cache');</pre>
</div>
</div>
@@ -498,7 +506,7 @@ window.addEventListener('focus', async () => {
const ready = await isUserScriptsAvailable();
if (ready !== state.userScriptsReady) {
state.userScriptsReady = ready;
if (ready) await syncUserScripts();
if (ready) await syncScripts(false);
render();
}
});
+9 -23
View File
@@ -1,10 +1,11 @@
// Parse & serialize Tampermonkey metadata blocks
// Parse OpenScript's deliberately small metadata format.
const MULTI_KEYS = new Set(['match', 'include', 'exclude', 'grant', 'require']);
const SINGLE_KEYS = new Set(['name', 'description', 'run-at']);
const MULTI_KEYS = new Set(['match', 'require']);
export const parseMeta = code => {
const block = code.match(/\/\/ ==UserScript==([\s\S]*?)\/\/ ==\/UserScript==/)?.[1] || '';
const meta = { matches: [], grants: [] };
const meta = { match: [], require: [] };
for (const line of block.split('\n')) {
const m = line.match(/\/\/\s*@([\w-]+)\s+(.*)/);
@@ -13,22 +14,16 @@ export const parseMeta = code => {
const k = rawK.trim().toLowerCase();
const v = rawV.trim();
if (k === 'match' || k === 'include') meta.matches.push(v);
else if (k === 'grant') meta.grants.push(v);
else if (MULTI_KEYS.has(k)) (meta[k] ??= []).push(v);
else meta[k] = v;
if (MULTI_KEYS.has(k)) meta[k].push(v);
else if (SINGLE_KEYS.has(k)) meta[k] = v;
}
return {
name: meta.name || 'Untitled Script',
version: meta.version || '1.0.0',
description: meta.description || '',
author: meta.author || '',
matches: meta.matches.length ? meta.matches : ['*://*/*'],
matches: meta.match.length ? meta.match : ['*://*/*'],
runAt: (meta['run-at'] || 'document_idle').replace('-', '_'),
grants: meta.grants,
icon: meta.icon || '',
raw: meta,
requires: meta.require,
};
};
@@ -43,17 +38,8 @@ export const normalizeMatch = pattern => {
export const getBoilerplate = (name = 'New Userscript') =>
`// ==UserScript==
// @name ${name}
// @version 1.0.0
// @description try to take over the world!
// @author You
// @match *://*/*
// @grant none
// ==/UserScript==
(function() {
'use strict';
// Access secrets via OpenScript.env or env:
// console.log(OpenScript.env);
})();
console.log('Running on', location.hostname);
`;
+43
View File
@@ -1,5 +1,11 @@
// Storage utilities for OpenScript (local for scripts, sync for secrets)
const SCRIPT_STORAGE_PREFIX = 'storage_';
const scriptStorageKey = (id, key) => `${SCRIPT_STORAGE_PREFIX}${id}_${key}`;
const assertKey = key => {
if (typeof key !== 'string' || !key) throw new TypeError('Storage keys must be non-empty strings');
};
export const getScripts = async () =>
(await chrome.storage.local.get('scripts'))?.scripts || [];
@@ -11,3 +17,40 @@ export const getSecrets = async () =>
export const saveSecrets = secrets =>
chrome.storage.sync.set({ secrets });
export const runScriptStorageOperation = async (token, operation, key, value) => {
const script = (await getScripts()).find(s => s.storageToken === token);
if (!script) throw new Error('Invalid script storage token');
const prefix = scriptStorageKey(script.id, '');
if (operation === 'list') {
const values = await chrome.storage.local.get(null);
return { keys: Object.keys(values).filter(k => k.startsWith(prefix)).map(k => k.slice(prefix.length)) };
}
assertKey(key);
const storageKey = scriptStorageKey(script.id, key);
if (operation === 'set') {
await chrome.storage.local.set({ [storageKey]: value });
return {};
}
if (operation === 'get') {
const values = await chrome.storage.local.get(storageKey);
return { found: Object.hasOwn(values, storageKey), value: values[storageKey] };
}
if (operation === 'delete') {
await chrome.storage.local.remove(storageKey);
return {};
}
throw new Error(`Unknown storage operation: ${operation}`);
};
export const garbageCollectScriptStorage = async scriptIds => {
const prefixes = scriptIds.map(id => scriptStorageKey(id, ''));
const values = await chrome.storage.local.get(null);
const orphaned = Object.keys(values).filter(key =>
key.startsWith(SCRIPT_STORAGE_PREFIX) && !prefixes.some(prefix => key.startsWith(prefix))
);
if (orphaned.length) await chrome.storage.local.remove(orphaned);
return orphaned;
};
+119 -36
View File
@@ -1,5 +1,7 @@
import { getScripts, getSecrets } from './storage.js';
import { normalizeMatch } from './parser.js';
import { getScripts, saveScripts, getSecrets } from './storage.js';
import { normalizeMatch, parseMeta } from './parser.js';
const STORAGE_MESSAGE = 'OPEN_SCRIPT_STORAGE';
export const isUserScriptsAvailable = async () => {
if (!chrome.userScripts) return false;
@@ -11,49 +13,130 @@ export const isUserScriptsAvailable = async () => {
}
};
export const wrapScriptCode = (code, secrets = {}) => {
const envInjection = `
// [OpenScript Injected Environment]
(function() {
const secretsObj = Object.freeze(${JSON.stringify(secrets)});
const openScriptObj = Object.freeze({
version: "1.0.0",
env: secretsObj
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),
});
globalThis.OpenScript = openScriptObj;
globalThis.env = secretsObj;
globalThis.GM_getValue = (k, def) => (secretsObj[k] ?? def);
})();
var OpenScript = globalThis.OpenScript;
var env = globalThis.env;
var GM_getValue = globalThis.GM_getValue;
const OpenScript = Object.freeze({ version: '1.0.0', env, storage });
globalThis.OpenScript = OpenScript;
globalThis.env = env;
return [OpenScript, env];
})()).catch(error => console.error('[OpenScript] Script failed:', error));
`;
return `${envInjection}\n${code}`;
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 syncUserScripts = async () => {
if (!await isUserScriptsAvailable()) return false;
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 };
};
const [scripts, secrets] = await Promise.all([getScripts(), getSecrets()]);
const activeScripts = scripts.filter(s => s.enabled);
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();
if (requires.some(url => script.requireCache?.[url] === undefined)) missingCache = true;
if (storageToken === script.storageToken &&
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, 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 };
if (!activeScripts.length) return true;
const toRegister = activeScripts.map(s => ({
id: s.id,
matches: (s.matches?.length ? s.matches : ['*://*/*']).map(normalizeMatch),
runAt: s.runAt || 'document_idle',
js: [{ code: wrapScriptCode(s.code, secrets) }],
}));
await chrome.userScripts.register(toRegister);
return true;
} catch (err) {
console.error('[OpenScript] sync failed:', err);
return false;
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 };
}
};