mirror of
https://github.com/GetOpenScript/OpenScript.git
synced 2026-09-18 09:45:43 +00:00
Complete OpenScript runtime roadmap
This commit is contained in:
@@ -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.
|
||||
|
||||
186
README.md
186
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).
|
||||
|
||||
85
TODO.md
85
TODO.md
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
40
src/popup.js
40
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 = () => {
|
||||
<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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
52
tests/storage.test.js
Normal file
52
tests/storage.test.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user