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)));