Files
SpotiFLAC-Mobile/go_backend/extension_manager.go
T
zarzet 42c266eafc fix(extensions): enforce minAppVersion and requiredRuntimeFeatures on load
Both manifest gates were parsed but only checked by the Store UI, so
manual .sflx installs and directory loads of incompatible extensions
failed cryptically at runtime despite SIGNED_SESSION_GUIDE promising a
clear refusal. validateExtensionLoad now rejects them on every install
path, and ensureRuntimeReady re-checks so a gated package cannot be
enabled anyway. Unknown runtime features and too-new feature contract
versions fail with explicit messages; an empty app version (tests)
skips the version gate.
2026-07-26 19:14:29 +07:00

1547 lines
45 KiB
Go

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)
}
const (
maxExtensionArchiveEntries = 2048
maxExtensionArchiveUncompressedBytes = 256 * 1024 * 1024
maxExtensionManifestBytes = 1024 * 1024
)
func validateExtensionArchive(files []*zip.File) error {
if len(files) > maxExtensionArchiveEntries {
return fmt.Errorf(
"extension archive contains too many entries (maximum %d)",
maxExtensionArchiveEntries,
)
}
seenPaths := make(map[string]struct{}, len(files))
var totalUncompressed uint64
for _, file := range files {
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)
}
pathKey := strings.ToLower(relPath)
if _, exists := seenPaths[pathKey]; exists {
return fmt.Errorf("duplicate path in extension archive: %s", file.Name)
}
seenPaths[pathKey] = struct{}{}
if file.FileInfo().IsDir() {
continue
}
if file.UncompressedSize64 > maxExtensionArchiveUncompressedBytes-totalUncompressed {
return fmt.Errorf(
"extension archive exceeds the %d MiB extracted size limit",
maxExtensionArchiveUncompressedBytes/(1024*1024),
)
}
totalUncompressed += file.UncompressedSize64
}
return nil
}
func inspectExtensionPackage(files []*zip.File) (*ExtensionManifest, error) {
if err := validateExtensionArchive(files); err != nil {
return nil, err
}
var manifestFile *zip.File
hasIndexJS := false
for _, file := range files {
switch path.Clean(file.Name) {
case "manifest.json":
manifestFile = file
case "index.js":
hasIndexJS = !file.FileInfo().IsDir()
}
}
if manifestFile == nil || manifestFile.FileInfo().IsDir() {
return nil, fmt.Errorf("invalid extension package: root manifest.json not found")
}
if !hasIndexJS {
return nil, fmt.Errorf("invalid extension package: root index.js not found")
}
if manifestFile.UncompressedSize64 > maxExtensionManifestBytes {
return nil, fmt.Errorf("invalid extension package: manifest.json is too large")
}
rc, err := manifestFile.Open()
if err != nil {
return nil, fmt.Errorf("failed to open manifest.json: %w", err)
}
manifestData, readErr := io.ReadAll(io.LimitReader(rc, maxExtensionManifestBytes+1))
closeErr := rc.Close()
if readErr != nil {
return nil, fmt.Errorf("failed to read manifest.json: %w", readErr)
}
if closeErr != nil {
return nil, fmt.Errorf("failed to close manifest.json: %w", closeErr)
}
if len(manifestData) > maxExtensionManifestBytes {
return nil, fmt.Errorf("invalid extension package: manifest.json is too large")
}
manifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("invalid extension manifest: %w", err)
}
return manifest, nil
}
func extractExtensionArchive(zipReader *zip.ReadCloser, destination string) error {
if err := validateExtensionArchive(zipReader.File); err != nil {
return err
}
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 {
// Gate enabling too, so a package installed with a failed gate cannot be
// switched on anyway.
if err := validateManifestGates(ext.Manifest); err != nil {
ext.Error = err.Error()
ext.Enabled = false
return err
}
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()
manifest, err := inspectExtensionPackage(zipReader.File)
if err != nil {
return nil, 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
}
// supportedRuntimeFeatures maps every feature name the goja runtime provides
// to its current contract version (documented in SIGNED_SESSION_GUIDE.md).
// Bump a version here when a feature's contract changes.
var supportedRuntimeFeatures = map[string]int{
"signedSession": 1,
"sessionRefresh": 1,
"sessionGrant": 1,
"globalAction": 1,
"webviewAuth": 1,
}
// validateManifestGates enforces minAppVersion and requiredRuntimeFeatures
// on every load path (.sflx install, upgrade, directory load); the Store UI
// check alone never covered manual installs. An empty app version (tests,
// dev harnesses) skips the version gate.
func validateManifestGates(manifest *ExtensionManifest) error {
if manifest == nil {
return nil
}
minVersion := strings.TrimSpace(manifest.MinAppVersion)
appVersion := strings.TrimSpace(GetAppVersion())
if minVersion != "" && appVersion != "" && compareVersions(appVersion, minVersion) < 0 {
return fmt.Errorf("requires app %s or later (installed: %s)", minVersion, appVersion)
}
for _, raw := range manifest.RequiredRuntimeFeatures {
name := strings.TrimSpace(raw)
if name == "" {
continue
}
wantVersion := 1
if at := strings.LastIndex(name, "@"); at > 0 {
if v, err := strconv.Atoi(name[at+1:]); err == nil && v > 0 {
wantVersion = v
}
name = name[:at]
}
have, ok := supportedRuntimeFeatures[name]
if !ok {
return fmt.Errorf("requires runtime feature %q this app build does not provide", name)
}
if have < wantVersion {
return fmt.Errorf("requires runtime feature %s@%d (app provides @%d)", name, wantVersion, have)
}
}
return nil
}
func validateExtensionLoad(ext *loadedExtension) error {
if err := validateManifestGates(ext.Manifest); err != nil {
return err
}
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()
newManifest, err := inspectExtensionPackage(zipReader.File)
if err != nil {
return nil, 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()
newManifest, err := inspectExtensionPackage(zipReader.File)
if err != nil {
return nil, 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
}