diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 2b38818a..3f2a21d4 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -118,7 +118,13 @@ func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) { } type extensionManager struct { - mu sync.RWMutex + mu sync.RWMutex + // mutationMu serializes heavy mutating operations (install / upgrade / + // remove). These tear down and re-extract files and rebuild goja VMs; + // running two at once (e.g. updating two extensions simultaneously) races + // the non-thread-safe goja runtime and can hard-crash the process. Always + // acquired before m.mu; internal "*Locked" helpers assume it is held. + mutationMu sync.Mutex extensions map[string]*loadedExtension extensionsDir string dataDir string @@ -156,6 +162,12 @@ func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error { } func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) { + m.mutationMu.Lock() + defer m.mutationMu.Unlock() + return m.loadExtensionFromFileLocked(filePath) +} + +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") } @@ -212,7 +224,7 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens if exists { versionCompare := compareVersions(manifest.Version, existingVersion) if versionCompare > 0 { - return m.UpgradeExtension(filePath) + return m.upgradeExtensionLocked(filePath) } else if versionCompare == 0 { return nil, fmt.Errorf("extension '%s' v%s is already installed", existingDisplayName, existingVersion) } else { @@ -736,6 +748,9 @@ func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedEx } func (m *extensionManager) RemoveExtension(extensionID string) error { + m.mutationMu.Lock() + defer m.mutationMu.Unlock() + ext, err := m.GetExtension(extensionID) if err != nil { return err @@ -756,6 +771,12 @@ func (m *extensionManager) RemoveExtension(extensionID string) error { // Only allows upgrades (new version > current version), not downgrades func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) { + m.mutationMu.Lock() + defer m.mutationMu.Unlock() + return m.upgradeExtensionLocked(filePath) +} + +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") } diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 5d43ccbf..2aa7fe9f 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -20,6 +20,14 @@ import Gobackend // Import Go framework /// Currently accessed security-scoped URL for library folder private var activeSecurityScopedURL: URL? + + /// Whether a download queue is currently active. When true, the app begins a + /// fresh background task each time it enters the background so an in-flight + /// download keeps running for the limited window iOS allows. iOS has no + /// foreground-service equivalent, so this is best-effort. Only touched on the + /// main thread. + private var downloadsActive = false + private var downloadBackgroundTask: UIBackgroundTaskIdentifier = .invalid override func application( _ application: UIApplication, @@ -233,6 +241,25 @@ import Gobackend // Import Go framework } private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) { + // Background-task management must run on the main thread and does not + // touch the Go backend, so handle it directly without dispatching. + switch call.method { + case "beginBackgroundDownloadTask": + // Queue started: remember it so we extend background time whenever + // the app is backgrounded while downloads run. + downloadsActive = true + result(nil) + return + case "endBackgroundDownloadTask": + // Queue finished/paused: stop extending background time. + downloadsActive = false + endBackgroundDownloadTask() + result(nil) + return + default: + break + } + DispatchQueue.global(qos: .userInitiated).async { do { let response = try self.invokeGoMethod(call: call) @@ -246,6 +273,39 @@ import Gobackend // Import Go framework } } } + + override func applicationDidEnterBackground(_ application: UIApplication) { + super.applicationDidEnterBackground(application) + if downloadsActive { + beginBackgroundDownloadTask() + } + } + + override func applicationWillEnterForeground(_ application: UIApplication) { + super.applicationWillEnterForeground(application) + // No background-time countdown while in the foreground. + endBackgroundDownloadTask() + } + + /// Begins a background task (if one is not already active) so an in-flight + /// download keeps running for the limited window iOS allows after the app is + /// backgrounded. The expiration handler ends the task to avoid the app being + /// force-terminated by the watchdog. Must run on the main thread. + private func beginBackgroundDownloadTask() { + if downloadBackgroundTask != .invalid { return } + downloadBackgroundTask = UIApplication.shared.beginBackgroundTask( + withName: "SpotiFLACDownloads" + ) { [weak self] in + self?.endBackgroundDownloadTask() + } + } + + private func endBackgroundDownloadTask() { + if downloadBackgroundTask != .invalid { + UIApplication.shared.endBackgroundTask(downloadBackgroundTask) + downloadBackgroundTask = .invalid + } + } private func invokeGoMethod(call: FlutterMethodCall) throws -> Any? { var error: NSError? diff --git a/lib/main.dart b/lib/main.dart index 0eaf8b0c..76410a1b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,20 +15,45 @@ import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/utils/local_library_scan_prefs.dart'; +import 'package:spotiflac_android/utils/logger.dart'; -void main() async { - WidgetsFlutterBinding.ensureInitialized(); - final runtimeProfile = await _resolveRuntimeProfile(); - _configureImageCache(runtimeProfile); +final _log = AppLogger('Main'); - runApp( - ProviderScope( - child: _EagerInitialization( - child: SpotiFLACApp( - disableOverscrollEffects: runtimeProfile.disableOverscrollEffects, +void main() { + // Catch uncaught errors so a failure in a provider/async path (e.g. a + // misbehaving extension request) is logged instead of taking the whole app + // down. Native (Go) fatal crashes can't be caught here, but Dart-side ones + // can. + runZonedGuarded( + () async { + WidgetsFlutterBinding.ensureInitialized(); + + final previousOnError = FlutterError.onError; + FlutterError.onError = (details) { + previousOnError?.call(details); + _log.e('Uncaught Flutter error: ${details.exceptionAsString()}'); + }; + WidgetsBinding.instance.platformDispatcher.onError = (error, stack) { + _log.e('Uncaught platform error: $error'); + return true; + }; + + final runtimeProfile = await _resolveRuntimeProfile(); + _configureImageCache(runtimeProfile); + + runApp( + ProviderScope( + child: _EagerInitialization( + child: SpotiFLACApp( + disableOverscrollEffects: runtimeProfile.disableOverscrollEffects, + ), + ), ), - ), - ), + ); + }, + (error, stack) { + _log.e('Uncaught zone error: $error'); + }, ); } diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index e6e0aace..52b4b45e 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -6676,6 +6676,13 @@ class DownloadQueueNotifier extends Notifier { } } + // iOS has no foreground service; request a background execution window so + // an in-flight download can keep running for a short time after the app is + // backgrounded instead of being suspended immediately. + if (Platform.isIOS && _totalQueuedAtStart > 0) { + await PlatformBridge.beginBackgroundDownloadTask(); + } + if (!isSafMode && state.outputDir.isEmpty) { _log.d('Output dir empty, initializing...'); await _initOutputDir(); @@ -6794,6 +6801,10 @@ class DownloadQueueNotifier extends Notifier { } } + if (Platform.isIOS) { + await PlatformBridge.endBackgroundDownloadTask(); + } + if (_downloadCount > 0) { _log.d('Final connection cleanup...'); try { diff --git a/lib/providers/store_provider.dart b/lib/providers/store_provider.dart index b9c91697..4d9ed74b 100644 --- a/lib/providers/store_provider.dart +++ b/lib/providers/store_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'dart:async'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -205,6 +206,24 @@ class StoreState { } class StoreNotifier extends Notifier { + /// Serializes extension install/upgrade operations. Running two of these at + /// once (e.g. updating two extensions simultaneously) races the native + /// extension manager's goja VM teardown/reload and can hard-crash the app, so + /// they must run strictly one at a time. + Future _mutationChain = Future.value(); + + Future _runSerialized(Future Function() action) { + final completer = Completer(); + _mutationChain = _mutationChain.then((_) async { + try { + completer.complete(await action()); + } catch (e, st) { + completer.completeError(e, st); + } + }); + return completer.future; + } + @override StoreState build() { return const StoreState(); @@ -330,6 +349,16 @@ class StoreNotifier extends Notifier { String extensionId, String tempDir, String extensionsDir, + ) { + return _runSerialized( + () => _installExtensionInternal(extensionId, tempDir, extensionsDir), + ); + } + + Future _installExtensionInternal( + String extensionId, + String tempDir, + String extensionsDir, ) async { state = state.copyWith( isDownloading: true, @@ -366,7 +395,16 @@ class StoreNotifier extends Notifier { } } - Future updateExtension(String extensionId, String tempDir) async { + Future updateExtension(String extensionId, String tempDir) { + return _runSerialized( + () => _updateExtensionInternal(extensionId, tempDir), + ); + } + + Future _updateExtensionInternal( + String extensionId, + String tempDir, + ) async { state = state.copyWith( isDownloading: true, downloadingId: extensionId, diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 126f68fa..0a9fcd0e 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -968,6 +968,11 @@ class _DownloadedAlbumScreenState extends ConsumerState { if (formats.isEmpty) return; + final sheetTitle = context.l10n.selectionBatchConvertConfirmTitle; + final sheetConfirmLabel = context.l10n.selectionConvertCount( + _selectedIds.length, + ); + showModalBottomSheet( context: context, useRootNavigator: true, @@ -976,8 +981,8 @@ class _DownloadedAlbumScreenState extends ConsumerState { ), builder: (sheetContext) => BatchConvertSheet( formats: formats, - title: context.l10n.selectionBatchConvertConfirmTitle, - confirmLabel: context.l10n.selectionConvertCount(_selectedIds.length), + title: sheetTitle, + confirmLabel: sheetConfirmLabel, onConvert: (format, bitrate) { Navigator.pop(sheetContext); _performBatchConversion( diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 2ac690da..1cedc93d 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -1216,6 +1216,11 @@ class _LocalAlbumScreenState extends ConsumerState { if (formats.isEmpty) return; + final sheetTitle = context.l10n.selectionBatchConvertConfirmTitle; + final sheetConfirmLabel = context.l10n.selectionConvertCount( + _selectedIds.length, + ); + showModalBottomSheet( context: context, useRootNavigator: true, @@ -1224,8 +1229,8 @@ class _LocalAlbumScreenState extends ConsumerState { ), builder: (sheetContext) => BatchConvertSheet( formats: formats, - title: context.l10n.selectionBatchConvertConfirmTitle, - confirmLabel: context.l10n.selectionConvertCount(_selectedIds.length), + title: sheetTitle, + confirmLabel: sheetConfirmLabel, onConvert: (format, bitrate) { Navigator.pop(sheetContext); _performBatchConversion( diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index ef2fc5eb..9870c874 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -4849,6 +4849,14 @@ class _QueueTabState extends ConsumerState { var didStartConversion = false; + // Resolve localized strings up front: the builder closure must not look up + // Localizations via the outer (State) context, which may be deactivated by + // the time the root-navigator sheet rebuilds. + final sheetTitle = context.l10n.selectionBatchConvertConfirmTitle; + final sheetConfirmLabel = context.l10n.selectionConvertCount( + _selectedIds.length, + ); + _suppressSelectionOverlay = true; _hideSelectionOverlay(); _hidePlaylistSelectionOverlay(); @@ -4861,8 +4869,8 @@ class _QueueTabState extends ConsumerState { ), builder: (sheetContext) => BatchConvertSheet( formats: formats, - title: context.l10n.selectionBatchConvertConfirmTitle, - confirmLabel: context.l10n.selectionConvertCount(_selectedIds.length), + title: sheetTitle, + confirmLabel: sheetConfirmLabel, onConvert: (format, bitrate) { didStartConversion = true; Navigator.pop(sheetContext); diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index a5ec65fe..3f78beef 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -865,6 +865,26 @@ class PlatformBridge { return result as bool; } + /// iOS only: mark that a download queue is active so the app keeps in-flight + /// downloads running for a short window whenever it is backgrounded. iOS + /// grants a limited budget (roughly 30s on modern versions) per background + /// entry; there is no foreground-service equivalent, so this is best-effort. + static Future beginBackgroundDownloadTask() async { + if (!Platform.isIOS) return; + try { + await _channel.invokeMethod('beginBackgroundDownloadTask'); + } catch (_) {} + } + + /// iOS only: mark that the download queue is no longer active (queue finished + /// or paused), stopping background-time extension. + static Future endBackgroundDownloadTask() async { + if (!Platform.isIOS) return; + try { + await _channel.invokeMethod('endBackgroundDownloadTask'); + } catch (_) {} + } + static Future startNativeDownloadWorker({ required List> requests, Map settings = const {},