-
Notifications
You must be signed in to change notification settings - Fork 286
feat: better plugin manager #1097
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v3.3.3
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,6 +5,7 @@ import ( | |||||||||||||||||||||
| "fmt" | ||||||||||||||||||||||
| "io" | ||||||||||||||||||||||
| "net/http" | ||||||||||||||||||||||
| urlpkg "net/url" | ||||||||||||||||||||||
| "os" | ||||||||||||||||||||||
| "path/filepath" | ||||||||||||||||||||||
| "runtime" | ||||||||||||||||||||||
|
|
@@ -23,26 +24,103 @@ import ( | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| // See https://github.com/aroz-online/zoraxy-official-plugins/blob/main/directories/index2.json for the standard format | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| var defaultPluginStoreURLs = []string{ | ||||||||||||||||||||||
| "https://raw.githubusercontent.com/aroz-online/zoraxy-official-plugins/refs/heads/main/directories/index2.json", | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| type DownloadablePlugin struct { | ||||||||||||||||||||||
| IconPath string `json:"IconPath"` //Icon path or URL for the plugin | ||||||||||||||||||||||
| PluginIntroSpect zoraxy_plugin.IntroSpect `json:"PluginIntroSpect"` //Plugin introspect information | ||||||||||||||||||||||
| DownloadURLs map[string]string `json:"DownloadURLs"` //Download URLs for different platforms | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func GetDefaultPluginStoreURLs() []string { | ||||||||||||||||||||||
| return append([]string{}, defaultPluginStoreURLs...) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func normalizePluginStoreURLs(rawURLs []string) []string { | ||||||||||||||||||||||
| seen := map[string]bool{} | ||||||||||||||||||||||
| normalized := []string{} | ||||||||||||||||||||||
| for _, rawURL := range rawURLs { | ||||||||||||||||||||||
| trimmed := strings.TrimSpace(rawURL) | ||||||||||||||||||||||
| if trimmed == "" || seen[trimmed] { | ||||||||||||||||||||||
| continue | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| seen[trimmed] = true | ||||||||||||||||||||||
| normalized = append(normalized, trimmed) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| if len(normalized) == 0 { | ||||||||||||||||||||||
| return GetDefaultPluginStoreURLs() | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| return normalized | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func (m *Manager) GetPluginStoreURLs() []string { | ||||||||||||||||||||||
| return normalizePluginStoreURLs(m.Options.PluginStoreURLs) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func (m *Manager) LoadPluginStoreURLs() error { | ||||||||||||||||||||||
| if m.Options.Database == nil { | ||||||||||||||||||||||
| m.Options.PluginStoreURLs = normalizePluginStoreURLs(m.Options.PluginStoreURLs) | ||||||||||||||||||||||
| return nil | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| loaded := []string{} | ||||||||||||||||||||||
| if err := m.Options.Database.Read("plugins", "store_urls", &loaded); err != nil { | ||||||||||||||||||||||
| m.Options.PluginStoreURLs = normalizePluginStoreURLs(m.Options.PluginStoreURLs) | ||||||||||||||||||||||
| return nil | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| m.Options.PluginStoreURLs = normalizePluginStoreURLs(loaded) | ||||||||||||||||||||||
| return nil | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func (m *Manager) SavePluginStoreURLs(urls []string) error { | ||||||||||||||||||||||
| normalized := normalizePluginStoreURLs(urls) | ||||||||||||||||||||||
| m.Options.PluginStoreURLs = normalized | ||||||||||||||||||||||
| if m.Options.Database == nil { | ||||||||||||||||||||||
| return nil | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| return m.Options.Database.Write("plugins", "store_urls", normalized) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
24
to
+85
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /* Plugin Store Index List Sync */ | ||||||||||||||||||||||
| //Update the plugin list from the plugin store URLs | ||||||||||||||||||||||
| func (m *Manager) UpdateDownloadablePluginList() error { | ||||||||||||||||||||||
| //Get downloadable plugins from each of the plugin store URLS | ||||||||||||||||||||||
| m.Options.DownloadablePluginCache = []*DownloadablePlugin{} | ||||||||||||||||||||||
| for _, url := range m.Options.PluginStoreURLs { | ||||||||||||||||||||||
| pluginList, err := m.getPluginListFromURL(url) | ||||||||||||||||||||||
| m.Options.PluginStoreURLs = normalizePluginStoreURLs(m.Options.PluginStoreURLs) | ||||||||||||||||||||||
| combined := []*DownloadablePlugin{} | ||||||||||||||||||||||
| seenPluginIDs := map[string]bool{} | ||||||||||||||||||||||
| errors := []string{} | ||||||||||||||||||||||
| successfulSources := 0 | ||||||||||||||||||||||
| for _, sourceURL := range m.Options.PluginStoreURLs { | ||||||||||||||||||||||
| pluginList, err := m.getPluginListFromURL(sourceURL) | ||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||
| return fmt.Errorf("failed to get plugin list from %s: %w", url, err) | ||||||||||||||||||||||
| errors = append(errors, fmt.Sprintf("%s: %s", sourceURL, err.Error())) | ||||||||||||||||||||||
| continue | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| successfulSources++ | ||||||||||||||||||||||
| for _, plugin := range pluginList { | ||||||||||||||||||||||
| if plugin == nil || plugin.PluginIntroSpect.ID == "" { | ||||||||||||||||||||||
| continue | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| if seenPluginIDs[plugin.PluginIntroSpect.ID] { | ||||||||||||||||||||||
| continue | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| seenPluginIDs[plugin.PluginIntroSpect.ID] = true | ||||||||||||||||||||||
| combined = append(combined, plugin) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| m.Options.DownloadablePluginCache = append(m.Options.DownloadablePluginCache, pluginList...) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| m.Options.LastSuccPluginSyncTime = time.Now().Unix() | ||||||||||||||||||||||
| m.Options.DownloadablePluginCache = combined | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if successfulSources > 0 { | ||||||||||||||||||||||
| m.Options.LastSuccPluginSyncTime = time.Now().Unix() | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if successfulSources == 0 && len(errors) > 0 { | ||||||||||||||||||||||
| return fmt.Errorf("failed to sync any plugin repository: %s", strings.Join(errors, "; ")) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return nil | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
@@ -72,16 +150,42 @@ func (m *Manager) getPluginListFromURL(url string) ([]*DownloadablePlugin, error | |||||||||||||||||||||
| return nil, fmt.Errorf("failed to unmarshal plugin list from %s: %w", url, err) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| baseURL, _ := urlpkg.Parse(url) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Filter out if IconPath is empty string, set it to "/img/plugin_icon.png" | ||||||||||||||||||||||
| for _, plugin := range pluginList { | ||||||||||||||||||||||
|
Comment on lines
150
to
156
|
||||||||||||||||||||||
| if strings.TrimSpace(plugin.IconPath) == "" { | ||||||||||||||||||||||
| plugin.IconPath = "/img/plugin_icon.png" | ||||||||||||||||||||||
| } else if resolvedURL := resolvePluginStoreAssetURL(baseURL, plugin.IconPath); resolvedURL != "" { | ||||||||||||||||||||||
| plugin.IconPath = resolvedURL | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| for platform, downloadURL := range plugin.DownloadURLs { | ||||||||||||||||||||||
| if resolvedURL := resolvePluginStoreAssetURL(baseURL, downloadURL); resolvedURL != "" { | ||||||||||||||||||||||
| plugin.DownloadURLs[platform] = resolvedURL | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return pluginList, nil | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func resolvePluginStoreAssetURL(baseURL *urlpkg.URL, raw string) string { | ||||||||||||||||||||||
| raw = strings.TrimSpace(raw) | ||||||||||||||||||||||
| if raw == "" { | ||||||||||||||||||||||
| return "" | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| parsed, err := urlpkg.Parse(raw) | ||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||
| return raw | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| if parsed.IsAbs() || baseURL == nil { | ||||||||||||||||||||||
| return raw | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| return baseURL.ResolveReference(parsed).String() | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func (m *Manager) ListDownloadablePlugins() []*DownloadablePlugin { | ||||||||||||||||||||||
| //List all downloadable plugins | ||||||||||||||||||||||
| if len(m.Options.DownloadablePluginCache) == 0 { | ||||||||||||||||||||||
|
|
@@ -217,6 +321,43 @@ func (m *Manager) HandleListDownloadablePlugins(w http.ResponseWriter, r *http.R | |||||||||||||||||||||
| utils.SendJSONResponse(w, string(js)) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func (m *Manager) HandlePluginStoreURLs(w http.ResponseWriter, r *http.Request) { | ||||||||||||||||||||||
| switch r.Method { | ||||||||||||||||||||||
| case http.MethodGet: | ||||||||||||||||||||||
| payload := map[string]any{ | ||||||||||||||||||||||
| "urls": m.GetPluginStoreURLs(), | ||||||||||||||||||||||
| "default_urls": GetDefaultPluginStoreURLs(), | ||||||||||||||||||||||
| "last_sync_unix": m.Options.LastSuccPluginSyncTime, | ||||||||||||||||||||||
| "repository_count": len(m.GetPluginStoreURLs()), | ||||||||||||||||||||||
| "downloadable_count": len(m.Options.DownloadablePluginCache), | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| js, _ := json.Marshal(payload) | ||||||||||||||||||||||
| utils.SendJSONResponse(w, string(js)) | ||||||||||||||||||||||
| case http.MethodPost: | ||||||||||||||||||||||
| rawURLs, err := utils.PostPara(r, "urls") | ||||||||||||||||||||||
| if err != nil && !strings.Contains(err.Error(), "invalid urls") { | ||||||||||||||||||||||
| utils.SendErrorResponse(w, "urls not found") | ||||||||||||||||||||||
| return | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+337
to
+341
|
||||||||||||||||||||||
| rawURLs, err := utils.PostPara(r, "urls") | |
| if err != nil && !strings.Contains(err.Error(), "invalid urls") { | |
| utils.SendErrorResponse(w, "urls not found") | |
| return | |
| } | |
| if err := r.ParseForm(); err != nil { | |
| utils.SendErrorResponse(w, "invalid form data: "+err.Error()) | |
| return | |
| } | |
| rawURLs := r.FormValue("urls") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,6 +32,25 @@ func (m *Manager) GetPluginEntryPoint(folderpath string) (string, error) { | |
| if !info.IsDir() { | ||
| return "", errors.New("path is not a directory") | ||
| } | ||
|
|
||
| startScriptPath := filepath.Join(folderpath, "start.sh") | ||
| startBatchPath := filepath.Join(folderpath, "start.bat") | ||
| goModPath := filepath.Join(folderpath, "go.mod") | ||
|
|
||
| // Prefer source entrypoints for plugin repositories that ship source code. | ||
| // This avoids stale checked-in binaries shadowing newer API/UI changes. | ||
| if _, err := os.Stat(goModPath); err == nil { | ||
| if runtime.GOOS == "windows" { | ||
| if _, err := os.Stat(startBatchPath); err == nil { | ||
| return startBatchPath, nil | ||
| } | ||
| } else { | ||
| if _, err := os.Stat(startScriptPath); err == nil { | ||
| return startScriptPath, nil | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
Comment on lines
+40
to
+53
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure about this. Typically plugins should ship the executable, and if you're developing a plugin you can just recompile it to get the latest version.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right — this is specific to Go, so checking for go.mod here isn't necessary. We'll still get support for Windows and other operating systems. |
||
| expectedBinaryPath := filepath.Join(folderpath, filepath.Base(folderpath)) | ||
| if runtime.GOOS == "windows" { | ||
| expectedBinaryPath += ".exe" | ||
|
|
@@ -41,12 +60,12 @@ func (m *Manager) GetPluginEntryPoint(folderpath string) (string, error) { | |
| return expectedBinaryPath, nil | ||
| } | ||
|
|
||
| if _, err := os.Stat(filepath.Join(folderpath, "start.sh")); err == nil { | ||
| return filepath.Join(folderpath, "start.sh"), nil | ||
| if _, err := os.Stat(startScriptPath); err == nil { | ||
| return startScriptPath, nil | ||
| } | ||
|
|
||
| if _, err := os.Stat(filepath.Join(folderpath, "start.bat")); err == nil { | ||
| return filepath.Join(folderpath, "start.bat"), nil | ||
| if _, err := os.Stat(startBatchPath); err == nil { | ||
| return startBatchPath, nil | ||
| } | ||
|
|
||
| return "", errors.New("no valid entry point found") | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -90,29 +90,27 @@ | |||
|
|
||||
| </div> | ||||
| <button class="ui basic button" onclick="forceResyncPlugins();"><i class="ui green refresh icon"></i> Update Plugin List</button> | ||||
| <!-- <div class="ui divider"></div> | ||||
| <div class="ui basic segment advanceoptions"> | ||||
| <div class="ui accordion advanceSettings"> | ||||
| <div class="title"> | ||||
| <div class="ui divider"></div> | ||||
| <div class="ui basic segment advanceoptions"> | ||||
| <div class="ui accordion advanceSettings"> | ||||
| <div class="title"> | ||||
| <i class="dropdown icon"></i> | ||||
| Advance Settings | ||||
| </div> | ||||
| <div class="content"> | ||||
| <p>Plugin Store URLs</p> | ||||
| <div class="ui form"> | ||||
| Advanced Settings | ||||
| </div> | ||||
| <div class="content"> | ||||
| <div class="ui form"> | ||||
| <div class="field"> | ||||
| <label>Plugin Store Repositories</label> | ||||
| <textarea id="pluginStoreURLs" rows="5"></textarea> | ||||
| <label>Enter plugin store URLs, separating each URL with a new line</label> | ||||
| <small>One repository index URL per line. Leave empty to restore the default repository list.</small> | ||||
| </div> | ||||
| <button class="ui basic button" onclick="savePluginStoreURLs()"> | ||||
| <i class="ui green save icon"></i>Save | ||||
| <button class="ui basic button" onclick="savePluginManagerURLs()"> | ||||
| <i class="ui green save icon"></i> Save | ||||
| </button> | ||||
| </div> | ||||
|
|
||||
| </div> | ||||
| </div> | ||||
| </div> | ||||
| --> | ||||
| </div> | ||||
| <div class="ui divider"></div> | ||||
| <div class="field" > | ||||
| <button class="ui basic button" style="float: right;" onclick="closeThisWrapper();">Close</button> | ||||
|
|
@@ -168,6 +166,18 @@ | |||
| } | ||||
| initStoreList(); | ||||
|
|
||||
| function initPluginStoreRepositories() { | ||||
| $.get('/api/plugins/store/urls', function(data) { | ||||
| if (data.error != undefined) { | ||||
| parent.msgbox(data.error, false); | ||||
| return; | ||||
| } | ||||
| const urls = data.urls || []; | ||||
| $('#pluginStoreURLs').val(urls.join('\n')); | ||||
| }); | ||||
| } | ||||
| initPluginStoreRepositories(); | ||||
|
|
||||
| /* Plugin Search */ | ||||
| function searchPlugins() { | ||||
| const query = document.getElementById('searchInput').value.toLowerCase(); | ||||
|
|
@@ -213,6 +223,7 @@ | |||
| } else { | ||||
| parent.msgbox("Plugin list updated successfully", true); | ||||
| initStoreList(); | ||||
| initPluginStoreRepositories(); | ||||
| } | ||||
| } | ||||
| }); | ||||
|
|
@@ -383,22 +394,25 @@ | |||
|
|
||||
| /* Advanced Options */ | ||||
| function savePluginManagerURLs() { | ||||
| const urls = document.getElementById('pluginStoreURLs').value.split('\n').map(url => url.trim()).filter(url => url !== ''); | ||||
| console.log('Saving URLs:', urls); | ||||
| // Add your logic to save the URLs here, e.g., send them to the server | ||||
| const urls = document.getElementById('pluginStoreURLs').value | ||||
| .split('\n') | ||||
| .map(url => url.trim()) | ||||
| .filter(url => url !== ''); | ||||
| $.cjax({ | ||||
| url: '/api/plugins/store/saveURLs', | ||||
| url: '/api/plugins/store/urls', | ||||
| type: 'POST', | ||||
| data: { urls }, | ||||
| data: { urls: urls.join('\n') }, | ||||
| success: function(data) { | ||||
| if (data.error != undefined) { | ||||
| parent.msgbox(data.error, false); | ||||
| } else { | ||||
| parent.msgbox("URLs saved successfully", true); | ||||
| parent.msgbox("Plugin store repositories saved", true); | ||||
| initPluginStoreRepositories(); | ||||
|
||||
| initPluginStoreRepositories(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SavePluginStoreURLsalways normalizes an empty list to the current defaults and then persists that default slice to the database. That means “reset to defaults” will pin the user to today’s default list and they won’t automatically pick up future default repository additions/removals. Consider treating an empty input as “unset override” (e.g.,Database.Delete("plugins","store_urls")) while keepingm.Options.PluginStoreURLsin-memory set toGetDefaultPluginStoreURLs().