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
2026-09-14 22:58:27 +07:00
parent 1da0da18a6
commit aa99726439
452 changed files with 64417 additions and 65088 deletions
+528
View File
@@ -0,0 +1,528 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/repo_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/extension_storage_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
class _DelayedSettingsNotifier extends SettingsNotifier {
final _ready = Completer<void>();
int _loads = 0;
@override
AppSettings build() => const AppSettings();
@override
Future<void> ensureLoaded() async {
_loads++;
await _ready.future;
}
void complete() {
if (!_ready.isCompleted) _ready.complete();
}
}
class _RetryingSettingsNotifier extends SettingsNotifier {
int _syncAttempts = 0;
@override
AppSettings build() => const AppSettings();
@override
Future<void> syncLyricsSettingsToBackend({AppSettings? settings}) async {
_syncAttempts++;
if (_syncAttempts == 1) {
throw StateError('Settings load failed');
}
}
}
class _FailingSettingsSyncNotifier extends SettingsNotifier {
int _syncs = 0;
@override
Future<void> syncLyricsSettingsToBackend({AppSettings? settings}) async {
if (++_syncs == 1) throw StateError('Settings sync unavailable');
await super.syncLyricsSettingsToBackend(settings: settings);
}
}
class _DelayedExtensionNotifier extends ExtensionNotifier {
final _started = Completer<void>();
final _ready = Completer<void>();
int _initializations = 0;
bool _failInitialization = false;
@override
Future<void> initialize(
String extensionsDir,
String dataDir, {
required String masterKey,
}) async {
_initializations++;
expect(await Directory(extensionsDir).exists(), isTrue);
expect(await Directory(dataDir).exists(), isTrue);
expect(base64Decode(masterKey), hasLength(32));
if (!_started.isCompleted) _started.complete();
await _ready.future;
state = state.copyWith(
isInitialized: !_failInitialization,
error: _failInitialization ? 'Native initialization failed' : null,
);
}
@override
Future<void> refreshExtensions() async {}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const paths = MethodChannel('plugins.flutter.io/path_provider');
const secure = MethodChannel('plugins.it_nomads.com/flutter_secure_storage');
const backend = MethodChannel('com.zarz.spotiflac/backend');
late Directory root;
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
setUpAll(() async {
root = await Directory.systemTemp.createTemp('extension-initialization-');
messenger.setMockMethodCallHandler(paths, (call) async {
return call.method == 'getApplicationSupportDirectory'
? '${root.path}/support'
: '${root.path}/documents';
});
messenger.setMockMethodCallHandler(secure, (call) async {
if (call.method == 'read') return base64Encode(List.filled(32, 1));
return null;
});
});
tearDownAll(() async {
messenger.setMockMethodCallHandler(paths, null);
messenger.setMockMethodCallHandler(secure, null);
messenger.setMockMethodCallHandler(backend, null);
await root.delete(recursive: true);
});
test(
'storage preparation retries a transient failure and coalesces callers',
() async {
var supportReads = 0;
messenger.setMockMethodCallHandler(paths, (call) async {
if (call.method == 'getApplicationSupportDirectory') {
supportReads++;
if (supportReads == 1) {
throw PlatformException(code: 'temporarily_unavailable');
}
return '${root.path}/support';
}
return '${root.path}/documents';
});
await expectLater(
ExtensionStorageService.prepare(),
throwsA(isA<PlatformException>()),
);
final first = ExtensionStorageService.prepare();
final second = ExtensionStorageService.prepare();
expect(identical(first, second), isTrue);
final storage = await first;
expect(supportReads, 2);
expect(storage.extensionsDir, '${root.path}/support/extensions');
expect(storage.dataDir, '${root.path}/support/extension_data');
},
);
test(
'delayed settings load gates startup, install, upgrade, and repository init',
() async {
SharedPreferences.setMockInitialValues({});
final settings = _DelayedSettingsNotifier();
final notifier = _DelayedExtensionNotifier();
final container = ProviderContainer(
overrides: [
extensionProvider.overrideWith(() => notifier),
settingsProvider.overrideWith(() => settings),
],
);
addTearDown(container.dispose);
container.read(extensionProvider);
final calls = <String>[];
messenger.setMockMethodCallHandler(backend, (call) async {
calls.add(call.method);
return {
'name': 'example-extension',
'display_name': 'Example',
'version': '1.0.0',
};
});
final startup = notifier.ensureInitialized();
final install = notifier.installExtension(
'/cache/example-extension.sflx',
);
final upgrade = notifier.upgradeExtension(
'/cache/example-extension.sflx',
);
final repository = container
.read(repoProvider.notifier)
.initialize('/cache/store');
await Future<void>.delayed(Duration.zero);
expect(settings._loads, 1);
expect(notifier._initializations, 0);
expect(notifier._started.isCompleted, isFalse);
expect(calls, isEmpty);
settings.complete();
await notifier._started.future;
expect(notifier._initializations, 1);
expect(calls, isEmpty);
notifier._ready.complete();
await startup;
expect(await install, isTrue);
expect(await upgrade, isTrue);
await repository;
expect(container.read(repoProvider).isInitialized, isTrue);
expect(settings._loads, 1);
expect(notifier._initializations, 1);
expect(
calls,
containsAll([
'loadExtensionFromPath',
'upgradeExtension',
'initExtensionRepo',
]),
);
},
);
test('startup, install and upgrade wait for one initialization', () async {
SharedPreferences.setMockInitialValues({});
final notifier = _DelayedExtensionNotifier();
final container = ProviderContainer(
overrides: [extensionProvider.overrideWith(() => notifier)],
);
addTearDown(container.dispose);
container.read(extensionProvider);
final calls = <String>[];
messenger.setMockMethodCallHandler(backend, (call) async {
if (call.method == 'setLoggingEnabled') return null;
expect(container.read(extensionProvider).isInitialized, isTrue);
calls.add(call.method);
return {
'name': 'example-extension',
'display_name': 'Example',
'version': '1.0.0',
};
});
final startup = notifier.ensureInitialized();
await notifier._started.future;
final install = notifier.installExtension('/cache/example-extension.sflx');
final batch = notifier.installExtensions(['/cache/example-b.sflx']);
final check = notifier.checkExtensionUpgrade(
'/cache/example-extension.sflx',
);
final upgrade = notifier.upgradeExtension('/cache/example-extension.sflx');
await Future<void>.delayed(Duration.zero);
expect(calls, isEmpty);
notifier._ready.complete();
await startup;
expect(await install, isTrue);
expect((await batch).installed, 1);
expect((await check)['error'], isNull);
expect(await upgrade, isTrue);
expect(notifier._initializations, 1);
expect(
calls,
containsAll([
'loadExtensionFromPath',
'checkExtensionUpgrade',
'upgradeExtension',
]),
);
});
test(
'failed initialization prevents installation and can be retried',
() async {
SharedPreferences.setMockInitialValues({});
final notifier = _DelayedExtensionNotifier().._failInitialization = true;
notifier._ready.complete();
final container = ProviderContainer(
overrides: [extensionProvider.overrideWith(() => notifier)],
);
addTearDown(container.dispose);
container.read(extensionProvider);
var installs = 0;
messenger.setMockMethodCallHandler(backend, (call) async {
if (call.method == 'setLoggingEnabled') return null;
expect(call.method, 'loadExtensionFromPath');
installs++;
return {'name': 'example-extension'};
});
expect(await notifier.installExtension('/cache/example.sflx'), isFalse);
expect(installs, 0);
expect(
container.read(extensionProvider).error,
contains('Native initialization failed'),
);
notifier._failInitialization = false;
expect(await notifier.installExtension('/cache/example.sflx'), isTrue);
expect(installs, 1);
expect(notifier._initializations, 2);
},
);
test('repository waits for a successful owner and retries failure', () async {
SharedPreferences.setMockInitialValues({});
final notifier = _DelayedExtensionNotifier().._failInitialization = true;
notifier._ready.complete();
final container = ProviderContainer(
overrides: [extensionProvider.overrideWith(() => notifier)],
);
addTearDown(container.dispose);
var repositoryInitializations = 0;
messenger.setMockMethodCallHandler(backend, (call) async {
if (call.method == 'setLoggingEnabled') return null;
expect(call.method, 'initExtensionRepo');
expect(container.read(extensionProvider).isInitialized, isTrue);
repositoryInitializations++;
return null;
});
final repository = container.read(repoProvider.notifier);
await repository.initialize('/cache/store');
expect(repositoryInitializations, 0);
expect(container.read(repoProvider).isInitialized, isFalse);
expect(
container.read(repoProvider).error,
contains('Native initialization failed'),
);
notifier._failInitialization = false;
await repository.initialize('/cache/store');
expect(repositoryInitializations, 1);
expect(container.read(repoProvider).isInitialized, isTrue);
expect(container.read(repoProvider).error, isNull);
});
for (final failureMethod in const [
'loadExtensionsFromDir',
'getInstalledExtensions',
]) {
test('extension refresh failure remains visible and can be retried '
'($failureMethod)', () async {
SharedPreferences.setMockInitialValues({});
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(extensionProvider.notifier);
final extensionsDir = Directory('${root.path}/production-$failureMethod');
await extensionsDir.create(recursive: true);
var targetAttempts = 0;
var loadAttempts = 0;
var refreshAttempts = 0;
final expectedError = failureMethod == 'loadExtensionsFromDir'
? 'transient_load'
: 'transient_refresh';
messenger.setMockMethodCallHandler(backend, (call) async {
switch (call.method) {
case 'loadExtensionsFromDir':
loadAttempts++;
if (failureMethod == call.method && ++targetAttempts == 1) {
throw PlatformException(code: 'transient_load');
}
return {'loaded': <String>[], 'errors': <Map<String, dynamic>>[]};
case 'getInstalledExtensions':
refreshAttempts++;
if (failureMethod == call.method && ++targetAttempts == 1) {
throw PlatformException(code: 'transient_refresh');
}
return <Map<String, dynamic>>[];
default:
throw StateError('Unexpected backend method: ${call.method}');
}
});
await notifier.loadExtensions(extensionsDir.path);
expect(notifier.state.isInitialized, isFalse);
expect(notifier.state.error, contains(expectedError));
expect(targetAttempts, 1);
await notifier.loadExtensions(extensionsDir.path);
expect(notifier.state.isInitialized, isFalse);
expect(notifier.state.error, isNull);
expect(loadAttempts, 2);
expect(
refreshAttempts,
failureMethod == 'getInstalledExtensions' ? 2 : 1,
);
expect(targetAttempts, 2);
});
}
test(
'settings load failure prevents initialization and a retry works',
() async {
SharedPreferences.setMockInitialValues({});
final settings = _RetryingSettingsNotifier();
final notifier = _DelayedExtensionNotifier().._ready.complete();
final container = ProviderContainer(
overrides: [
extensionProvider.overrideWith(() => notifier),
settingsProvider.overrideWith(() => settings),
],
);
addTearDown(container.dispose);
container.read(extensionProvider);
var installs = 0;
messenger.setMockMethodCallHandler(backend, (call) async {
if (call.method == 'loadExtensionFromPath') installs++;
return {'name': 'example-extension'};
});
await expectLater(
notifier.ensureInitialized(),
throwsA(
isA<StateError>().having(
(error) => error.message,
'message',
'Settings load failed',
),
),
);
expect(settings._syncAttempts, 1);
expect(notifier._initializations, 0);
expect(container.read(extensionProvider).isInitialized, isFalse);
expect(installs, 0);
expect(
await notifier.installExtension('/cache/example-extension.sflx'),
isTrue,
);
expect(settings._syncAttempts, 2);
expect(notifier._initializations, 1);
expect(installs, 1);
},
);
test(
'SettingsNotifier coalesces load and restores persisted lyrics settings',
() async {
const providers = [
'extension:example.provider.primary',
'extension:example.provider.fallback',
];
const persisted = AppSettings(
lyricsProviders: providers,
lyricsIncludeTranslationNetease: true,
lyricsIncludeRomanizationNetease: true,
lyricsMultiPersonWordByWord: true,
lyricsAppleElrcWordSync: true,
musixmatchLanguage: 'id',
);
SharedPreferences.setMockInitialValues({
'app_settings': jsonEncode(persisted.toJson()),
});
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(settingsProvider.notifier);
final first = notifier.ensureLoaded();
final second = notifier.ensureLoaded();
expect(identical(first, second), isTrue);
await first;
final loaded = container.read(settingsProvider);
expect(loaded.lyricsProviders, providers);
expect(loaded.lyricsIncludeTranslationNetease, isTrue);
expect(loaded.lyricsIncludeRomanizationNetease, isTrue);
expect(loaded.lyricsMultiPersonWordByWord, isTrue);
expect(loaded.lyricsAppleElrcWordSync, isTrue);
expect(loaded.musixmatchLanguage, 'id');
expect(loaded.lyricsFetchOptions, {
'include_translation_netease': true,
'include_romanization_netease': true,
'multi_person_word_by_word': true,
'apple_elrc_word_sync': true,
'musixmatch_language': 'id',
});
},
);
test('real settings loader retries a failed shared future', () async {
SharedPreferences.setMockInitialValues({});
final notifier = _FailingSettingsSyncNotifier();
final container = ProviderContainer(
overrides: [settingsProvider.overrideWith(() => notifier)],
);
addTearDown(container.dispose);
container.read(settingsProvider);
final failed = notifier.ensureLoaded();
expect(identical(failed, notifier.ensureLoaded()), isTrue);
await expectLater(
failed,
throwsA(
isA<StateError>().having(
(error) => error.message,
'message',
'Settings sync unavailable',
),
),
);
final retry = notifier.ensureLoaded();
expect(identical(failed, retry), isFalse);
expect(identical(retry, notifier.ensureLoaded()), isTrue);
await retry;
expect(notifier._syncs, 2);
expect(identical(retry, notifier.ensureLoaded()), isTrue);
});
test(
'init channel encodes nondefault lyrics providers and options',
() async {
late Map<String, dynamic> arguments;
messenger.setMockMethodCallHandler(backend, (call) async {
expect(call.method, 'initExtensionSystem');
arguments = Map<String, dynamic>.from(call.arguments as Map);
return null;
});
const providers = [
'extension:example.provider.primary',
'extension:example.provider.fallback',
];
const options = <String, dynamic>{
'include_translation_netease': true,
'musixmatch_language': 'id',
};
final masterKey = base64Encode(List.filled(32, 7));
await PlatformBridge.initExtensionSystem(
'/tmp/example-extensions',
'/tmp/example-extension-data',
masterKey: masterKey,
lyricsProviders: providers,
lyricsFetchOptions: options,
allowedDirectories: const ['/tmp/example-output'],
);
expect(arguments['extensions_dir'], '/tmp/example-extensions');
expect(arguments['data_dir'], '/tmp/example-extension-data');
expect(arguments['master_key'], masterKey);
expect(arguments['allowed_directories'], ['/tmp/example-output']);
expect(
jsonDecode(arguments['lyrics_providers_json'] as String),
providers,
);
expect(jsonDecode(arguments['lyrics_options_json'] as String), options);
},
);
}
+103
View File
@@ -0,0 +1,103 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/utils/ios_container_paths.dart';
void main() {
group('rebaseIosSandboxPath', () {
const oldContainer =
'/var/mobile/Containers/Data/Application/'
'11111111-1111-4111-8111-111111111111';
const newDocuments =
'/var/mobile/Containers/Data/Application/'
'22222222-2222-4222-8222-222222222222/Documents';
const newContainer =
'/var/mobile/Containers/Data/Application/'
'22222222-2222-4222-8222-222222222222';
test('rebases supported device roots and preserves suffixes', () {
for (final root in const [
'Documents',
'Library/Application Support',
'Library/Caches',
]) {
for (final suffix in const [
'',
'/Music/曲 name #track01.flac',
'/Music/曲 name.flac#track01',
]) {
final oldPath = '$oldContainer/$root$suffix';
expect(
rebaseIosSandboxPath(oldPath, newDocuments),
'$newContainer/$root$suffix',
);
}
}
const privatePath =
'/private/var/mobile/Containers/Data/Application/'
'11111111-1111-4111-8111-111111111111/Documents/曲.flac';
expect(
rebaseIosSandboxPath(privatePath, newDocuments),
'$newContainer/Documents/曲.flac',
);
});
test('rebases simulator containers', () {
const oldPath =
'/Users/test/Library/Developer/CoreSimulator/Devices/'
'33333333-3333-4333-8333-333333333333/data/Containers/Data/'
'Application/55555555-5555-4555-8555-555555555555/'
'Documents/音楽 folder/song #track01.m4a';
const newDocuments =
'/Users/test/Library/Developer/CoreSimulator/Devices/'
'44444444-4444-4444-8444-444444444444/data/Containers/Data/'
'Application/66666666-6666-4666-8666-666666666666/'
'Documents';
final newContainer = newDocuments.substring(
0,
newDocuments.length - '/Documents'.length,
);
expect(
rebaseIosSandboxPath(oldPath, newDocuments),
'$newContainer/Documents/音楽 folder/song #track01.m4a',
);
});
test('is idempotent for the current container', () {
const currentPath =
'$newContainer/Documents/音楽 folder/song #track01.flac';
expect(rebaseIosSandboxPath(currentPath, newDocuments), currentPath);
});
test('leaves roots, traversal, URIs, and external paths unchanged', () {
const unchanged = [
oldContainer,
'$oldContainer/',
'$oldContainer/tmp/song.flac',
'$oldContainer/Documents/../Library/Caches/cover.jpg',
'$oldContainer/Documents-extra/song.flac',
'/outside$oldContainer/Documents/song.flac',
'/tmp/song.flac',
'content://downloads/song.flac',
'file:///var/mobile/Containers/Data/Application/OLD/song.flac',
'/data/user/0/com.example/files/song.flac',
'/private/var/mobile/Library/Mobile Documents/com~apple~CloudDocs/song.flac',
'Documents/song.flac',
];
for (final path in unchanged) {
expect(rebaseIosSandboxPath(path, newDocuments), path);
}
});
test('requires a current iOS Documents directory', () {
const oldPath = '$oldContainer/Documents/song.flac';
for (final documents in const [
'',
'/Documents',
'/data/user/0/com.example/files/Documents',
'$newContainer/Documents/subfolder',
'$newContainer/Library/Application Support',
]) {
expect(rebaseIosSandboxPath(oldPath, documents), oldPath);
}
});
});
}
+1 -1
View File
@@ -27,7 +27,7 @@ void main() {
expect(
formatLogEntries(entries),
'[10:20:30.040] [INFO] [Search] first\n'
'[10:20:31.050] [ERROR] [Go] [Download] second | failed',
'[10:20:31.050] [ERROR] [Native] [Download] second | failed',
);
});
+17 -7
View File
@@ -298,16 +298,23 @@ void main() {
).firstMatch(appDelegateSource);
expect(expirationHandler, isNotNull);
expect(
expirationHandler!
expirationHandler!.group(0)!,
contains('self?.coreBackend.cancelActiveDownloads()'),
);
expect(
expirationHandler
.group(0)!
.indexOf('GobackendCancelAllActiveDownloads()'),
.indexOf('self?.coreBackend.cancelActiveDownloads()'),
lessThan(
expirationHandler.group(0)!.indexOf('iosBackgroundDownloadExpired'),
),
);
expect(
queueProviderSource,
contains('pauseQueue(persistAcrossRestarts: false)'),
RegExp(
r'pauseQueue\(\s*persistAcrossRestarts:\s*false,\s*'
r'nativeCancelledItemIds:\s*cancelledItemIds,\s*\)',
).hasMatch(queueProviderSource),
true,
);
expect(queueProviderSource, contains('_iosBackgroundExecutionExpired'));
expect(queueProviderSource, contains('final requeueItemIds ='));
@@ -366,13 +373,16 @@ void main() {
() {
expect(
RegExp(
r'Gobackend\.waitForAllDownloadProgressDelta\(',
r'coreBackend\.openDownloadProgress\(',
).allMatches(workerSnapshotSource),
hasLength(1),
);
expect(workerSnapshotSource, isNot(contains('Gobackend.')));
expect(
workerSnapshotSource,
isNot(contains('Gobackend.getAllDownloadProgress()')),
RegExp(
r'\.waitDelta\(sinceSeq, 5_000L\)',
).allMatches(workerSnapshotSource),
hasLength(1),
);
expect(workerSnapshotSource, contains('"item_deltas"'));
expect(
@@ -0,0 +1,93 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('com.zarz.spotiflac/backend');
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
test(
'native replacement is exposed to the download queue as file_path',
() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
expect(call.method, 'runPostProcessingV2');
expect(call.arguments, {
'input': jsonEncode({
'item_id': 'queue-item',
'path': '/music/original.flac',
}),
'metadata': jsonEncode({'title': 'Example'}),
});
return jsonEncode({
'success': true,
'new_file_path': '/music/converted.m4a',
});
});
final result = await PlatformBridge.runPostProcessingV2(
'/music/original.flac',
metadata: {'title': 'Example'},
itemId: 'queue-item',
);
expect(result['file_path'], '/music/converted.m4a');
expect(result['new_file_path'], '/music/converted.m4a');
},
);
test(
'SAF keeps the destination URI instead of exposing a staging path',
() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
expect(call.arguments, {
'input': jsonEncode({'uri': 'content://example/document/track'}),
'metadata': '',
});
return jsonEncode({
'success': true,
'new_file_path': '/cache/processed.flac',
'new_file_uri': 'content://example/document/track',
});
});
final result = await PlatformBridge.runPostProcessingV2(
'content://example/document/track',
);
expect(result['file_path'], 'content://example/document/track');
},
);
test(
'legacy results, no-op hooks and failures retain their contracts',
() async {
for (final response in <Map<String, dynamic>>[
{'success': true, 'file_path': '/music/legacy.flac'},
{'success': true, 'new_file_path': ''},
{'success': false, 'error': 'processing failed'},
]) {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
channel,
(_) async => jsonEncode(response),
);
final result = await PlatformBridge.runPostProcessingV2(
'/music/original.flac',
);
if (response['success'] == true) {
expect(
result['file_path'],
response['file_path'] ?? '/music/original.flac',
);
} else {
expect(result, response);
}
}
},
);
}