From 595bfb271167703e0efe47776297a9a70e2e8167 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 19 Jan 2026 00:50:30 +0700 Subject: [PATCH] feat: add button setting type for extension actions - Add SettingTypeButton for action buttons in extension settings - Add Action field to ExtensionSetting for JS function name - Update extension detail page UI to render button settings - Add InvokeAction method to execute button actions --- go_backend/extension_manager.go | 57 +++++++ go_backend/extension_manifest.go | 11 ++ lib/providers/extension_provider.dart | 5 +- .../settings/extension_detail_page.dart | 149 +++++++++++++++--- 4 files changed, 201 insertions(+), 21 deletions(-) diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 857a8dae..a601c1a4 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -959,3 +959,60 @@ func (m *ExtensionManager) UnloadAllExtensions() { GoLog("[Extension] All extensions unloaded\n") } + +// InvokeAction calls a custom action function on an extension (e.g., for button settings) +// The function is called as extension.() and can return a result +func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) { + m.mu.Lock() + defer m.mu.Unlock() + + ext, exists := m.extensions[extensionID] + if !exists { + return nil, fmt.Errorf("extension not found: %s", extensionID) + } + + if ext.VM == nil { + return nil, fmt.Errorf("extension VM not initialized") + } + + if !ext.Enabled { + return nil, fmt.Errorf("extension is disabled") + } + + // Call the action function on the extension object + 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' }; + } + return { success: true, result: result }; + } catch (e) { + return { success: false, error: e.toString() }; + } + } + return { success: false, error: 'Action function not found: %s' }; + })() + `, actionName, actionName, actionName) + + result, err := RunWithTimeoutAndRecover(ext.VM, script, DefaultJSTimeout) + if err != nil { + GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err) + return nil, fmt.Errorf("action failed: %v", err) + } + + if result == nil || goja.IsUndefined(result) { + return map[string]interface{}{"success": true}, nil + } + + exported := result.Export() + if resultMap, ok := exported.(map[string]interface{}); ok { + GoLog("[Extension] InvokeAction %s.%s result: %v\n", extensionID, actionName, resultMap) + return resultMap, nil + } + + return map[string]interface{}{"success": true, "result": exported}, nil +} diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go index 7a7a37f3..0a4fce24 100644 --- a/go_backend/extension_manifest.go +++ b/go_backend/extension_manifest.go @@ -23,6 +23,7 @@ const ( SettingTypeNumber SettingType = "number" SettingTypeBool SettingType = "boolean" SettingTypeSelect SettingType = "select" + SettingTypeButton SettingType = "button" // Action button that calls a JS function ) // ExtensionPermissions defines what resources an extension can access @@ -42,6 +43,7 @@ type ExtensionSetting struct { Secret bool `json:"secret,omitempty"` Default interface{} `json:"default,omitempty"` Options []string `json:"options,omitempty"` // For select type + Action string `json:"action,omitempty"` // For button type: JS function name to call (e.g., "startLogin") } // QualityOption represents a quality option for download providers @@ -204,6 +206,7 @@ func (m *ExtensionManifest) Validate() error { SettingTypeNumber: true, SettingTypeBool: true, SettingTypeSelect: true, + SettingTypeButton: true, } if !validTypes[setting.Type] { return &ManifestValidationError{ @@ -219,6 +222,14 @@ func (m *ExtensionManifest) Validate() error { Message: "select type requires options", } } + + // Button type requires action + if setting.Type == SettingTypeButton && setting.Action == "" { + return &ManifestValidationError{ + Field: fmt.Sprintf("settings[%d].action", i), + Message: "button type requires action (JS function name)", + } + } } return nil diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 7086051b..3eb6f444 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -355,11 +355,12 @@ class QualitySpecificSetting { class ExtensionSetting { final String key; final String label; - final String type; // 'string', 'number', 'boolean', 'select' + final String type; // 'string', 'number', 'boolean', 'select', 'button' final dynamic defaultValue; final String? description; final List? options; // For select type final bool required; + final String? action; // For button type: JS function name to call const ExtensionSetting({ required this.key, @@ -369,6 +370,7 @@ class ExtensionSetting { this.description, this.options, this.required = false, + this.action, }); factory ExtensionSetting.fromJson(Map json) { @@ -380,6 +382,7 @@ class ExtensionSetting { description: json['description'] as String?, options: (json['options'] as List?)?.cast(), required: json['required'] as bool? ?? false, + action: json['action'] as String?, ); } } diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 34571577..1f27ef88 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/store_provider.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; class ExtensionDetailPage extends ConsumerStatefulWidget { @@ -342,6 +343,7 @@ class _ExtensionDetailPageState extends ConsumerState { value: _settings[setting.key] ?? setting.defaultValue, showDivider: index < extension.settings.length - 1, onChanged: (value) => _updateSetting(setting.key, value), + extensionId: widget.extensionId, ); }).toList(), ), @@ -587,41 +589,62 @@ class _PermissionItem extends StatelessWidget { } } -class _SettingItem extends StatelessWidget { +class _SettingItem extends StatefulWidget { final ExtensionSetting setting; final dynamic value; final bool showDivider; final ValueChanged onChanged; + final String extensionId; const _SettingItem({ required this.setting, required this.value, required this.onChanged, + required this.extensionId, this.showDivider = true, }); + @override + State<_SettingItem> createState() => _SettingItemState(); +} + +class _SettingItemState extends State<_SettingItem> { + bool _isLoading = false; + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; Widget trailing; - switch (setting.type) { + switch (widget.setting.type) { case 'boolean': trailing = Switch( - value: value as bool? ?? false, - onChanged: onChanged, + value: widget.value as bool? ?? false, + onChanged: widget.onChanged, ); break; case 'select': trailing = DropdownButton( - value: value as String?, - items: setting.options?.map((opt) { + value: widget.value as String?, + items: widget.setting.options?.map((opt) { return DropdownMenuItem(value: opt, child: Text(opt)); }).toList(), - onChanged: onChanged, + onChanged: widget.onChanged, underline: const SizedBox(), ); break; + case 'button': + trailing = _isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : FilledButton.tonal( + onPressed: () => _invokeAction(context), + child: Text(widget.setting.label), + ); + break; default: trailing = Icon( Icons.chevron_right, @@ -629,11 +652,52 @@ class _SettingItem extends StatelessWidget { ); } + // For button type, show a different layout + if (widget.setting.type == 'button') { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.setting.description != null) ...[ + Text( + widget.setting.description!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + ], + ], + ), + ), + trailing, + ], + ), + ), + if (widget.showDivider) + Divider( + height: 1, + thickness: 1, + indent: 16, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } + return Column( mainAxisSize: MainAxisSize.min, children: [ InkWell( - onTap: setting.type == 'string' || setting.type == 'number' + onTap: widget.setting.type == 'string' || widget.setting.type == 'number' ? () => _showEditDialog(context) : null, child: Padding( @@ -645,22 +709,22 @@ class _SettingItem extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - setting.label, + widget.setting.label, style: Theme.of(context).textTheme.bodyLarge, ), - if (setting.description != null) ...[ + if (widget.setting.description != null) ...[ const SizedBox(height: 2), Text( - setting.description!, + widget.setting.description!, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ], - if (setting.type == 'string' || setting.type == 'number') ...[ + if (widget.setting.type == 'string' || widget.setting.type == 'number') ...[ const SizedBox(height: 4), Text( - value?.toString() ?? 'Not set', + widget.value?.toString() ?? 'Not set', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.primary, ), @@ -674,7 +738,7 @@ class _SettingItem extends StatelessWidget { ), ), ), - if (showDivider) + if (widget.showDivider) Divider( height: 1, thickness: 1, @@ -686,21 +750,66 @@ class _SettingItem extends StatelessWidget { ); } + Future _invokeAction(BuildContext context) async { + if (widget.setting.action == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No action defined for this button')), + ); + return; + } + + setState(() => _isLoading = true); + + try { + final result = await PlatformBridge.invokeExtensionAction( + widget.extensionId, + widget.setting.action!, + ); + + if (context.mounted) { + final success = result['success'] as bool? ?? false; + if (!success) { + final error = result['error'] as String? ?? 'Action failed'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error)), + ); + } else { + final message = result['message'] as String?; + if (message != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + } + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + void _showEditDialog(BuildContext context) { - final controller = TextEditingController(text: value?.toString() ?? ''); + final controller = TextEditingController(text: widget.value?.toString() ?? ''); final colorScheme = Theme.of(context).colorScheme; showDialog( context: context, builder: (context) => AlertDialog( - title: Text(setting.label), + title: Text(widget.setting.label), content: TextField( controller: controller, - keyboardType: setting.type == 'number' + keyboardType: widget.setting.type == 'number' ? TextInputType.number : TextInputType.text, decoration: InputDecoration( - hintText: setting.description ?? 'Enter value', + hintText: widget.setting.description ?? 'Enter value', filled: true, fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), border: OutlineInputBorder( @@ -716,10 +825,10 @@ class _SettingItem extends StatelessWidget { ), FilledButton( onPressed: () { - final newValue = setting.type == 'number' + final newValue = widget.setting.type == 'number' ? num.tryParse(controller.text) : controller.text; - onChanged(newValue); + widget.onChanged(newValue); Navigator.pop(context); }, child: Text(context.l10n.dialogSave),