mirror of
https://github.com/GetOpenScript/OpenScript.git
synced 2026-09-18 09:45:43 +00:00
Initial commit: OpenScript browser extension
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.log
|
||||
31
manifest.json
Normal file
31
manifest.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4nOxfn7Uh3jqdKvTpngmPIzOsCvbQRamThIRxw6fswMRDflfyx2Bspxc1Aq/lQEm3ataP3GoTRCwz0h3bGJvGBu8B5Uw49AAdR6jwp5ZThAfbkBxRQmtXU8+xduji5OT2KyQKsHOj2oSjg6E05WN1vMNOdS+ehwNNCDkc/MjKBLSOdwwOpkDX0BoeC4uAmFTWFzSl/ObFSl7ViJwZVPVkvPc6F0Fxn3OTZN3uFgnT/alsHVqwojnbfC91N+oxVW/3ha869l23PhvoK68zH8Iobs+vgf8TgQOgL1x5eq9Uakn810xVaNbhJVL2I3072r1qpsAYA5ELCjSBLoi5ypLuwIDAQAB",
|
||||
"name": "OpenScript",
|
||||
"version": "1.0.0",
|
||||
"description": "A lightweight user script manager for modern browsers",
|
||||
"action": {
|
||||
"default_popup": "src/popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "src/background.js"
|
||||
},
|
||||
"permissions": [
|
||||
"userScripts",
|
||||
"storage",
|
||||
"unlimitedStorage"
|
||||
],
|
||||
"host_permissions": [
|
||||
"*://*/*"
|
||||
],
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
}
|
||||
5014
package-lock.json
generated
Normal file
5014
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "openscript",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide": "^0.475.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"sharp": "^0.33.5",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^5.4.14",
|
||||
"vite-plugin-web-extension": "4.5.*"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
public/icons/icon-128.png
Normal file
BIN
public/icons/icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
BIN
public/icons/icon-16.png
Normal file
BIN
public/icons/icon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 234 B |
BIN
public/icons/icon-48.png
Normal file
BIN
public/icons/icon-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 553 B |
BIN
public/icons/logo.png
Normal file
BIN
public/icons/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 470 KiB |
16
src/background.js
Normal file
16
src/background.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { syncUserScripts } from './utils/userScripts.js';
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
syncUserScripts();
|
||||
});
|
||||
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
syncUserScripts();
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg.type === 'SYNC_SCRIPTS') {
|
||||
syncUserScripts().then(success => sendResponse({ success }));
|
||||
return true;
|
||||
}
|
||||
});
|
||||
13
src/popup.html
Normal file
13
src/popup.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OpenScript</title>
|
||||
<link rel="stylesheet" href="./styles/index.css" />
|
||||
</head>
|
||||
<body class="bg-navy-950 text-slate-100 flex flex-col h-[560px] w-[480px] overflow-hidden select-none">
|
||||
<div id="app" class="flex flex-col h-full"></div>
|
||||
<script type="module" src="./popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
504
src/popup.js
Normal file
504
src/popup.js
Normal file
@@ -0,0 +1,504 @@
|
||||
import { getScripts, saveScripts, getSecrets, saveSecrets } from './utils/storage.js';
|
||||
import { parseMeta, getBoilerplate } from './utils/parser.js';
|
||||
import { isUserScriptsAvailable, syncUserScripts } from './utils/userScripts.js';
|
||||
import { renderIcons, icon } from './utils/icons.js';
|
||||
|
||||
// Application State
|
||||
const state = {
|
||||
tab: 'list', // 'list' | 'editor' | 'secrets'
|
||||
editingId: null,
|
||||
scripts: [],
|
||||
secrets: {},
|
||||
revealedSecrets: new Set(),
|
||||
userScriptsReady: true,
|
||||
search: '',
|
||||
};
|
||||
|
||||
const $ = sel => document.querySelector(sel);
|
||||
const app = $('#app');
|
||||
|
||||
// Toast feedback helper
|
||||
let toastTimeout;
|
||||
const showToast = (msg, isErr = false) => {
|
||||
const el = $('#toast');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = `fixed bottom-8 left-1/2 -translate-x-1/2 text-xs px-3 py-1.5 rounded shadow-lg transition-all z-50 font-medium ${
|
||||
isErr ? 'bg-red-600 text-white' : 'bg-emerald-600 text-white'
|
||||
}`;
|
||||
clearTimeout(toastTimeout);
|
||||
toastTimeout = setTimeout(() => el.classList.add('hidden'), 2200);
|
||||
};
|
||||
|
||||
// Initialize & Load
|
||||
const init = async () => {
|
||||
state.userScriptsReady = await isUserScriptsAvailable();
|
||||
const [scripts, secrets] = await Promise.all([getScripts(), getSecrets()]);
|
||||
state.scripts = scripts;
|
||||
state.secrets = secrets;
|
||||
render();
|
||||
};
|
||||
|
||||
// Actions
|
||||
const setTab = (tab, editingId = null) => {
|
||||
state.tab = tab;
|
||||
state.editingId = editingId;
|
||||
render();
|
||||
};
|
||||
|
||||
const toggleScript = async id => {
|
||||
const s = state.scripts.find(x => x.id === id);
|
||||
if (!s) return;
|
||||
s.enabled = !s.enabled;
|
||||
await saveScripts(state.scripts);
|
||||
await syncUserScripts();
|
||||
render();
|
||||
showToast(`Script ${s.enabled ? 'enabled' : 'disabled'}`);
|
||||
};
|
||||
|
||||
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();
|
||||
render();
|
||||
showToast('Script deleted');
|
||||
};
|
||||
|
||||
const saveCurrentScript = async () => {
|
||||
const textarea = $('#editor-code');
|
||||
if (!textarea) return;
|
||||
const code = textarea.value.trim();
|
||||
if (!code) return showToast('Script cannot be empty', true);
|
||||
|
||||
const meta = parseMeta(code);
|
||||
const existing = state.scripts.find(s => s.id === state.editingId);
|
||||
const scriptObj = {
|
||||
id: state.editingId || `script_${Date.now()}`,
|
||||
name: meta.name,
|
||||
version: meta.version,
|
||||
description: meta.description,
|
||||
matches: meta.matches,
|
||||
runAt: $('#run-at-select')?.value || meta.runAt || 'document_idle',
|
||||
code,
|
||||
enabled: existing ? existing.enabled : true,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
state.scripts = state.editingId
|
||||
? state.scripts.map(s => (s.id === state.editingId ? scriptObj : s))
|
||||
: [scriptObj, ...state.scripts];
|
||||
|
||||
await saveScripts(state.scripts);
|
||||
await syncUserScripts();
|
||||
setTab('list');
|
||||
showToast('Script saved & synced!');
|
||||
};
|
||||
|
||||
const addSecret = async (key, val) => {
|
||||
const k = key.trim().toUpperCase().replace(/[^A-Z0-9_]/g, '_');
|
||||
const v = val.trim();
|
||||
if (!k) return showToast('Enter variable name', true);
|
||||
state.secrets[k] = v;
|
||||
await saveSecrets(state.secrets);
|
||||
await syncUserScripts();
|
||||
render();
|
||||
showToast(`Saved secret: ${k}`);
|
||||
};
|
||||
|
||||
const removeSecret = async k => {
|
||||
delete state.secrets[k];
|
||||
state.revealedSecrets.delete(k);
|
||||
await saveSecrets(state.secrets);
|
||||
await syncUserScripts();
|
||||
render();
|
||||
showToast(`Removed secret: ${k}`);
|
||||
};
|
||||
|
||||
// UI Templates
|
||||
const renderHeader = () => `
|
||||
<header class="bg-navy-900 border-b border-navy-700/60 px-3 py-2 flex items-center justify-between shrink-0">
|
||||
<div class="flex items-center gap-2 cursor-pointer" id="nav-brand">
|
||||
<img src="/icons/icon-16.png" class="w-4 h-4 rounded-sm" />
|
||||
<span class="font-bold text-sm tracking-tight text-white flex items-center gap-1.5">
|
||||
OpenScript
|
||||
<span class="text-[10px] font-mono px-1 py-0.2 rounded bg-navy-800 text-sky-400 border border-navy-700">1.0</span>
|
||||
</span>
|
||||
</div>
|
||||
<nav class="flex items-center gap-1.5 text-xs font-medium">
|
||||
<button id="nav-list" class="px-2.5 py-1 rounded transition-colors ${
|
||||
state.tab === 'list' ? 'bg-navy-700 text-white font-semibold' : 'text-slate-300 hover:text-white hover:bg-navy-800'
|
||||
}">
|
||||
Scripts <span class="text-[10px] opacity-75">(${state.scripts.length})</span>
|
||||
</button>
|
||||
<button id="nav-secrets" class="px-2.5 py-1 rounded transition-colors ${
|
||||
state.tab === 'secrets' ? 'bg-navy-700 text-white font-semibold' : 'text-slate-300 hover:text-white hover:bg-navy-800'
|
||||
}">
|
||||
Secrets <span class="text-[10px] opacity-75">(${Object.keys(state.secrets).length})</span>
|
||||
</button>
|
||||
<button id="nav-new" class="ml-1 text-amber-400 hover:text-amber-300 font-bold px-2 py-0.5 rounded border border-amber-500/40 bg-amber-500/10 transition-colors flex items-center gap-1">
|
||||
${icon('Plus', 'w-3 h-3')} New
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
`;
|
||||
|
||||
const renderBanner = () => state.userScriptsReady ? '' : `
|
||||
<div class="bg-amber-500/15 border-b border-amber-500/30 px-3 py-2 flex items-center justify-between text-amber-300 text-xs shrink-0 gap-2">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
${icon('AlertTriangle', 'w-4 h-4 text-amber-400 shrink-0')}
|
||||
<span class="leading-tight">
|
||||
Enable <strong class="text-amber-200">"Allow User Scripts"</strong> in extension details to run scripts.
|
||||
</span>
|
||||
</div>
|
||||
<button id="btn-open-settings" class="bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 text-amber-200 px-2 py-1 rounded text-[11px] font-medium shrink-0 flex items-center gap-1 transition-colors cursor-pointer">
|
||||
${icon('ExternalLink', 'w-3 h-3')} Details
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const renderScriptList = () => {
|
||||
const filtered = state.scripts.filter(s =>
|
||||
!state.search ||
|
||||
s.name.toLowerCase().includes(state.search.toLowerCase()) ||
|
||||
s.matches?.some(m => m.toLowerCase().includes(state.search.toLowerCase()))
|
||||
);
|
||||
|
||||
return `
|
||||
<div class="flex flex-col flex-1 overflow-hidden bg-navy-950">
|
||||
<div class="p-2.5 border-b border-navy-800/80 flex items-center gap-2 shrink-0">
|
||||
<input
|
||||
id="script-search"
|
||||
type="text"
|
||||
placeholder="Filter scripts or matches..."
|
||||
value="${state.search}"
|
||||
class="bg-navy-900 border border-navy-800 text-slate-200 placeholder-slate-500 text-xs px-2.5 py-1 rounded w-full focus:outline-none focus:border-navy-600 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-2.5 space-y-2">
|
||||
${filtered.length ? filtered.map(s => `
|
||||
<div class="bg-navy-900/90 border border-navy-800 hover:border-navy-700/80 rounded p-2.5 transition-all flex flex-col gap-1.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<button
|
||||
data-action="toggle"
|
||||
data-id="${s.id}"
|
||||
class="relative inline-flex h-4 w-7 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
|
||||
s.enabled ? 'bg-emerald-500' : 'bg-slate-700'
|
||||
}">
|
||||
<span class="pointer-events-none inline-block h-3 w-3 transform rounded-full bg-white shadow transition duration-200 ease-in-out ${
|
||||
s.enabled ? 'translate-x-3' : 'translate-x-0'
|
||||
}"></span>
|
||||
</button>
|
||||
<span class="font-semibold text-xs text-slate-100 truncate cursor-pointer hover:text-sky-300" data-action="edit" data-id="${s.id}">
|
||||
${s.name}
|
||||
</span>
|
||||
<span class="text-[10px] font-mono text-slate-400 bg-navy-800 px-1 rounded border border-navy-700/60 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-navy-800 rounded text-slate-400 hover:text-sky-400" title="Edit Script">
|
||||
${icon('Pencil', 'w-3.5 h-3.5')}
|
||||
</button>
|
||||
<button data-action="delete" data-id="${s.id}" class="p-1 hover:bg-navy-800 rounded text-slate-400 hover:text-red-400" title="Delete Script">
|
||||
${icon('Trash2', 'w-3.5 h-3.5')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
${s.description ? `<p class="text-[11px] text-slate-400 line-clamp-1">${s.description}</p>` : ''}
|
||||
<div class="flex flex-wrap gap-1 mt-0.5">
|
||||
${(s.matches || ['*://*/*']).slice(0, 3).map(m => `
|
||||
<span class="text-[10px] font-mono px-1.5 py-0.2 bg-navy-950 text-sky-300 border border-navy-800 rounded">
|
||||
${m}
|
||||
</span>
|
||||
`).join('')}
|
||||
${(s.matches?.length > 3) ? `<span class="text-[10px] font-mono text-slate-400">+${s.matches.length - 3}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('') : `
|
||||
<div class="h-full flex flex-col items-center justify-center text-center p-6 text-slate-400">
|
||||
${icon('FileCode', 'w-10 h-10 text-navy-700 mb-2')}
|
||||
<p class="text-xs font-medium text-slate-300">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>
|
||||
<button id="btn-empty-new" class="mt-4 bg-white text-navy-950 hover:bg-slate-100 font-semibold text-xs px-3.5 py-1.5 rounded border border-slate-300 shadow-sm cursor-pointer">
|
||||
+ New Script
|
||||
</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
<footer class="bg-navy-900 border-t border-navy-800/80 px-3 py-1.5 flex items-center justify-between text-[10px] text-slate-400 shrink-0 font-mono">
|
||||
<span>${state.scripts.filter(s => s.enabled).length} active / ${state.scripts.length} total</span>
|
||||
<span>Storage: chrome.storage.local</span>
|
||||
</footer>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const renderEditor = () => {
|
||||
const script = state.scripts.find(s => s.id === state.editingId);
|
||||
const code = script ? script.code : getBoilerplate();
|
||||
const runAt = script?.runAt || 'document_idle';
|
||||
|
||||
return `
|
||||
<div class="flex flex-col flex-1 overflow-hidden bg-navy-950">
|
||||
<!-- Editor Textarea Area styled like reference image -->
|
||||
<div class="flex-1 p-2 bg-navy-900 flex flex-col min-h-0">
|
||||
<textarea
|
||||
id="editor-code"
|
||||
spellcheck="false"
|
||||
placeholder="// ==UserScript== // Paste or write script here..."
|
||||
class="flex-1 w-full p-2.5 bg-[#eef1f5] text-slate-900 font-mono text-[11px] leading-relaxed rounded border border-navy-800 focus:outline-none focus:ring-1 focus:ring-sky-500 resize-none overflow-y-auto"
|
||||
>${code}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Action Bar styled after reference image -->
|
||||
<div class="bg-navy-900 border-t border-navy-800 px-3 py-2 flex items-center justify-between gap-2 shrink-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button id="btn-save-script" class="bg-white hover:bg-slate-100 text-navy-950 font-semibold text-xs px-3 py-1 rounded border border-slate-300 shadow-sm cursor-pointer transition-colors">
|
||||
save script
|
||||
</button>
|
||||
<button id="btn-cancel-edit" class="bg-navy-800 hover:bg-navy-700 text-slate-200 text-xs px-2.5 py-1 rounded border border-navy-700 cursor-pointer transition-colors">
|
||||
cancel
|
||||
</button>
|
||||
<button id="btn-reset-boilerplate" class="bg-navy-800 hover:bg-navy-700 text-slate-300 text-xs px-2 py-1 rounded border border-navy-700 cursor-pointer transition-colors" title="Insert default template">
|
||||
template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<label class="text-[10px] text-slate-400 font-mono">run-at:</label>
|
||||
<select id="run-at-select" class="bg-navy-950 border border-navy-700 text-slate-200 text-[11px] font-mono rounded px-1.5 py-1 focus:outline-none">
|
||||
<option value="document_idle" ${runAt === 'document_idle' ? 'selected' : ''}>document_idle</option>
|
||||
<option value="document_start" ${runAt === 'document_start' ? 'selected' : ''}>document_start</option>
|
||||
<option value="document_end" ${runAt === 'document_end' ? 'selected' : ''}>document_end</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notice Bar matching reference image -->
|
||||
<footer class="bg-navy-950 border-t border-navy-800/80 px-3 py-1 text-[10px] text-slate-400 font-mono shrink-0">
|
||||
notice: scripts run on matched URLs with synced secrets injected.
|
||||
</footer>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const renderSecrets = () => {
|
||||
const keys = Object.keys(state.secrets);
|
||||
|
||||
return `
|
||||
<div class="flex flex-col flex-1 overflow-hidden bg-navy-950">
|
||||
<div class="p-3 border-b border-navy-800/80 bg-navy-900/60 shrink-0">
|
||||
<h2 class="text-xs font-semibold text-slate-200 mb-1 flex items-center gap-1.5">
|
||||
${icon('Key', 'w-3.5 h-3.5 text-sky-400')} Synced Secrets / Environment Variables
|
||||
</h2>
|
||||
<p class="text-[11px] text-slate-400 leading-tight">
|
||||
Secrets are saved to <span class="text-sky-300 font-mono">chrome.storage.sync</span> and available across all devices.
|
||||
</p>
|
||||
|
||||
<!-- Add Secret Form -->
|
||||
<div class="mt-2.5 flex items-center gap-1.5">
|
||||
<input
|
||||
id="new-secret-key"
|
||||
type="text"
|
||||
placeholder="KEY (e.g. API_KEY)"
|
||||
class="w-1/3 bg-navy-950 border border-navy-700 text-slate-100 text-xs px-2 py-1 rounded font-mono uppercase placeholder-slate-500 focus:outline-none focus:border-sky-500"
|
||||
/>
|
||||
<input
|
||||
id="new-secret-val"
|
||||
type="text"
|
||||
placeholder="Value..."
|
||||
class="flex-1 bg-navy-950 border border-navy-700 text-slate-100 text-xs px-2 py-1 rounded font-mono placeholder-slate-500 focus:outline-none focus:border-sky-500"
|
||||
/>
|
||||
<button id="btn-add-secret" class="bg-white hover:bg-slate-100 text-navy-950 font-semibold text-xs px-3 py-1 rounded border border-slate-300 shadow-sm shrink-0 cursor-pointer">
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-2.5 space-y-2">
|
||||
${keys.length ? keys.map(k => {
|
||||
const isRevealed = state.revealedSecrets.has(k);
|
||||
const val = state.secrets[k];
|
||||
return `
|
||||
<div class="bg-navy-900 border border-navy-800 rounded p-2 flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span class="font-mono font-semibold text-xs text-sky-300 shrink-0">${k}</span>
|
||||
<span class="text-slate-500 text-xs shrink-0">=</span>
|
||||
<span class="font-mono text-xs text-slate-300 truncate select-all">
|
||||
${isRevealed ? val : '••••••••••••'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button data-action="toggle-secret" data-key="${k}" class="p-1 hover:bg-navy-800 rounded text-slate-400 hover:text-slate-200" title="${isRevealed ? 'Hide' : 'Reveal'}">
|
||||
${icon(isRevealed ? 'EyeOff' : 'Eye', 'w-3.5 h-3.5')}
|
||||
</button>
|
||||
<button data-action="copy-secret" data-key="${k}" class="p-1 hover:bg-navy-800 rounded text-slate-400 hover:text-sky-400" title="Copy Value">
|
||||
${icon('Copy', 'w-3.5 h-3.5')}
|
||||
</button>
|
||||
<button data-action="delete-secret" data-key="${k}" class="p-1 hover:bg-navy-800 rounded text-slate-400 hover:text-red-400" title="Delete Secret">
|
||||
${icon('Trash2', 'w-3.5 h-3.5')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('') : `
|
||||
<div class="h-full flex flex-col items-center justify-center text-center p-6 text-slate-400">
|
||||
${icon('Key', 'w-8 h-8 text-navy-700 mb-2')}
|
||||
<p class="text-xs font-medium text-slate-300">No secrets configured</p>
|
||||
<p class="text-[11px] text-slate-500 mt-1 max-w-[240px]">Add API tokens or credentials here to securely access them in your user scripts.</p>
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div class="mt-4 p-2.5 bg-navy-900/40 border border-navy-800/80 rounded">
|
||||
<p class="text-[11px] text-slate-400 font-semibold mb-1">Code usage in scripts:</p>
|
||||
<pre class="bg-navy-950 p-2 rounded text-[10px] font-mono text-emerald-400 overflow-x-auto">const token = OpenScript.env.MY_API_KEY;
|
||||
// or: const token = env.MY_API_KEY;
|
||||
// or: const token = GM_getValue('MY_API_KEY');</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="bg-navy-900 border-t border-navy-800/80 px-3 py-1.5 flex items-center justify-between text-[10px] text-slate-400 shrink-0 font-mono">
|
||||
<span>${keys.length} secrets stored</span>
|
||||
<span>Storage: chrome.storage.sync</span>
|
||||
</footer>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
// Main Render
|
||||
const render = () => {
|
||||
let content = '';
|
||||
if (state.tab === 'list') content = renderScriptList();
|
||||
else if (state.tab === 'editor') content = renderEditor();
|
||||
else if (state.tab === 'secrets') content = renderSecrets();
|
||||
|
||||
app.innerHTML = `
|
||||
${renderHeader()}
|
||||
${renderBanner()}
|
||||
${content}
|
||||
<div id="toast" class="hidden"></div>
|
||||
`;
|
||||
|
||||
renderIcons();
|
||||
bindEvents();
|
||||
};
|
||||
|
||||
// Event Bindings
|
||||
const bindEvents = () => {
|
||||
// Navigation
|
||||
$('#nav-brand')?.addEventListener('click', () => setTab('list'));
|
||||
$('#nav-list')?.addEventListener('click', () => setTab('list'));
|
||||
$('#nav-secrets')?.addEventListener('click', () => setTab('secrets'));
|
||||
$('#nav-new')?.addEventListener('click', () => setTab('editor', null));
|
||||
$('#btn-empty-new')?.addEventListener('click', () => setTab('editor', null));
|
||||
|
||||
// Banner settings button
|
||||
$('#btn-open-settings')?.addEventListener('click', async () => {
|
||||
const extUrl = `chrome://extensions/?id=${chrome.runtime.id}`;
|
||||
try {
|
||||
await chrome.tabs.create({ url: extUrl });
|
||||
} catch {
|
||||
await navigator.clipboard.writeText(extUrl);
|
||||
showToast('Copied extension URL to clipboard!');
|
||||
}
|
||||
});
|
||||
|
||||
// Search
|
||||
const searchInput = $('#script-search');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', e => {
|
||||
state.search = e.target.value;
|
||||
// Re-render only script list body or re-render
|
||||
render();
|
||||
const el = $('#script-search');
|
||||
if (el) {
|
||||
el.focus();
|
||||
el.selectionStart = el.selectionEnd = el.value.length;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Script List Actions (delegated)
|
||||
app.querySelectorAll('[data-action="toggle"]').forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
toggleScript(btn.dataset.id);
|
||||
});
|
||||
});
|
||||
|
||||
app.querySelectorAll('[data-action="edit"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => setTab('editor', btn.dataset.id));
|
||||
});
|
||||
|
||||
app.querySelectorAll('[data-action="delete"]').forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
deleteScript(btn.dataset.id);
|
||||
});
|
||||
});
|
||||
|
||||
// Editor Actions
|
||||
$('#btn-save-script')?.addEventListener('click', saveCurrentScript);
|
||||
$('#btn-cancel-edit')?.addEventListener('click', () => setTab('list'));
|
||||
$('#btn-reset-boilerplate')?.addEventListener('click', () => {
|
||||
const el = $('#editor-code');
|
||||
if (el && confirm('Reset code to default boilerplate?')) el.value = getBoilerplate();
|
||||
});
|
||||
|
||||
// Tab key indent in editor
|
||||
const textarea = $('#editor-code');
|
||||
if (textarea) {
|
||||
textarea.addEventListener('keydown', e => {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
textarea.value = textarea.value.substring(0, start) + ' ' + textarea.value.substring(end);
|
||||
textarea.selectionStart = textarea.selectionEnd = start + 2;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Secrets Actions
|
||||
$('#btn-add-secret')?.addEventListener('click', () => {
|
||||
const k = $('#new-secret-key')?.value || '';
|
||||
const v = $('#new-secret-val')?.value || '';
|
||||
addSecret(k, v);
|
||||
});
|
||||
|
||||
app.querySelectorAll('[data-action="toggle-secret"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const k = btn.dataset.key;
|
||||
if (state.revealedSecrets.has(k)) state.revealedSecrets.delete(k);
|
||||
else state.revealedSecrets.add(k);
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
app.querySelectorAll('[data-action="copy-secret"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const val = state.secrets[btn.dataset.key] || '';
|
||||
navigator.clipboard.writeText(val);
|
||||
showToast('Copied to clipboard');
|
||||
});
|
||||
});
|
||||
|
||||
app.querySelectorAll('[data-action="delete-secret"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => removeSecret(btn.dataset.key));
|
||||
});
|
||||
};
|
||||
|
||||
// Start
|
||||
init();
|
||||
|
||||
// Auto re-check when returning to popup after toggling setting
|
||||
window.addEventListener('focus', async () => {
|
||||
const ready = await isUserScriptsAvailable();
|
||||
if (ready !== state.userScriptsReady) {
|
||||
state.userScriptsReady = ready;
|
||||
if (ready) await syncUserScripts();
|
||||
render();
|
||||
}
|
||||
});
|
||||
28
src/styles/index.css
Normal file
28
src/styles/index.css
Normal file
@@ -0,0 +1,28 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 480px;
|
||||
height: 560px;
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #072138;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #1e3a5f;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
45
src/utils/icons.js
Normal file
45
src/utils/icons.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
createIcons,
|
||||
Terminal,
|
||||
Code,
|
||||
Plus,
|
||||
Trash2,
|
||||
Pencil,
|
||||
Key,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileCode,
|
||||
Play,
|
||||
ExternalLink,
|
||||
RotateCcw,
|
||||
Sliders,
|
||||
Info
|
||||
} from 'lucide';
|
||||
|
||||
const icons = {
|
||||
Terminal,
|
||||
Code,
|
||||
Plus,
|
||||
Trash2,
|
||||
Pencil,
|
||||
Key,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileCode,
|
||||
Play,
|
||||
ExternalLink,
|
||||
RotateCcw,
|
||||
Sliders,
|
||||
Info
|
||||
};
|
||||
|
||||
export const renderIcons = () => createIcons({ icons });
|
||||
|
||||
export const icon = (name, cls = 'w-3.5 h-3.5') =>
|
||||
`<i data-lucide="${name}" class="${cls} inline-block align-middle"></i>`;
|
||||
59
src/utils/parser.js
Normal file
59
src/utils/parser.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// Parse & serialize Tampermonkey metadata blocks
|
||||
|
||||
const MULTI_KEYS = new Set(['match', 'include', 'exclude', 'grant', 'require']);
|
||||
|
||||
export const parseMeta = code => {
|
||||
const block = code.match(/\/\/ ==UserScript==([\s\S]*?)\/\/ ==\/UserScript==/)?.[1] || '';
|
||||
const meta = { matches: [], grants: [] };
|
||||
|
||||
for (const line of block.split('\n')) {
|
||||
const m = line.match(/\/\/\s*@([\w-]+)\s+(.*)/);
|
||||
if (!m) continue;
|
||||
const [, rawK, rawV] = m;
|
||||
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;
|
||||
}
|
||||
|
||||
return {
|
||||
name: meta.name || 'Untitled Script',
|
||||
version: meta.version || '1.0.0',
|
||||
description: meta.description || '',
|
||||
author: meta.author || '',
|
||||
matches: meta.matches.length ? meta.matches : ['*://*/*'],
|
||||
runAt: (meta['run-at'] || 'document_idle').replace('-', '_'),
|
||||
grants: meta.grants,
|
||||
icon: meta.icon || '',
|
||||
raw: meta,
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeMatch = pattern => {
|
||||
let p = pattern.trim();
|
||||
if (!p) return '*://*/*';
|
||||
if (/^https?:\/\/[^/]+$/.test(p)) p += '/*';
|
||||
if (!/^[a-z*]+:\/\//i.test(p)) p = `*://${p}`;
|
||||
return p;
|
||||
};
|
||||
|
||||
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);
|
||||
})();
|
||||
`;
|
||||
13
src/utils/storage.js
Normal file
13
src/utils/storage.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// Storage utilities for OpenScript (local for scripts, sync for secrets)
|
||||
|
||||
export const getScripts = async () =>
|
||||
(await chrome.storage.local.get('scripts'))?.scripts || [];
|
||||
|
||||
export const saveScripts = scripts =>
|
||||
chrome.storage.local.set({ scripts });
|
||||
|
||||
export const getSecrets = async () =>
|
||||
(await chrome.storage.sync.get('secrets'))?.secrets || {};
|
||||
|
||||
export const saveSecrets = secrets =>
|
||||
chrome.storage.sync.set({ secrets });
|
||||
52
src/utils/userScripts.js
Normal file
52
src/utils/userScripts.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { getScripts, getSecrets } from './storage.js';
|
||||
import { normalizeMatch } from './parser.js';
|
||||
|
||||
export const isUserScriptsAvailable = async () => {
|
||||
if (!chrome.userScripts) return false;
|
||||
try {
|
||||
await chrome.userScripts.getScripts();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const wrapScriptCode = (code, secrets = {}) => {
|
||||
const envInjection = `
|
||||
// [OpenScript Injected Environment]
|
||||
const OpenScript = Object.freeze({
|
||||
version: "1.0.0",
|
||||
env: Object.freeze(${JSON.stringify(secrets)})
|
||||
});
|
||||
const env = OpenScript.env;
|
||||
const GM_getValue = (k, def) => (OpenScript.env[k] ?? def);
|
||||
`;
|
||||
return `${envInjection}\n${code}`;
|
||||
};
|
||||
|
||||
export const syncUserScripts = async () => {
|
||||
if (!await isUserScriptsAvailable()) return false;
|
||||
|
||||
const [scripts, secrets] = await Promise.all([getScripts(), getSecrets()]);
|
||||
const activeScripts = scripts.filter(s => s.enabled);
|
||||
|
||||
try {
|
||||
const existing = await chrome.userScripts.getScripts();
|
||||
if (existing.length) await chrome.userScripts.unregister({ ids: existing.map(s => s.id) });
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
25
tailwind.config.js
Normal file
25
tailwind.config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./src/**/*.{html,js,jsx}",
|
||||
"./src/*.{html,js,jsx}"
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
navy: {
|
||||
950: '#041322',
|
||||
900: '#072138',
|
||||
800: '#0b2d4c',
|
||||
700: '#0e3a63',
|
||||
600: '#144e83',
|
||||
},
|
||||
slatebg: '#ebedf0',
|
||||
},
|
||||
fontFamily: {
|
||||
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace'],
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
66
tests/parser.test.js
Normal file
66
tests/parser.test.js
Normal file
@@ -0,0 +1,66 @@
|
||||
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';
|
||||
|
||||
test('parseMeta extracts standard Tampermonkey metadata', () => {
|
||||
const sample = `
|
||||
// ==UserScript==
|
||||
// @name Test Script
|
||||
// @version 2.1.0
|
||||
// @description Sample description
|
||||
// @author Alice
|
||||
// @match https://gemini.google.com/*
|
||||
// @match https://example.com/*
|
||||
// @run-at document-start
|
||||
// @grant none
|
||||
// ==/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');
|
||||
});
|
||||
|
||||
test('parseMeta falls back to defaults when fields are missing', () => {
|
||||
const sample = `
|
||||
// ==UserScript==
|
||||
// ==/UserScript==
|
||||
`;
|
||||
const meta = parseMeta(sample);
|
||||
assert.equal(meta.name, 'Untitled Script');
|
||||
assert.equal(meta.version, '1.0.0');
|
||||
assert.deepEqual(meta.matches, ['*://*/*']);
|
||||
assert.equal(meta.runAt, 'document_idle');
|
||||
});
|
||||
|
||||
test('normalizeMatch formats URL patterns for Chrome userScripts API', () => {
|
||||
assert.equal(normalizeMatch('https://example.com'), 'https://example.com/*');
|
||||
assert.equal(normalizeMatch('example.com/*'), '*://example.com/*');
|
||||
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' });
|
||||
|
||||
assert.ok(wrapped.includes('const OpenScript = Object.freeze('));
|
||||
assert.ok(wrapped.includes('"API_KEY":"secret123"'));
|
||||
assert.ok(wrapped.includes('const env = OpenScript.env;'));
|
||||
assert.ok(wrapped.includes('const GM_getValue ='));
|
||||
assert.ok(wrapped.includes(code));
|
||||
});
|
||||
|
||||
test('getBoilerplate produces valid Tampermonkey template without namespace', () => {
|
||||
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');
|
||||
});
|
||||
11
vite.config.js
Normal file
11
vite.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vite";
|
||||
import webExtension from "vite-plugin-web-extension";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
webExtension({
|
||||
manifest: "manifest.json",
|
||||
browser: "chrome",
|
||||
}),
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user