mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-02 02:55:36 +02:00
feat: extension runtime and provider improvements
This commit is contained in:
+3
-1
@@ -60,7 +60,9 @@ ios/Flutter/Flutter.framework/
|
||||
ios/Flutter/Flutter.podspec
|
||||
|
||||
# Extension folder
|
||||
extension/
|
||||
extension/*
|
||||
extension/v2/
|
||||
extension/v2/**
|
||||
|
||||
# Agent instructions
|
||||
AGENTS.md
|
||||
|
||||
@@ -100,6 +100,12 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="spotiflac" android:host="spotify-callback" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="spotiflac" android:host="session-grant" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Download Service -->
|
||||
|
||||
@@ -47,6 +47,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
private val LARGE_JSON_RESULT_FILE_KEY = "__json_file"
|
||||
private val LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var backendChannel: MethodChannel? = null
|
||||
private val pendingSessionGrantEvents = mutableListOf<Map<String, Any>>()
|
||||
private var pendingSafTreeResult: MethodChannel.Result? = null
|
||||
private val safScanLock = Any()
|
||||
private val safDirLock = Any()
|
||||
@@ -2073,14 +2075,22 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
val host = (uri.host ?: "").lowercase(Locale.US)
|
||||
val path = (uri.path ?: "").lowercase(Locale.US)
|
||||
val isSessionGrant = host == "session-grant"
|
||||
val isCallback =
|
||||
host == "callback" ||
|
||||
isSessionGrant ||
|
||||
host == "callback" ||
|
||||
host == "spotify-callback" ||
|
||||
path.contains("callback")
|
||||
if (!isCallback) {
|
||||
return
|
||||
}
|
||||
val code = uri.getQueryParameter("code")?.trim().orEmpty()
|
||||
val code = (
|
||||
if (isSessionGrant) {
|
||||
uri.getQueryParameter("grant") ?: uri.getQueryParameter("code")
|
||||
} else {
|
||||
uri.getQueryParameter("code")
|
||||
}
|
||||
)?.trim().orEmpty()
|
||||
if (code.isEmpty()) {
|
||||
return
|
||||
}
|
||||
@@ -2092,15 +2102,38 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
intent.data = null
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Gobackend.setExtensionAuthCodeByID(extId, code)
|
||||
val json = Gobackend.invokeExtensionActionJSON(extId, "completeSpotifyLogin")
|
||||
android.util.Log.i("SpotiFLAC", "Extension OAuth complete for $extId: $json")
|
||||
val json = if (isSessionGrant) {
|
||||
Gobackend.setExtensionSessionGrantByID(extId, code)
|
||||
Gobackend.invokeExtensionActionJSON(extId, "completeGrant")
|
||||
} else {
|
||||
Gobackend.setExtensionAuthCodeByID(extId, code)
|
||||
Gobackend.invokeExtensionActionJSON(extId, "completeSpotifyLogin")
|
||||
}
|
||||
android.util.Log.i("SpotiFLAC", "Extension callback complete for $extId: $json")
|
||||
if (isSessionGrant) {
|
||||
withContext(Dispatchers.Main) {
|
||||
notifySessionGrantCompleted(extId)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SpotiFLAC", "Extension OAuth failed: ${e.message}")
|
||||
android.util.Log.w("SpotiFLAC", "Extension callback failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifySessionGrantCompleted(extensionId: String) {
|
||||
val payload = mapOf(
|
||||
"extension_id" to extensionId,
|
||||
"success" to true,
|
||||
)
|
||||
val channel = backendChannel
|
||||
if (channel == null) {
|
||||
pendingSessionGrantEvents.add(payload)
|
||||
return
|
||||
}
|
||||
channel.invokeMethod("extensionSessionGrantCompleted", payload)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
try {
|
||||
Gobackend.cleanupExtensions()
|
||||
@@ -2164,7 +2197,17 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
},
|
||||
)
|
||||
|
||||
MethodChannel(messenger, CHANNEL).setMethodCallHandler { call, result ->
|
||||
val channel = MethodChannel(messenger, CHANNEL)
|
||||
backendChannel = channel
|
||||
if (pendingSessionGrantEvents.isNotEmpty()) {
|
||||
val events = pendingSessionGrantEvents.toList()
|
||||
pendingSessionGrantEvents.clear()
|
||||
for (event in events) {
|
||||
channel.invokeMethod("extensionSessionGrantCompleted", event)
|
||||
}
|
||||
}
|
||||
|
||||
channel.setMethodCallHandler { call, result ->
|
||||
scope.launch {
|
||||
try {
|
||||
when (call.method) {
|
||||
|
||||
@@ -3263,6 +3263,10 @@ func SetExtensionAuthCodeByID(extensionID, authCode string) {
|
||||
SetExtensionAuthCode(extensionID, authCode)
|
||||
}
|
||||
|
||||
func SetExtensionSessionGrantByID(extensionID, grant string) {
|
||||
setPendingSignedSessionGrant(extensionID, grant)
|
||||
}
|
||||
|
||||
func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) {
|
||||
var expiresAt time.Time
|
||||
if expiresIn > 0 {
|
||||
@@ -3935,9 +3939,12 @@ func callExtensionFunctionJSONWithRequestID(extensionID, functionName string, ti
|
||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||
return extension.%s();
|
||||
}
|
||||
if (typeof %s === 'function') {
|
||||
return %s();
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
`, functionName, functionName)
|
||||
`, functionName, functionName, functionName, functionName)
|
||||
|
||||
jsStartedAt := time.Now()
|
||||
result, err := RunWithTimeoutContextAndRecover(requestCtx, vm, script, timeout)
|
||||
|
||||
@@ -44,6 +44,11 @@ func compareVersions(v1, v2 string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func isExtensionPackagePath(filePath string) bool {
|
||||
lowerPath := strings.ToLower(filePath)
|
||||
return strings.HasSuffix(lowerPath, ".spotiflac-ext") || strings.HasSuffix(lowerPath, ".sflx")
|
||||
}
|
||||
|
||||
type loadedExtension struct {
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
@@ -166,8 +171,8 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
|
||||
}
|
||||
|
||||
func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loadedExtension, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
||||
if !isExtensionPackagePath(filePath) {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
|
||||
}
|
||||
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
@@ -673,7 +678,7 @@ func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
||||
loaded = append(loaded, ext.ID)
|
||||
}
|
||||
}
|
||||
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
|
||||
} 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)
|
||||
@@ -775,8 +780,8 @@ func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension,
|
||||
}
|
||||
|
||||
func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExtension, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
||||
if !isExtensionPackagePath(filePath) {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
|
||||
}
|
||||
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
@@ -924,8 +929,8 @@ type ExtensionUpgradeInfo struct {
|
||||
}
|
||||
|
||||
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
||||
if !isExtensionPackagePath(filePath) {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
|
||||
}
|
||||
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
|
||||
@@ -3,6 +3,7 @@ package gobackend
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -113,28 +114,49 @@ type ExtensionHealthCheck struct {
|
||||
Required bool `json:"required,omitempty"`
|
||||
}
|
||||
|
||||
type SignedSessionEndpoints struct {
|
||||
Bootstrap string `json:"bootstrap,omitempty"`
|
||||
Challenge string `json:"challenge,omitempty"`
|
||||
Exchange string `json:"exchange,omitempty"`
|
||||
Refresh string `json:"refresh,omitempty"`
|
||||
}
|
||||
|
||||
type SignedSessionConfig struct {
|
||||
Namespace string `json:"namespace"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
AppVersion string `json:"appVersion,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
CallbackURL string `json:"callbackUrl,omitempty"`
|
||||
SchemeLabel string `json:"schemeLabel,omitempty"`
|
||||
HeaderPrefix string `json:"headerPrefix,omitempty"`
|
||||
TimeWindowSeconds int `json:"timeWindowSeconds,omitempty"`
|
||||
Endpoints SignedSessionEndpoints `json:"endpoints,omitempty"`
|
||||
}
|
||||
|
||||
type ExtensionManifest struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Types []ExtensionType `json:"type"`
|
||||
Permissions ExtensionPermissions `json:"permissions"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
||||
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Types []ExtensionType `json:"type"`
|
||||
Permissions ExtensionPermissions `json:"permissions"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
||||
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
|
||||
SignedSession *SignedSessionConfig `json:"signedSession,omitempty"`
|
||||
RequiredRuntimeFeatures []string `json:"requiredRuntimeFeatures,omitempty"`
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestValidationError struct {
|
||||
@@ -238,6 +260,26 @@ func (m *ExtensionManifest) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if m.SignedSession != nil {
|
||||
if strings.TrimSpace(m.SignedSession.Namespace) == "" {
|
||||
return &ManifestValidationError{Field: "signedSession.namespace", Message: "namespace is required"}
|
||||
}
|
||||
baseURL := strings.TrimSpace(m.SignedSession.BaseURL)
|
||||
if baseURL == "" {
|
||||
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl is required"}
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(baseURL), "https://") {
|
||||
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl must use https"}
|
||||
}
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if err != nil || parsed.Hostname() == "" {
|
||||
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl is invalid"}
|
||||
}
|
||||
if !m.IsDomainAllowed(parsed.Hostname()) {
|
||||
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl host must be listed in permissions.network"}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2135,6 +2135,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
var lastErrType string
|
||||
var stopProviderFallback bool
|
||||
var sourceExtensionLocked bool
|
||||
var sourceExtensionAvailability *ExtAvailabilityResult
|
||||
@@ -2449,11 +2450,23 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}, nil
|
||||
}
|
||||
lastErr = err
|
||||
lastErrType = ""
|
||||
} else if result.ErrorMessage != "" {
|
||||
lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
||||
lastErrType = strings.TrimSpace(result.ErrorType)
|
||||
}
|
||||
GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr)
|
||||
|
||||
if strings.EqualFold(lastErrType, "verification_required") {
|
||||
GoLog("[DownloadWithExtensionFallback] Source extension %s requires verification, not trying other providers\n", req.Source)
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Download failed: " + lastErr.Error(),
|
||||
ErrorType: "verification_required",
|
||||
Service: req.Source,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if stopProviderFallback || sourceExtensionLocked {
|
||||
if sourceExtensionLocked {
|
||||
GoLog("[DownloadWithExtensionFallback] Source extension %s requested skip_fallback, not trying other providers\n", req.Source)
|
||||
@@ -2463,7 +2476,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Download failed: " + lastErr.Error(),
|
||||
ErrorType: "extension_error",
|
||||
ErrorType: firstNonEmptyString(lastErrType, "extension_error"),
|
||||
Service: req.Source,
|
||||
}, nil
|
||||
}
|
||||
@@ -2632,8 +2645,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}, nil
|
||||
}
|
||||
lastErr = err
|
||||
lastErrType = ""
|
||||
} else if result.ErrorMessage != "" {
|
||||
lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
||||
lastErrType = strings.TrimSpace(result.ErrorType)
|
||||
}
|
||||
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, lastErr)
|
||||
if terminalAvailability {
|
||||
@@ -2644,7 +2659,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
errorType := classifyDownloadErrorType(lastErr.Error())
|
||||
errorType := firstNonEmptyString(lastErrType, classifyDownloadErrorType(lastErr.Error()))
|
||||
if errorType == "unknown" {
|
||||
errorType = "not_found"
|
||||
}
|
||||
|
||||
@@ -465,6 +465,15 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
||||
vm.Set("auth", authObj)
|
||||
|
||||
if r.manifest != nil && r.manifest.SignedSession != nil {
|
||||
sessionObj := vm.NewObject()
|
||||
sessionObj.Set("signedFetch", r.signedSessionFetch)
|
||||
sessionObj.Set("completeGrant", r.signedSessionCompleteGrant)
|
||||
sessionObj.Set("status", r.signedSessionStatus)
|
||||
sessionObj.Set("clear", r.signedSessionClear)
|
||||
vm.Set("session", sessionObj)
|
||||
}
|
||||
|
||||
fileObj := vm.NewObject()
|
||||
fileObj.Set("download", r.fileDownload)
|
||||
fileObj.Set("exists", r.fileExists)
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
const signedSessionRefreshSkew = time.Hour
|
||||
|
||||
var (
|
||||
pendingSignedSessionGrants = make(map[string]string)
|
||||
pendingSignedSessionGrantsMu sync.Mutex
|
||||
)
|
||||
|
||||
type signedSessionRecord struct {
|
||||
InstallID string `json:"install_id"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
SessionSecret string `json:"session_secret,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
type signedSessionExchangeResponse struct {
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
SessionSecret string `json:"session_secret,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
ChallengeID string `json:"challenge_id,omitempty"`
|
||||
ChallengeURL string `json:"challenge_url,omitempty"`
|
||||
AuthURL string `json:"auth_url,omitempty"`
|
||||
}
|
||||
|
||||
func signedSessionConfigWithDefaults(config *SignedSessionConfig) SignedSessionConfig {
|
||||
if config == nil {
|
||||
return SignedSessionConfig{}
|
||||
}
|
||||
resolved := *config
|
||||
if resolved.AppVersion == "" {
|
||||
resolved.AppVersion = "ext-1.0"
|
||||
}
|
||||
if resolved.Platform == "" {
|
||||
resolved.Platform = "extension"
|
||||
}
|
||||
if resolved.CallbackURL == "" {
|
||||
resolved.CallbackURL = "spotiflac://session-grant"
|
||||
}
|
||||
if resolved.SchemeLabel == "" {
|
||||
resolved.SchemeLabel = "SPOTIFLAC-HMAC-V1"
|
||||
}
|
||||
if resolved.HeaderPrefix == "" {
|
||||
resolved.HeaderPrefix = "X-Sig-"
|
||||
}
|
||||
if resolved.TimeWindowSeconds <= 0 {
|
||||
resolved.TimeWindowSeconds = 300
|
||||
}
|
||||
if resolved.Endpoints.Bootstrap == "" {
|
||||
resolved.Endpoints.Bootstrap = "/bootstrap"
|
||||
}
|
||||
if resolved.Endpoints.Challenge == "" {
|
||||
resolved.Endpoints.Challenge = "/challenge"
|
||||
}
|
||||
if resolved.Endpoints.Exchange == "" {
|
||||
resolved.Endpoints.Exchange = "/session/exchange"
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) signedSessionFilePath(config SignedSessionConfig) (string, error) {
|
||||
namespace := sanitizeSignedSessionNamespace(config.Namespace)
|
||||
if namespace == "" {
|
||||
return "", fmt.Errorf("signed session namespace is empty")
|
||||
}
|
||||
baseDir := filepath.Dir(r.dataDir)
|
||||
if baseDir == "." || baseDir == "" {
|
||||
baseDir = r.dataDir
|
||||
}
|
||||
dir := filepath.Join(baseDir, "signed_sessions")
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, namespace+".json"), nil
|
||||
}
|
||||
|
||||
func sanitizeSignedSessionNamespace(namespace string) string {
|
||||
namespace = strings.TrimSpace(strings.ToLower(namespace))
|
||||
var b strings.Builder
|
||||
for _, ch := range namespace {
|
||||
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' {
|
||||
b.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), ".-_")
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) loadSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) {
|
||||
path, err := r.signedSessionFilePath(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record := &signedSessionRecord{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
_ = json.Unmarshal(data, record)
|
||||
}
|
||||
if strings.TrimSpace(record.InstallID) == "" {
|
||||
record.InstallID = randomHex(16)
|
||||
if err := r.saveSignedSession(config, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) saveSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
|
||||
path, err := r.signedSessionFilePath(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(record, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
func randomHex(bytesLen int) string {
|
||||
buf := make([]byte, bytesLen)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func parseSignedSessionTime(value string) (time.Time, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05.000Z",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if parsed, err := time.Parse(layout, value); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) signedSessionStatus(call goja.FunctionCall) goja.Value {
|
||||
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||
if config.Namespace == "" || config.BaseURL == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{"authenticated": false, "error": "signedSession is not configured"})
|
||||
}
|
||||
record, err := r.loadSignedSession(config)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{"authenticated": false, "error": err.Error()})
|
||||
}
|
||||
authenticated := record.SessionID != "" && record.SessionSecret != ""
|
||||
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok && time.Now().After(expiresAt) {
|
||||
authenticated = false
|
||||
}
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"authenticated": authenticated,
|
||||
"expires_at": record.ExpiresAt,
|
||||
"install_id": record.InstallID,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) signedSessionClear(call goja.FunctionCall) goja.Value {
|
||||
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||
record, err := r.loadSignedSession(config)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
||||
}
|
||||
record.SessionID = ""
|
||||
record.SessionSecret = ""
|
||||
record.ExpiresAt = ""
|
||||
if err := r.saveSignedSession(config, record); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
||||
}
|
||||
return r.vm.ToValue(map[string]interface{}{"success": true})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) signedSessionCompleteGrant(call goja.FunctionCall) goja.Value {
|
||||
grant := ""
|
||||
if len(call.Arguments) > 0 {
|
||||
grant = strings.TrimSpace(call.Arguments[0].String())
|
||||
}
|
||||
if grant == "" {
|
||||
pendingSignedSessionGrantsMu.Lock()
|
||||
grant = pendingSignedSessionGrants[r.extensionID]
|
||||
delete(pendingSignedSessionGrants, r.extensionID)
|
||||
pendingSignedSessionGrantsMu.Unlock()
|
||||
}
|
||||
if grant == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{"success": false, "error": "no pending grant"})
|
||||
}
|
||||
if err := r.exchangeSignedSessionGrant(grant); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
||||
}
|
||||
return r.vm.ToValue(map[string]interface{}{"success": true})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) exchangeSignedSessionGrant(grant string) error {
|
||||
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||
record, err := r.loadSignedSession(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endpoint, err := signedSessionURL(config, config.Endpoints.Exchange)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"grant": grant,
|
||||
"install_id": record.InstallID,
|
||||
"app_version": config.AppVersion,
|
||||
"platform": config.Platform,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("session exchange failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var exchanged signedSessionExchangeResponse
|
||||
if err := json.Unmarshal(respBody, &exchanged); err != nil {
|
||||
return fmt.Errorf("invalid session exchange response: %w", err)
|
||||
}
|
||||
if exchanged.SessionID == "" || exchanged.SessionSecret == "" || exchanged.ExpiresAt == "" {
|
||||
return fmt.Errorf("session exchange response missing session fields")
|
||||
}
|
||||
record.SessionID = exchanged.SessionID
|
||||
record.SessionSecret = exchanged.SessionSecret
|
||||
record.ExpiresAt = exchanged.ExpiresAt
|
||||
return r.saveSignedSession(config, record)
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": "method and path are required"})
|
||||
}
|
||||
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||
if config.Namespace == "" || config.BaseURL == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": "signedSession is not configured"})
|
||||
}
|
||||
method := strings.ToUpper(strings.TrimSpace(call.Arguments[0].String()))
|
||||
requestPath := call.Arguments[1].String()
|
||||
body := []byte{}
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
switch v := call.Arguments[2].Export().(type) {
|
||||
case string:
|
||||
body = []byte(v)
|
||||
case map[string]interface{}, []interface{}:
|
||||
encoded, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
|
||||
}
|
||||
body = encoded
|
||||
default:
|
||||
body = []byte(call.Arguments[2].String())
|
||||
}
|
||||
}
|
||||
extraHeaders := map[string]string{}
|
||||
if len(call.Arguments) > 3 && !goja.IsUndefined(call.Arguments[3]) && !goja.IsNull(call.Arguments[3]) {
|
||||
if h, ok := call.Arguments[3].Export().(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
extraHeaders[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record, err := r.ensureSignedSession(config)
|
||||
if err != nil {
|
||||
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
|
||||
return r.signedSessionVerificationRequiredValue(authURL)
|
||||
}
|
||||
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
|
||||
}
|
||||
|
||||
resp, respBody, respHeaders, err := r.doSignedSessionRequest(config, record, method, requestPath, body, extraHeaders)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusPreconditionRequired {
|
||||
record.SessionID = ""
|
||||
record.SessionSecret = ""
|
||||
record.ExpiresAt = ""
|
||||
_ = r.saveSignedSession(config, record)
|
||||
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
|
||||
return r.signedSessionVerificationRequiredValue(authURL)
|
||||
}
|
||||
}
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"url": resp.Request.URL.String(),
|
||||
"body": string(respBody),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) signedSessionVerificationRequiredValue(authURL string) goja.Value {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"ok": false,
|
||||
"needsVerification": true,
|
||||
"error": "VERIFY_REQUIRED",
|
||||
"open_auth_url": authURL,
|
||||
"auth_url": authURL,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) ensureSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) {
|
||||
record, err := r.loadSignedSession(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record.SessionID == "" || record.SessionSecret == "" {
|
||||
return nil, fmt.Errorf("signed session is not authenticated")
|
||||
}
|
||||
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok {
|
||||
if time.Now().After(expiresAt) {
|
||||
record.SessionID = ""
|
||||
record.SessionSecret = ""
|
||||
record.ExpiresAt = ""
|
||||
_ = r.saveSignedSession(config, record)
|
||||
return nil, fmt.Errorf("signed session expired")
|
||||
}
|
||||
if config.Endpoints.Refresh != "" && time.Until(expiresAt) <= signedSessionRefreshSkew {
|
||||
_ = r.refreshSignedSession(config, record)
|
||||
}
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) refreshSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
|
||||
body, _ := json.Marshal(map[string]string{"install_id": record.InstallID})
|
||||
resp, respBody, _, err := r.doSignedSessionRequest(config, record, http.MethodPost, config.Endpoints.Refresh, body, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("session refresh failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var refreshed signedSessionExchangeResponse
|
||||
if err := json.Unmarshal(respBody, &refreshed); err != nil {
|
||||
return err
|
||||
}
|
||||
changed := false
|
||||
if refreshed.SessionID != "" {
|
||||
record.SessionID = refreshed.SessionID
|
||||
changed = true
|
||||
}
|
||||
if refreshed.SessionSecret != "" {
|
||||
record.SessionSecret = refreshed.SessionSecret
|
||||
changed = true
|
||||
}
|
||||
if refreshed.ExpiresAt != "" && refreshed.ExpiresAt != record.ExpiresAt {
|
||||
record.ExpiresAt = refreshed.ExpiresAt
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
return r.saveSignedSession(config, record)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionConfig, reason string) string {
|
||||
record, err := r.loadSignedSession(config)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
bootstrapURL, err := signedSessionURL(config, config.Endpoints.Bootstrap)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parsed, _ := url.Parse(bootstrapURL)
|
||||
query := parsed.Query()
|
||||
query.Set("app_version", config.AppVersion)
|
||||
query.Set("install_id", record.InstallID)
|
||||
parsed.RawQuery = query.Encode()
|
||||
req, err := http.NewRequest(http.MethodGet, parsed.String(), nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes))
|
||||
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return ""
|
||||
}
|
||||
var boot signedSessionExchangeResponse
|
||||
if err := json.Unmarshal(body, &boot); err != nil {
|
||||
return ""
|
||||
}
|
||||
if boot.SessionID != "" && boot.SessionSecret != "" && boot.ExpiresAt != "" {
|
||||
record.SessionID = boot.SessionID
|
||||
record.SessionSecret = boot.SessionSecret
|
||||
record.ExpiresAt = boot.ExpiresAt
|
||||
_ = r.saveSignedSession(config, record)
|
||||
return ""
|
||||
}
|
||||
authURL := boot.AuthURL
|
||||
if authURL == "" && boot.ChallengeURL != "" {
|
||||
authURL = boot.ChallengeURL
|
||||
}
|
||||
if authURL == "" && boot.ChallengeID != "" {
|
||||
authURL = r.buildSignedSessionChallengeURL(config, boot.ChallengeID)
|
||||
}
|
||||
if authURL != "" {
|
||||
pendingAuthRequestsMu.Lock()
|
||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||
ExtensionID: r.extensionID,
|
||||
AuthURL: authURL,
|
||||
CallbackURL: config.CallbackURL,
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
}
|
||||
return authURL
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) buildSignedSessionChallengeURL(config SignedSessionConfig, challengeID string) string {
|
||||
challengeURL, err := signedSessionURL(config, config.Endpoints.Challenge)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(challengeURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
callback, err := url.Parse(config.CallbackURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
q := callback.Query()
|
||||
q.Set("cb_version", "v2grant")
|
||||
q.Set("state", r.extensionID)
|
||||
callback.RawQuery = q.Encode()
|
||||
|
||||
query := parsed.Query()
|
||||
query.Set("id", challengeID)
|
||||
query.Set("cb", callback.String())
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func signedSessionURL(config SignedSessionConfig, endpoint string) (string, error) {
|
||||
base, err := url.Parse(strings.TrimRight(config.BaseURL, "/") + "/")
|
||||
if err != nil || base.Scheme != "https" || base.Host == "" {
|
||||
return "", fmt.Errorf("invalid signed session baseUrl")
|
||||
}
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
if endpoint == "" {
|
||||
return "", fmt.Errorf("signed session endpoint is empty")
|
||||
}
|
||||
if strings.HasPrefix(endpoint, "https://") {
|
||||
return endpoint, nil
|
||||
}
|
||||
endpoint = strings.TrimLeft(endpoint, "/")
|
||||
ref, _ := url.Parse(endpoint)
|
||||
return base.ResolveReference(ref).String(), nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) doSignedSessionRequest(
|
||||
config SignedSessionConfig,
|
||||
record *signedSessionRecord,
|
||||
method string,
|
||||
requestPath string,
|
||||
body []byte,
|
||||
extraHeaders map[string]string,
|
||||
) (*http.Response, []byte, map[string]interface{}, error) {
|
||||
fullURL, err := signedSessionURL(config, requestPath)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
parsed, err := url.Parse(fullURL)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
nonce := randomHex(12)
|
||||
bodyHashBytes := sha256.Sum256(body)
|
||||
bodyHash := hex.EncodeToString(bodyHashBytes[:])
|
||||
parsedTs, _ := time.Parse("2006-01-02T15:04:05.000Z", ts)
|
||||
window := parsedTs.Unix() / int64(config.TimeWindowSeconds)
|
||||
rollingInput := fmt.Sprintf("%d:%s", window, record.SessionID)
|
||||
rk := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(record.SessionSecret), []byte(rollingInput)))
|
||||
signingInput := strings.Join([]string{
|
||||
config.SchemeLabel,
|
||||
method,
|
||||
parsed.EscapedPath(),
|
||||
"",
|
||||
bodyHash,
|
||||
ts,
|
||||
nonce,
|
||||
record.SessionID,
|
||||
config.AppVersion,
|
||||
config.Platform,
|
||||
}, "\n")
|
||||
sig := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(rk), []byte(signingInput)))
|
||||
|
||||
req, err := http.NewRequest(method, fullURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if len(body) > 0 {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
||||
prefix := config.HeaderPrefix
|
||||
req.Header.Set(prefix+"Session", record.SessionID)
|
||||
req.Header.Set(prefix+"Timestamp", ts)
|
||||
req.Header.Set(prefix+"Nonce", nonce)
|
||||
req.Header.Set(prefix+"Body-SHA256", bodyHash)
|
||||
req.Header.Set(prefix+"Signature", sig)
|
||||
req.Header.Set(prefix+"App-Version", config.AppVersion)
|
||||
req.Header.Set(prefix+"Platform", config.Platform)
|
||||
for k, v := range extraHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
headers := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
headers[k] = v[0]
|
||||
} else {
|
||||
headers[k] = v
|
||||
}
|
||||
}
|
||||
return resp, respBody, headers, nil
|
||||
}
|
||||
|
||||
func hmacSHA256Bytes(key, message []byte) []byte {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write(message)
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func setPendingSignedSessionGrant(extensionID, grant string) {
|
||||
extensionID = strings.TrimSpace(extensionID)
|
||||
grant = strings.TrimSpace(grant)
|
||||
if extensionID == "" || grant == "" {
|
||||
return
|
||||
}
|
||||
pendingSignedSessionGrantsMu.Lock()
|
||||
pendingSignedSessionGrants[extensionID] = grant
|
||||
pendingSignedSessionGrantsMu.Unlock()
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import Gobackend // Import Go framework
|
||||
private var libraryScanProgressTimer: DispatchSourceTimer?
|
||||
private var libraryScanProgressEventSink: FlutterEventSink?
|
||||
private var lastLibraryScanProgressPayload: String?
|
||||
private var backendChannel: FlutterMethodChannel?
|
||||
private var pendingSessionGrantEvents: [[String: Any]] = []
|
||||
|
||||
/// Currently accessed security-scoped URL for library folder
|
||||
private var activeSecurityScopedURL: URL?
|
||||
@@ -39,6 +41,14 @@ import Gobackend // Import Go framework
|
||||
name: CHANNEL,
|
||||
binaryMessenger: controller.binaryMessenger
|
||||
)
|
||||
backendChannel = channel
|
||||
if !pendingSessionGrantEvents.isEmpty {
|
||||
let events = pendingSessionGrantEvents
|
||||
pendingSessionGrantEvents.removeAll()
|
||||
for event in events {
|
||||
channel.invokeMethod("extensionSessionGrantCompleted", arguments: event)
|
||||
}
|
||||
}
|
||||
let downloadProgressEvents = FlutterEventChannel(
|
||||
name: DOWNLOAD_PROGRESS_STREAM_CHANNEL,
|
||||
binaryMessenger: controller.binaryMessenger
|
||||
@@ -83,20 +93,25 @@ import Gobackend // Import Go framework
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
/// PKCE OAuth return URL: spotiflac://callback?code=...&state=<extension_id>
|
||||
/// Extension return URLs:
|
||||
/// - OAuth: spotiflac://callback?code=...&state=<extension_id>
|
||||
/// - Signed session: spotiflac://session-grant?grant=...&state=<extension_id>
|
||||
@discardableResult
|
||||
private func handleExtensionOAuthRedirect(url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased(), scheme == "spotiflac" else { return false }
|
||||
let host = (url.host ?? "").lowercased()
|
||||
let path = url.path.lowercased()
|
||||
let isSessionGrant = host == "session-grant"
|
||||
let ok =
|
||||
host == "callback" || host == "spotify-callback" || path.contains("callback")
|
||||
isSessionGrant || host == "callback" || host == "spotify-callback" || path.contains("callback")
|
||||
guard ok else { return false }
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return false
|
||||
}
|
||||
let q = components.queryItems ?? []
|
||||
let code =
|
||||
q.first { $0.name == (isSessionGrant ? "grant" : "code") }?.value?.trimmingCharacters(
|
||||
in: .whitespacesAndNewlines) ??
|
||||
q.first { $0.name == "code" }?.value?.trimmingCharacters(
|
||||
in: .whitespacesAndNewlines) ?? ""
|
||||
let state =
|
||||
@@ -109,16 +124,37 @@ import Gobackend // Import Go framework
|
||||
}
|
||||
streamQueue.async {
|
||||
var err: NSError?
|
||||
GobackendSetExtensionAuthCodeByID(state, code)
|
||||
_ = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err)
|
||||
if isSessionGrant {
|
||||
GobackendSetExtensionSessionGrantByID(state, code)
|
||||
_ = GobackendInvokeExtensionActionJSON(state, "completeGrant", &err)
|
||||
} else {
|
||||
GobackendSetExtensionAuthCodeByID(state, code)
|
||||
_ = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err)
|
||||
}
|
||||
if let err = err {
|
||||
NSLog(
|
||||
"SpotiFLAC: Extension OAuth complete failed: \(err.localizedDescription)")
|
||||
"SpotiFLAC: Extension callback complete failed: \(err.localizedDescription)")
|
||||
} else if isSessionGrant {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.notifySessionGrantCompleted(extensionId: state)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func notifySessionGrantCompleted(extensionId: String) {
|
||||
let payload: [String: Any] = [
|
||||
"extension_id": extensionId,
|
||||
"success": true,
|
||||
]
|
||||
if let channel = backendChannel {
|
||||
channel.invokeMethod("extensionSessionGrantCompleted", arguments: payload)
|
||||
} else {
|
||||
pendingSessionGrantEvents.append(payload)
|
||||
}
|
||||
}
|
||||
|
||||
override func application(
|
||||
_ app: UIApplication,
|
||||
open url: URL,
|
||||
|
||||
@@ -12,7 +12,14 @@ enum DownloadStatus {
|
||||
skipped,
|
||||
}
|
||||
|
||||
enum DownloadErrorType { unknown, notFound, rateLimit, network, permission }
|
||||
enum DownloadErrorType {
|
||||
unknown,
|
||||
notFound,
|
||||
rateLimit,
|
||||
network,
|
||||
permission,
|
||||
verificationRequired,
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class DownloadItem {
|
||||
@@ -94,6 +101,8 @@ class DownloadItem {
|
||||
return 'Connection failed, check your internet';
|
||||
case DownloadErrorType.permission:
|
||||
return 'Cannot write to folder, check storage permission';
|
||||
case DownloadErrorType.verificationRequired:
|
||||
return 'Verification required. Open the extension and complete the security check.';
|
||||
default:
|
||||
return error ?? 'An error occurred';
|
||||
}
|
||||
|
||||
@@ -58,4 +58,5 @@ const _$DownloadErrorTypeEnumMap = {
|
||||
DownloadErrorType.rateLimit: 'rateLimit',
|
||||
DownloadErrorType.network: 'network',
|
||||
DownloadErrorType.permission: 'permission',
|
||||
DownloadErrorType.verificationRequired: 'verificationRequired',
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
import 'package:spotiflac_android/utils/int_utils.dart';
|
||||
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
|
||||
|
||||
export 'package:spotiflac_android/services/history_database.dart'
|
||||
show HistoryLookupRequest, HistoryBatchLookupRequest;
|
||||
@@ -4902,7 +4903,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
'totalDiscs': track.totalDiscs!.toString(),
|
||||
if (track.isrc != null) 'isrc': track.isrc!,
|
||||
if (label != null && label.isNotEmpty) 'label': label,
|
||||
if (copyright != null && copyright.isNotEmpty) 'copyright': copyright,
|
||||
if (copyright != null && copyright.isNotEmpty)
|
||||
'copyright': copyright,
|
||||
if (shouldEmbedLyrics) 'lyrics': ?lrcContent,
|
||||
};
|
||||
final ac4Result = await PlatformBridge.writeAC4Metadata(
|
||||
@@ -6636,6 +6638,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return DownloadErrorType.network;
|
||||
case 'permission':
|
||||
return DownloadErrorType.permission;
|
||||
case 'verification_required':
|
||||
return DownloadErrorType.verificationRequired;
|
||||
default:
|
||||
return DownloadErrorType.unknown;
|
||||
}
|
||||
@@ -6643,6 +6647,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
DownloadErrorType _downloadErrorTypeFromMessage(String errorMsg) {
|
||||
final lowerMsg = errorMsg.toLowerCase();
|
||||
if (isExtensionVerificationRequired(errorMsg)) {
|
||||
return DownloadErrorType.verificationRequired;
|
||||
}
|
||||
if (errorMsg.contains('429') ||
|
||||
lowerMsg.contains('rate limit') ||
|
||||
lowerMsg.contains('too many requests')) {
|
||||
@@ -7609,7 +7616,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
// Repair AC-4 (dac4 + ISO MP4) using the still-present encrypted
|
||||
// source. No-op for other codecs.
|
||||
try {
|
||||
await PlatformBridge.ensureAC4Config(decryptedTempPath, tempPath);
|
||||
await PlatformBridge.ensureAC4Config(
|
||||
decryptedTempPath,
|
||||
tempPath,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w('AC-4 container repair skipped: $e');
|
||||
}
|
||||
@@ -7688,7 +7698,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
// Repair AC-4 (dac4 + ISO MP4) using the still-present encrypted
|
||||
// source before discarding it. No-op for other codecs.
|
||||
try {
|
||||
await PlatformBridge.ensureAC4Config(decryptedPath, encryptedSource);
|
||||
await PlatformBridge.ensureAC4Config(
|
||||
decryptedPath,
|
||||
encryptedSource,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w('AC-4 container repair skipped: $e');
|
||||
}
|
||||
@@ -8862,6 +8875,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
case 'permission':
|
||||
errorType = DownloadErrorType.permission;
|
||||
break;
|
||||
case 'verification_required':
|
||||
errorType = DownloadErrorType.verificationRequired;
|
||||
break;
|
||||
default:
|
||||
errorType = _downloadErrorTypeFromMessage(errorMsg);
|
||||
}
|
||||
@@ -8873,6 +8889,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
error: errorMsg,
|
||||
errorType: errorType,
|
||||
);
|
||||
if (errorType == DownloadErrorType.verificationRequired) {
|
||||
unawaited(openPendingExtensionVerification(item.service));
|
||||
}
|
||||
_failedInSession++;
|
||||
|
||||
try {
|
||||
@@ -8927,6 +8946,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
error: errorMsg,
|
||||
errorType: errorType,
|
||||
);
|
||||
if (errorType == DownloadErrorType.verificationRequired) {
|
||||
unawaited(openPendingExtensionVerification(item.service));
|
||||
}
|
||||
_failedInSession++;
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
@@ -195,9 +198,20 @@ class SearchPlaylist {
|
||||
|
||||
class TrackNotifier extends Notifier<TrackState> {
|
||||
int _currentRequestId = 0;
|
||||
StreamSubscription<ExtensionSessionGrantEvent>? _sessionGrantSub;
|
||||
_PendingVerificationSearch? _pendingVerificationSearch;
|
||||
bool _retryingPendingVerificationSearch = false;
|
||||
|
||||
@override
|
||||
TrackState build() {
|
||||
_sessionGrantSub ??= PlatformBridge.extensionSessionGrantEvents().listen(
|
||||
_handleExtensionSessionGrantCompleted,
|
||||
);
|
||||
ref.onDispose(() {
|
||||
_sessionGrantSub?.cancel();
|
||||
_sessionGrantSub = null;
|
||||
_pendingVerificationSearch = null;
|
||||
});
|
||||
return const TrackState();
|
||||
}
|
||||
|
||||
@@ -314,7 +328,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||
final topTracksList =
|
||||
artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||
final topTracks = topTracksList
|
||||
.map(
|
||||
(t) => _parseSearchTrack(
|
||||
@@ -359,10 +374,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> search(
|
||||
String query, {
|
||||
String? filterOverride,
|
||||
}) async {
|
||||
Future<void> search(String query, {String? filterOverride}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
||||
final requestFilter = currentFilter == 'all' ? null : currentFilter;
|
||||
@@ -601,6 +613,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
_log.i(
|
||||
'Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)',
|
||||
);
|
||||
_clearPendingVerificationSearch(extensionId, query, currentFilter);
|
||||
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
@@ -614,6 +627,18 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
_log.e('Custom search failed: $e', e, stackTrace);
|
||||
if (isExtensionVerificationRequired(e)) {
|
||||
_pendingVerificationSearch = _PendingVerificationSearch(
|
||||
extensionId: extensionId,
|
||||
query: query,
|
||||
options: Map<String, dynamic>.from(
|
||||
options ?? const <String, dynamic>{},
|
||||
),
|
||||
selectedFilter: currentFilter,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
await openPendingExtensionVerification(extensionId);
|
||||
}
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
@@ -624,6 +649,49 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
void _clearPendingVerificationSearch(
|
||||
String extensionId,
|
||||
String query,
|
||||
String? selectedFilter,
|
||||
) {
|
||||
final pending = _pendingVerificationSearch;
|
||||
if (pending == null) return;
|
||||
if (pending.extensionId == extensionId &&
|
||||
pending.query == query &&
|
||||
pending.selectedFilter == selectedFilter) {
|
||||
_pendingVerificationSearch = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleExtensionSessionGrantCompleted(ExtensionSessionGrantEvent event) {
|
||||
if (!event.success || _retryingPendingVerificationSearch) return;
|
||||
final pending = _pendingVerificationSearch;
|
||||
if (pending == null || pending.extensionId != event.extensionId) return;
|
||||
if (DateTime.now().difference(pending.createdAt) >
|
||||
const Duration(minutes: 10)) {
|
||||
_pendingVerificationSearch = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingVerificationSearch = null;
|
||||
_retryingPendingVerificationSearch = true;
|
||||
Future<void>.delayed(const Duration(milliseconds: 300), () async {
|
||||
try {
|
||||
_log.i(
|
||||
'Retrying custom search after verification: extension=${pending.extensionId}',
|
||||
);
|
||||
await customSearch(
|
||||
pending.extensionId,
|
||||
pending.query,
|
||||
options: pending.options,
|
||||
selectedFilter: pending.selectedFilter,
|
||||
);
|
||||
} finally {
|
||||
_retryingPendingVerificationSearch = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> checkAvailability(int index) async {
|
||||
if (index < 0 || index >= state.tracks.length) return;
|
||||
|
||||
@@ -826,7 +894,22 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingVerificationSearch {
|
||||
final String extensionId;
|
||||
final String query;
|
||||
final Map<String, dynamic> options;
|
||||
final String? selectedFilter;
|
||||
final DateTime createdAt;
|
||||
|
||||
const _PendingVerificationSearch({
|
||||
required this.extensionId,
|
||||
required this.query,
|
||||
required this.options,
|
||||
required this.selectedFilter,
|
||||
required this.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
|
||||
|
||||
@@ -293,9 +293,11 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
.map((file) => file.path)
|
||||
.whereType<String>()
|
||||
.toList();
|
||||
final extensionPaths = selectedPaths
|
||||
.where((path) => path.toLowerCase().endsWith('.spotiflac-ext'))
|
||||
.toList();
|
||||
final extensionPaths = selectedPaths.where((path) {
|
||||
final lowerPath = path.toLowerCase();
|
||||
return lowerPath.endsWith('.spotiflac-ext') ||
|
||||
lowerPath.endsWith('.sflx');
|
||||
}).toList();
|
||||
|
||||
if (extensionPaths.length != selectedPaths.length) {
|
||||
if (mounted) {
|
||||
|
||||
@@ -12,6 +12,16 @@ final _log = AppLogger('PlatformBridge');
|
||||
|
||||
Object? _decodeJsonInBackground(String json) => jsonDecode(json);
|
||||
|
||||
class ExtensionSessionGrantEvent {
|
||||
final String extensionId;
|
||||
final bool success;
|
||||
|
||||
const ExtensionSessionGrantEvent({
|
||||
required this.extensionId,
|
||||
required this.success,
|
||||
});
|
||||
}
|
||||
|
||||
class _BridgeCacheEntry {
|
||||
final Map<String, dynamic> value;
|
||||
final DateTime expiresAt;
|
||||
@@ -76,12 +86,46 @@ class PlatformBridge {
|
||||
static Future<void>? _persistentLookupCacheLoadFuture;
|
||||
static int _lookupCacheGeneration = 0;
|
||||
static int _extensionRequestSequence = 0;
|
||||
static final StreamController<ExtensionSessionGrantEvent>
|
||||
_extensionSessionGrantEvents =
|
||||
StreamController<ExtensionSessionGrantEvent>.broadcast();
|
||||
static bool _backendEventHandlerInstalled = false;
|
||||
|
||||
static bool get supportsCoreBackend => Platform.isAndroid || Platform.isIOS;
|
||||
|
||||
static bool get supportsExtensionSystem =>
|
||||
Platform.isAndroid || Platform.isIOS;
|
||||
|
||||
static Stream<ExtensionSessionGrantEvent> extensionSessionGrantEvents() {
|
||||
_ensureBackendEventHandler();
|
||||
return _extensionSessionGrantEvents.stream;
|
||||
}
|
||||
|
||||
static void _ensureBackendEventHandler() {
|
||||
if (_backendEventHandlerInstalled) return;
|
||||
_backendEventHandlerInstalled = true;
|
||||
_channel.setMethodCallHandler((call) async {
|
||||
switch (call.method) {
|
||||
case 'extensionSessionGrantCompleted':
|
||||
final args = call.arguments;
|
||||
if (args is Map) {
|
||||
final extensionId = args['extension_id']?.toString().trim() ?? '';
|
||||
if (extensionId.isNotEmpty) {
|
||||
_extensionSessionGrantEvents.add(
|
||||
ExtensionSessionGrantEvent(
|
||||
extensionId: extensionId,
|
||||
success: args['success'] != false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> checkAvailability(
|
||||
String spotifyId,
|
||||
String isrc,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
final _log = AppLogger('ExtensionAuthLauncher');
|
||||
|
||||
bool isExtensionVerificationRequired(Object error) {
|
||||
final message = error.toString().toLowerCase();
|
||||
return message.contains('verify_required') ||
|
||||
message.contains('verification_required') ||
|
||||
message.contains('needsverification') ||
|
||||
message.contains('needs verification');
|
||||
}
|
||||
|
||||
Future<void> openPendingExtensionVerification(String extensionId) async {
|
||||
final normalizedExtensionId = extensionId.trim();
|
||||
if (normalizedExtensionId.isEmpty) return;
|
||||
|
||||
try {
|
||||
final pending = await PlatformBridge.getExtensionPendingAuth(
|
||||
normalizedExtensionId,
|
||||
);
|
||||
final authUrl = pending?['auth_url']?.toString().trim() ?? '';
|
||||
if (authUrl.isEmpty) return;
|
||||
|
||||
final uri = Uri.tryParse(authUrl);
|
||||
if (uri == null) return;
|
||||
|
||||
final launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (launched) {
|
||||
_log.i('Opened verification challenge for $normalizedExtensionId');
|
||||
} else {
|
||||
_log.w(
|
||||
'Could not open verification challenge for $normalizedExtensionId',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w(
|
||||
'Failed to open verification challenge for $normalizedExtensionId: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user