mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 12:52:13 +02:00
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.
164 lines
5.3 KiB
Dart
164 lines
5.3 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
const _extensionMasterKeyName = 'extension_storage_master_key_v2';
|
|
|
|
class ExtensionStoragePaths {
|
|
const ExtensionStoragePaths({
|
|
required this.extensionsDir,
|
|
required this.dataDir,
|
|
required this.masterKey,
|
|
});
|
|
|
|
final String extensionsDir;
|
|
final String dataDir;
|
|
final String masterKey;
|
|
}
|
|
|
|
class ExtensionStorageService {
|
|
ExtensionStorageService._();
|
|
|
|
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage();
|
|
static Future<ExtensionStoragePaths>? _preparing;
|
|
|
|
static Future<ExtensionStoragePaths> 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 {
|
|
final supportDir = await getApplicationSupportDirectory();
|
|
final documentsDir = await getApplicationDocumentsDirectory();
|
|
final extensionsDir = Directory(p.join(supportDir.path, 'extensions'));
|
|
final dataDir = Directory(p.join(supportDir.path, 'extension_data'));
|
|
|
|
await _migrateDirectory(
|
|
Directory(p.join(documentsDir.path, 'extensions')),
|
|
extensionsDir,
|
|
);
|
|
await _migrateDirectory(
|
|
Directory(p.join(documentsDir.path, 'extension_data')),
|
|
dataDir,
|
|
);
|
|
await extensionsDir.create(recursive: true);
|
|
await dataDir.create(recursive: true);
|
|
|
|
return ExtensionStoragePaths(
|
|
extensionsDir: extensionsDir.path,
|
|
dataDir: dataDir.path,
|
|
masterKey: await _loadOrCreateMasterKey(),
|
|
);
|
|
}
|
|
|
|
static Future<String> _loadOrCreateMasterKey() async {
|
|
final existing = await _secureStorage.read(key: _extensionMasterKeyName);
|
|
if (existing != null) {
|
|
try {
|
|
if (base64Decode(existing).length == 32) return existing;
|
|
} on FormatException {
|
|
// Replace malformed legacy data with a fresh keystore-backed key.
|
|
}
|
|
}
|
|
|
|
final random = Random.secure();
|
|
final key = List<int>.generate(32, (_) => random.nextInt(256));
|
|
final encoded = base64Encode(key);
|
|
await _secureStorage.write(key: _extensionMasterKeyName, value: encoded);
|
|
return encoded;
|
|
}
|
|
|
|
static Future<void> _migrateDirectory(
|
|
Directory source,
|
|
Directory destination,
|
|
) async {
|
|
if (p.equals(source.path, destination.path)) {
|
|
return;
|
|
}
|
|
final sourceType = await FileSystemEntity.type(
|
|
source.path,
|
|
followLinks: false,
|
|
);
|
|
if (sourceType == FileSystemEntityType.notFound) return;
|
|
if (sourceType == FileSystemEntityType.link) {
|
|
await Link(source.path).delete();
|
|
return;
|
|
}
|
|
if (sourceType != FileSystemEntityType.directory) {
|
|
throw FileSystemException(
|
|
'Extension storage path is not a directory',
|
|
source.path,
|
|
);
|
|
}
|
|
if (await FileSystemEntity.type(destination.path, followLinks: false) ==
|
|
FileSystemEntityType.link) {
|
|
throw FileSystemException(
|
|
'Refusing extension storage symlink',
|
|
destination.path,
|
|
);
|
|
}
|
|
|
|
await destination.create(recursive: true);
|
|
await for (final entity in source.list(followLinks: false)) {
|
|
final targetPath = p.join(destination.path, p.basename(entity.path));
|
|
if (entity is Directory) {
|
|
await _migrateDirectory(entity, Directory(targetPath));
|
|
} else if (entity is File) {
|
|
final target = File(targetPath);
|
|
await target.parent.create(recursive: true);
|
|
if (await target.exists()) {
|
|
final sourceModified = await entity.lastModified();
|
|
final targetModified = await target.lastModified();
|
|
if (!sourceModified.isAfter(targetModified)) {
|
|
await entity.delete();
|
|
continue;
|
|
}
|
|
}
|
|
await _copyFileAtomically(entity, target);
|
|
await entity.delete();
|
|
} else if (entity is Link) {
|
|
// Extension storage must never preserve links into another sandbox.
|
|
await entity.delete();
|
|
}
|
|
}
|
|
if (await source.exists() && await source.list().isEmpty) {
|
|
await source.delete();
|
|
}
|
|
}
|
|
|
|
static Future<void> _copyFileAtomically(File source, File target) async {
|
|
final nonce = Random.secure().nextInt(0x7fffffff).toRadixString(16);
|
|
final temporary = File('${target.path}.migration-$nonce.tmp');
|
|
try {
|
|
await source.openRead().pipe(temporary.openWrite());
|
|
final sourceLength = await source.length();
|
|
if (await temporary.length() != sourceLength) {
|
|
throw const FileSystemException('Incomplete extension storage copy');
|
|
}
|
|
try {
|
|
await temporary.rename(target.path);
|
|
} on FileSystemException {
|
|
// Windows cannot atomically replace an existing file. Mobile platforms
|
|
// take the first branch; this fallback keeps development migrations
|
|
// functional while retaining the source until replacement succeeds.
|
|
if (!await target.exists()) rethrow;
|
|
await target.delete();
|
|
await temporary.rename(target.path);
|
|
}
|
|
} finally {
|
|
if (await temporary.exists()) await temporary.delete();
|
|
}
|
|
}
|
|
}
|