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
|
# 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
186
README.md
@@ -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 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
|
```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
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,
|
"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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
|||||||
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 { 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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
|
||||||
})();
|
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
globalThis.OpenScript = openScriptObj;
|
if (!response?.ok) throw new Error(response?.error || 'OpenScript storage request failed');
|
||||||
globalThis.env = secretsObj;
|
return response;
|
||||||
globalThis.GM_getValue = (k, def) => (secretsObj[k] ?? def);
|
};
|
||||||
})();
|
const storage = Object.freeze({
|
||||||
var OpenScript = globalThis.OpenScript;
|
set: (key, value) => call('set', key, value).then(() => undefined),
|
||||||
var env = globalThis.env;
|
get: key => call('get', key).then(result => result.found ? result.value : undefined),
|
||||||
var GM_getValue = globalThis.GM_getValue;
|
delete: key => call('delete', key).then(() => undefined),
|
||||||
|
list: () => call('list').then(result => result.keys),
|
||||||
|
});
|
||||||
|
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 () => {
|
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 };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
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