mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00
feat(metadata): add plugin update checker and dialog for available updates
This commit is contained in:
parent
c9556c2ecb
commit
7309e900bc
@ -32,6 +32,7 @@ import 'package:spotube/provider/audio_player/audio_player_streams.dart';
|
|||||||
import 'package:spotube/provider/database/database.dart';
|
import 'package:spotube/provider/database/database.dart';
|
||||||
import 'package:spotube/provider/glance/glance.dart';
|
import 'package:spotube/provider/glance/glance.dart';
|
||||||
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
|
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
|
||||||
|
import 'package:spotube/provider/metadata_plugin/updater/update_checker.dart';
|
||||||
import 'package:spotube/provider/server/bonsoir.dart';
|
import 'package:spotube/provider/server/bonsoir.dart';
|
||||||
import 'package:spotube/provider/server/server.dart';
|
import 'package:spotube/provider/server/server.dart';
|
||||||
import 'package:spotube/provider/tray_manager/tray_manager.dart';
|
import 'package:spotube/provider/tray_manager/tray_manager.dart';
|
||||||
@ -153,6 +154,7 @@ class Spotube extends HookConsumerWidget {
|
|||||||
ref.listen(metadataPluginProvider, (_, __) {});
|
ref.listen(metadataPluginProvider, (_, __) {});
|
||||||
ref.listen(serverProvider, (_, __) {});
|
ref.listen(serverProvider, (_, __) {});
|
||||||
ref.listen(trayManagerProvider, (_, __) {});
|
ref.listen(trayManagerProvider, (_, __) {});
|
||||||
|
ref.listen(metadataPluginUpdateCheckerProvider, (_, __) {});
|
||||||
|
|
||||||
useFixWindowStretching();
|
useFixWindowStretching();
|
||||||
useDisableBatteryOptimizations();
|
useDisableBatteryOptimizations();
|
||||||
|
@ -3,8 +3,10 @@ import 'package:shadcn_flutter/shadcn_flutter.dart';
|
|||||||
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
|
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/models/metadata/metadata.dart';
|
import 'package:spotube/models/metadata/metadata.dart';
|
||||||
|
import 'package:spotube/modules/metadata_plugins/plugin_update_available_dialog.dart';
|
||||||
import 'package:spotube/provider/metadata_plugin/core/auth.dart';
|
import 'package:spotube/provider/metadata_plugin/core/auth.dart';
|
||||||
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
|
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
|
||||||
|
import 'package:spotube/provider/metadata_plugin/updater/update_checker.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class MetadataInstalledPluginItem extends HookConsumerWidget {
|
class MetadataInstalledPluginItem extends HookConsumerWidget {
|
||||||
@ -23,6 +25,9 @@ class MetadataInstalledPluginItem extends HookConsumerWidget {
|
|||||||
final pluginsNotifier = ref.watch(metadataPluginsProvider.notifier);
|
final pluginsNotifier = ref.watch(metadataPluginsProvider.notifier);
|
||||||
final requiresAuth =
|
final requiresAuth =
|
||||||
isDefault && plugin.abilities.contains(PluginAbilities.authentication);
|
isDefault && plugin.abilities.contains(PluginAbilities.authentication);
|
||||||
|
final updateAvailable =
|
||||||
|
isDefault ? ref.watch(metadataPluginUpdateCheckerProvider) : null;
|
||||||
|
final hasUpdate = isDefault && updateAvailable?.asData?.value != null;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -105,21 +110,50 @@ class MetadataInstalledPluginItem extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (plugin.abilities.contains(PluginAbilities.authentication) &&
|
if (requiresAuth || hasUpdate)
|
||||||
isDefault)
|
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: context.theme.colorScheme.secondary,
|
color: context.theme.colorScheme.secondary,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: const Row(
|
child: Column(
|
||||||
|
spacing: 12,
|
||||||
|
children: [
|
||||||
|
if (requiresAuth)
|
||||||
|
const Row(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
Icon(SpotubeIcons.warning, color: Colors.yellow),
|
Icon(SpotubeIcons.warning, color: Colors.yellow),
|
||||||
Text("Plugin requires authentication"),
|
Text("Plugin requires authentication"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (hasUpdate)
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: Basic(
|
||||||
|
leading: const Icon(SpotubeIcons.update),
|
||||||
|
title: const Text("Update available"),
|
||||||
|
subtitle: Text(
|
||||||
|
updateAvailable!.asData!.value!.version,
|
||||||
|
),
|
||||||
|
trailing: Button.primary(
|
||||||
|
onPressed: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) =>
|
||||||
|
MetadataPluginUpdateAvailableDialog(
|
||||||
|
plugin: plugin,
|
||||||
|
update: updateAvailable.asData!.value!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text("Update"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
@ -0,0 +1,99 @@
|
|||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||||
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
|
import 'package:spotube/models/metadata/metadata.dart';
|
||||||
|
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
|
class MetadataPluginUpdateAvailableDialog extends HookConsumerWidget {
|
||||||
|
final PluginConfiguration plugin;
|
||||||
|
final PluginUpdateAvailable update;
|
||||||
|
const MetadataPluginUpdateAvailableDialog({
|
||||||
|
super.key,
|
||||||
|
required this.plugin,
|
||||||
|
required this.update,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, ref) {
|
||||||
|
final isUpdating = useState(false);
|
||||||
|
|
||||||
|
final showErrorSnackbar = useCallback(
|
||||||
|
(BuildContext context, String message) {
|
||||||
|
showToast(
|
||||||
|
context: context,
|
||||||
|
builder: (context, overlay) {
|
||||||
|
return SurfaceCard(
|
||||||
|
child: Basic(
|
||||||
|
leading: const Icon(SpotubeIcons.error, color: Colors.red),
|
||||||
|
title: Text(message),
|
||||||
|
leadingAlignment: Alignment.center,
|
||||||
|
trailing: IconButton.ghost(
|
||||||
|
size: ButtonSize.small,
|
||||||
|
icon: const Icon(SpotubeIcons.close),
|
||||||
|
onPressed: () {
|
||||||
|
overlay.close();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Plugin update available'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
Text('${plugin.name} (${update.version}) available.'),
|
||||||
|
if (update.changelog != null && update.changelog!.isNotEmpty)
|
||||||
|
MarkdownBody(
|
||||||
|
data: '### Changelog: \n\n${update.changelog}',
|
||||||
|
onTapLink: (text, href, title) {
|
||||||
|
if (href != null) {
|
||||||
|
launchUrlString(href);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
SecondaryButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: const Text('Dismiss'),
|
||||||
|
),
|
||||||
|
PrimaryButton(
|
||||||
|
enabled: !isUpdating.value,
|
||||||
|
onPressed: () async {
|
||||||
|
isUpdating.value = true;
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(metadataPluginsProvider.notifier)
|
||||||
|
.updatePlugin(plugin, update);
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
showErrorSnackbar(context, e.toString());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (context.mounted) {
|
||||||
|
isUpdating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Update'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -5,6 +5,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
import 'package:spotube/modules/metadata_plugins/plugin_update_available_dialog.dart';
|
||||||
|
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
|
||||||
|
import 'package:spotube/provider/metadata_plugin/updater/update_checker.dart';
|
||||||
import 'package:spotube/provider/server/routes/connect.dart';
|
import 'package:spotube/provider/server/routes/connect.dart';
|
||||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||||
import 'package:spotube/services/connectivity_adapter.dart';
|
import 'package:spotube/services/connectivity_adapter.dart';
|
||||||
@ -18,6 +21,22 @@ void useGlobalSubscriptions(WidgetRef ref) {
|
|||||||
useEffect(() {
|
useEffect(() {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
ServiceUtils.checkForUpdates(context, ref);
|
ServiceUtils.checkForUpdates(context, ref);
|
||||||
|
|
||||||
|
final pluginUpdate =
|
||||||
|
await ref.read(metadataPluginUpdateCheckerProvider.future);
|
||||||
|
|
||||||
|
if (pluginUpdate != null) {
|
||||||
|
final pluginConfig = await ref.read(metadataPluginsProvider.future);
|
||||||
|
if (context.mounted) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => MetadataPluginUpdateAvailableDialog(
|
||||||
|
plugin: pluginConfig.defaultPluginConfig!,
|
||||||
|
update: pluginUpdate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
StreamSubscription? audioPlayerSubscription;
|
StreamSubscription? audioPlayerSubscription;
|
||||||
|
@ -135,9 +135,11 @@ class AudioPlayerStreamListeners {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final nextTrack = audioPlayerState.tracks
|
final nextTrack = audioPlayerState.tracks
|
||||||
.elementAt(audioPlayerState.currentIndex + 1);
|
.elementAtOrNull(audioPlayerState.currentIndex + 1);
|
||||||
|
|
||||||
if (lastTrack == nextTrack.id || nextTrack is SpotubeLocalTrackObject) {
|
if (nextTrack == null ||
|
||||||
|
lastTrack == nextTrack.id ||
|
||||||
|
nextTrack is SpotubeLocalTrackObject) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,9 +6,10 @@ import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
|
|||||||
final metadataPluginUserProvider = FutureProvider<SpotubeUserObject?>(
|
final metadataPluginUserProvider = FutureProvider<SpotubeUserObject?>(
|
||||||
(ref) async {
|
(ref) async {
|
||||||
final metadataPlugin = await ref.watch(metadataPluginProvider.future);
|
final metadataPlugin = await ref.watch(metadataPluginProvider.future);
|
||||||
ref.watch(metadataPluginAuthenticatedProvider);
|
final authenticated =
|
||||||
|
await ref.watch(metadataPluginAuthenticatedProvider.future);
|
||||||
|
|
||||||
if (metadataPlugin == null) {
|
if (!authenticated || metadataPlugin == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return metadataPlugin.user.me();
|
return metadataPlugin.user.me();
|
||||||
|
@ -11,10 +11,13 @@ import 'package:spotube/models/database/database.dart';
|
|||||||
import 'package:spotube/models/metadata/metadata.dart';
|
import 'package:spotube/models/metadata/metadata.dart';
|
||||||
import 'package:spotube/provider/database/database.dart';
|
import 'package:spotube/provider/database/database.dart';
|
||||||
import 'package:spotube/services/dio/dio.dart';
|
import 'package:spotube/services/dio/dio.dart';
|
||||||
|
import 'package:spotube/services/logger/logger.dart';
|
||||||
|
import 'package:spotube/services/metadata/errors/exceptions.dart';
|
||||||
import 'package:spotube/services/metadata/metadata.dart';
|
import 'package:spotube/services/metadata/metadata.dart';
|
||||||
import 'package:spotube/utils/service_utils.dart';
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import 'package:archive/archive.dart';
|
import 'package:archive/archive.dart';
|
||||||
|
import 'package:pub_semver/pub_semver.dart';
|
||||||
|
|
||||||
final allowedDomainsRegex = RegExp(
|
final allowedDomainsRegex = RegExp(
|
||||||
r"^(https?:\/\/)?(www\.)?(github\.com|codeberg\.org)\/.+",
|
r"^(https?:\/\/)?(www\.)?(github\.com|codeberg\.org)\/.+",
|
||||||
@ -166,43 +169,56 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<String> _getPluginDownloadUrl(Uri uri) async {
|
Future<String> _getPluginDownloadUrl(Uri uri) async {
|
||||||
print("Getting plugin download URL from: $uri");
|
AppLogger.log.i("Getting plugin download URL from: $uri");
|
||||||
final res = await globalDio.getUri(
|
final res = await globalDio.getUri(
|
||||||
uri,
|
uri,
|
||||||
options: Options(responseType: ResponseType.json),
|
options: Options(responseType: ResponseType.json),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (res.statusCode != 200) {
|
if (res.statusCode != 200) {
|
||||||
throw Exception("Failed to get releases");
|
throw MetadataPluginException.failedToGetRelease();
|
||||||
}
|
}
|
||||||
final releases = res.data as List;
|
final releases = res.data as List;
|
||||||
if (releases.isEmpty) {
|
if (releases.isEmpty) {
|
||||||
throw Exception("No releases found");
|
throw MetadataPluginException.noReleasesFound();
|
||||||
}
|
}
|
||||||
final latestRelease = releases.first;
|
final latestRelease = releases.first;
|
||||||
final downloadUrl = (latestRelease["assets"] as List).firstWhere(
|
final downloadUrl = (latestRelease["assets"] as List).firstWhere(
|
||||||
(asset) => (asset["name"] as String).endsWith(".smplug"),
|
(asset) => (asset["name"] as String).endsWith(".smplug"),
|
||||||
)["browser_download_url"];
|
)["browser_download_url"];
|
||||||
if (downloadUrl == null) {
|
if (downloadUrl == null) {
|
||||||
throw Exception("No download URL found");
|
throw MetadataPluginException.assetUrlNotFound();
|
||||||
}
|
}
|
||||||
return downloadUrl;
|
return downloadUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Directory> _getPluginDir() async => Directory(
|
/// Root directory where all metadata plugins are stored.
|
||||||
|
Future<Directory> _getPluginRootDir() async => Directory(
|
||||||
join(
|
join(
|
||||||
(await getApplicationCacheDirectory()).path,
|
(await getApplicationCacheDirectory()).path,
|
||||||
"metadata-plugins",
|
"metadata-plugins",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Directory where the plugin will be extracted.
|
||||||
|
/// This is a unique directory for each plugin version.
|
||||||
|
/// It is used to avoid conflicts when multiple versions of the same plugin are installed
|
||||||
|
Future<Directory> _getPluginExtractionDir(PluginConfiguration plugin) async {
|
||||||
|
final pluginDir = await _getPluginRootDir();
|
||||||
|
final pluginExtractionDirPath = join(
|
||||||
|
pluginDir.path,
|
||||||
|
"${ServiceUtils.sanitizeFilename(plugin.name)}-${plugin.version}",
|
||||||
|
);
|
||||||
|
return Directory(pluginExtractionDirPath);
|
||||||
|
}
|
||||||
|
|
||||||
Future<PluginConfiguration> extractPluginArchive(List<int> bytes) async {
|
Future<PluginConfiguration> extractPluginArchive(List<int> bytes) async {
|
||||||
final archive = ZipDecoder().decodeBytes(bytes);
|
final archive = ZipDecoder().decodeBytes(bytes);
|
||||||
final pluginJson = archive
|
final pluginJson = archive
|
||||||
.firstWhereOrNull((file) => file.isFile && file.name == "plugin.json");
|
.firstWhereOrNull((file) => file.isFile && file.name == "plugin.json");
|
||||||
|
|
||||||
if (pluginJson == null) {
|
if (pluginJson == null) {
|
||||||
throw Exception("No plugin.json found");
|
throw MetadataPluginException.pluginConfigJsonNotFound();
|
||||||
}
|
}
|
||||||
final pluginConfig = PluginConfiguration.fromJson(
|
final pluginConfig = PluginConfiguration.fromJson(
|
||||||
jsonDecode(
|
jsonDecode(
|
||||||
@ -210,20 +226,17 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
|
|||||||
) as Map<String, dynamic>,
|
) as Map<String, dynamic>,
|
||||||
);
|
);
|
||||||
|
|
||||||
final pluginDir = await _getPluginDir();
|
final pluginDir = await _getPluginRootDir();
|
||||||
await pluginDir.create(recursive: true);
|
await pluginDir.create(recursive: true);
|
||||||
|
|
||||||
final pluginExtractionDirPath = join(
|
final pluginExtractionDir = await _getPluginExtractionDir(pluginConfig);
|
||||||
pluginDir.path,
|
|
||||||
ServiceUtils.sanitizeFilename(pluginConfig.name),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (final file in archive) {
|
for (final file in archive) {
|
||||||
if (file.isFile) {
|
if (file.isFile) {
|
||||||
final filename = file.name;
|
final filename = file.name;
|
||||||
final data = file.content as List<int>;
|
final data = file.content as List<int>;
|
||||||
final extractedFile = File(join(
|
final extractedFile = File(join(
|
||||||
pluginExtractionDirPath,
|
pluginExtractionDir.path,
|
||||||
filename,
|
filename,
|
||||||
));
|
));
|
||||||
await extractedFile.create(recursive: true);
|
await extractedFile.create(recursive: true);
|
||||||
@ -251,12 +264,12 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
|
|||||||
final uri = _getCodebergeReleasesUrl(url);
|
final uri = _getCodebergeReleasesUrl(url);
|
||||||
pluginDownloadUrl = await _getPluginDownloadUrl(uri);
|
pluginDownloadUrl = await _getPluginDownloadUrl(uri);
|
||||||
} else {
|
} else {
|
||||||
throw Exception("Unsupported website");
|
throw MetadataPluginException.unsupportedPluginDownloadWebsite();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now let's download, extract and cache the plugin
|
// Now let's download, extract and cache the plugin
|
||||||
final pluginDir = await _getPluginDir();
|
final pluginDir = await _getPluginRootDir();
|
||||||
await pluginDir.create(recursive: true);
|
await pluginDir.create(recursive: true);
|
||||||
|
|
||||||
final tempPluginName = "${const Uuid().v4()}.smplug";
|
final tempPluginName = "${const Uuid().v4()}.smplug";
|
||||||
@ -273,13 +286,32 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if ((pluginRes.statusCode ?? 500) > 299) {
|
if ((pluginRes.statusCode ?? 500) > 299) {
|
||||||
throw Exception("Failed to download plugin");
|
throw MetadataPluginException.pluginDownloadFailed();
|
||||||
}
|
}
|
||||||
|
|
||||||
return await extractPluginArchive(await pluginFile.readAsBytes());
|
return await extractPluginArchive(await pluginFile.readAsBytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool validatePluginApiCompatibility(PluginConfiguration plugin) {
|
||||||
|
final configPluginApiVersion = Version.parse(plugin.pluginApiVersion);
|
||||||
|
final appPluginApiVersion = MetadataPlugin.pluginApiVersion;
|
||||||
|
|
||||||
|
// Plugin API's major version must match the app's major version
|
||||||
|
if (configPluginApiVersion.major != appPluginApiVersion.major) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return configPluginApiVersion >= appPluginApiVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _assertPluginApiCompatibility(PluginConfiguration plugin) {
|
||||||
|
if (!validatePluginApiCompatibility(plugin)) {
|
||||||
|
throw MetadataPluginException.pluginApiVersionMismatch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> addPlugin(PluginConfiguration plugin) async {
|
Future<void> addPlugin(PluginConfiguration plugin) async {
|
||||||
|
_assertPluginApiCompatibility(plugin);
|
||||||
|
|
||||||
final pluginRes = await (database.metadataPluginsTable.select()
|
final pluginRes = await (database.metadataPluginsTable.select()
|
||||||
..where(
|
..where(
|
||||||
(tbl) => tbl.name.equals(plugin.name),
|
(tbl) => tbl.name.equals(plugin.name),
|
||||||
@ -288,7 +320,7 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (pluginRes.isNotEmpty) {
|
if (pluginRes.isNotEmpty) {
|
||||||
throw Exception("Plugin already exists");
|
throw MetadataPluginException.duplicatePlugin();
|
||||||
}
|
}
|
||||||
|
|
||||||
await database.metadataPluginsTable.insertOne(
|
await database.metadataPluginsTable.insertOne(
|
||||||
@ -307,12 +339,8 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> removePlugin(PluginConfiguration plugin) async {
|
Future<void> removePlugin(PluginConfiguration plugin) async {
|
||||||
final pluginDir = await _getPluginDir();
|
final pluginExtractionDir = await _getPluginExtractionDir(plugin);
|
||||||
final pluginExtractionDirPath = join(
|
|
||||||
pluginDir.path,
|
|
||||||
ServiceUtils.sanitizeFilename(plugin.name),
|
|
||||||
);
|
|
||||||
final pluginExtractionDir = Directory(pluginExtractionDirPath);
|
|
||||||
if (pluginExtractionDir.existsSync()) {
|
if (pluginExtractionDir.existsSync()) {
|
||||||
await pluginExtractionDir.delete(recursive: true);
|
await pluginExtractionDir.delete(recursive: true);
|
||||||
}
|
}
|
||||||
@ -320,6 +348,27 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
|
|||||||
.deleteWhere((tbl) => tbl.name.equals(plugin.name));
|
.deleteWhere((tbl) => tbl.name.equals(plugin.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updatePlugin(
|
||||||
|
PluginConfiguration plugin,
|
||||||
|
PluginUpdateAvailable update,
|
||||||
|
) async {
|
||||||
|
final isDefault = plugin == state.valueOrNull?.defaultPluginConfig;
|
||||||
|
final pluginUpdatedConfig =
|
||||||
|
await downloadAndCachePlugin(update.downloadUrl);
|
||||||
|
|
||||||
|
if (pluginUpdatedConfig.name != plugin.name) {
|
||||||
|
throw MetadataPluginException.invalidPluginConfiguration();
|
||||||
|
}
|
||||||
|
_assertPluginApiCompatibility(pluginUpdatedConfig);
|
||||||
|
|
||||||
|
await removePlugin(plugin);
|
||||||
|
await addPlugin(pluginUpdatedConfig);
|
||||||
|
|
||||||
|
if (isDefault) {
|
||||||
|
await setDefaultPlugin(pluginUpdatedConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setDefaultPlugin(PluginConfiguration plugin) async {
|
Future<void> setDefaultPlugin(PluginConfiguration plugin) async {
|
||||||
await (database.metadataPluginsTable.update()
|
await (database.metadataPluginsTable.update()
|
||||||
..where((tbl) => tbl.name.equals(plugin.name)))
|
..where((tbl) => tbl.name.equals(plugin.name)))
|
||||||
@ -329,29 +378,21 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Uint8List> getPluginByteCode(PluginConfiguration plugin) async {
|
Future<Uint8List> getPluginByteCode(PluginConfiguration plugin) async {
|
||||||
final pluginDir = await _getPluginDir();
|
final pluginExtractionDirPath = await _getPluginExtractionDir(plugin);
|
||||||
final pluginExtractionDirPath = join(
|
|
||||||
pluginDir.path,
|
|
||||||
ServiceUtils.sanitizeFilename(plugin.name),
|
|
||||||
);
|
|
||||||
|
|
||||||
final libraryFile = File(join(pluginExtractionDirPath, "plugin.out"));
|
final libraryFile = File(join(pluginExtractionDirPath.path, "plugin.out"));
|
||||||
|
|
||||||
if (!libraryFile.existsSync()) {
|
if (!libraryFile.existsSync()) {
|
||||||
throw Exception("No plugin.out (Bytecode) file found");
|
throw MetadataPluginException.pluginByteCodeFileNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return await libraryFile.readAsBytes();
|
return await libraryFile.readAsBytes();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<File?> getLogoPath(PluginConfiguration plugin) async {
|
Future<File?> getLogoPath(PluginConfiguration plugin) async {
|
||||||
final pluginDir = await _getPluginDir();
|
final pluginExtractionDirPath = await _getPluginExtractionDir(plugin);
|
||||||
final pluginExtractionDirPath = join(
|
|
||||||
pluginDir.path,
|
|
||||||
ServiceUtils.sanitizeFilename(plugin.name),
|
|
||||||
);
|
|
||||||
|
|
||||||
final logoFile = File(join(pluginExtractionDirPath, "logo.png"));
|
final logoFile = File(join(pluginExtractionDirPath.path, "logo.png"));
|
||||||
|
|
||||||
if (!logoFile.existsSync()) {
|
if (!logoFile.existsSync()) {
|
||||||
return null;
|
return null;
|
||||||
|
17
lib/provider/metadata_plugin/updater/update_checker.dart
Normal file
17
lib/provider/metadata_plugin/updater/update_checker.dart
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:spotube/models/metadata/metadata.dart';
|
||||||
|
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
|
||||||
|
|
||||||
|
final metadataPluginUpdateCheckerProvider =
|
||||||
|
FutureProvider<PluginUpdateAvailable?>((ref) async {
|
||||||
|
final metadataPluginConfigs = await ref.watch(metadataPluginsProvider.future);
|
||||||
|
final metadataPlugin = await ref.watch(metadataPluginProvider.future);
|
||||||
|
|
||||||
|
if (metadataPlugin == null ||
|
||||||
|
metadataPluginConfigs.defaultPluginConfig == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadataPlugin.updater
|
||||||
|
.check(metadataPluginConfigs.defaultPluginConfig!);
|
||||||
|
});
|
73
lib/services/metadata/errors/exceptions.dart
Normal file
73
lib/services/metadata/errors/exceptions.dart
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
enum MetadataPluginErrorCode {
|
||||||
|
pluginApiVersionMismatch,
|
||||||
|
invalidPluginConfiguration,
|
||||||
|
failedToGetReleaseInfo,
|
||||||
|
noReleasesFound,
|
||||||
|
assetUrlNotFound,
|
||||||
|
pluginConfigJsonNotFound,
|
||||||
|
unsupportedPluginDownloadWebsite,
|
||||||
|
pluginDownloadFailed,
|
||||||
|
duplicatePlugin,
|
||||||
|
pluginByteCodeFileNotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
class MetadataPluginException implements Exception {
|
||||||
|
final String message;
|
||||||
|
final MetadataPluginErrorCode errorCode;
|
||||||
|
|
||||||
|
MetadataPluginException._(this.message, {required this.errorCode});
|
||||||
|
MetadataPluginException.pluginApiVersionMismatch()
|
||||||
|
: this._(
|
||||||
|
'Plugin API version mismatch',
|
||||||
|
errorCode: MetadataPluginErrorCode.pluginApiVersionMismatch,
|
||||||
|
);
|
||||||
|
MetadataPluginException.invalidPluginConfiguration()
|
||||||
|
: this._(
|
||||||
|
'Invalid plugin configuration',
|
||||||
|
errorCode: MetadataPluginErrorCode.invalidPluginConfiguration,
|
||||||
|
);
|
||||||
|
MetadataPluginException.failedToGetRelease()
|
||||||
|
: this._(
|
||||||
|
'Failed to get release information',
|
||||||
|
errorCode: MetadataPluginErrorCode.failedToGetReleaseInfo,
|
||||||
|
);
|
||||||
|
MetadataPluginException.noReleasesFound()
|
||||||
|
: this._(
|
||||||
|
'No releases found for the plugin',
|
||||||
|
errorCode: MetadataPluginErrorCode.noReleasesFound,
|
||||||
|
);
|
||||||
|
|
||||||
|
MetadataPluginException.assetUrlNotFound()
|
||||||
|
: this._(
|
||||||
|
'No asset URL found for the plugin release',
|
||||||
|
errorCode: MetadataPluginErrorCode.assetUrlNotFound,
|
||||||
|
);
|
||||||
|
MetadataPluginException.pluginConfigJsonNotFound()
|
||||||
|
: this._(
|
||||||
|
'Plugin configuration JSON, plugin.json file not found',
|
||||||
|
errorCode: MetadataPluginErrorCode.pluginConfigJsonNotFound,
|
||||||
|
);
|
||||||
|
MetadataPluginException.unsupportedPluginDownloadWebsite()
|
||||||
|
: this._(
|
||||||
|
'Unsupported plugin download website. Please use GitHub or Codeberg.',
|
||||||
|
errorCode: MetadataPluginErrorCode.unsupportedPluginDownloadWebsite,
|
||||||
|
);
|
||||||
|
MetadataPluginException.pluginDownloadFailed()
|
||||||
|
: this._(
|
||||||
|
'Failed to download the plugin. Please check your internet connection or try again later.',
|
||||||
|
errorCode: MetadataPluginErrorCode.pluginDownloadFailed,
|
||||||
|
);
|
||||||
|
MetadataPluginException.duplicatePlugin()
|
||||||
|
: this._(
|
||||||
|
'Same plugin already exists with the same name and version.',
|
||||||
|
errorCode: MetadataPluginErrorCode.duplicatePlugin,
|
||||||
|
);
|
||||||
|
MetadataPluginException.pluginByteCodeFileNotFound()
|
||||||
|
: this._(
|
||||||
|
'Plugin byte code file, plugin.out not found. Please ensure the plugin is correctly packaged.',
|
||||||
|
errorCode: MetadataPluginErrorCode.pluginByteCodeFileNotFound,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'MetadataPluginException: $message';
|
||||||
|
}
|
@ -5,6 +5,7 @@ import 'package:hetu_otp_util/hetu_otp_util.dart';
|
|||||||
import 'package:hetu_script/hetu_script.dart';
|
import 'package:hetu_script/hetu_script.dart';
|
||||||
import 'package:hetu_spotube_plugin/hetu_spotube_plugin.dart';
|
import 'package:hetu_spotube_plugin/hetu_spotube_plugin.dart';
|
||||||
import 'package:hetu_std/hetu_std.dart';
|
import 'package:hetu_std/hetu_std.dart';
|
||||||
|
import 'package:pub_semver/pub_semver.dart';
|
||||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:spotube/collections/routes.dart';
|
import 'package:spotube/collections/routes.dart';
|
||||||
@ -19,11 +20,14 @@ import 'package:spotube/services/metadata/endpoints/browse.dart';
|
|||||||
import 'package:spotube/services/metadata/endpoints/playlist.dart';
|
import 'package:spotube/services/metadata/endpoints/playlist.dart';
|
||||||
import 'package:spotube/services/metadata/endpoints/search.dart';
|
import 'package:spotube/services/metadata/endpoints/search.dart';
|
||||||
import 'package:spotube/services/metadata/endpoints/track.dart';
|
import 'package:spotube/services/metadata/endpoints/track.dart';
|
||||||
|
import 'package:spotube/services/metadata/endpoints/updater.dart';
|
||||||
import 'package:spotube/services/metadata/endpoints/user.dart';
|
import 'package:spotube/services/metadata/endpoints/user.dart';
|
||||||
|
|
||||||
const defaultMetadataLimit = "20";
|
const defaultMetadataLimit = "20";
|
||||||
|
|
||||||
class MetadataPlugin {
|
class MetadataPlugin {
|
||||||
|
static final pluginApiVersion = Version.parse("1.0.0");
|
||||||
|
|
||||||
static Future<MetadataPlugin> create(
|
static Future<MetadataPlugin> create(
|
||||||
PluginConfiguration config,
|
PluginConfiguration config,
|
||||||
Uint8List byteCode,
|
Uint8List byteCode,
|
||||||
@ -101,6 +105,7 @@ class MetadataPlugin {
|
|||||||
late final MetadataPluginPlaylistEndpoint playlist;
|
late final MetadataPluginPlaylistEndpoint playlist;
|
||||||
late final MetadataPluginTrackEndpoint track;
|
late final MetadataPluginTrackEndpoint track;
|
||||||
late final MetadataPluginUserEndpoint user;
|
late final MetadataPluginUserEndpoint user;
|
||||||
|
late final MetadataPluginUpdaterEndpoint updater;
|
||||||
|
|
||||||
MetadataPlugin._(this.hetu) {
|
MetadataPlugin._(this.hetu) {
|
||||||
auth = MetadataAuthEndpoint(hetu);
|
auth = MetadataAuthEndpoint(hetu);
|
||||||
@ -112,5 +117,6 @@ class MetadataPlugin {
|
|||||||
playlist = MetadataPluginPlaylistEndpoint(hetu);
|
playlist = MetadataPluginPlaylistEndpoint(hetu);
|
||||||
track = MetadataPluginTrackEndpoint(hetu);
|
track = MetadataPluginTrackEndpoint(hetu);
|
||||||
user = MetadataPluginUserEndpoint(hetu);
|
user = MetadataPluginUserEndpoint(hetu);
|
||||||
|
updater = MetadataPluginUpdaterEndpoint(hetu);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1988,13 +1988,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
version: "3.0.0"
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: pub_semver
|
name: pub_semver
|
||||||
sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd"
|
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.5"
|
version: "2.2.0"
|
||||||
pubspec_parse:
|
pubspec_parse:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
@ -157,6 +157,7 @@ dependencies:
|
|||||||
ref: main
|
ref: main
|
||||||
get_it: ^8.0.3
|
get_it: ^8.0.3
|
||||||
flutter_markdown_plus: ^1.0.3
|
flutter_markdown_plus: ^1.0.3
|
||||||
|
pub_semver: ^2.2.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
build_runner: ^2.4.13
|
build_runner: ^2.4.13
|
||||||
|
Loading…
Reference in New Issue
Block a user