import {
getScripts, saveScripts, getSecrets, saveSecrets, garbageCollectScriptStorage,
} from './utils/storage.js';
import { parseMeta, getBoilerplate } from './utils/parser.js';
import { isUserScriptsAvailable } from './utils/userScripts.js';
import { renderIcons, icon } from './utils/icons.js';
import { VERSION } from './version.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');
const syncScripts = refreshRequires =>
chrome.runtime.sendMessage({ type: 'SYNC_SCRIPTS', refreshRequires });
// 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();
await syncScripts(false);
const [scripts, secrets] = await Promise.all([getScripts(), getSecrets()]);
await garbageCollectScriptStorage(scripts.map(s => s.id));
state.scripts = scripts.map(s => ({
...s,
version: parseMeta(s.code || '').version,
}));
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 syncScripts(false);
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 Promise.all([syncScripts(false), garbageCollectScriptStorage(state.scripts.map(s => s.id))]);
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,
requires: meta.requires,
requireCache: existing?.requireCache || {},
storageToken: existing?.storageToken,
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);
const result = await syncScripts(true);
state.scripts = await getScripts();
setTab('list');
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 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 syncScripts(false);
render();
showToast(`Saved secret: ${k}`);
};
const removeSecret = async k => {
delete state.secrets[k];
state.revealedSecrets.delete(k);
await saveSecrets(state.secrets);
await syncScripts(false);
render();
showToast(`Removed secret: ${k}`);
};
// UI Templates
const renderHeader = () => `
OpenScript
${VERSION}
`;
const renderBanner = () => state.userScriptsReady ? '' : `
${icon('AlertTriangle', 'w-4 h-4 text-amber-600 shrink-0')}
Enable "Allow User Scripts" in extension details to run scripts.
`;
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 `
${filtered.length ? filtered.map(s => `
${s.name}
${s.version ? `v${s.version}` : ''}
${s.description ? `
${s.description}
` : ''}
${(s.matches || ['*://*/*']).slice(0, 3).map(m => `
${m}
`).join('')}
${(s.matches?.length > 3) ? `+${s.matches.length - 3}` : ''}
`).join('') : `
${icon('FileCode', 'w-10 h-10 text-slate-300 mb-2')}
No user scripts found
Create a new OpenScript to get started.
`}
`;
};
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 `
`;
};
const renderSecrets = () => {
const keys = Object.keys(state.secrets);
return `
${keys.length ? keys.map(k => {
const isRevealed = state.revealedSecrets.has(k);
const val = state.secrets[k];
return `
${k}
=
${isRevealed ? val : '••••••••••••'}
`;
}).join('') : `
${icon('Key', 'w-8 h-8 text-slate-300 mb-2')}
No secrets configured
Add API tokens or credentials (like GH_PAT) here to securely access them in your user scripts.
`}
Code usage in scripts:
const token = OpenScript.env.GH_PAT;
// or: const token = env.GH_PAT;
const cache = await OpenScript.storage.get('cache');
`;
};
// 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}
`;
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 syncScripts(false);
render();
}
});