mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
feat(backend): migrate production backend to Rust
Replace the Go backend with the Rust workspace and route Android/iOS through UniFFI bindings. Include the migrated extension runtime, network, providers, media metadata, downloads and library operations, with version 5.0.0+147. Remove Go sources, adapters, native build selection and CI dependencies. Build Rust unconditionally and lock the iOS Rust pod using a relative path. Retain legacy source and migration evidence in a local ignored archive. Validation: Android Kotlin compile and 53 native tests; Swift Rust-branch equivalence and syntax; CocoaPods install; workflow, shell, Ruby, plist and diff checks. Reuse the preceding 100 Rust tests, fmt/Clippy and five-ABI build checkpoint; no new APK or iOS application build for this cleanup. Known follow-up: URL/URLSearchParams globals are missing from the Rust JS runtime. A controlled extension replay confirms a URL-resolution regression; this commit does not fix that runtime gap.
This commit is contained in:
1 parent
1da0da18a6
commit
aa99726439
452 files changed
+64417
-65088
No files matched your search
@@ -31,16 +31,17 @@ class CoverDownloadService {
|
||||
? 'cover'
|
||||
: safeBaseName.trim();
|
||||
final tempDir = await Directory.systemTemp.createTemp('save_cover_');
|
||||
final tempPath = p.join(tempDir.path, 'cover.image');
|
||||
var tempPath = p.join(tempDir.path, 'cover.image');
|
||||
IosSecurityScopedAccess? iosBookmarkAccess;
|
||||
|
||||
try {
|
||||
final download = await PlatformBridge.downloadCoverToFile(
|
||||
normalizedUrl,
|
||||
tempPath,
|
||||
Platform.isAndroid || Platform.isIOS ? '' : tempPath,
|
||||
);
|
||||
final error = download['error']?.toString().trim() ?? '';
|
||||
if (error.isNotEmpty) throw StateError(error);
|
||||
tempPath = download['file_path'] as String? ?? tempPath;
|
||||
|
||||
final tempFile = File(tempPath);
|
||||
if (!await tempFile.exists() || await tempFile.length() <= 0) {
|
||||
@@ -104,6 +105,8 @@ class CoverDownloadService {
|
||||
await PlatformBridge.stopAccessingIosBookmark(iosBookmarkAccess);
|
||||
}
|
||||
try {
|
||||
final tempFile = File(tempPath);
|
||||
if (await tempFile.exists()) await tempFile.delete();
|
||||
if (await tempDir.exists()) await tempDir.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,15 @@ class ExtensionStorageService {
|
||||
static Future<ExtensionStoragePaths>? _preparing;
|
||||
|
||||
static Future<ExtensionStoragePaths> prepare() {
|
||||
return _preparing ??= _prepare();
|
||||
return _preparing ??= _prepare().catchError((
|
||||
Object error,
|
||||
StackTrace stack,
|
||||
) {
|
||||
// A transient filesystem/keystore failure must not poison every retry
|
||||
// until the process is restarted.
|
||||
_preparing = null;
|
||||
Error.throwWithStackTrace(error, stack);
|
||||
});
|
||||
}
|
||||
|
||||
static Future<ExtensionStoragePaths> _prepare() async {
|
||||
|
||||
@@ -2117,7 +2117,7 @@ class FFmpegService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Write tags + cover via the native ID3-chunk writer in the Go backend.
|
||||
// Write tags + cover via the native ID3-chunk writer.
|
||||
final hasMetadata = metadata.values.any((v) => v.trim().isNotEmpty);
|
||||
final hasCover = coverPath != null && coverPath.trim().isNotEmpty;
|
||||
if (hasMetadata || hasCover) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
|
||||
import 'package:spotiflac_android/utils/isrc_utils.dart' as isrc;
|
||||
import 'package:spotiflac_android/utils/ios_container_paths.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/path_match_keys.dart';
|
||||
|
||||
@@ -507,21 +508,13 @@ class HistoryDatabase {
|
||||
void _putPathKeysInBatch(Batch batch, String id, String? filePath) =>
|
||||
sqlite.putPathKeysInBatch(batch, 'history_path_keys', id, filePath);
|
||||
|
||||
static final _iosContainerPattern = RegExp(
|
||||
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+/',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
Future<void> _initContainerPath() async {
|
||||
if (!Platform.isIOS || _currentContainerPath != null) return;
|
||||
|
||||
try {
|
||||
final docDir = await getApplicationDocumentsDirectory();
|
||||
final match = _iosContainerPattern.firstMatch(docDir.path);
|
||||
if (match != null) {
|
||||
_currentContainerPath = match.group(0);
|
||||
_log.d('iOS container path: $_currentContainerPath');
|
||||
}
|
||||
_currentContainerPath = docDir.parent.path;
|
||||
_log.d('iOS container path: $_currentContainerPath');
|
||||
} catch (e) {
|
||||
_log.w('Failed to get iOS container path: $e');
|
||||
}
|
||||
@@ -531,18 +524,7 @@ class HistoryDatabase {
|
||||
if (filePath == null || filePath.isEmpty) return filePath ?? '';
|
||||
if (!Platform.isIOS || _currentContainerPath == null) return filePath;
|
||||
|
||||
if (_iosContainerPattern.hasMatch(filePath)) {
|
||||
final normalized = filePath.replaceFirst(
|
||||
_iosContainerPattern,
|
||||
_currentContainerPath!,
|
||||
);
|
||||
if (normalized != filePath) {
|
||||
_log.d('Normalized iOS path: $filePath -> $normalized');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return filePath;
|
||||
return rebaseIosSandboxPath(filePath, '$_currentContainerPath/Documents');
|
||||
}
|
||||
|
||||
Future<bool> migrateIosContainerPaths() async {
|
||||
@@ -572,7 +554,7 @@ class HistoryDatabase {
|
||||
final id = row['id'] as String;
|
||||
final oldPath = row['file_path'] as String?;
|
||||
|
||||
if (oldPath != null && _iosContainerPattern.hasMatch(oldPath)) {
|
||||
if (oldPath != null) {
|
||||
final newPath = _normalizeIosPath(oldPath);
|
||||
if (newPath != oldPath) {
|
||||
batch.update(
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/ios_container_paths.dart';
|
||||
import 'package:spotiflac_android/services/history_database.dart';
|
||||
import 'package:spotiflac_android/services/library_cleanup.dart';
|
||||
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
|
||||
@@ -48,6 +49,7 @@ class LibraryDatabase {
|
||||
version: schemaVersion,
|
||||
onCreate: _createDB,
|
||||
onUpgrade: _upgradeDB,
|
||||
onOpen: _migrateIosContainerPaths,
|
||||
);
|
||||
// Library upserts use INSERT OR REPLACE. Recursive triggers ensure the
|
||||
// implicit delete also decrements materialized lookup ref-counts.
|
||||
@@ -62,6 +64,56 @@ class LibraryDatabase {
|
||||
|
||||
bool get searchFtsAvailable => _searchFtsAvailable ?? false;
|
||||
|
||||
Future<void> _migrateIosContainerPaths(Database db) async {
|
||||
if (!Platform.isIOS) return;
|
||||
final documents = await getApplicationDocumentsDirectory();
|
||||
// Keep rows and their lookup keys consistent before any startup cleanup
|
||||
// can mistake a relocated file for a deleted one. Bookmarks remain the
|
||||
// authority for external sources.
|
||||
await db.transaction((txn) async {
|
||||
const localSources = "bookmark IS NULL OR bookmark = ''";
|
||||
final sources = await txn.query(
|
||||
'library_sources',
|
||||
columns: ['id', 'path'],
|
||||
where: localSources,
|
||||
);
|
||||
final rows = await txn.query(
|
||||
'library',
|
||||
columns: ['id', 'file_path', 'cover_path'],
|
||||
where:
|
||||
'source_id IN (SELECT id FROM library_sources WHERE $localSources)',
|
||||
);
|
||||
final batch = txn.batch();
|
||||
for (final row in rows) {
|
||||
final updates = <String, Object?>{};
|
||||
for (final column in ['file_path', 'cover_path']) {
|
||||
final previous = row[column] as String?;
|
||||
if (previous == null) continue;
|
||||
final current = rebaseIosSandboxPath(previous, documents.path);
|
||||
if (current != previous) updates[column] = current;
|
||||
}
|
||||
if (updates.isEmpty) continue;
|
||||
final id = row['id'] as String;
|
||||
batch.update('library', updates, where: 'id = ?', whereArgs: [id]);
|
||||
if (updates.containsKey('file_path')) {
|
||||
_putPathKeysInBatch(batch, id, updates['file_path'] as String);
|
||||
}
|
||||
}
|
||||
for (final source in sources) {
|
||||
final previous = source['path'] as String;
|
||||
final current = rebaseIosSandboxPath(previous, documents.path);
|
||||
if (current == previous) continue;
|
||||
batch.update(
|
||||
'library_sources',
|
||||
{'path': current},
|
||||
where: 'id = ?',
|
||||
whereArgs: [source['id']],
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _ensureHistoryAttached(Database db) async {
|
||||
if (_historyAttached) return;
|
||||
await HistoryDatabase.instance.database;
|
||||
|
||||
@@ -727,6 +727,20 @@ class PlatformBridge {
|
||||
await _channel.invokeMethod('setDownloadDirectory', {'path': path});
|
||||
}
|
||||
|
||||
/// Holds backend access through download and finalization, including folder changes.
|
||||
static Future<String> acquireDownloadDirectory(String path) async {
|
||||
if (!supportsCoreBackend) return '';
|
||||
return await _channel.invokeMethod<String>('acquireDownloadDirectory', {
|
||||
'path': path,
|
||||
}) ??
|
||||
'';
|
||||
}
|
||||
|
||||
static Future<void> releaseDownloadDirectory(String token) async {
|
||||
if (!supportsCoreBackend || token.isEmpty) return;
|
||||
await _channel.invokeMethod('releaseDownloadDirectory', {'token': token});
|
||||
}
|
||||
|
||||
static Future<void> setNetworkCompatibilityOptions({
|
||||
required bool allowHttp,
|
||||
required bool insecureTls,
|
||||
@@ -741,6 +755,17 @@ class PlatformBridge {
|
||||
await _channel.invokeMethod('setAllowPrivateNetwork', {'allowed': allowed});
|
||||
}
|
||||
|
||||
/// Reports domain ownership during native backend migration.
|
||||
static Future<Map<String, String>> getBackendImplementations({
|
||||
String? filePath,
|
||||
}) async {
|
||||
final result = await _channel.invokeMapMethod<String, String>(
|
||||
'getBackendImplementations',
|
||||
filePath == null ? null : {'file_path': filePath},
|
||||
);
|
||||
return result ?? const {};
|
||||
}
|
||||
|
||||
static Future<String> buildFilename(
|
||||
String template,
|
||||
Map<String, dynamic> metadata,
|
||||
@@ -1040,9 +1065,11 @@ class PlatformBridge {
|
||||
String audioPath,
|
||||
String outputPath,
|
||||
) {
|
||||
return _invokeMap('extractCoverToFile', {
|
||||
'audio_path': audioPath,
|
||||
'output_path': outputPath,
|
||||
return _withMediaFileAccess([audioPath, outputPath], () {
|
||||
return _invokeMap('extractCoverToFile', {
|
||||
'audio_path': audioPath,
|
||||
'output_path': outputPath,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1100,7 +1127,12 @@ class PlatformBridge {
|
||||
static Future<Map<String, dynamic>> reEnrichFile(
|
||||
Map<String, dynamic> request,
|
||||
) {
|
||||
return _invokeMap('reEnrichFile', {'request_json': jsonEncode(request)});
|
||||
Future<Map<String, dynamic>> invoke() =>
|
||||
_invokeMap('reEnrichFile', {'request_json': jsonEncode(request)});
|
||||
if (request['preview_only'] == true) return invoke();
|
||||
return _withMediaFileAccess([
|
||||
request['file_path'] as String? ?? '',
|
||||
], invoke);
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> readFileMetadata(
|
||||
@@ -1136,12 +1168,41 @@ class PlatformBridge {
|
||||
String filePath,
|
||||
Map<String, String> metadata,
|
||||
) {
|
||||
return _invokeMap('editFileMetadata', {
|
||||
'file_path': filePath,
|
||||
'metadata_json': jsonEncode(metadata),
|
||||
return _withMediaFileAccess([filePath, metadata['cover_path'] ?? ''], () {
|
||||
return _invokeMap('editFileMetadata', {
|
||||
'file_path': filePath,
|
||||
'metadata_json': jsonEncode(metadata),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static Future<T> _withMediaFileAccess<T>(
|
||||
Iterable<String> paths,
|
||||
Future<T> Function() operation,
|
||||
) async {
|
||||
// A user-selected library file or cover can live outside the download
|
||||
// folder. Hold only this operation's backend grants; SAF is staged natively.
|
||||
final scopes = <String>[];
|
||||
try {
|
||||
final directories = <String>{
|
||||
for (final path in paths)
|
||||
if (path.startsWith('/')) File(path).parent.path,
|
||||
};
|
||||
for (final directory in directories) {
|
||||
scopes.add(await acquireDownloadDirectory(directory));
|
||||
}
|
||||
return await operation();
|
||||
} finally {
|
||||
for (final scope in scopes.reversed) {
|
||||
try {
|
||||
await releaseDownloadDirectory(scope);
|
||||
} catch (e) {
|
||||
_log.w('Failed to release media file access: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes ISRC and label into an M4A/MP4 file as iTunes freeform atoms.
|
||||
/// FFmpeg's MP4 muxer drops these keys, so they must be written natively
|
||||
/// after the FFmpeg metadata pass. [filePath] must be a local file path.
|
||||
@@ -1150,9 +1211,11 @@ class PlatformBridge {
|
||||
String filePath,
|
||||
Map<String, String> fields,
|
||||
) {
|
||||
return _invokeMap('writeM4AFreeformTags', {
|
||||
'file_path': filePath,
|
||||
'metadata_json': jsonEncode(fields),
|
||||
return _withMediaFileAccess([filePath], () {
|
||||
return _invokeMap('writeM4AFreeformTags', {
|
||||
'file_path': filePath,
|
||||
'metadata_json': jsonEncode(fields),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1164,9 +1227,11 @@ class PlatformBridge {
|
||||
String filePath,
|
||||
String sourcePath,
|
||||
) {
|
||||
return _invokeMap('ensureAC4Config', {
|
||||
'file_path': filePath,
|
||||
'source_path': sourcePath,
|
||||
return _withMediaFileAccess([filePath, sourcePath], () {
|
||||
return _invokeMap('ensureAC4Config', {
|
||||
'file_path': filePath,
|
||||
'source_path': sourcePath,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1179,24 +1244,28 @@ class PlatformBridge {
|
||||
Map<String, String> metadata,
|
||||
String coverPath,
|
||||
) {
|
||||
return _invokeMap('writeAC4Metadata', {
|
||||
'file_path': filePath,
|
||||
'metadata_json': jsonEncode(metadata),
|
||||
'cover_path': coverPath,
|
||||
return _withMediaFileAccess([filePath, coverPath], () {
|
||||
return _invokeMap('writeAC4Metadata', {
|
||||
'file_path': filePath,
|
||||
'metadata_json': jsonEncode(metadata),
|
||||
'cover_path': coverPath,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Rewrites ARTIST/ALBUMARTIST Vorbis comments as multiple split entries
|
||||
/// using the native Go FLAC writer, fixing FFmpeg's tag deduplication.
|
||||
/// using the selected native FLAC writer, fixing FFmpeg's tag deduplication.
|
||||
static Future<Map<String, dynamic>> rewriteSplitArtistTags(
|
||||
String filePath,
|
||||
String artist,
|
||||
String albumArtist,
|
||||
) {
|
||||
return _invokeMap('rewriteSplitArtistTags', {
|
||||
'file_path': filePath,
|
||||
'artist': artist,
|
||||
'album_artist': albumArtist,
|
||||
return _withMediaFileAccess([filePath], () {
|
||||
return _invokeMap('rewriteSplitArtistTags', {
|
||||
'file_path': filePath,
|
||||
'artist': artist,
|
||||
'album_artist': albumArtist,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1493,7 +1562,7 @@ class PlatformBridge {
|
||||
await _channel.invokeMethod('clearLogs');
|
||||
}
|
||||
|
||||
/// Ask the Go backend to GC and return freed heap to the OS. Best-effort:
|
||||
/// Ask the native backend to release unused memory. Best-effort:
|
||||
/// safe to call on memory pressure or when the app is backgrounded.
|
||||
static Future<void> releaseNativeMemory({bool underPressure = false}) async {
|
||||
try {
|
||||
@@ -1524,12 +1593,18 @@ class PlatformBridge {
|
||||
String extensionsDir,
|
||||
String dataDir, {
|
||||
required String masterKey,
|
||||
required List<String> lyricsProviders,
|
||||
required Map<String, dynamic> lyricsFetchOptions,
|
||||
List<String> allowedDirectories = const [],
|
||||
}) async {
|
||||
_log.d('initExtensionSystem: $extensionsDir, $dataDir');
|
||||
await _channel.invokeMethod('initExtensionSystem', {
|
||||
'extensions_dir': extensionsDir,
|
||||
'data_dir': dataDir,
|
||||
'master_key': masterKey,
|
||||
'lyrics_providers_json': jsonEncode(lyricsProviders),
|
||||
'lyrics_options_json': jsonEncode(lyricsFetchOptions),
|
||||
'allowed_directories': allowedDirectories,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2513,17 +2588,34 @@ class PlatformBridge {
|
||||
static Future<Map<String, dynamic>> runPostProcessingV2(
|
||||
String filePath, {
|
||||
Map<String, dynamic>? metadata,
|
||||
String? itemId,
|
||||
}) async {
|
||||
final input = <String, dynamic>{};
|
||||
if (itemId != null && itemId.isNotEmpty) input['item_id'] = itemId;
|
||||
if (filePath.startsWith('content://')) {
|
||||
input['uri'] = filePath;
|
||||
} else {
|
||||
input['path'] = filePath;
|
||||
}
|
||||
return _invokeMap('runPostProcessingV2', {
|
||||
final result = await _invokeMap('runPostProcessingV2', {
|
||||
'input': jsonEncode(input),
|
||||
'metadata': metadata != null ? jsonEncode(metadata) : '',
|
||||
});
|
||||
if (result['success'] != true) return result;
|
||||
|
||||
// The Go/Rust result uses new_file_path; the queue consumes file_path.
|
||||
// SAF publishes back to its document URI after processing a temporary file.
|
||||
final uri = result['new_file_uri'];
|
||||
final path = result['new_file_path'];
|
||||
final legacyPath = result['file_path'];
|
||||
if (filePath.startsWith('content://') && uri is String && uri.isNotEmpty) {
|
||||
result['file_path'] = uri;
|
||||
} else if (path is String && path.isNotEmpty) {
|
||||
result['file_path'] = path;
|
||||
} else if (legacyPath is! String || legacyPath.isEmpty) {
|
||||
result['file_path'] = filePath;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static Future<void> initExtensionRepo(String cacheDir) async {
|
||||
|
||||
@@ -50,6 +50,7 @@ Future<Database> openAppDatabase(
|
||||
required Future<void> Function(Database db, int version) onCreate,
|
||||
required Future<void> Function(Database db, int oldVersion, int newVersion)
|
||||
onUpgrade,
|
||||
Future<void> Function(Database db)? onOpen,
|
||||
bool foreignKeys = false,
|
||||
bool incrementalAutoVacuum = true,
|
||||
}) async {
|
||||
@@ -94,6 +95,7 @@ Future<Database> openAppDatabase(
|
||||
},
|
||||
onCreate: onCreate,
|
||||
onUpgrade: onUpgrade,
|
||||
onOpen: onOpen,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user