package gobackend import ( "archive/zip" "encoding/json" "fmt" "io" "os" "path" "path/filepath" "strconv" "strings" "sync" "time" "github.com/dop251/goja" ) func compareVersions(v1, v2 string) int { parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".") parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".") maxLen := len(parts1) if len(parts2) > maxLen { maxLen = len(parts2) } for i := 0; i < maxLen; i++ { var n1, n2 int if i < len(parts1) { n1, _ = strconv.Atoi(parts1[i]) } if i < len(parts2) { n2, _ = strconv.Atoi(parts2[i]) } if n1 < n2 { return -1 } if n1 > n2 { return 1 } } return 0 } func isExtensionPackagePath(filePath string) bool { lowerPath := strings.ToLower(filePath) return strings.HasSuffix(lowerPath, ".spotiflac-ext") || strings.HasSuffix(lowerPath, ".sflx") } func managedExtensionPath(root, extensionID string) (string, error) { if root == "" { return "", fmt.Errorf("extension directory is not configured") } if !extensionIDPattern.MatchString(extensionID) { return "", fmt.Errorf("invalid extension ID %q", extensionID) } fullPath := filepath.Join(root, extensionID) if !isPathWithinBase(root, fullPath) { return "", fmt.Errorf("extension path escapes its managed directory") } return fullPath, nil } func safeExtensionAssetPath(root, assetPath string) (string, bool) { if root == "" || assetPath == "" || filepath.IsAbs(assetPath) || strings.Contains(assetPath, `\`) { return "", false } cleaned := path.Clean(assetPath) if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") { return "", false } fullPath := filepath.Join(root, filepath.FromSlash(cleaned)) return fullPath, isPathWithinBase(root, fullPath) } func extractExtensionArchive(zipReader *zip.ReadCloser, destination string) error { for _, file := range zipReader.File { if file.FileInfo().IsDir() { continue } if file.FileInfo().Mode()&os.ModeSymlink != 0 || strings.Contains(file.Name, `\`) { return fmt.Errorf("unsafe path in extension archive: %s", file.Name) } relPath := path.Clean(file.Name) if relPath == "." || relPath == ".." || strings.HasPrefix(relPath, "../") || path.IsAbs(relPath) { return fmt.Errorf("unsafe path in extension archive: %s", file.Name) } destPath := filepath.Join(destination, filepath.FromSlash(relPath)) if !isPathWithinBase(destination, destPath) { return fmt.Errorf("unsafe path in extension archive: %s", file.Name) } if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { return fmt.Errorf("failed to create extension directory: %w", err) } destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) if err != nil { return fmt.Errorf("failed to create extension file: %w", err) } srcFile, err := file.Open() if err != nil { destFile.Close() return fmt.Errorf("failed to open file in archive: %w", err) } _, copyErr := io.Copy(destFile, srcFile) closeSrcErr := srcFile.Close() closeDestErr := destFile.Close() if copyErr != nil { return fmt.Errorf("failed to extract extension file: %w", copyErr) } if closeSrcErr != nil || closeDestErr != nil { return fmt.Errorf("failed to close extracted extension file") } } return nil } type loadedExtension struct { ID string `json:"id"` Manifest *ExtensionManifest `json:"manifest"` VM *goja.Runtime `json:"-"` VMMu sync.Mutex `json:"-"` runtime *extensionRuntime indexProgram *goja.Program initialized bool Enabled bool `json:"enabled"` Error string `json:"error,omitempty"` DataDir string `json:"data_dir"` SourceDir string `json:"source_dir"` IconPath string `json:"icon_path"` isolatedPoolMu sync.Mutex isolatedPool []*isolatedRuntimeHandle } type isolatedRuntimeHandle struct { vm *goja.Runtime runtime *extensionRuntime } func getExtensionInitSettings(extensionID string) map[string]any { settings := GetExtensionSettingsStore().GetAll(extensionID) if len(settings) == 0 { return settings } filtered := make(map[string]any, len(settings)) for key, value := range settings { if strings.HasPrefix(key, "_") { continue } filtered[key] = value } return filtered } func ensureRuntimeReadyLocked(ext *loadedExtension, applyStoredSettings bool) error { if ext.VM == nil || ext.runtime == nil { if err := initializeVMLocked(ext); err != nil { ext.Error = err.Error() ext.Enabled = false return err } } if applyStoredSettings && !ext.initialized { settings := getExtensionInitSettings(ext.ID) if len(settings) > 0 { if err := initializeExtensionWithSettingsLocked(ext, settings); err != nil { teardownVMLocked(ext) ext.Error = err.Error() ext.Enabled = false return err } } else { ext.initialized = true } } ext.Error = "" return nil } func (ext *loadedExtension) ensureRuntimeReady() error { ext.VMMu.Lock() defer ext.VMMu.Unlock() return ensureRuntimeReadyLocked(ext, true) } func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) { ext.VMMu.Lock() if err := ensureRuntimeReadyLocked(ext, true); err != nil { ext.VMMu.Unlock() return nil, err } return ext.VM, nil } type extensionManager struct { mu sync.RWMutex // mutationMu serializes install/upgrade/remove (heavy FS + goja VM // teardown/reload), which are not safe to run concurrently. Acquired before // m.mu; "*Locked" helpers assume it is held. mutationMu sync.Mutex extensions map[string]*loadedExtension extensionsDir string dataDir string } var ( globalExtManager *extensionManager globalExtManagerOnce sync.Once ) func getExtensionManager() *extensionManager { globalExtManagerOnce.Do(func() { globalExtManager = &extensionManager{ extensions: make(map[string]*loadedExtension), } }) return globalExtManager } func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error { m.mu.Lock() defer m.mu.Unlock() m.extensionsDir = extensionsDir m.dataDir = dataDir if err := os.MkdirAll(extensionsDir, 0755); err != nil { return fmt.Errorf("failed to create extensions directory: %w", err) } if err := os.MkdirAll(dataDir, 0755); err != nil { return fmt.Errorf("failed to create data directory: %w", err) } return nil } func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) { m.mutationMu.Lock() defer m.mutationMu.Unlock() return m.loadExtensionFromFileLocked(filePath) } func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loadedExtension, error) { if !isExtensionPackagePath(filePath) { return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file") } zipReader, err := zip.OpenReader(filePath) if err != nil { return nil, fmt.Errorf("cannot open extension file: the file may be corrupted or not a valid extension package") } defer zipReader.Close() var manifestData []byte var hasIndexJS bool for _, file := range zipReader.File { name := filepath.Base(file.Name) if name == "manifest.json" { rc, err := file.Open() if err != nil { return nil, fmt.Errorf("failed to open manifest.json: %w", err) } manifestData, err = io.ReadAll(rc) rc.Close() if err != nil { return nil, fmt.Errorf("failed to read manifest.json: %w", err) } } if name == "index.js" { hasIndexJS = true } } if manifestData == nil { return nil, fmt.Errorf("invalid extension package: manifest.json not found") } if !hasIndexJS { return nil, fmt.Errorf("invalid extension package: index.js not found") } manifest, err := ParseManifest(manifestData) if err != nil { return nil, fmt.Errorf("invalid extension manifest: %w", err) } m.mu.RLock() existing, exists := m.extensions[manifest.Name] var existingVersion string var existingDisplayName string if exists { existingVersion = existing.Manifest.Version existingDisplayName = existing.Manifest.DisplayName } m.mu.RUnlock() if exists { versionCompare := compareVersions(manifest.Version, existingVersion) if versionCompare > 0 { return m.upgradeExtensionLocked(filePath) } else if versionCompare == 0 { return nil, fmt.Errorf("extension '%s' v%s is already installed", existingDisplayName, existingVersion) } else { return nil, fmt.Errorf("cannot downgrade '%s' from v%s to v%s", existingDisplayName, existingVersion, manifest.Version) } } m.mu.Lock() defer m.mu.Unlock() if _, exists := m.extensions[manifest.Name]; exists { return nil, fmt.Errorf("extension '%s' was installed by another process", manifest.DisplayName) } extDir, err := managedExtensionPath(m.extensionsDir, manifest.Name) if err != nil { return nil, err } if _, err := os.Lstat(extDir); err == nil { return nil, fmt.Errorf("extension directory already exists for %q", manifest.Name) } else if !os.IsNotExist(err) { return nil, fmt.Errorf("failed to inspect extension directory: %w", err) } stagingDir, err := os.MkdirTemp(m.extensionsDir, "."+manifest.Name+"-install-*") if err != nil { return nil, fmt.Errorf("failed to create extension staging directory: %w", err) } stagingCommitted := false defer func() { if !stagingCommitted { _ = os.RemoveAll(stagingDir) } }() if err := extractExtensionArchive(zipReader, stagingDir); err != nil { return nil, err } extDataDir, err := managedExtensionPath(m.dataDir, manifest.Name) if err != nil { return nil, err } if err := os.MkdirAll(extDataDir, 0755); err != nil { return nil, fmt.Errorf("failed to create extension data directory: %w", err) } ext := &loadedExtension{ ID: manifest.Name, Manifest: manifest, Enabled: false, // New extensions start disabled DataDir: extDataDir, SourceDir: stagingDir, } if err := validateExtensionLoad(ext); err != nil { ext.Error = err.Error() ext.Enabled = false GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err) } if err := os.Rename(stagingDir, extDir); err != nil { return nil, fmt.Errorf("failed to activate extension: %w", err) } stagingCommitted = true ext.SourceDir = extDir m.extensions[manifest.Name] = ext GoLog("[Extension] Loaded extension: %s v%s\n", manifest.DisplayName, manifest.Version) return ext, nil } func initializeVMLocked(ext *loadedExtension) error { ext.VM = nil ext.runtime = nil ext.indexProgram = nil ext.initialized = false vm := goja.New() ext.VM = vm indexPath := filepath.Join(ext.SourceDir, "index.js") jsCode, err := os.ReadFile(indexPath) if err != nil { return fmt.Errorf("failed to read index.js: %w", err) } indexProgram, err := goja.Compile(indexPath, string(jsCode), false) if err != nil { return fmt.Errorf("failed to compile extension code: %w", err) } ext.indexProgram = indexProgram runtime := newExtensionRuntime(ext) ext.runtime = runtime runtime.RegisterAPIs(vm) runtime.RegisterGoBackendAPIs(vm) console := vm.NewObject() console.Set("log", func(call goja.FunctionCall) goja.Value { args := make([]any, len(call.Arguments)) for i, arg := range call.Arguments { args[i] = arg.Export() } GoLog("[Extension:%s] %v\n", ext.ID, args) return goja.Undefined() }) vm.Set("console", console) var registeredExtension goja.Value vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value { if len(call.Arguments) > 0 { registeredExtension = call.Arguments[0] vm.Set("extension", call.Arguments[0]) } return goja.Undefined() }) _, err = vm.RunProgram(indexProgram) if err != nil { return fmt.Errorf("failed to execute extension code: %w", err) } if registeredExtension == nil || goja.IsUndefined(registeredExtension) { return fmt.Errorf("extension did not call registerExtension()") } return nil } func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) { vm := goja.New() indexProgram := ext.indexProgram if indexProgram == nil { indexPath := filepath.Join(ext.SourceDir, "index.js") jsCode, err := os.ReadFile(indexPath) if err != nil { return nil, nil, fmt.Errorf("failed to read index.js: %w", err) } indexProgram, err = goja.Compile(indexPath, string(jsCode), false) if err != nil { return nil, nil, fmt.Errorf("failed to compile extension code: %w", err) } } runtime := &extensionRuntime{ extensionID: ext.ID, manifest: ext.Manifest, settings: make(map[string]any), cookieJar: nil, dataDir: ext.DataDir, vm: vm, } if ext.runtime != nil && ext.runtime.cookieJar != nil { runtime.cookieJar = ext.runtime.cookieJar } else { jar, _ := newSimpleCookieJar() runtime.cookieJar = jar } runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second), true) runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout, false) runtime.RegisterAPIs(vm) runtime.RegisterGoBackendAPIs(vm) console := vm.NewObject() console.Set("log", func(call goja.FunctionCall) goja.Value { args := make([]any, len(call.Arguments)) for i, arg := range call.Arguments { args[i] = arg.Export() } GoLog("[Extension:%s] %v\n", ext.ID, args) return goja.Undefined() }) vm.Set("console", console) var registeredExtension goja.Value vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value { if len(call.Arguments) > 0 { registeredExtension = call.Arguments[0] vm.Set("extension", call.Arguments[0]) } return goja.Undefined() }) if _, err := vm.RunProgram(indexProgram); err != nil { runtime.closeStorageFlusher() return nil, nil, fmt.Errorf("failed to execute extension code: %w", err) } if registeredExtension == nil || goja.IsUndefined(registeredExtension) { runtime.closeStorageFlusher() return nil, nil, fmt.Errorf("extension did not call registerExtension()") } settings := getExtensionInitSettings(ext.ID) if len(settings) > 0 { if err := initializeExtensionRuntimeWithSettings(vm, ext.ID, settings); err != nil { runtime.closeStorageFlusher() return nil, nil, err } } return vm, runtime, nil } // A goja runtime plus an executed extension program is several MB of live // heap; rebuilding one per download multiplies that by the number of tracks. // Extensions already serve many calls on the persistent shared VM, so reusing // an initialized isolated runtime for consecutive downloads is the same // lifecycle contract. const maxIdleIsolatedRuntimes = 1 // acquireIsolatedExtensionRuntime pops an idle pooled runtime or builds one. func acquireIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) { ext.isolatedPoolMu.Lock() if n := len(ext.isolatedPool); n > 0 { handle := ext.isolatedPool[n-1] ext.isolatedPool = ext.isolatedPool[:n-1] ext.isolatedPoolMu.Unlock() return handle.vm, handle.runtime, nil } ext.isolatedPoolMu.Unlock() ext.VMMu.Lock() defer ext.VMMu.Unlock() return newIsolatedExtensionRuntime(ext) } // releaseIsolatedExtensionRuntime pools a healthy runtime for reuse or tears // it down. Pass healthy=false after an interrupt/timeout/script error, whose // VM state can't be trusted for reuse. func releaseIsolatedExtensionRuntime(ext *loadedExtension, vm *goja.Runtime, runtime *extensionRuntime, healthy, cleanupSafe bool) { if runtime != nil { if err := runtime.flushStorageNow(); err != nil { GoLog("[Extension:%s] isolated download storage flush failed: %v\n", ext.ID, err) } } if healthy && vm != nil && runtime != nil && ext.Enabled { ext.isolatedPoolMu.Lock() if len(ext.isolatedPool) < maxIdleIsolatedRuntimes { ext.isolatedPool = append(ext.isolatedPool, &isolatedRuntimeHandle{vm: vm, runtime: runtime}) ext.isolatedPoolMu.Unlock() return } ext.isolatedPoolMu.Unlock() } if cleanupSafe { if cleanupErr := runCleanupOnVM(vm); cleanupErr != nil { GoLog("[Extension:%s] isolated download cleanup failed: %v\n", ext.ID, cleanupErr) } } if runtime != nil { runtime.closeStorageFlusher() } } // quarantineRuntimeLocked detaches a VM that remained busy after interrupt. // The caller holds VMMu. Touching or cleaning up that VM would race its stuck // goroutine; a later call will build a fresh runtime from indexProgram. func quarantineRuntimeLocked(ext *loadedExtension, vm *goja.Runtime) { if ext == nil || ext.VM != vm { return } ext.VM = nil ext.runtime = nil ext.initialized = false ext.Error = "extension runtime was quarantined after an unresponsive script" } // drainIsolatedRuntimePool tears down idle isolated runtimes. Called on // extension teardown and on app-wide memory release. func drainIsolatedRuntimePool(ext *loadedExtension) { ext.isolatedPoolMu.Lock() pool := ext.isolatedPool ext.isolatedPool = nil ext.isolatedPoolMu.Unlock() for _, handle := range pool { if cleanupErr := runCleanupOnVM(handle.vm); cleanupErr != nil { GoLog("[Extension:%s] isolated pool cleanup failed: %v\n", ext.ID, cleanupErr) } if handle.runtime != nil { if err := handle.runtime.flushStorageNow(); err != nil { GoLog("[Extension:%s] isolated pool storage flush failed: %v\n", ext.ID, err) } handle.runtime.closeStorageFlusher() } } } // drainAllIsolatedRuntimePools releases every extension's idle isolated // runtimes (memory-pressure hook). func drainAllIsolatedRuntimePools() { m := getExtensionManager() m.mu.RLock() exts := make([]*loadedExtension, 0, len(m.extensions)) for _, ext := range m.extensions { exts = append(exts, ext) } m.mu.RUnlock() for _, ext := range exts { drainIsolatedRuntimePool(ext) } } func (m *extensionManager) initializeVM(ext *loadedExtension) error { ext.VMMu.Lock() defer ext.VMMu.Unlock() return initializeVMLocked(ext) } func initializeExtensionRuntimeWithSettings( vm *goja.Runtime, extensionID string, settings map[string]any, ) error { settingsJSON, err := json.Marshal(settings) if err != nil { return fmt.Errorf("failed to save settings") } script := fmt.Sprintf(` (function() { var settings = %s; if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') { try { extension.initialize(settings); return { success: true }; } catch (e) { return { success: false, error: e.toString() }; } } return { success: true, message: 'no initialize function' }; })() `, string(settingsJSON)) result, err := vm.RunString(script) if err != nil { GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err) return err } if result != nil && !goja.IsUndefined(result) { exported := result.Export() if resultMap, ok := exported.(map[string]any); ok { if success, ok := resultMap["success"].(bool); ok && !success { errMsg := "unknown error" if e, ok := resultMap["error"].(string); ok { errMsg = e } GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg) return fmt.Errorf("initialize failed: %s", errMsg) } } } return nil } func initializeExtensionWithSettingsLocked( ext *loadedExtension, settings map[string]any, ) error { if ext.VM == nil { return fmt.Errorf("extension failed to load: please reinstall the extension") } if err := initializeExtensionRuntimeWithSettings(ext.VM, ext.ID, settings); err != nil { ext.Error = err.Error() ext.Enabled = false return err } ext.initialized = true GoLog("[Extension] Initialized %s\n", ext.ID) return nil } func runCleanupLocked(ext *loadedExtension) error { if ext.VM != nil { if err := runCleanupOnVM(ext.VM); err != nil { return err } if ext.VM.Get("extension") != nil { GoLog("[Extension] Cleanup called for %s\n", ext.ID) } } return nil } func runCleanupOnVM(vm *goja.Runtime) error { if vm == nil { return nil } script := ` (function() { if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') { try { extension.cleanup(); return { success: true }; } catch (e) { return { success: false, error: e.toString() }; } } return { success: true, message: 'no cleanup function' }; })() ` result, err := vm.RunString(script) if err != nil { return err } if result != nil && !goja.IsUndefined(result) { exported := result.Export() if resultMap, ok := exported.(map[string]any); ok { if success, ok := resultMap["success"].(bool); ok && !success { errMsg := "unknown error" if e, ok := resultMap["error"].(string); ok { errMsg = e } return fmt.Errorf("cleanup failed: %s", errMsg) } } } return nil } func teardownVMLocked(ext *loadedExtension) { drainIsolatedRuntimePool(ext) if err := runCleanupLocked(ext); err != nil { GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err) } if ext.runtime != nil { if err := ext.runtime.flushStorageNow(); err != nil { GoLog("[Extension] Failed to flush storage for %s: %v\n", ext.ID, err) } ext.runtime.closeStorageFlusher() } ext.runtime = nil ext.VM = nil ext.initialized = false } func validateExtensionLoad(ext *loadedExtension) error { ext.VMMu.Lock() defer ext.VMMu.Unlock() if err := initializeVMLocked(ext); err != nil { return err } teardownVMLocked(ext) return nil } func teardownExtension(ext *loadedExtension) { if ext == nil { return } ext.Enabled = false ext.VMMu.Lock() teardownVMLocked(ext) ext.VMMu.Unlock() } func (m *extensionManager) UnloadExtension(extensionID string) error { m.mu.Lock() defer m.mu.Unlock() ext, exists := m.extensions[extensionID] if !exists { return fmt.Errorf("extension not found") } ext.Enabled = false ext.VMMu.Lock() teardownVMLocked(ext) ext.VMMu.Unlock() delete(m.extensions, extensionID) GoLog("[Extension] Unloaded extension: %s\n", extensionID) return nil } func (m *extensionManager) GetExtension(extensionID string) (*loadedExtension, error) { m.mu.RLock() defer m.mu.RUnlock() ext, exists := m.extensions[extensionID] if !exists { return nil, fmt.Errorf("extension not found") } return ext, nil } func (m *extensionManager) GetAllExtensions() []*loadedExtension { m.mu.RLock() defer m.mu.RUnlock() result := make([]*loadedExtension, 0, len(m.extensions)) for _, ext := range m.extensions { result = append(result, ext) } return result } func (m *extensionManager) SetExtensionEnabled(extensionID string, enabled bool) error { m.mu.Lock() defer m.mu.Unlock() ext, exists := m.extensions[extensionID] if !exists { return fmt.Errorf("extension not found") } if enabled { ext.Enabled = true if err := ext.ensureRuntimeReady(); err != nil { store := GetExtensionSettingsStore() ext.Enabled = false _ = store.Set(extensionID, "_enabled", false) return err } } else { ext.Enabled = false ext.Error = "" ext.VMMu.Lock() teardownVMLocked(ext) ext.VMMu.Unlock() } GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled]) store := GetExtensionSettingsStore() if err := store.Set(extensionID, "_enabled", enabled); err != nil { GoLog("[Extension] Failed to persist enabled state for %s: %v\n", extensionID, err) } return nil } func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) { var loaded []string var errors []error entries, err := os.ReadDir(dirPath) if err != nil { if os.IsNotExist(err) { return loaded, errors } return nil, []error{fmt.Errorf("failed to read extensions directory: %w", err)} } for _, entry := range entries { if entry.IsDir() { manifestPath := filepath.Join(dirPath, entry.Name(), "manifest.json") if _, err := os.Stat(manifestPath); err == nil { ext, err := m.loadExtensionFromDirectory(filepath.Join(dirPath, entry.Name())) if err != nil { GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err) errors = append(errors, fmt.Errorf("%s: %w", entry.Name(), err)) } else { loaded = append(loaded, ext.ID) } } } else if isExtensionPackagePath(entry.Name()) { ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name())) if err != nil { GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err) errors = append(errors, fmt.Errorf("%s: %w", entry.Name(), err)) } else { loaded = append(loaded, ext.ID) } } } return loaded, errors } func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedExtension, error) { m.mu.Lock() defer m.mu.Unlock() manifestPath := filepath.Join(dirPath, "manifest.json") manifestData, err := os.ReadFile(manifestPath) if err != nil { return nil, fmt.Errorf("failed to read manifest.json: %w", err) } manifest, err := ParseManifest(manifestData) if err != nil { return nil, fmt.Errorf("invalid extension manifest: %w", err) } indexPath := filepath.Join(dirPath, "index.js") if _, err := os.Stat(indexPath); os.IsNotExist(err) { return nil, fmt.Errorf("extension is missing index.js file") } if existing, exists := m.extensions[manifest.Name]; exists { GoLog("[Extension] Extension '%s' already loaded, skipping\n", manifest.DisplayName) return existing, nil } expectedSourceDir, err := managedExtensionPath(m.extensionsDir, manifest.Name) if err != nil || filepath.Clean(dirPath) != filepath.Clean(expectedSourceDir) { return nil, fmt.Errorf("extension directory name must match manifest name %q", manifest.Name) } extDataDir, err := managedExtensionPath(m.dataDir, manifest.Name) if err != nil { return nil, err } if err := os.MkdirAll(extDataDir, 0755); err != nil { return nil, fmt.Errorf("failed to create extension data directory: %w", err) } ext := &loadedExtension{ ID: manifest.Name, Manifest: manifest, Enabled: false, // Will be restored from settings store DataDir: extDataDir, SourceDir: dirPath, } store := GetExtensionSettingsStore() if enabledVal, err := store.Get(manifest.Name, "_enabled"); err == nil { if enabled, ok := enabledVal.(bool); ok { ext.Enabled = enabled GoLog("[Extension] Restored enabled state for %s: %v\n", manifest.Name, enabled) } } if err := validateExtensionLoad(ext); err != nil { ext.Error = err.Error() ext.Enabled = false GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err) } m.extensions[manifest.Name] = ext GoLog("[Extension] Loaded extension: %s v%s\n", manifest.DisplayName, manifest.Version) return ext, nil } func (m *extensionManager) RemoveExtension(extensionID string) error { m.mutationMu.Lock() defer m.mutationMu.Unlock() ext, err := m.GetExtension(extensionID) if err != nil { return err } sourceDir, err := managedExtensionPath(m.extensionsDir, ext.ID) if err != nil || !isPathWithinBase(m.extensionsDir, ext.SourceDir) || filepath.Clean(ext.SourceDir) != filepath.Clean(sourceDir) { return fmt.Errorf("refusing to remove extension outside the managed source directory") } dataDir, err := managedExtensionPath(m.dataDir, ext.ID) if err != nil || !isPathWithinBase(m.dataDir, ext.DataDir) || filepath.Clean(ext.DataDir) != filepath.Clean(dataDir) { return fmt.Errorf("refusing to remove extension outside the managed data directory") } if err := m.UnloadExtension(extensionID); err != nil { return err } if err := os.RemoveAll(sourceDir); err != nil { GoLog("[Extension] Warning: failed to remove source dir: %v\n", err) } // Uninstall means gone: storage.json and encrypted credentials must not // linger on disk after the extension is removed. if err := os.RemoveAll(dataDir); err != nil { GoLog("[Extension] Warning: failed to remove data dir: %v\n", err) } return nil } // Only allows upgrades (new version > current version), not downgrades func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) { m.mutationMu.Lock() defer m.mutationMu.Unlock() return m.upgradeExtensionLocked(filePath) } func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExtension, error) { if !isExtensionPackagePath(filePath) { return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file") } zipReader, err := zip.OpenReader(filePath) if err != nil { return nil, fmt.Errorf("cannot open extension file: the file may be corrupted or not a valid extension package") } defer zipReader.Close() var manifestData []byte var hasIndexJS bool for _, file := range zipReader.File { name := filepath.Base(file.Name) if name == "manifest.json" { rc, err := file.Open() if err != nil { return nil, fmt.Errorf("failed to open manifest.json: %w", err) } manifestData, err = io.ReadAll(rc) rc.Close() if err != nil { return nil, fmt.Errorf("failed to read manifest.json: %w", err) } } if name == "index.js" { hasIndexJS = true } } if manifestData == nil { return nil, fmt.Errorf("invalid extension package: manifest.json not found") } if !hasIndexJS { return nil, fmt.Errorf("invalid extension package: index.js not found") } newManifest, err := ParseManifest(manifestData) if err != nil { return nil, fmt.Errorf("invalid extension manifest: %w", err) } m.mu.RLock() existing, exists := m.extensions[newManifest.Name] m.mu.RUnlock() if !exists { return nil, fmt.Errorf("extension '%s' is not installed; use install instead of upgrade", newManifest.DisplayName) } versionCompare := compareVersions(newManifest.Version, existing.Manifest.Version) if versionCompare < 0 { return nil, fmt.Errorf("cannot downgrade extension: current version: %s, new version: %s", existing.Manifest.Version, newManifest.Version) } if versionCompare == 0 { return nil, fmt.Errorf("extension is already at version %s", existing.Manifest.Version) } GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version) extDataDir, err := managedExtensionPath(m.dataDir, newManifest.Name) if err != nil || filepath.Clean(existing.DataDir) != filepath.Clean(extDataDir) { return nil, fmt.Errorf("installed extension has an invalid data directory") } extDir, err := managedExtensionPath(m.extensionsDir, newManifest.Name) if err != nil || filepath.Clean(existing.SourceDir) != filepath.Clean(extDir) { return nil, fmt.Errorf("installed extension has an invalid source directory") } wasEnabled := existing.Enabled stagingDir, err := os.MkdirTemp(m.extensionsDir, "."+newManifest.Name+"-upgrade-*") if err != nil { return nil, fmt.Errorf("failed to create upgrade staging directory: %w", err) } stagingActive := true defer func() { if stagingActive { _ = os.RemoveAll(stagingDir) } }() if err := extractExtensionArchive(zipReader, stagingDir); err != nil { return nil, err } ext := &loadedExtension{ ID: newManifest.Name, Manifest: newManifest, Enabled: wasEnabled, // Preserve enabled state from before upgrade DataDir: extDataDir, SourceDir: stagingDir, } if wasEnabled { if err := ext.ensureRuntimeReady(); err != nil { return nil, fmt.Errorf("upgraded extension failed validation: %w", err) } } else if err := validateExtensionLoad(ext); err != nil { return nil, fmt.Errorf("upgraded extension failed validation: %w", err) } backupDir, err := os.MkdirTemp(m.extensionsDir, "."+newManifest.Name+"-backup-*") if err != nil { teardownExtension(ext) return nil, fmt.Errorf("failed to prepare upgrade backup: %w", err) } if err := os.Remove(backupDir); err != nil { teardownExtension(ext) return nil, fmt.Errorf("failed to prepare upgrade backup: %w", err) } if err := os.Rename(extDir, backupDir); err != nil { teardownExtension(ext) return nil, fmt.Errorf("failed to preserve current extension: %w", err) } if err := os.Rename(stagingDir, extDir); err != nil { _ = os.Rename(backupDir, extDir) teardownExtension(ext) return nil, fmt.Errorf("failed to activate upgraded extension: %w", err) } stagingActive = false ext.SourceDir = extDir existing.Enabled = false if err := m.UnloadExtension(existing.ID); err != nil { _ = os.RemoveAll(extDir) _ = os.Rename(backupDir, extDir) existing.Enabled = wasEnabled teardownExtension(ext) return nil, fmt.Errorf("failed to unload current extension: %w", err) } m.mu.Lock() m.extensions[newManifest.Name] = ext m.mu.Unlock() if err := os.RemoveAll(backupDir); err != nil { GoLog("[Extension] Warning: failed to remove upgrade backup: %v\n", err) } GoLog("[Extension] Upgraded extension: %s to v%s\n", newManifest.DisplayName, newManifest.Version) return ext, nil } type ExtensionUpgradeInfo struct { ExtensionID string `json:"extension_id"` CurrentVersion string `json:"current_version"` NewVersion string `json:"new_version"` CanUpgrade bool `json:"can_upgrade"` IsInstalled bool `json:"is_installed"` } func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) { if !isExtensionPackagePath(filePath) { return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file") } zipReader, err := zip.OpenReader(filePath) if err != nil { return nil, fmt.Errorf("cannot open extension file") } defer zipReader.Close() var manifestData []byte for _, file := range zipReader.File { name := filepath.Base(file.Name) if name == "manifest.json" { rc, err := file.Open() if err != nil { return nil, fmt.Errorf("failed to open manifest.json") } manifestData, err = io.ReadAll(rc) rc.Close() if err != nil { return nil, fmt.Errorf("failed to read manifest.json") } break } } if manifestData == nil { return nil, fmt.Errorf("manifest.json not found") } newManifest, err := ParseManifest(manifestData) if err != nil { return nil, fmt.Errorf("invalid manifest: %w", err) } m.mu.RLock() existing, exists := m.extensions[newManifest.Name] m.mu.RUnlock() info := &ExtensionUpgradeInfo{ ExtensionID: newManifest.Name, NewVersion: newManifest.Version, IsInstalled: exists, } if !exists { info.CurrentVersion = "" info.CanUpgrade = false } else { info.CurrentVersion = existing.Manifest.Version info.CanUpgrade = compareVersions(newManifest.Version, existing.Manifest.Version) > 0 } return info, nil } func (m *extensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) { info, err := m.checkExtensionUpgradeInternal(filePath) if err != nil { return "", err } jsonBytes, err := json.Marshal(info) if err != nil { return "", err } return string(jsonBytes), nil } func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) { extensions := m.GetAllExtensions() type ExtensionInfo struct { ID string `json:"id"` Name string `json:"name"` DisplayName string `json:"display_name"` Version string `json:"version"` Description string `json:"description"` Homepage string `json:"homepage,omitempty"` IconPath string `json:"icon_path,omitempty"` Types []ExtensionType `json:"types"` Enabled bool `json:"enabled"` Status string `json:"status"` Error string `json:"error_message,omitempty"` Settings []ExtensionSetting `json:"settings,omitempty"` QualityOptions []QualityOption `json:"quality_options,omitempty"` Permissions []string `json:"permissions"` HasMetadataProvider bool `json:"has_metadata_provider"` HasDownloadProvider bool `json:"has_download_provider"` HasLyricsProvider bool `json:"has_lyrics_provider"` SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"` SkipLyrics bool `json:"skip_lyrics"` StopProviderFallback bool `json:"stop_provider_fallback"` SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"` TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"` PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"` ServiceHealth []ExtensionHealthCheck `json:"service_health,omitempty"` Capabilities map[string]any `json:"capabilities,omitempty"` } infos := make([]ExtensionInfo, len(extensions)) for i, ext := range extensions { permissions := []string{} for _, domain := range ext.Manifest.Permissions.Network { permissions = append(permissions, "network:"+domain) } if ext.Manifest.Permissions.Storage { permissions = append(permissions, "storage:enabled") } if ext.Manifest.Permissions.File { permissions = append(permissions, "file:enabled") } if ext.Manifest.Permissions.AllowHTTP { permissions = append(permissions, "network:http") } if ext.Manifest.HasCapability("rawFfmpeg") { permissions = append(permissions, "ffmpeg:raw") } status := "loaded" if ext.Error != "" { status = "error" } else if !ext.Enabled { status = "disabled" } iconPath := "" if ext.Manifest.Icon != "" && ext.SourceDir != "" { possibleIcon, safe := safeExtensionAssetPath(ext.SourceDir, ext.Manifest.Icon) if safe { if _, err := os.Stat(possibleIcon); err == nil { iconPath = possibleIcon } } } if iconPath == "" && ext.SourceDir != "" { possibleIcon, safe := safeExtensionAssetPath(ext.SourceDir, "icon.png") if safe { if _, err := os.Stat(possibleIcon); err == nil { iconPath = possibleIcon } } } infos[i] = ExtensionInfo{ ID: ext.ID, Name: ext.Manifest.Name, DisplayName: ext.Manifest.DisplayName, Version: ext.Manifest.Version, Description: ext.Manifest.Description, Homepage: ext.Manifest.Homepage, IconPath: iconPath, Types: ext.Manifest.Types, Enabled: ext.Enabled, Status: status, Error: ext.Error, Settings: ext.Manifest.Settings, QualityOptions: ext.Manifest.QualityOptions, Permissions: permissions, HasMetadataProvider: ext.Manifest.IsMetadataProvider(), HasDownloadProvider: ext.Manifest.IsDownloadProvider(), HasLyricsProvider: ext.Manifest.IsLyricsProvider(), SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment, SkipLyrics: ext.Manifest.SkipLyrics, StopProviderFallback: ext.Manifest.StopsProviderFallback(), SearchBehavior: ext.Manifest.SearchBehavior, TrackMatching: ext.Manifest.TrackMatching, PostProcessing: ext.Manifest.PostProcessing, ServiceHealth: ext.Manifest.ServiceHealth, Capabilities: ext.Manifest.Capabilities, } } jsonBytes, err := json.Marshal(infos) if err != nil { return "", err } return string(jsonBytes), nil } func (m *extensionManager) InitializeExtension(extensionID string, settings map[string]any) error { m.mu.Lock() defer m.mu.Unlock() ext, exists := m.extensions[extensionID] if !exists { return fmt.Errorf("extension not found") } ext.VMMu.Lock() defer ext.VMMu.Unlock() if err := ensureRuntimeReadyLocked(ext, false); err != nil { return err } return initializeExtensionWithSettingsLocked(ext, settings) } func (m *extensionManager) CleanupExtension(extensionID string) error { m.mu.Lock() defer m.mu.Unlock() ext, exists := m.extensions[extensionID] if !exists { return fmt.Errorf("extension not found") } if ext.VM == nil { return nil } ext.VMMu.Lock() defer ext.VMMu.Unlock() if err := runCleanupLocked(ext); err != nil { GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err) return err } GoLog("[Extension] Cleaned up %s\n", extensionID) return nil } func (m *extensionManager) UnloadAllExtensions() { m.mu.Lock() extensionIDs := make([]string, 0, len(m.extensions)) for id := range m.extensions { extensionIDs = append(extensionIDs, id) } m.mu.Unlock() for _, id := range extensionIDs { m.UnloadExtension(id) } GoLog("[Extension] All extensions unloaded\n") } func (m *extensionManager) InvokeAction(extensionID string, actionName string) (map[string]any, error) { m.mu.Lock() defer m.mu.Unlock() ext, exists := m.extensions[extensionID] if !exists { return nil, fmt.Errorf("extension not found: %s", extensionID) } if !ext.Enabled { return nil, fmt.Errorf("extension is disabled") } vm, err := ext.lockReadyVM() if err != nil { return nil, err } defer ext.VMMu.Unlock() // Merge extension return values onto the top-level JSON object so Flutter can read // message, open_auth_url, setting_updates without unwrapping a nested "result" key. actionNameLiteral := strconv.Quote(actionName) script := fmt.Sprintf(` (function() { var actionName = %s; function runAction(fn) { try { var result = fn(); if (result && typeof result.then === 'function') { return { success: true, pending: true, message: 'Action started' }; } if (result !== null && result !== undefined && typeof result === 'object') { var isArr = false; if (typeof Array !== 'undefined' && Array.isArray) { isArr = Array.isArray(result); } if (!isArr) { var out = { success: true }; for (var k in result) { out[k] = result[k]; } return out; } } return { success: true, result: result }; } catch (e) { return { success: false, error: e.toString() }; } } if (typeof extension !== 'undefined' && extension && typeof extension[actionName] === 'function') { return runAction(function() { return extension[actionName](); }); } if (actionName === 'completeGrant' && typeof session !== 'undefined' && session && typeof session.completeGrant === 'function') { return runAction(function() { return session.completeGrant(); }); } return { success: false, error: 'Action function not found: ' + actionName }; })() `, actionNameLiteral) result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout) if err != nil { if IsRuntimeUnsafeError(err) { quarantineRuntimeLocked(ext, vm) } GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err) return nil, fmt.Errorf("action failed: %v", err) } if result == nil || goja.IsUndefined(result) { return map[string]any{"success": true}, nil } exported := result.Export() if resultMap, ok := exported.(map[string]any); ok { GoLog("[Extension] InvokeAction %s.%s result: %v\n", extensionID, actionName, resultMap) return resultMap, nil } return map[string]any{"success": true, "result": exported}, nil }