diff --git a/PRIVACY.md b/PRIVACY.md index 87be720..4e4c1d1 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy Policy for OpenScript -**Last updated:** September 8, 2026 +**Last updated:** September 10, 2026 OpenScript is an open-source browser extension designed with a strict privacy-first architecture. @@ -9,12 +9,15 @@ OpenScript **does not collect, store, transmit, or sell** any personal informati ### 2. Local & Synced Storage * **User Scripts:** Script names, code, and configurations are stored solely on your device using Chrome's `chrome.storage.local` API. +* **Script State & Libraries:** Per-script values and downloaded `@require` library source are stored locally with the script that owns them. * **Environment Secrets:** Variables and API tokens you add are stored via `chrome.storage.sync` and are only synchronized across your own browser sessions using your authenticated Google account. OpenScript has no access to external servers or databases. +OpenScript makes no analytics or telemetry requests. When you save a script containing `@require`, it requests each URL you declared to cache that library. Those hosts receive the ordinary network information associated with a download, such as your IP address. + ### 3. Permissions Justification * **`userScripts`:** Used exclusively to register and execute user-defined scripts inside pages you visit matching your `@match` rules. -* **`storage` & `unlimitedStorage`:** Used exclusively to save your scripts and secrets on your local machine. -* **Host Permissions (`*://*/*`):** Used solely to allow scripts to run on URLs specified in the user scripts you configure. +* **`storage` & `unlimitedStorage`:** Used to save scripts, per-script state, cached libraries, and synced secrets. +* **Host Permissions (`*://*/*`):** Used to run scripts on configured `@match` URLs and download libraries from user-configured `@require` URLs. ### 4. Third-Party Sharing OpenScript does not share, transfer, or sell user data to any third party under any circumstances. diff --git a/README.md b/README.md index bf3dce0..390dcba 100644 --- a/README.md +++ b/README.md @@ -1,169 +1,95 @@ # OpenScript -A lightweight, modern user script manager built for Chrome Manifest V3 using the native `chrome.userScripts` API. +A lightweight, modern script manager built for Chrome Manifest V3 with the native `chrome.userScripts` API. ---- +## Prerequisites -## ⚡ Prerequisites +OpenScript requires Chrome 138 or newer. To run scripts: -To run user scripts in Chrome MV3: -1. Open `chrome://extensions` in your browser. -2. Click **Details** on the **OpenScript** extension card. -3. Enable the **"Allow User Scripts"** toggle. +1. Open `chrome://extensions`. +2. Select **Details** on the OpenScript extension card. +3. Enable **Allow User Scripts**. ---- +## Writing scripts -## 📖 Writing Scripts Tutorial - -OpenScript uses standard Tampermonkey-compatible metadata headers with built-in secret injection. - -### 1. The Metadata Block - -Every user script begins with a `// ==UserScript==` block that tells OpenScript when and where to run: - -```javascript -// ==UserScript== -// @name GitHub Notification Cleaner -// @version 1.0.0 -// @description Hides read notifications automatically -// @author YourName -// @match https://github.com/* -// @run-at document_idle -// @grant none -// ==/UserScript== - -(function() { - 'use strict'; - console.log('OpenScript running on GitHub!'); -})(); -``` - -#### Supported Header Directives - -| Directive | Description | Example | -| :--- | :--- | :--- | -| `@name` | Script title shown in OpenScript popup list | `@name My Custom Tool` | -| `@version` | Version badge displayed in popup list | `@version 1.2.0` | -| `@description` | Summary shown under the script title | `@description Auto-clicks accept buttons` | -| `@author` | Author metadata | `@author Alice` | -| `@match` / `@include` | URL patterns where script runs (supports multiple) | `@match https://*.example.com/*` | -| `@run-at` | Injection timing: `document_idle` (default), `document_start`, `document_end` | `@run-at document_start` | -| `@grant` | Compatibility header (e.g. `none`) | `@grant none` | - -> **Note on `@match` normalization:** OpenScript automatically normalizes bare URLs (e.g., `github.com/*` becomes `*://github.com/*` and `https://github.com` becomes `https://github.com/*`). - ---- - -### 2. Execution Timing (`@run-at`) - -Control when your script executes relative to page lifecycle: - -* **`document_idle` (Default):** Runs after the page DOM is fully built and subresources have finished loading. Best for DOM manipulation and button clicks. -* **`document_start`:** Runs before any DOM elements are constructed or external page scripts execute. Best for early theme injection, ad/tracker blockers, or prototype overrides. -* **`document_end`:** Runs right as the DOM content is parsed (`DOMContentLoaded`), before images and stylesheets finish loading. - -*(Both hyphenated `document-idle` and underscore `document_idle` formats are supported).* - ---- - -### 3. Using Synced Secrets & Environment Variables - -OpenScript allows you to store sensitive API tokens or passwords in the **Secrets** tab. Secrets are synced across your devices via `chrome.storage.sync` and injected into every active user script. - -#### Accessing Secrets in Code - -You can read secrets using any of these 3 equivalent syntaxes: - -```javascript -// 1. Direct OpenScript namespace -const token = OpenScript.env.GH_PAT; - -// 2. Shorthand env global -const token = env.GH_PAT; - -// 3. Standard Tampermonkey GM_getValue polyfill -const token = GM_getValue('GH_PAT', 'default_value'); -``` - -#### Complete Example: GitHub API Fetcher with Secrets +Scripts use a small metadata block followed by ordinary JavaScript. OpenScript supplies the async wrapper, so top-level `await`, `return`, and isolated declarations work without boilerplate. ```javascript // ==UserScript== // @name GitHub Repo Stats -// @version 1.0.0 -// @description Fetches repository star count with personal token +// @description Logs repository metadata // @match https://github.com/* // @run-at document_idle // ==/UserScript== -(async function() { - 'use strict'; +const [, owner, repo] = location.pathname.split('/'); +if (!owner || !repo) return; - // Retrieve secret saved in OpenScript "Secrets" tab - const token = env.GH_PAT; - if (!token) { - console.warn('[OpenScript] Please configure GH_PAT in OpenScript Secrets tab.'); - return; - } - - const [, owner, repo] = location.pathname.split('/'); - if (!owner || !repo) return; - - const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { - headers: { Authorization: `Bearer ${token}` } - }); - const data = await res.json(); - console.log(`[OpenScript] ${data.full_name} has ${data.stargazers_count} stars!`); -})(); +const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`); +console.log(await response.json()); ``` ---- +### Metadata -### 4. Boilerplate Template +| Directive | Description | Required | +| :--- | :--- | :--- | +| `@name` | Name displayed in the popup | Yes | +| `@match` | Chrome match pattern; repeat for multiple patterns | Yes | +| `@description` | Short summary displayed in the popup | No | +| `@run-at` | `document_idle`, `document_start`, or `document_end` | No | +| `@require` | HTTP(S) library URL; repeat for multiple libraries | No | -When you click **+ New** in the extension popup, OpenScript gives you this clean starter template: +The editor’s run-at selector is authoritative when a script is saved. Bare match URLs are normalized: `github.com/*` becomes `*://github.com/*`, and `https://github.com` becomes `https://github.com/*`. + +### Secrets and environment variables + +Secrets saved in the popup are synchronized through `chrome.storage.sync` and exposed to every script through either namespace: + +```javascript +const token = OpenScript.env.GH_PAT; +const sameToken = env.GH_PAT; +``` + +### Per-script storage + +Every script gets isolated, persistent storage backed by `chrome.storage.local`: + +```javascript +await OpenScript.storage.set('repo_cache', { size: 1024 }); +const cached = await OpenScript.storage.get('repo_cache'); // undefined when absent +const keys = await OpenScript.storage.list(); +await OpenScript.storage.delete('repo_cache'); +``` + +Stored keys are scoped to the current script and survive reloads and browser restarts. Orphaned values are removed when the popup opens after their script has been deleted. + +### External libraries + +Use `@require` to cache libraries when a script is saved: ```javascript // ==UserScript== -// @name New Userscript -// @version 1.0.0 -// @description try to take over the world! -// @author You +// @name Alerts // @match *://*/* -// @grant none +// @require https://cdn.jsdelivr.net/npm/sweetalert2@11 // ==/UserScript== -(function() { - 'use strict'; - - // Access secrets via OpenScript.env or env: - // console.log(OpenScript.env); -})(); +await Swal.fire('OpenScript is ready'); ``` ---- +Libraries are prepended in declaration order inside the isolated `USER_SCRIPT` world. The cached source avoids page CSP restrictions and remains available offline. If a refresh fails, OpenScript uses the last cached copy; a script with a dependency that has never been cached is not registered. -## 🛠️ Development & Building +## Development ```bash -# Install dependencies npm install - -# Run Vite dev server npm run dev - -# Run unit tests npm test - -# Generate icons from master v2 logo +npm run build npm run build:icons - -# Build and package Chrome Web Store zip npm run zip ``` ---- +## Privacy -## 🔒 Privacy - -OpenScript does not track users, log data, or contact external servers. All user scripts are stored locally on your machine. See our [Privacy Policy](PRIVACY.md). +OpenScript does not track users or send analytics. Script code and per-script state stay in `chrome.storage.local`; secrets use `chrome.storage.sync`. URLs declared with `@require` are contacted only to download their requested libraries. See the [Privacy Policy](PRIVACY.md). diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 150ba2e..0000000 --- a/TODO.md +++ /dev/null @@ -1,85 +0,0 @@ -# OpenScript Architecture & Roadmap (TODO) - -## 🎯 Vision & Philosophy -OpenScript is built for personal control, security, and developer ergonomics—not legacy compatibility. We are intentionally divorcing from Greasemonkey/Tampermonkey conventions and Greasy Fork baggage in favor of a clean, modern, zero-overhead script runner for Chrome MV3. - ---- - -## 1. Implement `OpenScript.storage.*` (Stateful Scripts) -Add native, per-script key-value persistence so scripts can retain state, caches, counters, and UI toggle preferences across page reloads and browser restarts without relying on in-memory `Map`s. - -### Proposed API -```javascript -await OpenScript.storage.set('repo_cache', { size: 1024 }); -const cached = await OpenScript.storage.get('repo_cache'); // returns undefined if not found -await OpenScript.storage.delete('repo_cache'); -const allKeys = await OpenScript.storage.list(); -``` - -### Implementation Details -* Store values under `chrome.storage.local`. -* Prefix keys by script ID (`storage_${scriptId}_${key}`) to guarantee strict isolation between scripts. -* Expose via background worker messaging or direct bridge in script injection context. -* Universally available to all scripts without requiring any permission gates. - ---- - -## 2. Eliminate Legacy Metadata Bloat (`@grant`, `@namespace`) -Tampermonkey required headers designed for third-party security audits and sandboxing hacks from 15 years ago. For OpenScript, these are purely friction. - -### TODO: -- [ ] **Remove `@grant`:** Eliminate `@grant` from the parser, template boilerplate, and docs. Since OpenScript is designed for personal scripts, artificial permission gating is unnecessary red tape. All built-in APIs (`env`, `storage`) should be available out of the box. -- [ ] **Remove `@namespace`:** Unnecessary metadata relic; completely ignore and omit. -- [ ] **Minimalist Header Standard:** Retain only the essentials: - - `@name` (UI display in popup) - - `@match` (URL injection pattern for Chrome) - - `@run-at` (`document_idle` | `document_start` | `document_end`) - - `@description` *(Optional)* - ---- - -## 3. Drop Ritualistic Wrappers & Enable Native Async -Forcing scripts to start with `(function() { 'use strict'; })();` is ugly, redundant, and visually noisy. - -### Proposed Improvement: -* Automatically wrap user code behind the scenes inside `wrapScriptCode`: - ```javascript - (async function() { - 'use strict'; - // User's clean script code runs here - })(); - ``` - -### Benefits: -- **Top-Level `await` Everywhere:** Users can write `const res = await fetch(...)` directly at the root of the script without nesting inside an async function. -- **Zero Boilerplate:** The default new script template drops down to: - ```javascript - // ==UserScript== - // @name My Script - // @match *://*/* - // ==/UserScript== - - console.log('Running on', location.hostname); - ``` -- **Scope Isolation:** Variables (`const`, `let`, `var`) won't collide across multiple user scripts on the same page. -- **Clean Early Exits:** Top-level `return;` continues to work cleanly to halt execution early when needed. - ---- - -## 4. Purge `GM_*` Polyfills -- [ ] Remove `GM_getValue` polyfill from `src/utils/userScripts.js`. -- [ ] Transition strictly to the modern, canonical OpenScript namespace: - - `OpenScript.env.KEY` / `env.KEY` (for synced credentials) - - `OpenScript.storage.*` (for persistent per-script state) -- [ ] Clean up tests and examples to remove references to `GM_*`. - ---- - -## 5. External Libraries: Bundling via `@require` -Instead of relying on dynamic `import()` (which is blocked by website Content Security Policies on hardened domains like GitHub), support `@require `. - -### Implementation: -- Parser extracts `@require ` directives from metadata. -- When saving/syncing scripts, OpenScript fetches external scripts (e.g. SweetAlert2, UI helpers) in the background. -- Downloaded libraries are prepended directly into the user script bundle inside Chrome's isolated `USER_SCRIPT` world. -- Bypasses target website CSP restrictions completely and works offline. diff --git a/manifest.json b/manifest.json index 268efa0..2141983 100644 --- a/manifest.json +++ b/manifest.json @@ -1,5 +1,6 @@ { "manifest_version": 3, + "minimum_chrome_version": "138", "name": "OpenScript", "version": "1.0.0", "description": "A lightweight user script manager for modern browsers", diff --git a/src/background.js b/src/background.js index d9e6e3c..a17ca1f 100644 --- a/src/background.js +++ b/src/background.js @@ -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; +}); diff --git a/src/popup.js b/src/popup.js index e8e6143..a0b2f85 100644 --- a/src/popup.js +++ b/src/popup.js @@ -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 = () => { ${s.name} - - v${s.version || '1.0'} -
@@ -278,7 +286,7 @@ const renderEditor = () => {
- notice: scripts run on matched URLs with synced secrets injected. + notice: scripts run asynchronously with env, storage, and cached @require libraries.
`; @@ -355,7 +363,7 @@ const renderSecrets = () => {

Code usage in scripts:

const token = OpenScript.env.GH_PAT;
 // or: const token = env.GH_PAT;
-// or: const token = GM_getValue('GH_PAT');
+const cache = await OpenScript.storage.get('cache'); @@ -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(); } }); diff --git a/src/utils/parser.js b/src/utils/parser.js index d300545..e06c52c 100644 --- a/src/utils/parser.js +++ b/src/utils/parser.js @@ -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); `; diff --git a/src/utils/storage.js b/src/utils/storage.js index ac880e8..672dd9b 100644 --- a/src/utils/storage.js +++ b/src/utils/storage.js @@ -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; +}; diff --git a/src/utils/userScripts.js b/src/utils/userScripts.js index 9a44233..482e6aa 100644 --- a/src/utils/userScripts.js +++ b/src/utils/userScripts.js @@ -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 }; } }; diff --git a/tests/parser.test.js b/tests/parser.test.js index 6b0ed4f..ba1d55c 100644 --- a/tests/parser.test.js +++ b/tests/parser.test.js @@ -1,43 +1,42 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { parseMeta, normalizeMatch, getBoilerplate } from '../src/utils/parser.js'; -import { wrapScriptCode } from '../src/utils/userScripts.js'; +import { + buildScriptCode, refreshRequireCaches, syncUserScripts, wrapScriptCode, +} from '../src/utils/userScripts.js'; -test('parseMeta extracts standard Tampermonkey metadata', () => { - const sample = ` +test('parseMeta extracts only OpenScript metadata', () => { + const meta = parseMeta(` // ==UserScript== // @name Test Script // @version 2.1.0 // @description Sample description // @author Alice -// @match https://gemini.google.com/* +// @namespace legacy // @match https://example.com/* +// @include https://ignored.example/* // @run-at document-start // @grant none +// @require https://cdn.example/library.js // ==/UserScript== +`); -console.log('hello'); -`; - - const meta = parseMeta(sample); - assert.equal(meta.name, 'Test Script'); - assert.equal(meta.version, '2.1.0'); - assert.equal(meta.description, 'Sample description'); - assert.equal(meta.author, 'Alice'); - assert.deepEqual(meta.matches, ['https://gemini.google.com/*', 'https://example.com/*']); - assert.equal(meta.runAt, 'document_start'); + assert.deepEqual(meta, { + name: 'Test Script', + description: 'Sample description', + matches: ['https://example.com/*'], + runAt: 'document_start', + requires: ['https://cdn.example/library.js'], + }); }); test('parseMeta falls back to defaults when fields are missing', () => { - const sample = ` -// ==UserScript== -// ==/UserScript== -`; - const meta = parseMeta(sample); + const meta = parseMeta('// ==UserScript==\n// ==/UserScript=='); assert.equal(meta.name, 'Untitled Script'); - assert.equal(meta.version, '1.0.0'); + assert.equal(meta.description, ''); assert.deepEqual(meta.matches, ['*://*/*']); assert.equal(meta.runAt, 'document_idle'); + assert.deepEqual(meta.requires, []); }); test('normalizeMatch formats URL patterns for Chrome userScripts API', () => { @@ -46,21 +45,98 @@ test('normalizeMatch formats URL patterns for Chrome userScripts API', () => { assert.equal(normalizeMatch('*://*/*'), '*://*/*'); }); -test('wrapScriptCode injects OpenScript.env and GM_getValue polyfill', () => { - const code = 'console.log(env.API_KEY, GM_getValue("API_KEY"));'; - const wrapped = wrapScriptCode(code, { API_KEY: 'secret123' }); +test('wrapScriptCode provides async OpenScript APIs without GM polyfills', () => { + const code = 'const cached = await OpenScript.storage.get("cache");\nif (!cached) return;'; + const wrapped = wrapScriptCode(code, { API_KEY: 'secret123' }, 'storage-token'); - assert.ok(wrapped.includes('OpenScript')); + assert.match(wrapped, /async function\(OpenScript, env\)/); assert.ok(wrapped.includes('"API_KEY":"secret123"')); - assert.ok(wrapped.includes('globalThis.OpenScript')); - assert.ok(wrapped.includes('GM_getValue')); + assert.ok(wrapped.includes("call('list')")); + assert.ok(wrapped.includes('storage-token')); assert.ok(wrapped.includes(code)); + assert.ok(!wrapped.includes('GM_getValue')); + assert.doesNotThrow(() => new Function(wrapped)); }); -test('getBoilerplate produces valid Tampermonkey template without namespace', () => { +test('buildScriptCode prepends cached requirements in declared order', () => { + const script = { + code: 'useLibraries();', + requires: ['https://cdn.example/a.js', 'https://cdn.example/b.js'], + requireCache: { + 'https://cdn.example/a.js': 'const a = 1;', + 'https://cdn.example/b.js': 'const b = 2;', + }, + }; + const bundle = buildScriptCode(script); + assert.ok(bundle.indexOf('const a = 1;') < bundle.indexOf('const b = 2;')); + assert.ok(bundle.indexOf('const b = 2;') < bundle.indexOf('useLibraries();')); +}); + +test('refreshRequireCaches downloads once and falls back to cached code', async () => { + const originalFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = async url => { + calls++; + if (url.endsWith('bad.js')) throw new Error('offline'); + return { ok: true, text: async () => 'globalThis.library = true;' }; + }; + + try { + const good = 'https://cdn.example/good.js'; + const bad = 'https://cdn.example/bad.js'; + const result = await refreshRequireCaches([ + { name: 'One', requires: [good, bad], requireCache: { [bad]: 'cached();' } }, + { name: 'Two', requires: [good] }, + ]); + assert.equal(calls, 2); + assert.equal(result.scripts[0].requireCache[good], 'globalThis.library = true;'); + assert.equal(result.scripts[0].requireCache[bad], 'cached();'); + assert.equal(result.scripts[1].requireCache[good], 'globalThis.library = true;'); + assert.equal(result.warnings.length, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('syncUserScripts migrates tokens and registers isolated script worlds', async () => { + const originalChrome = globalThis.chrome; + const data = { + scripts: [{ id: 'script_1', name: 'One', code: 'return;', enabled: true, matches: ['*://*/*'] }], + secrets: { TOKEN: 'secret' }, + }; + let registered; + const configured = []; + globalThis.chrome = { + storage: { + local: { + get: async key => ({ [key]: data[key] }), + set: async values => Object.assign(data, values), + }, + sync: { get: async key => ({ [key]: data[key] }) }, + }, + userScripts: { + getScripts: async () => [], + configureWorld: async value => configured.push(value), + register: async value => { registered = value; }, + }, + }; + + try { + const result = await syncUserScripts(); + assert.equal(result.success, true); + assert.ok(data.scripts[0].storageToken); + assert.deepEqual(configured, [{ worldId: 'script_1', messaging: true }]); + assert.equal(registered[0].worldId, 'script_1'); + assert.ok(registered[0].js[0].code.includes('"TOKEN":"secret"')); + } finally { + globalThis.chrome = originalChrome; + } +}); + +test('getBoilerplate is wrapper-free and minimalist', () => { const template = getBoilerplate('My Script'); assert.ok(template.includes('// @name My Script')); - assert.ok(!template.includes('@namespace')); - const parsed = parseMeta(template); - assert.equal(parsed.name, 'My Script'); + assert.ok(template.includes("console.log('Running on', location.hostname);")); + for (const legacy of ['@namespace', '@grant', '@version', '@author', '(function']) + assert.ok(!template.includes(legacy)); }); diff --git a/tests/storage.test.js b/tests/storage.test.js new file mode 100644 index 0000000..641b230 --- /dev/null +++ b/tests/storage.test.js @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + garbageCollectScriptStorage, runScriptStorageOperation, +} from '../src/utils/storage.js'; + +const mockChromeStorage = initial => { + const data = structuredClone(initial); + globalThis.chrome = { storage: { local: { + get: async key => key === null ? { ...data } : Object.hasOwn(data, key) ? { [key]: data[key] } : {}, + set: async values => Object.assign(data, values), + remove: async keys => [keys].flat().forEach(key => delete data[key]), + } } }; + return data; +}; + +test('script storage isolates, lists, and deletes values by script ID', async () => { + const data = mockChromeStorage({ + scripts: [ + { id: 'script_a', storageToken: 'token-a' }, + { id: 'script_b', storageToken: 'token-b' }, + ], + storage_script_b_shared: 'private-b', + }); + + await runScriptStorageOperation('token-a', 'set', 'shared', { count: 1 }); + assert.deepEqual(data.storage_script_a_shared, { count: 1 }); + assert.deepEqual(await runScriptStorageOperation('token-a', 'get', 'shared'), { + found: true, value: { count: 1 }, + }); + assert.deepEqual(await runScriptStorageOperation('token-a', 'get', 'missing'), { + found: false, value: undefined, + }); + assert.deepEqual(await runScriptStorageOperation('token-a', 'list'), { keys: ['shared'] }); + await runScriptStorageOperation('token-a', 'delete', 'shared'); + assert.equal(data.storage_script_a_shared, undefined); + await assert.rejects(() => runScriptStorageOperation('token-bad', 'list'), /Invalid script storage token/); +}); + +test('script storage garbage collection removes only orphaned script keys', async () => { + const data = mockChromeStorage({ + scripts: [{ id: 'script_a' }], + storage_script_a_keep: 1, + storage_script_deleted_remove: 2, + unrelated: 3, + }); + const removed = await garbageCollectScriptStorage(['script_a']); + assert.deepEqual(removed, ['storage_script_deleted_remove']); + assert.equal(data.storage_script_a_keep, 1); + assert.equal(data.storage_script_deleted_remove, undefined); + assert.equal(data.unrelated, 3); +});