mirror of
https://github.com/GetOpenScript/OpenScript.git
synced 2026-10-11 11:05:20 +00:00
Complete OpenScript runtime roadmap
This commit is contained in:
+25
-3
@@ -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
@@ -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
@@ -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);
|
||||
`;
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user