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

View File

@@ -1,6 +1,6 @@
# Privacy Policy for OpenScript # 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. 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 ### 2. Local & Synced Storage
* **User Scripts:** Script names, code, and configurations are stored solely on your device using Chrome's `chrome.storage.local` API. * **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. * **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 ### 3. Permissions Justification
* **`userScripts`:** Used exclusively to register and execute user-defined scripts inside pages you visit matching your `@match` rules. * **`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. * **`storage` & `unlimitedStorage`:** Used to save scripts, per-script state, cached libraries, and synced secrets.
* **Host Permissions (`*://*/*`):** Used solely to allow scripts to run on URLs specified in the user scripts you configure. * **Host Permissions (`*://*/*`):** Used to run scripts on configured `@match` URLs and download libraries from user-configured `@require` URLs.
### 4. Third-Party Sharing ### 4. Third-Party Sharing
OpenScript does not share, transfer, or sell user data to any third party under any circumstances. OpenScript does not share, transfer, or sell user data to any third party under any circumstances.

186
README.md
View File

@@ -1,169 +1,95 @@
# OpenScript # 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`.
1. Open `chrome://extensions` in your browser. 2. Select **Details** on the OpenScript extension card.
2. Click **Details** on the **OpenScript** extension card. 3. Enable **Allow User Scripts**.
3. Enable the **"Allow User Scripts"** toggle.
--- ## Writing scripts
## 📖 Writing Scripts Tutorial 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.
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
```javascript ```javascript
// ==UserScript== // ==UserScript==
// @name GitHub Repo Stats // @name GitHub Repo Stats
// @version 1.0.0 // @description Logs repository metadata
// @description Fetches repository star count with personal token
// @match https://github.com/* // @match https://github.com/*
// @run-at document_idle // @run-at document_idle
// ==/UserScript== // ==/UserScript==
(async function() { const [, owner, repo] = location.pathname.split('/');
'use strict'; if (!owner || !repo) return;
// Retrieve secret saved in OpenScript "Secrets" tab const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`);
const token = env.GH_PAT; console.log(await response.json());
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!`);
})();
``` ```
--- ### 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 editors 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 ```javascript
// ==UserScript== // ==UserScript==
// @name New Userscript // @name Alerts
// @version 1.0.0
// @description try to take over the world!
// @author You
// @match *://*/* // @match *://*/*
// @grant none // @require https://cdn.jsdelivr.net/npm/sweetalert2@11
// ==/UserScript== // ==/UserScript==
(function() { await Swal.fire('OpenScript is ready');
'use strict';
// Access secrets via OpenScript.env or env:
// console.log(OpenScript.env);
})();
``` ```
--- 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 ```bash
# Install dependencies
npm install npm install
# Run Vite dev server
npm run dev npm run dev
# Run unit tests
npm test npm test
npm run build
# Generate icons from master v2 logo
npm run build:icons npm run build:icons
# Build and package Chrome Web Store zip
npm run zip npm run zip
``` ```
--- ## Privacy
## 🔒 Privacy 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).
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).

85
TODO.md
View File

@@ -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 <url>`.
### Implementation:
- Parser extracts `@require <url>` 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.

View File

@@ -1,5 +1,6 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"minimum_chrome_version": "138",
"name": "OpenScript", "name": "OpenScript",
"version": "1.0.0", "version": "1.0.0",
"description": "A lightweight user script manager for modern browsers", "description": "A lightweight user script manager for modern browsers",

View File

@@ -1,16 +1,38 @@
import { syncUserScripts } from './utils/userScripts.js'; 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(() => { chrome.runtime.onInstalled.addListener(() => {
syncUserScripts(); queueSync();
}); });
chrome.runtime.onStartup.addListener(() => { chrome.runtime.onStartup.addListener(() => {
syncUserScripts(); queueSync();
}); });
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'SYNC_SCRIPTS') { if (msg.type === 'SYNC_SCRIPTS') {
syncUserScripts().then(success => sendResponse({ success })); queueSync({ refreshRequires: !!msg.refreshRequires }).then(sendResponse);
return true; 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;
});

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 { 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'; import { renderIcons, icon } from './utils/icons.js';
// Application State // Application State
@@ -16,6 +18,8 @@ const state = {
const $ = sel => document.querySelector(sel); const $ = sel => document.querySelector(sel);
const app = $('#app'); const app = $('#app');
const syncScripts = refreshRequires =>
chrome.runtime.sendMessage({ type: 'SYNC_SCRIPTS', refreshRequires });
// Toast feedback helper // Toast feedback helper
let toastTimeout; let toastTimeout;
@@ -33,7 +37,9 @@ const showToast = (msg, isErr = false) => {
// Initialize & Load // Initialize & Load
const init = async () => { const init = async () => {
state.userScriptsReady = await isUserScriptsAvailable(); state.userScriptsReady = await isUserScriptsAvailable();
await syncScripts(false);
const [scripts, secrets] = await Promise.all([getScripts(), getSecrets()]); const [scripts, secrets] = await Promise.all([getScripts(), getSecrets()]);
await garbageCollectScriptStorage(scripts.map(s => s.id));
state.scripts = scripts; state.scripts = scripts;
state.secrets = secrets; state.secrets = secrets;
render(); render();
@@ -51,7 +57,7 @@ const toggleScript = async id => {
if (!s) return; if (!s) return;
s.enabled = !s.enabled; s.enabled = !s.enabled;
await saveScripts(state.scripts); await saveScripts(state.scripts);
await syncUserScripts(); await syncScripts(false);
render(); render();
showToast(`Script ${s.enabled ? 'enabled' : 'disabled'}`); showToast(`Script ${s.enabled ? 'enabled' : 'disabled'}`);
}; };
@@ -60,7 +66,7 @@ const deleteScript = async id => {
if (!confirm('Delete this user script?')) return; if (!confirm('Delete this user script?')) return;
state.scripts = state.scripts.filter(s => s.id !== id); state.scripts = state.scripts.filter(s => s.id !== id);
await saveScripts(state.scripts); await saveScripts(state.scripts);
await syncUserScripts(); await Promise.all([syncScripts(false), garbageCollectScriptStorage(state.scripts.map(s => s.id))]);
render(); render();
showToast('Script deleted'); showToast('Script deleted');
}; };
@@ -76,9 +82,11 @@ const saveCurrentScript = async () => {
const scriptObj = { const scriptObj = {
id: state.editingId || `script_${Date.now()}`, id: state.editingId || `script_${Date.now()}`,
name: meta.name, name: meta.name,
version: meta.version,
description: meta.description, description: meta.description,
matches: meta.matches, matches: meta.matches,
requires: meta.requires,
requireCache: existing?.requireCache || {},
storageToken: existing?.storageToken,
runAt: $('#run-at-select')?.value || meta.runAt || 'document_idle', runAt: $('#run-at-select')?.value || meta.runAt || 'document_idle',
code, code,
enabled: existing ? existing.enabled : true, enabled: existing ? existing.enabled : true,
@@ -90,9 +98,12 @@ const saveCurrentScript = async () => {
: [scriptObj, ...state.scripts]; : [scriptObj, ...state.scripts];
await saveScripts(state.scripts); await saveScripts(state.scripts);
await syncUserScripts(); const result = await syncScripts(true);
state.scripts = await getScripts();
setTab('list'); 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) => { const addSecret = async (key, val) => {
@@ -101,7 +112,7 @@ const addSecret = async (key, val) => {
if (!k) return showToast('Enter variable name', true); if (!k) return showToast('Enter variable name', true);
state.secrets[k] = v; state.secrets[k] = v;
await saveSecrets(state.secrets); await saveSecrets(state.secrets);
await syncUserScripts(); await syncScripts(false);
render(); render();
showToast(`Saved secret: ${k}`); showToast(`Saved secret: ${k}`);
}; };
@@ -110,7 +121,7 @@ const removeSecret = async k => {
delete state.secrets[k]; delete state.secrets[k];
state.revealedSecrets.delete(k); state.revealedSecrets.delete(k);
await saveSecrets(state.secrets); await saveSecrets(state.secrets);
await syncUserScripts(); await syncScripts(false);
render(); render();
showToast(`Removed secret: ${k}`); 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}"> <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} ${s.name}
</span> </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>
<div class="flex items-center gap-1 shrink-0"> <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"> <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"> <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')} ${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-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"> <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 + New Script
</button> </button>
@@ -278,7 +286,7 @@ const renderEditor = () => {
<!-- Notice Bar --> <!-- 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"> <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> </footer>
</div> </div>
`; `;
@@ -355,7 +363,7 @@ const renderSecrets = () => {
<p class="text-[11px] text-slate-600 font-semibold mb-1">Code usage in scripts:</p> <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; <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 = env.GH_PAT;
// or: const token = GM_getValue('GH_PAT');</pre> const cache = await OpenScript.storage.get('cache');</pre>
</div> </div>
</div> </div>
@@ -498,7 +506,7 @@ window.addEventListener('focus', async () => {
const ready = await isUserScriptsAvailable(); const ready = await isUserScriptsAvailable();
if (ready !== state.userScriptsReady) { if (ready !== state.userScriptsReady) {
state.userScriptsReady = ready; state.userScriptsReady = ready;
if (ready) await syncUserScripts(); if (ready) await syncScripts(false);
render(); render();
} }
}); });

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 => { export const parseMeta = code => {
const block = code.match(/\/\/ ==UserScript==([\s\S]*?)\/\/ ==\/UserScript==/)?.[1] || ''; const block = code.match(/\/\/ ==UserScript==([\s\S]*?)\/\/ ==\/UserScript==/)?.[1] || '';
const meta = { matches: [], grants: [] }; const meta = { match: [], require: [] };
for (const line of block.split('\n')) { for (const line of block.split('\n')) {
const m = line.match(/\/\/\s*@([\w-]+)\s+(.*)/); const m = line.match(/\/\/\s*@([\w-]+)\s+(.*)/);
@@ -13,22 +14,16 @@ export const parseMeta = code => {
const k = rawK.trim().toLowerCase(); const k = rawK.trim().toLowerCase();
const v = rawV.trim(); const v = rawV.trim();
if (k === 'match' || k === 'include') meta.matches.push(v); if (MULTI_KEYS.has(k)) meta[k].push(v);
else if (k === 'grant') meta.grants.push(v); else if (SINGLE_KEYS.has(k)) meta[k] = v;
else if (MULTI_KEYS.has(k)) (meta[k] ??= []).push(v);
else meta[k] = v;
} }
return { return {
name: meta.name || 'Untitled Script', name: meta.name || 'Untitled Script',
version: meta.version || '1.0.0',
description: meta.description || '', description: meta.description || '',
author: meta.author || '', matches: meta.match.length ? meta.match : ['*://*/*'],
matches: meta.matches.length ? meta.matches : ['*://*/*'],
runAt: (meta['run-at'] || 'document_idle').replace('-', '_'), runAt: (meta['run-at'] || 'document_idle').replace('-', '_'),
grants: meta.grants, requires: meta.require,
icon: meta.icon || '',
raw: meta,
}; };
}; };
@@ -43,17 +38,8 @@ export const normalizeMatch = pattern => {
export const getBoilerplate = (name = 'New Userscript') => export const getBoilerplate = (name = 'New Userscript') =>
`// ==UserScript== `// ==UserScript==
// @name ${name} // @name ${name}
// @version 1.0.0
// @description try to take over the world!
// @author You
// @match *://*/* // @match *://*/*
// @grant none
// ==/UserScript== // ==/UserScript==
(function() { console.log('Running on', location.hostname);
'use strict';
// Access secrets via OpenScript.env or env:
// console.log(OpenScript.env);
})();
`; `;

View File

@@ -1,5 +1,11 @@
// Storage utilities for OpenScript (local for scripts, sync for secrets) // 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 () => export const getScripts = async () =>
(await chrome.storage.local.get('scripts'))?.scripts || []; (await chrome.storage.local.get('scripts'))?.scripts || [];
@@ -11,3 +17,40 @@ export const getSecrets = async () =>
export const saveSecrets = secrets => export const saveSecrets = secrets =>
chrome.storage.sync.set({ 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;
};

View File

@@ -1,5 +1,7 @@
import { getScripts, getSecrets } from './storage.js'; import { getScripts, saveScripts, getSecrets } from './storage.js';
import { normalizeMatch } from './parser.js'; import { normalizeMatch, parseMeta } from './parser.js';
const STORAGE_MESSAGE = 'OPEN_SCRIPT_STORAGE';
export const isUserScriptsAvailable = async () => { export const isUserScriptsAvailable = async () => {
if (!chrome.userScripts) return false; if (!chrome.userScripts) return false;
@@ -11,49 +13,130 @@ export const isUserScriptsAvailable = async () => {
} }
}; };
export const wrapScriptCode = (code, secrets = {}) => { export const wrapScriptCode = (code, secrets = {}, storageToken = '') => `
const envInjection = ` // [OpenScript runtime]
// [OpenScript Injected Environment] (async function(OpenScript, env) {
(function() { 'use strict';
const secretsObj = Object.freeze(${JSON.stringify(secrets)}); ${code}
const openScriptObj = Object.freeze({ })(...(() => {
version: "1.0.0", const env = Object.freeze(${JSON.stringify(secrets)});
env: secretsObj 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; const OpenScript = Object.freeze({ version: '1.0.0', env, storage });
globalThis.env = secretsObj; globalThis.OpenScript = OpenScript;
globalThis.GM_getValue = (k, def) => (secretsObj[k] ?? def); globalThis.env = env;
})(); return [OpenScript, env];
var OpenScript = globalThis.OpenScript; })()).catch(error => console.error('[OpenScript] Script failed:', error));
var env = globalThis.env;
var GM_getValue = globalThis.GM_getValue;
`; `;
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 () => { export const refreshRequireCaches = async scripts => {
if (!await isUserScriptsAvailable()) return false; 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()]); export const syncUserScripts = async ({ refreshRequires = false } = {}) => {
const activeScripts = scripts.filter(s => s.enabled); 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 { try {
const existing = await chrome.userScripts.getScripts(); const existing = await chrome.userScripts.getScripts();
if (existing.length) await chrome.userScripts.unregister({ ids: existing.map(s => s.id) }); 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; await Promise.all(activeScripts.map(script =>
chrome.userScripts.configureWorld({ worldId: script.id, messaging: true })
const toRegister = activeScripts.map(s => ({ ));
id: s.id, await chrome.userScripts.register(activeScripts.map(script => ({
matches: (s.matches?.length ? s.matches : ['*://*/*']).map(normalizeMatch), id: script.id,
runAt: s.runAt || 'document_idle', matches: (script.matches?.length ? script.matches : ['*://*/*']).map(normalizeMatch),
js: [{ code: wrapScriptCode(s.code, secrets) }], runAt: script.runAt || 'document_idle',
})); world: 'USER_SCRIPT',
worldId: script.id,
await chrome.userScripts.register(toRegister); js: [{ code: buildScriptCode(script, secrets) }],
return true; })));
} catch (err) { return { success: !errors.length, errors, warnings };
console.error('[OpenScript] sync failed:', err); } catch (error) {
return false; console.error('[OpenScript] sync failed:', error);
return { success: false, errors: [...errors, error.message], warnings };
} }
}; };

View File

@@ -1,43 +1,42 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { parseMeta, normalizeMatch, getBoilerplate } from '../src/utils/parser.js'; 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', () => { test('parseMeta extracts only OpenScript metadata', () => {
const sample = ` const meta = parseMeta(`
// ==UserScript== // ==UserScript==
// @name Test Script // @name Test Script
// @version 2.1.0 // @version 2.1.0
// @description Sample description // @description Sample description
// @author Alice // @author Alice
// @match https://gemini.google.com/* // @namespace legacy
// @match https://example.com/* // @match https://example.com/*
// @include https://ignored.example/*
// @run-at document-start // @run-at document-start
// @grant none // @grant none
// @require https://cdn.example/library.js
// ==/UserScript== // ==/UserScript==
`);
console.log('hello'); assert.deepEqual(meta, {
`; name: 'Test Script',
description: 'Sample description',
const meta = parseMeta(sample); matches: ['https://example.com/*'],
assert.equal(meta.name, 'Test Script'); runAt: 'document_start',
assert.equal(meta.version, '2.1.0'); requires: ['https://cdn.example/library.js'],
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');
}); });
test('parseMeta falls back to defaults when fields are missing', () => { test('parseMeta falls back to defaults when fields are missing', () => {
const sample = ` const meta = parseMeta('// ==UserScript==\n// ==/UserScript==');
// ==UserScript==
// ==/UserScript==
`;
const meta = parseMeta(sample);
assert.equal(meta.name, 'Untitled Script'); assert.equal(meta.name, 'Untitled Script');
assert.equal(meta.version, '1.0.0'); assert.equal(meta.description, '');
assert.deepEqual(meta.matches, ['*://*/*']); assert.deepEqual(meta.matches, ['*://*/*']);
assert.equal(meta.runAt, 'document_idle'); assert.equal(meta.runAt, 'document_idle');
assert.deepEqual(meta.requires, []);
}); });
test('normalizeMatch formats URL patterns for Chrome userScripts API', () => { 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('*://*/*'), '*://*/*'); assert.equal(normalizeMatch('*://*/*'), '*://*/*');
}); });
test('wrapScriptCode injects OpenScript.env and GM_getValue polyfill', () => { test('wrapScriptCode provides async OpenScript APIs without GM polyfills', () => {
const code = 'console.log(env.API_KEY, GM_getValue("API_KEY"));'; const code = 'const cached = await OpenScript.storage.get("cache");\nif (!cached) return;';
const wrapped = wrapScriptCode(code, { API_KEY: 'secret123' }); 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('"API_KEY":"secret123"'));
assert.ok(wrapped.includes('globalThis.OpenScript')); assert.ok(wrapped.includes("call('list')"));
assert.ok(wrapped.includes('GM_getValue')); assert.ok(wrapped.includes('storage-token'));
assert.ok(wrapped.includes(code)); 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'); const template = getBoilerplate('My Script');
assert.ok(template.includes('// @name My Script')); assert.ok(template.includes('// @name My Script'));
assert.ok(!template.includes('@namespace')); assert.ok(template.includes("console.log('Running on', location.hostname);"));
const parsed = parseMeta(template); for (const legacy of ['@namespace', '@grant', '@version', '@author', '(function'])
assert.equal(parsed.name, 'My Script'); assert.ok(!template.includes(legacy));
}); });

52
tests/storage.test.js Normal file
View File

@@ -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);
});