feat: add plugin system

This commit is contained in:
SethBurkart123
2025-03-18 07:51:12 +11:00
parent da3a680455
commit 587aa5eb89
9 changed files with 885 additions and 199 deletions
+116
View File
@@ -0,0 +1,116 @@
import type { Plugin, PluginAPI, PluginSettings, SEQTAAPI, SettingsAPI, StorageAPI, EventsAPI } from './types';
import { eventManager } from '@/seqta/utils/listeners/EventManager';
import ReactFiber from '@/seqta/utils/ReactFiber';
import browser from 'webextension-polyfill';
function createSEQTAAPI(): SEQTAAPI {
return {
onMount: (selector, callback) => {
eventManager.register(
`${selector}Added`,
{
customCheck: (element) => element.matches(selector),
},
callback
);
},
getFiber: (selector) => {
return ReactFiber.find(selector);
},
getCurrentPage: () => {
const path = window.location.hash.split('?page=/')[1] || '';
return path.split('/')[0];
},
onPageChange: (callback) => {
window.addEventListener('hashchange', () => {
const page = window.location.hash.split('?page=/')[1] || '';
callback(page.split('/')[0]);
});
},
};
}
function createSettingsAPI<T extends PluginSettings>(plugin: Plugin<T>): SettingsAPI<T> {
const storageKey = `plugin.${plugin.id}.settings`;
const listeners = new Map<keyof T, Set<(value: any) => void>>();
let settings: { [K in keyof T]: T[K]['default'] };
// Initialize settings with defaults
settings = Object.entries(plugin.settings).reduce((acc, [key, setting]) => {
acc[key as keyof T] = setting.default;
return acc;
}, {} as { [K in keyof T]: T[K]['default'] });
// Load saved settings
browser.storage.local.get(storageKey).then((stored) => {
if (stored[storageKey]) {
Object.assign(settings, stored[storageKey]);
}
});
// Create a proxy to handle direct property access
const proxy = new Proxy(settings, {
get(target, prop: string) {
if (prop === 'onChange') {
return (key: keyof T, callback: (value: any) => void) => {
if (!listeners.has(key)) {
listeners.set(key, new Set());
}
listeners.get(key)!.add(callback);
};
}
return target[prop as keyof T];
},
set(target, prop: string, value: any) {
if (prop === 'onChange') return false;
target[prop as keyof T] = value;
browser.storage.local.set({ [storageKey]: target });
listeners.get(prop as keyof T)?.forEach(callback => callback(value));
return true;
},
}) as SettingsAPI<T>;
return proxy;
}
function createStorageAPI(pluginId: string): StorageAPI {
const prefix = `plugin.${pluginId}.storage.`;
return {
get: async <T>(key: string) => {
const result = await browser.storage.local.get(prefix + key);
return result[prefix + key] as T || null;
},
set: async <T>(key: string, value: T) => {
await browser.storage.local.set({ [prefix + key]: value });
},
};
}
function createEventsAPI(pluginId: string): EventsAPI {
const prefix = `plugin.${pluginId}.`;
return {
on: (event, callback) => {
document.addEventListener(prefix + event, ((e: CustomEvent) => {
callback(...(e.detail || []));
}) as EventListener);
},
emit: (event, ...args) => {
document.dispatchEvent(
new CustomEvent(prefix + event, {
detail: args.length > 0 ? args : null
})
);
},
};
}
export function createPluginAPI<T extends PluginSettings>(plugin: Plugin<T>): PluginAPI<T> {
return {
seqta: createSEQTAAPI(),
settings: createSettingsAPI(plugin),
storage: createStorageAPI(plugin.id),
events: createEventsAPI(plugin.id),
};
}
+142
View File
@@ -0,0 +1,142 @@
import type { Plugin, PluginSettings } from './types';
import { createPluginAPI } from './createAPI';
export class PluginManager {
private static instance: PluginManager;
private plugins: Map<string, Plugin<any>> = new Map();
private runningPlugins: Map<string, boolean> = new Map();
private eventBacklog: Map<string, any[]> = new Map();
private cleanupFunctions: Map<string, () => void> = new Map();
private listeners: Map<string, Set<(...args: any[]) => void>> = new Map();
private constructor() {}
public static getInstance(): PluginManager {
if (!PluginManager.instance) {
PluginManager.instance = new PluginManager();
}
return PluginManager.instance;
}
public dispatchPluginEvent(pluginId: string, event: string, args?: any) {
const fullEventName = `plugin.${pluginId}.${event}`;
if (this.runningPlugins.get(pluginId)) {
// If plugin is running, dispatch immediately
document.dispatchEvent(new CustomEvent(fullEventName, { detail: args }));
} else {
// Otherwise queue it
const key = `${pluginId}:${event}`;
if (!this.eventBacklog.has(key)) {
this.eventBacklog.set(key, []);
}
this.eventBacklog.get(key)!.push(args);
}
}
private async processBackloggedEvents(pluginId: string) {
for (const [key, argsList] of this.eventBacklog.entries()) {
const [eventPluginId, event] = key.split(':');
if (eventPluginId === pluginId) {
for (const args of argsList) {
this.dispatchPluginEvent(pluginId, event, args);
}
this.eventBacklog.delete(key);
}
}
}
public registerPlugin<T extends PluginSettings>(plugin: Plugin<T>): void {
if (this.plugins.has(plugin.id)) {
throw new Error(`Plugin with id "${plugin.id}" is already registered`);
}
this.plugins.set(plugin.id, plugin);
}
public async startPlugin(pluginId: string): Promise<void> {
const plugin = this.plugins.get(pluginId);
if (!plugin) {
throw new Error(`Plugin "${pluginId}" not found`);
}
if (this.runningPlugins.get(pluginId)) {
console.warn(`Plugin "${pluginId}" is already running`);
return;
}
try {
const api = createPluginAPI(plugin);
const result = await plugin.run(api);
if (typeof result === 'function') {
this.cleanupFunctions.set(plugin.id, result);
}
this.runningPlugins.set(pluginId, true);
console.info(`Plugin "${pluginId}" started successfully`);
// Process any backlogged events
await this.processBackloggedEvents(pluginId);
} catch (error) {
console.error(`[BetterSEQTA+] Failed to start plugin ${pluginId}:`, error);
throw error;
}
}
public async startAllPlugins(): Promise<void> {
const startPromises = Array.from(this.plugins.keys()).map(id =>
this.startPlugin(id).catch(error => {
console.error(`Failed to start plugin "${id}":`, error);
return Promise.reject(error);
})
);
await Promise.allSettled(startPromises);
}
public async stopPlugin(pluginId: string): Promise<void> {
const cleanup = this.cleanupFunctions.get(pluginId);
if (cleanup) {
cleanup();
this.cleanupFunctions.delete(pluginId);
}
this.runningPlugins.set(pluginId, false);
console.info(`Plugin "${pluginId}" stopped`);
this.emit('plugin.stopped', pluginId);
}
public stopAllPlugins(): void {
Array.from(this.plugins.keys()).forEach(id => this.stopPlugin(id));
}
public getPlugin(pluginId: string): Plugin | undefined {
return this.plugins.get(pluginId);
}
public getAllPlugins(): Plugin[] {
return Array.from(this.plugins.values());
}
public isPluginRunning(pluginId: string): boolean {
return this.runningPlugins.get(pluginId) || false;
}
private emit(event: string, ...args: any[]): void {
const listeners = this.listeners.get(event);
if (listeners) {
listeners.forEach(listener => listener(...args));
}
}
public on(event: string, callback: (...args: any[]) => void): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(callback);
}
public off(event: string, callback: (...args: any[]) => void): void {
const listeners = this.listeners.get(event);
if (listeners) {
listeners.delete(callback);
}
}
}
+90
View File
@@ -0,0 +1,90 @@
import ReactFiber from '@/seqta/utils/ReactFiber';
interface BooleanSetting {
type: 'boolean';
default: boolean;
title: string;
description?: string;
}
interface StringSetting {
type: 'string';
default: string;
title: string;
description?: string;
}
interface NumberSetting {
type: 'number';
default: number;
title: string;
description?: string;
}
interface SelectSetting<T extends string> {
type: 'select';
options: readonly T[];
default: T;
title: string;
description?: string;
}
type PluginSetting = BooleanSetting | StringSetting | NumberSetting | SelectSetting<string>;
// Plugin settings configuration
export type PluginSettings = {
[key: string]: PluginSetting;
}
// Helper type to extract the actual value type from a setting
type SettingValue<T extends PluginSetting> = T extends BooleanSetting ? boolean :
T extends StringSetting ? string :
T extends NumberSetting ? number :
T extends SelectSetting<infer O> ? O :
never;
// Settings API interface
export type SettingsAPI<T extends PluginSettings> = {
[K in keyof T]: SettingValue<T[K]>;
} & {
onChange: <K extends keyof T>(key: K, callback: (value: SettingValue<T[K]>) => void) => void;
offChange: <K extends keyof T>(key: K, callback: (value: SettingValue<T[K]>) => void) => void;
}
// SEQTA API interface
export interface SEQTAAPI {
onMount: (selector: string, callback: (element: Element) => void) => void;
getFiber: (selector: string) => ReactFiber;
getCurrentPage: () => string;
onPageChange: (callback: (page: string) => void) => void;
}
// Storage API interface
export interface StorageAPI {
get: <T>(key: string) => Promise<T | null>;
set: <T>(key: string, value: T) => Promise<void>;
}
// Events API interface
export interface EventsAPI {
on: (event: string, callback: (...args: any[]) => void) => void;
emit: (event: string, ...args: any[]) => void;
}
// Complete Plugin API interface
export interface PluginAPI<T extends PluginSettings> {
seqta: SEQTAAPI;
settings: SettingsAPI<T>;
storage: StorageAPI;
events: EventsAPI;
}
// Plugin interface
export interface Plugin<T extends PluginSettings = PluginSettings> {
id: string;
name: string;
description: string;
version: string;
settings: T;
run: (api: PluginAPI<T>) => void | Promise<void> | (() => void) | Promise<() => void>;
}