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:
zarzet committed 2026-09-14 22:58:27 +07:00
1 parent 1da0da18a6
commit aa99726439
452 files changed
+64417 -65088

No files matched your search

+5 -2
View File
@@ -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 (_) {}
}
+9 -1
View File
@@ -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 {
+1 -1
View File
@@ -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 -23
View File
@@ -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(
+52
View File
@@ -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;
+116 -24
View File
@@ -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 {
+2
View File
@@ -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,
);
}