From 877692695ea1c3f07e9fe0b7c0d5d2af32c3fa6f Mon Sep 17 00:00:00 2001 From: Toby Chui Date: Sun, 11 May 2025 14:02:07 +0800 Subject: [PATCH] Added #653 - Added dev mode plugin auto-reload - Optimized struct in plugin manager options --- src/api.go | 4 + src/mod/plugins/development.go | 214 +++++++++++++++++++++++++++++++ src/mod/plugins/groups.go | 22 ++-- src/mod/plugins/plugins.go | 22 +++- src/mod/plugins/tags.go | 16 +-- src/mod/plugins/typdef.go | 12 +- src/start.go | 23 ++-- src/web/components/plugins.html | 116 +++++++++++++++++ src/web/snippet/pluginstore.html | 38 +++--- 9 files changed, 416 insertions(+), 51 deletions(-) create mode 100644 src/mod/plugins/development.go diff --git a/src/api.go b/src/api.go index 769cf1b..090e5d8 100644 --- a/src/api.go +++ b/src/api.go @@ -238,6 +238,10 @@ func RegisterPluginAPIs(authRouter *auth.RouterDef) { authRouter.HandleFunc("/api/plugins/store/resync", pluginManager.HandleResyncPluginList) authRouter.HandleFunc("/api/plugins/store/install", pluginManager.HandleInstallPlugin) authRouter.HandleFunc("/api/plugins/store/uninstall", pluginManager.HandleUninstallPlugin) + + // Developer options + authRouter.HandleFunc("/api/plugins/developer/enableAutoReload", pluginManager.HandleEnableHotReload) + authRouter.HandleFunc("/api/plugins/developer/setAutoReloadInterval", pluginManager.HandleSetHotReloadInterval) } // Register the APIs for Auth functions, due to scoping issue some functions are defined here diff --git a/src/mod/plugins/development.go b/src/mod/plugins/development.go new file mode 100644 index 0000000..5db1b86 --- /dev/null +++ b/src/mod/plugins/development.go @@ -0,0 +1,214 @@ +package plugins + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "strconv" + "time" + + "imuslab.com/zoraxy/mod/utils" +) + +// StartHotReloadTicker starts the hot reload ticker +func (m *Manager) StartHotReloadTicker() error { + if m.pluginReloadTicker != nil { + m.Options.Logger.PrintAndLog("plugin-manager", "Hot reload ticker already started", nil) + return errors.New("hot reload ticker already started") + } + + m.pluginReloadTicker = time.NewTicker(time.Duration(m.Options.HotReloadInterval) * time.Second) + m.pluginReloadStop = make(chan bool) + go func() { + for { + select { + case <-m.pluginReloadTicker.C: + err := m.UpdatePluginHashList(false) + if err != nil { + m.Options.Logger.PrintAndLog("plugin-manager", "Failed to update plugin hash list", err) + } + case <-m.pluginReloadStop: + return + } + } + }() + m.Options.Logger.PrintAndLog("plugin-manager", "Hot reload ticker started", nil) + return nil + +} + +// StopHotReloadTicker stops the hot reload ticker +func (m *Manager) StopHotReloadTicker() error { + if m.pluginReloadTicker != nil { + m.pluginReloadStop <- true + m.pluginReloadTicker.Stop() + m.pluginReloadTicker = nil + m.pluginReloadStop = nil + m.Options.Logger.PrintAndLog("plugin-manager", "Hot reload ticker stopped", nil) + } else { + m.Options.Logger.PrintAndLog("plugin-manager", "Hot reload ticker already stopped", nil) + } + return nil +} + +func (m *Manager) InitPluginHashList() error { + return m.UpdatePluginHashList(true) +} + +// Update the plugin hash list and if there are change, reload the plugin +func (m *Manager) UpdatePluginHashList(noReload bool) error { + for pluginId, plugin := range m.LoadedPlugins { + //Get the plugin Entry point + pluginEntryPoint, err := m.GetPluginEntryPoint(plugin.RootDir) + if err != nil { + //Unable to get the entry point of the plugin + return err + } + + file, err := os.Open(pluginEntryPoint) + if err != nil { + m.Options.Logger.PrintAndLog("plugin-manager", "Failed to open plugin entry point: "+pluginEntryPoint, err) + return err + } + defer file.Close() + + //Calculate the hash of the file + hasher := sha256.New() + if _, err := file.Seek(0, 0); err != nil { + m.Options.Logger.PrintAndLog("plugin-manager", "Failed to seek plugin entry point: "+pluginEntryPoint, err) + return err + } + if _, err := io.Copy(hasher, file); err != nil { + m.Options.Logger.PrintAndLog("plugin-manager", "Failed to copy plugin entry point: "+pluginEntryPoint, err) + return err + } + hash := hex.EncodeToString(hasher.Sum(nil)) + m.pluginCheckMutex.Lock() + if m.PluginHash[pluginId] != hash { + m.PluginHash[pluginId] = hash + m.pluginCheckMutex.Unlock() + if !noReload { + //Plugin file changed, reload the plugin + m.Options.Logger.PrintAndLog("plugin-manager", "Plugin file changed, reloading plugin: "+pluginId, nil) + err := m.HotReloadPlugin(pluginId) + if err != nil { + m.Options.Logger.PrintAndLog("plugin-manager", "Failed to reload plugin: "+pluginId, err) + return err + } else { + m.Options.Logger.PrintAndLog("plugin-manager", "Plugin reloaded: "+pluginId, nil) + } + } else { + m.Options.Logger.PrintAndLog("plugin-manager", "Plugin hash generated for: "+pluginId, nil) + } + } else { + m.pluginCheckMutex.Unlock() + } + + } + return nil +} + +// Reload the plugin from file system +func (m *Manager) HotReloadPlugin(pluginId string) error { + //Check if the plugin is currently running + thisPlugin, err := m.GetPluginByID(pluginId) + if err != nil { + return err + } + + if thisPlugin.IsRunning() { + err = m.StopPlugin(pluginId) + if err != nil { + return err + } + } + + //Remove the plugin from the loaded plugins list + m.loadedPluginsMutex.Lock() + if _, ok := m.LoadedPlugins[pluginId]; ok { + delete(m.LoadedPlugins, pluginId) + } else { + m.loadedPluginsMutex.Unlock() + return nil + } + m.loadedPluginsMutex.Unlock() + + //Reload the plugin from disk, it should reload the plugin from latest version + m.ReloadPluginFromDisk() + + return nil +} + +/* +Request handlers for developer options +*/ +func (m *Manager) HandleEnableHotReload(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + //Return the current status of hot reload + js, _ := json.Marshal(m.Options.EnableHotReload) + utils.SendJSONResponse(w, string(js)) + return + } + + enabled, err := utils.PostBool(r, "enabled") + if err != nil { + utils.SendErrorResponse(w, "enabled not found") + return + } + m.Options.EnableHotReload = enabled + if enabled { + //Start the hot reload ticker + err := m.StartHotReloadTicker() + if err != nil { + m.Options.Logger.PrintAndLog("plugin-manager", "Failed to start hot reload ticker", err) + utils.SendErrorResponse(w, "Failed to start hot reload ticker") + return + } + m.Options.Logger.PrintAndLog("plugin-manager", "Hot reload enabled", nil) + } else { + //Stop the hot reload ticker + err := m.StopHotReloadTicker() + if err != nil { + m.Options.Logger.PrintAndLog("plugin-manager", "Failed to stop hot reload ticker", err) + utils.SendErrorResponse(w, "Failed to stop hot reload ticker") + return + } + m.Options.Logger.PrintAndLog("plugin-manager", "Hot reload disabled", nil) + } + utils.SendOK(w) +} + +func (m *Manager) HandleSetHotReloadInterval(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + //Return the current status of hot reload + js, _ := json.Marshal(m.Options.HotReloadInterval) + utils.SendJSONResponse(w, string(js)) + return + } + + interval, err := utils.PostInt(r, "interval") + if err != nil { + utils.SendErrorResponse(w, "interval not found") + return + } + + if interval < 1 { + utils.SendErrorResponse(w, "interval must be at least 1 second") + return + } + m.Options.HotReloadInterval = interval + + //Restart the hot reload ticker + if m.pluginReloadTicker != nil { + m.StopHotReloadTicker() + time.Sleep(1 * time.Second) + //Start the hot reload ticker again + m.StartHotReloadTicker() + } + m.Options.Logger.PrintAndLog("plugin-manager", "Hot reload interval set to "+strconv.Itoa(interval)+" sec", nil) + utils.SendOK(w) +} diff --git a/src/mod/plugins/groups.go b/src/mod/plugins/groups.go index 0ca787c..59317ae 100644 --- a/src/mod/plugins/groups.go +++ b/src/mod/plugins/groups.go @@ -11,11 +11,11 @@ import ( // ListPluginGroups returns a map of plugin groups func (m *Manager) ListPluginGroups() map[string][]string { pluginGroup := map[string][]string{} - m.Options.pluginGroupsMutex.RLock() + m.pluginGroupsMutex.RLock() for k, v := range m.Options.PluginGroups { pluginGroup[k] = append([]string{}, v...) } - m.Options.pluginGroupsMutex.RUnlock() + m.pluginGroupsMutex.RUnlock() return pluginGroup } @@ -32,26 +32,26 @@ func (m *Manager) AddPluginToGroup(tag, pluginID string) error { return errors.New("plugin is not a router type plugin") } - m.Options.pluginGroupsMutex.Lock() + m.pluginGroupsMutex.Lock() //Check if the tag exists _, ok = m.Options.PluginGroups[tag] if !ok { m.Options.PluginGroups[tag] = []string{pluginID} - m.Options.pluginGroupsMutex.Unlock() + m.pluginGroupsMutex.Unlock() return nil } //Add the plugin to the group m.Options.PluginGroups[tag] = append(m.Options.PluginGroups[tag], pluginID) - m.Options.pluginGroupsMutex.Unlock() + m.pluginGroupsMutex.Unlock() return nil } // RemovePluginFromGroup removes a plugin from a group func (m *Manager) RemovePluginFromGroup(tag, pluginID string) error { - m.Options.pluginGroupsMutex.Lock() - defer m.Options.pluginGroupsMutex.Unlock() + m.pluginGroupsMutex.Lock() + defer m.pluginGroupsMutex.Unlock() //Check if the tag exists _, ok := m.Options.PluginGroups[tag] if !ok { @@ -72,8 +72,8 @@ func (m *Manager) RemovePluginFromGroup(tag, pluginID string) error { // RemovePluginGroup removes a plugin group func (m *Manager) RemovePluginGroup(tag string) error { - m.Options.pluginGroupsMutex.Lock() - defer m.Options.pluginGroupsMutex.Unlock() + m.pluginGroupsMutex.Lock() + defer m.pluginGroupsMutex.Unlock() _, ok := m.Options.PluginGroups[tag] if !ok { return errors.New("tag not found") @@ -84,12 +84,12 @@ func (m *Manager) RemovePluginGroup(tag string) error { // SavePluginGroupsFromFile loads plugin groups from a file func (m *Manager) SavePluginGroupsToFile() error { - m.Options.pluginGroupsMutex.RLock() + m.pluginGroupsMutex.RLock() pluginGroupsCopy := make(map[string][]string) for k, v := range m.Options.PluginGroups { pluginGroupsCopy[k] = append([]string{}, v...) } - m.Options.pluginGroupsMutex.RUnlock() + m.pluginGroupsMutex.RUnlock() //Write to file js, _ := json.Marshal(pluginGroupsCopy) diff --git a/src/mod/plugins/plugins.go b/src/mod/plugins/plugins.go index 039eaa1..6beb138 100644 --- a/src/mod/plugins/plugins.go +++ b/src/mod/plugins/plugins.go @@ -47,15 +47,26 @@ func NewPluginManager(options *ManagerOptions) *Manager { //Create database table options.Database.NewTable("plugins") - return &Manager{ + thisManager := &Manager{ LoadedPlugins: make(map[string]*Plugin), tagPluginMap: sync.Map{}, tagPluginListMutex: sync.RWMutex{}, tagPluginList: make(map[string][]*Plugin), Options: options, + PluginHash: make(map[string]string), /* Internal */ loadedPluginsMutex: sync.RWMutex{}, } + + //Check if hot reload is enabled + if options.EnableHotReload { + err := thisManager.StartHotReloadTicker() + if err != nil { + options.Logger.PrintAndLog("plugin-manager", "Failed to start hot reload ticker", err) + } + } + + return thisManager } // Reload all plugins from disk @@ -104,11 +115,16 @@ func (m *Manager) ReloadPluginFromDisk() { m.loadedPluginsMutex.Lock() m.LoadedPlugins[thisPlugin.Spec.ID] = thisPlugin m.loadedPluginsMutex.Unlock() - m.Log("Added new plugin: "+thisPlugin.Spec.Name, nil) + versionNumber := strconv.Itoa(thisPlugin.Spec.VersionMajor) + "." + strconv.Itoa(thisPlugin.Spec.VersionMinor) + "." + strconv.Itoa(thisPlugin.Spec.VersionPatch) + //Check if the plugin is enabled + m.Log("Found plugin: "+thisPlugin.Spec.Name+" (v"+versionNumber+")", nil) // The default state of the plugin is disabled, so no need to start it } } + + //Generate a hash list for plugins + m.InitPluginHashList() } // LoadPluginsFromDisk loads all plugins from the plugin directory @@ -156,6 +172,8 @@ func (m *Manager) LoadPluginsFromDisk() error { //Generate the static forwarder radix tree m.UpdateTagsToPluginMaps() + //Generate a hash list for plugins + m.InitPluginHashList() return nil } diff --git a/src/mod/plugins/tags.go b/src/mod/plugins/tags.go index 9c53c7b..d4a9b27 100644 --- a/src/mod/plugins/tags.go +++ b/src/mod/plugins/tags.go @@ -17,8 +17,8 @@ import ( // This will only load the plugin tags to option.PluginGroups map // to push the changes to runtime, call UpdateTagsToPluginMaps() func (m *Manager) LoadPluginGroupsFromConfig() error { - m.Options.pluginGroupsMutex.RLock() - defer m.Options.pluginGroupsMutex.RUnlock() + m.pluginGroupsMutex.RLock() + defer m.pluginGroupsMutex.RUnlock() //Read the config file rawConfig, err := os.ReadFile(m.Options.PluginGroupsConfig) @@ -39,8 +39,8 @@ func (m *Manager) LoadPluginGroupsFromConfig() error { // AddPluginToTag adds a plugin to a tag func (m *Manager) AddPluginToTag(tag string, pluginID string) error { - m.Options.pluginGroupsMutex.RLock() - defer m.Options.pluginGroupsMutex.RUnlock() + m.pluginGroupsMutex.RLock() + defer m.pluginGroupsMutex.RUnlock() //Check if the plugin exists _, err := m.GetPluginByID(pluginID) @@ -66,8 +66,8 @@ func (m *Manager) AddPluginToTag(tag string, pluginID string) error { // RemovePluginFromTag removes a plugin from a tag func (m *Manager) RemovePluginFromTag(tag string, pluginID string) error { // Check if the plugin exists in Options.PluginGroups - m.Options.pluginGroupsMutex.RLock() - defer m.Options.pluginGroupsMutex.RUnlock() + m.pluginGroupsMutex.RLock() + defer m.pluginGroupsMutex.RUnlock() pluginList, ok := m.Options.PluginGroups[tag] if !ok { return nil @@ -91,8 +91,8 @@ func (m *Manager) RemovePluginFromTag(tag string, pluginID string) error { // savePluginTagMap saves the plugin tag map to the config file func (m *Manager) savePluginTagMap() error { - m.Options.pluginGroupsMutex.RLock() - defer m.Options.pluginGroupsMutex.RUnlock() + m.pluginGroupsMutex.RLock() + defer m.pluginGroupsMutex.RUnlock() js, _ := json.Marshal(m.Options.PluginGroups) return os.WriteFile(m.Options.PluginGroupsConfig, js, 0644) diff --git a/src/mod/plugins/typdef.go b/src/mod/plugins/typdef.go index 23ad083..6b64068 100644 --- a/src/mod/plugins/typdef.go +++ b/src/mod/plugins/typdef.go @@ -5,6 +5,7 @@ import ( "net/http" "os/exec" "sync" + "time" "imuslab.com/zoraxy/mod/database" "imuslab.com/zoraxy/mod/dynamicproxy/dpcore" @@ -45,8 +46,9 @@ type ManagerOptions struct { Database *database.Database `json:"-"` Logger *logger.Logger `json:"-"` - /* Internal */ - pluginGroupsMutex sync.RWMutex //Mutex for the pluginGroups + /* Development */ + EnableHotReload bool //Check if the plugin file is changed and reload the plugin automatically + HotReloadInterval int //The interval for checking the plugin file change, in seconds } type Manager struct { @@ -56,6 +58,12 @@ type Manager struct { tagPluginList map[string][]*Plugin //Storing the plugin list for each tag, only concurrent READ is allowed Options *ManagerOptions + PluginHash map[string]string //The hash of the plugin file, used to check if the plugin file is changed + /* Internal */ loadedPluginsMutex sync.RWMutex //Mutex for the loadedPlugins + pluginGroupsMutex sync.RWMutex //Mutex for the pluginGroups + pluginCheckMutex sync.RWMutex //Mutex for the plugin hash + pluginReloadTicker *time.Ticker //Ticker for the plugin reload + pluginReloadStop chan bool //Channel to stop the plugin reload ticker } diff --git a/src/start.go b/src/start.go index bc9d363..d847744 100644 --- a/src/start.go +++ b/src/start.go @@ -307,21 +307,26 @@ func startupSequence() { pluginFolder := *path_plugin pluginFolder = strings.TrimSuffix(pluginFolder, "/") pluginManager = plugins.NewPluginManager(&plugins.ManagerOptions{ - PluginDir: pluginFolder, - SystemConst: &zoraxy_plugin.RuntimeConstantValue{ - ZoraxyVersion: SYSTEM_VERSION, - ZoraxyUUID: nodeUUID, - DevelopmentBuild: *development_build, - }, - PluginStoreURLs: []string{ - "https://raw.githubusercontent.com/aroz-online/zoraxy-official-plugins/refs/heads/main/directories/index.json", - }, + PluginDir: pluginFolder, Database: sysdb, Logger: SystemWideLogger, PluginGroupsConfig: CONF_PLUGIN_GROUPS, CSRFTokenGen: func(r *http.Request) string { return csrf.Token(r) }, + SystemConst: &zoraxy_plugin.RuntimeConstantValue{ + ZoraxyVersion: SYSTEM_VERSION, + ZoraxyUUID: nodeUUID, + DevelopmentBuild: *development_build, + }, + /* Plugin Store URLs */ + PluginStoreURLs: []string{ + "https://raw.githubusercontent.com/aroz-online/zoraxy-official-plugins/refs/heads/main/directories/index.json", + //TO BE ADDED + }, + /* Developer Options */ + EnableHotReload: *development_build, //Default to true if development build + HotReloadInterval: 5, //seconds }) //Sync latest plugin list from the plugin store diff --git a/src/web/components/plugins.html b/src/web/components/plugins.html index 9592723..870d701 100644 --- a/src/web/components/plugins.html +++ b/src/web/components/plugins.html @@ -185,6 +185,33 @@ +
+
+
+ + Developer Settings +
+
+
+
Developer Only
+

These functions are intended for developers only. Enabling them may add latency to plugin loading & routing. Proceed with caution.
+ Tips: You can start zoraxy with -dev=true to enable auto-reload when start

+
+
+ + +
+

+
+ + +
+ Specify the interval (in seconds) for checking plugin changes.
Minimum is 1 second, maximum is 60 seconds.
+
+
+
+
@@ -592,6 +619,95 @@ function uninstallPlugin(pluginId, pluginName, btn=undefined) { } } + + /* Developer Settings */ + + function initDeveloperSettings() { + // Fetch the auto reload status + $.get('/api/plugins/developer/enableAutoReload', function(data) { + if (data.error != undefined) { + msgbox(data.error, false); + return; + } + + // Set the checkbox for Plugin Auto Reload + if (data == true) { + $("#enablePluginAutoReload").checkbox('set checked'); + } else { + $("#enablePluginAutoReload").checkbox('set unchecked'); + } + + // Fetch the auto reload interval + $.get('/api/plugins/developer/setAutoReloadInterval', function(data) { + if (data.error != undefined) { + msgbox(data.error, false); + return; + } + + // Set the input value for Auto Reload Interval + if (data) { + $("#autoreload-interval").val(data); + } + + bindEventsToDeveloperSettings(); + }); + }); + } + + function bindEventsToDeveloperSettings(){ + $("#enablePluginAutoReload").checkbox({ + onChecked: function() { + $.cjax({ + url: '/api/plugins/developer/enableAutoReload', + type: 'POST', + data: { "enabled": true }, + success: function(data) { + if (data.error != undefined) { + msgbox(data.error, false); + } else { + msgbox("Plugin Auto Reload enabled", true); + } + } + }); + }, + onUnchecked: function() { + $.cjax({ + url: '/api/plugins/developer/enableAutoReload', + type: 'POST', + data: { "enabled": false }, + success: function(data) { + if (data.error != undefined) { + msgbox(data.error, false); + } else { + msgbox("Plugin Auto Reload disabled", true); + } + } + }); + } + }); + + $("#autoreload-interval").on("change", function() { + const interval = $(this).val(); + if (interval < 1 || interval > 60) { + msgbox("Interval must be between 1 and 60 seconds", false); + return; + } + $.cjax({ + url: '/api/plugins/developer/setAutoReloadInterval', + type: 'POST', + data: { "interval": interval }, + success: function(data) { + if (data.error != undefined) { + msgbox(data.error, false); + } else { + msgbox("Auto Reload Interval updated to " + interval + " seconds", true); + } + } + }); + }); + } + + initDeveloperSettings(); diff --git a/src/web/snippet/pluginstore.html b/src/web/snippet/pluginstore.html index e7205f0..fabb83b 100644 --- a/src/web/snippet/pluginstore.html +++ b/src/web/snippet/pluginstore.html @@ -58,28 +58,28 @@ + -->