mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +00:00
feat: add plugin system
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user