import { ipcRenderer, webFrame } from 'electron'; // ── Types ── export interface ScriptMetadata { name: string; author: string; version: string; desc: string; src: string; license: string; runAt: 'document-start' | 'document-end'; priority: number; } export interface UserscriptSetting { title: string; type: 'bool' | 'num' | 'sel' | 'color' | 'keybind'; value: unknown; desc?: string; min?: number; max?: number; step?: number; opts?: (string | number)[]; changed?: (value: unknown) => void; } export interface UserscriptInstance { filename: string; content: string; meta: ScriptMetadata; enabled: boolean; executed: boolean; unload: (() => void) | null; settings: Record | null; } // ── State ── const instances: UserscriptInstance[] = []; const cssHandles = new Map(); // identifier -> webFrame CSS key // ── Metadata parser ── export function parseMetadata(code: string): ScriptMetadata { const meta: ScriptMetadata = { name: '', author: '', version: '', desc: '', src: '', license: '', runAt: 'document-end', priority: 0, }; const startMatch = code.match(/\/\/\s*==UserScript==/); const endMatch = code.match(/\/\/\s*==\/UserScript==/); if (!startMatch || !endMatch) return meta; const block = code.substring( startMatch.index! + startMatch[0].length, endMatch.index!, ); for (const line of block.split('\n')) { const m = line.match(/\/\/\s*@(\S+)\s+(.*)/); if (!m) continue; const [, tag, val] = m; const v = val.trim(); switch (tag) { case 'name': meta.name = v; break; case 'author': meta.author = v; break; case 'version': meta.version = v; break; case 'desc': case 'description': meta.desc = v; break; case 'src': meta.src = v; break; case 'license': meta.license = v; break; case 'run-at': if (v === 'document-start') meta.runAt = 'document-start'; else meta.runAt = 'document-end'; break; case 'priority': meta.priority = parseInt(v, 10) || 0; break; } } return meta; } // ── CSS injection via webFrame ── function toggleCSS(css: string, identifier: string, value: boolean): void { const existing = cssHandles.get(identifier); if (value) { if (existing) return; // already inserted const key = webFrame.insertCSS(css); cssHandles.set(identifier, key); } else { if (!existing) return; webFrame.removeInsertedCSS(existing); cssHandles.delete(identifier); } } // ── Script execution ── function executeScript( instance: UserscriptInstance, _console: { log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void }, ): void { if (instance.executed) return; const context: Record = { _console, _css(css: string, identifier: string, value: boolean) { toggleCSS(css, instance.filename + ':' + identifier, value); }, unload: null as (() => void) | null, settings: null as Record | null, }; try { const fn = new Function(instance.content); const result = fn.apply(context); // Script returned `this` — capture settings and unload if (result === context) { instance.unload = (typeof context.unload === 'function') ? context.unload as () => void : null; instance.settings = context.settings as Record | null; } else { instance.unload = null; instance.settings = null; } instance.executed = true; _console.log('[KCC] Userscript executed:', instance.meta.name || instance.filename); } catch (err) { _console.error('[KCC] Userscript error in', instance.filename, ':', err); } } // ── Apply saved preferences ── async function applyPreferences(instance: UserscriptInstance): Promise { if (!instance.settings) return; const saved = await ipcRenderer.invoke('userscripts-load-prefs', instance.filename); for (const key of Object.keys(instance.settings)) { if (key in saved) { const setting = instance.settings[key]; setting.value = saved[key]; if (typeof setting.changed === 'function') { try { setting.changed(setting.value); } catch { /* ignore callback errors */ } } } } } // ── Public API ── export function getInstances(): UserscriptInstance[] { return instances; } export async function initUserscripts( _console: { log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void }, ): Promise { const { scripts, tracker } = await ipcRenderer.invoke('userscripts-scan'); if (!scripts || scripts.length === 0) { _console.log('[KCC] No userscripts found'); return; } // Build instances for (const script of scripts) { const meta = parseMetadata(script.content); instances.push({ filename: script.filename, content: script.content, meta, enabled: tracker[script.filename] === true, executed: false, unload: null, settings: null, }); } // Sort by priority descending instances.sort((a, b) => b.meta.priority - a.meta.priority); // Execute document-start scripts for (const inst of instances) { if (inst.enabled && inst.meta.runAt === 'document-start') { executeScript(inst, _console); await applyPreferences(inst); } } // Execute document-end scripts const runDocEnd = () => { for (const inst of instances) { if (inst.enabled && inst.meta.runAt === 'document-end' && !inst.executed) { executeScript(inst, _console); applyPreferences(inst); } } }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', runDocEnd, { once: true }); } else { runDocEnd(); } _console.log('[KCC] Userscripts initialized:', instances.length, 'scripts loaded'); } export function setScriptEnabled( filename: string, enabled: boolean, _console: { log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void }, ): { needsReload: boolean } { const inst = instances.find(i => i.filename === filename); if (!inst) return { needsReload: false }; inst.enabled = enabled; // Update tracker const tracker: Record = {}; for (const i of instances) tracker[i.filename] = i.enabled; ipcRenderer.invoke('userscripts-set-tracker', tracker); if (!enabled) { if (inst.unload && inst.executed) { try { inst.unload(); _console.log('[KCC] Userscript unloaded:', inst.meta.name || inst.filename); } catch (err) { _console.error('[KCC] Userscript unload error:', err); } inst.executed = false; inst.unload = null; inst.settings = null; return { needsReload: false }; } // No unload function — need page reload to fully disable return { needsReload: inst.executed }; } else { // Enabling if (!inst.executed) { executeScript(inst, _console); applyPreferences(inst); return { needsReload: false }; } return { needsReload: false }; } }