From 3fc371b8c482bf962f48243e895fee2adf3a5077 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 12 Apr 2026 02:40:31 -0600 Subject: [PATCH] Extension OAuth + store: flatten action JSON, open auth URLs, spotiflac:// callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-party extensions (e.g. Spotify PKCE addons) need three things the current app does not fully provide: Extension button results – The Go runtime returned { success, result: { message, open_auth_url, … } } while Flutter read message / open_auth_url only on the outer map, so OAuth buttons appeared to do nothing. InvokeAction now merges the extension’s return object onto the top-level JSON (arrays/non-objects still use result). Flutter – extension_detail_page: unwrap nested result for compatibility, merge setting_updates into saved extension settings (for copyable OAuth URLs), and launchUrl when open_auth_url is set. Mobile OAuth return – spotiflac://callback?code=…&state= was not handled on Android (manifest + MainActivity) or iOS (AppDelegate open URL + cold-start launchOptions). This wires SetExtensionAuthCodeByID + invokeExtensionAction(..., "completeSpotifyLogin") so PKCE extensions can finish login after the browser redirect. Extension store HTTP – Add Cache-Control: no-cache on registry and extension package downloads to reduce stale CDN/proxy responses. Testing: Install a metadata extension that uses PKCE; tap Connect; confirm browser opens, return via spotiflac://callback, and tokens complete without pasting the code manually. extension InvokeAction JSON was nested under result while the Flutter settings UI only read the top level, so OAuth-related buttons never showed messages or opened the browser. This PR flattens that payload, merges optional setting_updates, launches open_auth_url, adds spotiflac://callback handling on Android and iOS, and sends no-cache on store HTTP fetches. Needed for extensions like SpoitiLists (Spotify Web API + PKCE). --- android/app/src/main/AndroidManifest.xml | 14 +++ .../kotlin/com/zarz/spotiflac/MainActivity.kt | 46 +++++++ go_backend/extension_manager.go | 16 ++- go_backend/extension_store.go | 20 +++- ios/Runner/AppDelegate.swift | 46 +++++++ .../settings/extension_detail_page.dart | 112 ++++++++++++++++-- 6 files changed, 242 insertions(+), 12 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 13a8256..5525868 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -86,6 +86,20 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 497da4c..c982c3a 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.content.Intent import android.net.Uri import android.os.Build +import android.os.Bundle import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.documentfile.provider.DocumentFile @@ -1861,9 +1862,54 @@ class MainActivity: FlutterFragmentActivity() { // We handle these URLs ourselves via receive_sharing_intent + ShareIntentService. override fun shouldHandleDeeplinking(): Boolean = false + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + handleExtensionOAuthIntent(intent) + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) + handleExtensionOAuthIntent(intent) + } + + /** + * Deliver Spotify (or other) OAuth authorization code to the extension runtime + * and run its token exchange (e.g. completeSpotifyLogin). State must be the extension id. + */ + private fun handleExtensionOAuthIntent(intent: Intent?) { + val uri = intent?.data ?: return + if (!uri.scheme.equals("spotiflac", ignoreCase = true)) { + return + } + val host = (uri.host ?: "").lowercase(Locale.US) + val path = (uri.path ?: "").lowercase(Locale.US) + val isCallback = + host == "callback" || + host == "spotify-callback" || + path.contains("callback") + if (!isCallback) { + return + } + val code = uri.getQueryParameter("code")?.trim().orEmpty() + if (code.isEmpty()) { + return + } + val extId = uri.getQueryParameter("state")?.trim().orEmpty() + if (extId.isEmpty()) { + android.util.Log.w("SpotiFLAC", "Extension OAuth redirect missing state (extension id)") + return + } + 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") + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "Extension OAuth failed: ${e.message}") + } + } } override fun onDestroy() { diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 8f9e5df..7d6ee46 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -1052,15 +1052,29 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) ( return nil, fmt.Errorf("extension is disabled") } + // 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. script := fmt.Sprintf(` (function() { if (typeof extension !== 'undefined' && typeof extension.%s === 'function') { try { var result = extension.%s(); if (result && typeof result.then === 'function') { - // Handle promise - return pending status 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() }; diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go index 3a2c479..898ccf5 100644 --- a/go_backend/extension_store.go +++ b/go_backend/extension_store.go @@ -257,7 +257,17 @@ func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL) client := NewHTTPClientWithTimeout(30 * time.Second) - resp, err := client.Get(s.registryURL) + req, err := http.NewRequest(http.MethodGet, s.registryURL, nil) + if err != nil { + if s.cache != nil { + LogWarn("ExtensionStore", "Failed to build registry request, using cached registry: %v", err) + return s.cache, nil + } + return nil, fmt.Errorf("failed to build registry request: %w", err) + } + req.Header.Set("Cache-Control", "no-cache") + req.Header.Set("Pragma", "no-cache") + resp, err := client.Do(req) if err != nil { if s.cache != nil { LogWarn("ExtensionStore", "Network error, using cached registry: %v", err) @@ -352,7 +362,13 @@ func (s *extensionStore) downloadExtension(extensionID string, destPath string) LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL()) client := NewHTTPClientWithTimeout(5 * time.Minute) - resp, err := client.Get(ext.getDownloadURL()) + req, err := http.NewRequest(http.MethodGet, ext.getDownloadURL(), nil) + if err != nil { + return fmt.Errorf("failed to build download request: %w", err) + } + req.Header.Set("Cache-Control", "no-cache") + req.Header.Set("Pragma", "no-cache") + resp, err := client.Do(req) if err != nil { return fmt.Errorf("failed to download: %w", err) } diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 740a9fd..4ad8fb9 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -66,9 +66,55 @@ import Gobackend // Import Go framework ) GeneratedPluginRegistrant.register(with: self) + if let url = launchOptions?[.url] as? URL { + handleExtensionOAuthRedirect(url: url) + } return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + /// PKCE OAuth return URL: spotiflac://callback?code=...&state= + private func handleExtensionOAuthRedirect(url: URL) { + guard let scheme = url.scheme?.lowercased(), scheme == "spotiflac" else { return } + let host = (url.host ?? "").lowercased() + let path = url.path.lowercased() + let ok = + host == "callback" || host == "spotify-callback" || path.contains("callback") + guard ok else { return } + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return + } + let q = components.queryItems ?? [] + let code = + q.first { $0.name == "code" }?.value?.trimmingCharacters( + in: .whitespacesAndNewlines) ?? "" + let state = + q.first { $0.name == "state" }?.value?.trimmingCharacters( + in: .whitespacesAndNewlines) ?? "" + if code.isEmpty { return } + if state.isEmpty { + NSLog("SpotiFLAC: Extension OAuth redirect missing state (extension id)") + return + } + streamQueue.async { + var err: NSError? + GobackendSetExtensionAuthCodeByID(state, code) + _ = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err) + if let err = err { + NSLog( + "SpotiFLAC: Extension OAuth complete failed: \(err.localizedDescription)") + } + } + } + + override func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + handleExtensionOAuthRedirect(url: url) + return super.application(app, open: url, options: options) + } + deinit { stopDownloadProgressStream() stopLibraryScanProgressStream() diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 48d23d0..34abf8c 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -8,6 +8,7 @@ import 'package:spotiflac_android/providers/store_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:url_launcher/url_launcher.dart'; class ExtensionDetailPage extends ConsumerStatefulWidget { final String extensionId; @@ -404,6 +405,7 @@ class _ExtensionDetailPageState extends ConsumerState { onChanged: (value) => _updateSetting(setting.key, value), extensionId: widget.extensionId, + onActionPayload: _handleExtensionActionPayload, ); }).toList(), ), @@ -445,6 +447,25 @@ class _ExtensionDetailPageState extends ConsumerState { .setExtensionSettings(widget.extensionId, _settings); } + /// Extensions may return `setting_updates` from button actions (e.g. OAuth URL field). + Future _handleExtensionActionPayload(Map payload) async { + final raw = payload['setting_updates']; + if (raw is! Map) return; + final partial = {}; + for (final entry in raw.entries) { + partial[entry.key.toString()] = entry.value; + } + if (partial.isEmpty) return; + final merged = Map.from(_settings); + merged.addAll(partial); + await ref + .read(extensionProvider.notifier) + .setExtensionSettings(widget.extensionId, merged); + if (mounted) { + setState(() => _settings = merged); + } + } + Future _confirmRemove(BuildContext context) async { final colorScheme = Theme.of(context).colorScheme; final confirmed = await showDialog( @@ -478,6 +499,41 @@ class _ExtensionDetailPageState extends ConsumerState { } } +/// Long OAuth URLs: selectable text so users can copy without relying on snackbars. +class _OauthLoginLinkPreview extends StatelessWidget { + final String? value; + final ColorScheme colorScheme; + + const _OauthLoginLinkPreview({ + required this.value, + required this.colorScheme, + }); + + @override + Widget build(BuildContext context) { + final text = value?.trim() ?? ''; + if (text.isEmpty) { + return Text( + 'Tap Connect to Spotify to fill this field.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ); + } + return SelectionArea( + child: Text( + text, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + fontFamily: 'monospace', + fontSize: 11, + ), + ), + ); + } +} + class _InfoRow extends StatelessWidget { final String label; final String value; @@ -645,12 +701,14 @@ class _SettingItem extends StatefulWidget { final bool showDivider; final ValueChanged onChanged; final String extensionId; + final Future Function(Map payload)? onActionPayload; const _SettingItem({ required this.setting, required this.value, required this.onChanged, required this.extensionId, + this.onActionPayload, this.showDivider = true, }); @@ -772,11 +830,17 @@ class _SettingItemState extends State<_SettingItem> { if (widget.setting.type == 'string' || widget.setting.type == 'number') ...[ const SizedBox(height: 4), - Text( - widget.value?.toString() ?? 'Not set', - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.primary), - ), + if (widget.setting.key == 'oauth_login_url') + _OauthLoginLinkPreview( + value: widget.value?.toString(), + colorScheme: colorScheme, + ) + else + Text( + widget.value?.toString() ?? 'Not set', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.primary), + ), ], ], ), @@ -815,15 +879,45 @@ class _SettingItemState extends State<_SettingItem> { ); if (context.mounted) { - final success = result['success'] as bool? ?? false; + // Go may return either a flat map or { success, result: { ... } }. + Map payload = result; + final nested = result['result']; + if (nested is Map) { + payload = Map.from(nested as Map); + } + + final success = payload['success'] as bool? ?? false; if (!success) { - final error = result['error'] as String? ?? 'Action failed'; + final error = + payload['error'] as String? ?? + result['error'] as String? ?? + 'Action failed'; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(error))); } else { - final message = result['message'] as String?; - if (message != null) { + if (widget.onActionPayload != null) { + await widget.onActionPayload!(payload); + } + final openAuth = payload['open_auth_url'] as String?; + if (openAuth != null && openAuth.isNotEmpty) { + final uri = Uri.parse(openAuth); + final launched = await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + if (!launched && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarError('Could not open browser'), + ), + ), + ); + } + } + final message = payload['message'] as String?; + if (message != null && message.isNotEmpty && context.mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message)));