From 35532b0c7378e69ab1a0bba77bb18be13a4215ac Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 12 Jan 2026 06:37:18 +0700 Subject: [PATCH] feat(extension): Enhanced HTTP API for YouTube Music support - Add http.put(), http.delete(), http.patch() shortcut methods - Add persistent cookie jar per extension - Add http.clearCookies() to clear session - Fix User-Agent header respect (no longer overwritten) - Return multi-value headers as arrays (Set-Cookie support) - Auto-stringify objects in POST/PUT/PATCH body - Add response.ok and response.status properties - Update documentation with YouTube Music example --- CHANGELOG.md | 45 ++++ go_backend/extension_runtime.go | 375 ++++++++++++++++++++++++++++++-- lib/constants/app_info.dart | 4 +- pubspec.yaml | 2 +- pubspec_ios.yaml | 2 +- 5 files changed, 407 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b216840d..997ecf26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,50 @@ # Changelog +## [3.0.0-alpha.2] - 2026-01-12 + +### Added + +- **Full HTTP Method Support**: New shortcut methods for all common HTTP verbs + - `http.put(url, body, headers)` - PUT requests + - `http.delete(url, headers)` - DELETE requests + - `http.patch(url, body, headers)` - PATCH requests + - `http.clearCookies()` - Clear all cookies for the extension +- **Persistent Cookie Jar**: Each extension now has its own cookie jar + - Cookies automatically stored from `Set-Cookie` headers + - Cookies automatically sent with subsequent requests to same domain + - Useful for APIs requiring session cookies (YouTube, etc.) +- **Multi-Value Header Support**: Response headers now return arrays for multi-value headers + - `Set-Cookie` and other headers with multiple values returned as arrays + - Single-value headers still returned as strings for convenience +- **Generic HTTP Request Method**: New `http.request()` for full HTTP control + - Supports all HTTP methods (GET, POST, PUT, DELETE, PATCH, etc.) + - Single options object for cleaner API: `http.request(url, { method, body, headers })` +- **Response Helper Properties**: HTTP responses now include convenience properties + - `response.ok` - true if status code is 2xx + - `response.status` - alias for `statusCode` + +### Fixed + +- **User-Agent Header Respect**: Custom `User-Agent` headers are now respected + - Previously, extension-provided User-Agent was overwritten + - Now only sets default User-Agent if extension doesn't provide one +- **HTTP POST Body Auto-Stringify**: `http.post()` now automatically stringifies objects to JSON + - Previously, passing an object as body resulted in `[object Object]` + - Now objects and arrays are automatically JSON.stringify'd + - String bodies still work as before (no double-encoding) + +### Documentation + +- Updated `docs/EXTENSION_DEVELOPMENT.md`: + - Added complete HTTP API documentation with all methods + - Added Cookie Jar documentation + - Added `http.put()`, `http.delete()`, `http.patch()`, `http.clearCookies()` docs + - Added YouTube Music / Innertube API example with custom User-Agent + - Added common domain lists for YouTube, SoundCloud, Bandcamp + - Improved HTTP API documentation with response properties + +--- + ## [3.0.0-alpha.1] - 2026-01-11 #### Extension System diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 5ecb4264..34c5d6d9 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -101,24 +101,57 @@ type ExtensionRuntime struct { manifest *ExtensionManifest settings map[string]interface{} httpClient *http.Client + cookieJar http.CookieJar dataDir string vm *goja.Runtime } // NewExtensionRuntime creates a new runtime for an extension func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { + // Create a cookie jar for this extension + jar, _ := newSimpleCookieJar() + + client := &http.Client{ + Timeout: 30 * time.Second, + Jar: jar, + } + return &ExtensionRuntime{ extensionID: ext.ID, manifest: ext.Manifest, settings: make(map[string]interface{}), - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, - dataDir: ext.DataDir, - vm: ext.VM, + httpClient: client, + cookieJar: jar, + dataDir: ext.DataDir, + vm: ext.VM, } } +// simpleCookieJar is a simple in-memory cookie jar +type simpleCookieJar struct { + cookies map[string][]*http.Cookie + mu sync.RWMutex +} + +func newSimpleCookieJar() (*simpleCookieJar, error) { + return &simpleCookieJar{ + cookies: make(map[string][]*http.Cookie), + }, nil +} + +func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) { + j.mu.Lock() + defer j.mu.Unlock() + key := u.Host + j.cookies[key] = append(j.cookies[key], cookies...) +} + +func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie { + j.mu.RLock() + defer j.mu.RUnlock() + return j.cookies[u.Host] +} + // SetSettings updates the runtime settings func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) { r.settings = settings @@ -132,6 +165,11 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { httpObj := vm.NewObject() httpObj.Set("get", r.httpGet) httpObj.Set("post", r.httpPost) + httpObj.Set("put", r.httpPut) + httpObj.Set("delete", r.httpDelete) + httpObj.Set("patch", r.httpPatch) + httpObj.Set("request", r.httpRequest) // Generic HTTP request (GET, POST, PUT, DELETE, etc.) + httpObj.Set("clearCookies", r.httpClearCookies) vm.Set("http", httpObj) // Storage API @@ -274,11 +312,14 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { }) } - // Set headers + // Set headers - user headers first for k, v := range headers { req.Header.Set(k, v) } - req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") + // Only set default User-Agent if not provided by extension + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") + } // Execute request resp, err := r.httpClient.Do(req) @@ -297,16 +338,20 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { }) } - // Extract response headers - respHeaders := make(map[string]string) + // Extract response headers - return all values as arrays for multi-value headers (cookies, etc.) + respHeaders := make(map[string]interface{}) for k, v := range resp.Header { - if len(v) > 0 { + if len(v) == 1 { respHeaders[k] = v[0] + } else { + respHeaders[k] = v // Return as array if multiple values } } return r.vm.ToValue(map[string]interface{}{ "statusCode": resp.StatusCode, + "status": resp.StatusCode, // Alias for convenience + "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, "body": string(body), "headers": respHeaders, }) @@ -330,10 +375,26 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { }) } - // Get body if provided + // Get body if provided - support both string and object var bodyStr string if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { - bodyStr = call.Arguments[1].String() + bodyArg := call.Arguments[1].Export() + switch v := bodyArg.(type) { + case string: + bodyStr = v + case map[string]interface{}, []interface{}: + // Auto-stringify objects and arrays to JSON + jsonBytes, err := json.Marshal(v) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": fmt.Sprintf("failed to stringify body: %v", err), + }) + } + bodyStr = string(jsonBytes) + default: + // Fallback to string conversion + bodyStr = call.Arguments[1].String() + } } // Get headers if provided @@ -355,11 +416,14 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { }) } - // Set headers + // Set headers - user headers first for k, v := range headers { req.Header.Set(k, v) } - req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") + // Only set defaults if not provided by extension + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") + } if req.Header.Get("Content-Type") == "" { req.Header.Set("Content-Type", "application/json") } @@ -381,21 +445,298 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { }) } - // Extract response headers - respHeaders := make(map[string]string) + // Extract response headers - return all values as arrays for multi-value headers + respHeaders := make(map[string]interface{}) for k, v := range resp.Header { - if len(v) > 0 { + if len(v) == 1 { respHeaders[k] = v[0] + } else { + respHeaders[k] = v // Return as array if multiple values } } return r.vm.ToValue(map[string]interface{}{ "statusCode": resp.StatusCode, + "status": resp.StatusCode, // Alias for convenience + "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, "body": string(body), "headers": respHeaders, }) } +// httpRequest performs a generic HTTP request (GET, POST, PUT, DELETE, etc.) +// Usage: http.request(url, options) where options = { method, body, headers } +func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "error": "URL is required", + }) + } + + urlStr := call.Arguments[0].String() + + // Validate domain + if err := r.validateDomain(urlStr); err != nil { + GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Default options + method := "GET" + var bodyStr string + headers := make(map[string]string) + + // Parse options if provided + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + optionsObj := call.Arguments[1].Export() + if opts, ok := optionsObj.(map[string]interface{}); ok { + // Get method + if m, ok := opts["method"].(string); ok { + method = strings.ToUpper(m) + } + + // Get body - support both string and object + if bodyArg, ok := opts["body"]; ok && bodyArg != nil { + switch v := bodyArg.(type) { + case string: + bodyStr = v + case map[string]interface{}, []interface{}: + // Auto-stringify objects and arrays to JSON + jsonBytes, err := json.Marshal(v) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": fmt.Sprintf("failed to stringify body: %v", err), + }) + } + bodyStr = string(jsonBytes) + default: + bodyStr = fmt.Sprintf("%v", v) + } + } + + // Get headers + if h, ok := opts["headers"].(map[string]interface{}); ok { + for k, v := range h { + headers[k] = fmt.Sprintf("%v", v) + } + } + } + } + + // Create request + var reqBody io.Reader + if bodyStr != "" { + reqBody = strings.NewReader(bodyStr) + } + + req, err := http.NewRequest(method, urlStr, reqBody) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Set headers - user headers first + for k, v := range headers { + req.Header.Set(k, v) + } + // Only set defaults if not provided by extension + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") + } + if bodyStr != "" && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + // Execute request + resp, err := r.httpClient.Do(req) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + defer resp.Body.Close() + + // Read body + body, err := io.ReadAll(resp.Body) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Extract response headers - return all values as arrays for multi-value headers + respHeaders := make(map[string]interface{}) + for k, v := range resp.Header { + if len(v) == 1 { + respHeaders[k] = v[0] + } else { + respHeaders[k] = v // Return as array if multiple values + } + } + + // Return response with helper properties + return r.vm.ToValue(map[string]interface{}{ + "statusCode": resp.StatusCode, + "status": resp.StatusCode, // Alias for convenience + "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, + "body": string(body), + "headers": respHeaders, + }) +} + +// httpPut performs a PUT request (shortcut for http.request with method: "PUT") +func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value { + return r.httpMethodShortcut("PUT", call) +} + +// httpDelete performs a DELETE request (shortcut for http.request with method: "DELETE") +func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value { + return r.httpMethodShortcut("DELETE", call) +} + +// httpPatch performs a PATCH request (shortcut for http.request with method: "PATCH") +func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value { + return r.httpMethodShortcut("PATCH", call) +} + +// httpMethodShortcut is a helper for PUT/DELETE/PATCH shortcuts +// Signature: http.put(url, body, headers) / http.delete(url, headers) / http.patch(url, body, headers) +func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "error": "URL is required", + }) + } + + urlStr := call.Arguments[0].String() + + // Validate domain + if err := r.validateDomain(urlStr); err != nil { + GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + var bodyStr string + headers := make(map[string]string) + + // For DELETE, second arg is headers; for PUT/PATCH, second arg is body + if method == "DELETE" { + // http.delete(url, headers) + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + headersObj := call.Arguments[1].Export() + if h, ok := headersObj.(map[string]interface{}); ok { + for k, v := range h { + headers[k] = fmt.Sprintf("%v", v) + } + } + } + } else { + // http.put(url, body, headers) / http.patch(url, body, headers) + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + bodyArg := call.Arguments[1].Export() + switch v := bodyArg.(type) { + case string: + bodyStr = v + case map[string]interface{}, []interface{}: + jsonBytes, err := json.Marshal(v) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": fmt.Sprintf("failed to stringify body: %v", err), + }) + } + bodyStr = string(jsonBytes) + default: + bodyStr = call.Arguments[1].String() + } + } + + if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { + headersObj := call.Arguments[2].Export() + if h, ok := headersObj.(map[string]interface{}); ok { + for k, v := range h { + headers[k] = fmt.Sprintf("%v", v) + } + } + } + } + + // Create request + var reqBody io.Reader + if bodyStr != "" { + reqBody = strings.NewReader(bodyStr) + } + + req, err := http.NewRequest(method, urlStr, reqBody) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Set headers - user headers first + for k, v := range headers { + req.Header.Set(k, v) + } + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") + } + if bodyStr != "" && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + // Execute request + resp, err := r.httpClient.Do(req) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + defer resp.Body.Close() + + // Read body + body, err := io.ReadAll(resp.Body) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Extract response headers + respHeaders := make(map[string]interface{}) + for k, v := range resp.Header { + if len(v) == 1 { + respHeaders[k] = v[0] + } else { + respHeaders[k] = v + } + } + + return r.vm.ToValue(map[string]interface{}{ + "statusCode": resp.StatusCode, + "status": resp.StatusCode, + "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, + "body": string(body), + "headers": respHeaders, + }) +} + +// httpClearCookies clears all cookies for this extension +func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value { + if jar, ok := r.cookieJar.(*simpleCookieJar); ok { + jar.mu.Lock() + jar.cookies = make(map[string][]*http.Cookie) + jar.mu.Unlock() + GoLog("[Extension:%s] Cookies cleared\n", r.extensionID) + return r.vm.ToValue(true) + } + return r.vm.ToValue(false) +} + // ==================== File API (Sandboxed) ==================== // validatePath checks if the path is within the extension's data directory diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 4e8196f5..f8507aa5 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.0.0-alpha.1'; - static const String buildNumber = '50'; + static const String version = '3.0.0-alpha.2'; + static const String buildNumber = '51'; static const String fullVersion = '$version+$buildNumber'; diff --git a/pubspec.yaml b/pubspec.yaml index 1879f639..776d8764 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.0.0-alpha.1+50 +version: 3.0.0-alpha.2+51 environment: sdk: ^3.10.0 diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index 22b3ab62..d0d9087a 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.0.0-alpha.1+50 +version: 3.0.0-alpha.2+51 environment: sdk: ^3.10.0