feat(metadata): add plugin update checker and dialog for available updates

This commit is contained in:
Kingkor Roy Tirtho 2025-07-22 00:11:20 +06:00
parent c9556c2ecb
commit 7309e900bc
12 changed files with 343 additions and 48 deletions

View File

@ -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/glance/glance.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/server.dart';
import 'package:spotube/provider/tray_manager/tray_manager.dart';
@ -153,6 +154,7 @@ class Spotube extends HookConsumerWidget {
ref.listen(metadataPluginProvider, (_, __) {});
ref.listen(serverProvider, (_, __) {});
ref.listen(trayManagerProvider, (_, __) {});
ref.listen(metadataPluginUpdateCheckerProvider, (_, __) {});
useFixWindowStretching();
useDisableBatteryOptimizations();

View File

@ -3,8 +3,10 @@ import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/spotube_icons.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/metadata_plugin_provider.dart';
import 'package:spotube/provider/metadata_plugin/updater/update_checker.dart';
import 'package:url_launcher/url_launcher.dart';
class MetadataInstalledPluginItem extends HookConsumerWidget {
@ -23,6 +25,9 @@ class MetadataInstalledPluginItem extends HookConsumerWidget {
final pluginsNotifier = ref.watch(metadataPluginsProvider.notifier);
final requiresAuth =
isDefault && plugin.abilities.contains(PluginAbilities.authentication);
final updateAvailable =
isDefault ? ref.watch(metadataPluginUpdateCheckerProvider) : null;
final hasUpdate = isDefault && updateAvailable?.asData?.value != null;
return Card(
child: Column(
@ -105,21 +110,50 @@ class MetadataInstalledPluginItem extends HookConsumerWidget {
);
},
),
if (plugin.abilities.contains(PluginAbilities.authentication) &&
isDefault)
if (requiresAuth || hasUpdate)
Container(
decoration: BoxDecoration(
color: context.theme.colorScheme.secondary,
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(12),
child: const Row(
child: Column(
spacing: 12,
children: [
if (requiresAuth)
const Row(
spacing: 8,
children: [
Icon(SpotubeIcons.warning, color: Colors.yellow),
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(
mainAxisAlignment: MainAxisAlignment.spaceBetween,

View File

@ -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'),
),
],
);
}
}

View File

@ -5,6 +5,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.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/services/audio_player/audio_player.dart';
import 'package:spotube/services/connectivity_adapter.dart';
@ -18,6 +21,22 @@ void useGlobalSubscriptions(WidgetRef ref) {
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) async {
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;

View File

@ -135,9 +135,11 @@ class AudioPlayerStreamListeners {
return;
}
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;
}

View File

@ -6,9 +6,10 @@ import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
final metadataPluginUserProvider = FutureProvider<SpotubeUserObject?>(
(ref) async {
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 metadataPlugin.user.me();

View File

@ -11,10 +11,13 @@ import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/database/database.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/utils/service_utils.dart';
import 'package:uuid/uuid.dart';
import 'package:archive/archive.dart';
import 'package:pub_semver/pub_semver.dart';
final allowedDomainsRegex = RegExp(
r"^(https?:\/\/)?(www\.)?(github\.com|codeberg\.org)\/.+",
@ -166,43 +169,56 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
}
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(
uri,
options: Options(responseType: ResponseType.json),
);
if (res.statusCode != 200) {
throw Exception("Failed to get releases");
throw MetadataPluginException.failedToGetRelease();
}
final releases = res.data as List;
if (releases.isEmpty) {
throw Exception("No releases found");
throw MetadataPluginException.noReleasesFound();
}
final latestRelease = releases.first;
final downloadUrl = (latestRelease["assets"] as List).firstWhere(
(asset) => (asset["name"] as String).endsWith(".smplug"),
)["browser_download_url"];
if (downloadUrl == null) {
throw Exception("No download URL found");
throw MetadataPluginException.assetUrlNotFound();
}
return downloadUrl;
}
Future<Directory> _getPluginDir() async => Directory(
/// Root directory where all metadata plugins are stored.
Future<Directory> _getPluginRootDir() async => Directory(
join(
(await getApplicationCacheDirectory()).path,
"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 {
final archive = ZipDecoder().decodeBytes(bytes);
final pluginJson = archive
.firstWhereOrNull((file) => file.isFile && file.name == "plugin.json");
if (pluginJson == null) {
throw Exception("No plugin.json found");
throw MetadataPluginException.pluginConfigJsonNotFound();
}
final pluginConfig = PluginConfiguration.fromJson(
jsonDecode(
@ -210,20 +226,17 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
) as Map<String, dynamic>,
);
final pluginDir = await _getPluginDir();
final pluginDir = await _getPluginRootDir();
await pluginDir.create(recursive: true);
final pluginExtractionDirPath = join(
pluginDir.path,
ServiceUtils.sanitizeFilename(pluginConfig.name),
);
final pluginExtractionDir = await _getPluginExtractionDir(pluginConfig);
for (final file in archive) {
if (file.isFile) {
final filename = file.name;
final data = file.content as List<int>;
final extractedFile = File(join(
pluginExtractionDirPath,
pluginExtractionDir.path,
filename,
));
await extractedFile.create(recursive: true);
@ -251,12 +264,12 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
final uri = _getCodebergeReleasesUrl(url);
pluginDownloadUrl = await _getPluginDownloadUrl(uri);
} else {
throw Exception("Unsupported website");
throw MetadataPluginException.unsupportedPluginDownloadWebsite();
}
}
// Now let's download, extract and cache the plugin
final pluginDir = await _getPluginDir();
final pluginDir = await _getPluginRootDir();
await pluginDir.create(recursive: true);
final tempPluginName = "${const Uuid().v4()}.smplug";
@ -273,13 +286,32 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
);
if ((pluginRes.statusCode ?? 500) > 299) {
throw Exception("Failed to download plugin");
throw MetadataPluginException.pluginDownloadFailed();
}
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 {
_assertPluginApiCompatibility(plugin);
final pluginRes = await (database.metadataPluginsTable.select()
..where(
(tbl) => tbl.name.equals(plugin.name),
@ -288,7 +320,7 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
.get();
if (pluginRes.isNotEmpty) {
throw Exception("Plugin already exists");
throw MetadataPluginException.duplicatePlugin();
}
await database.metadataPluginsTable.insertOne(
@ -307,12 +339,8 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
}
Future<void> removePlugin(PluginConfiguration plugin) async {
final pluginDir = await _getPluginDir();
final pluginExtractionDirPath = join(
pluginDir.path,
ServiceUtils.sanitizeFilename(plugin.name),
);
final pluginExtractionDir = Directory(pluginExtractionDirPath);
final pluginExtractionDir = await _getPluginExtractionDir(plugin);
if (pluginExtractionDir.existsSync()) {
await pluginExtractionDir.delete(recursive: true);
}
@ -320,6 +348,27 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
.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 {
await (database.metadataPluginsTable.update()
..where((tbl) => tbl.name.equals(plugin.name)))
@ -329,29 +378,21 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
}
Future<Uint8List> getPluginByteCode(PluginConfiguration plugin) async {
final pluginDir = await _getPluginDir();
final pluginExtractionDirPath = join(
pluginDir.path,
ServiceUtils.sanitizeFilename(plugin.name),
);
final pluginExtractionDirPath = await _getPluginExtractionDir(plugin);
final libraryFile = File(join(pluginExtractionDirPath, "plugin.out"));
final libraryFile = File(join(pluginExtractionDirPath.path, "plugin.out"));
if (!libraryFile.existsSync()) {
throw Exception("No plugin.out (Bytecode) file found");
throw MetadataPluginException.pluginByteCodeFileNotFound();
}
return await libraryFile.readAsBytes();
}
Future<File?> getLogoPath(PluginConfiguration plugin) async {
final pluginDir = await _getPluginDir();
final pluginExtractionDirPath = join(
pluginDir.path,
ServiceUtils.sanitizeFilename(plugin.name),
);
final pluginExtractionDirPath = await _getPluginExtractionDir(plugin);
final logoFile = File(join(pluginExtractionDirPath, "logo.png"));
final logoFile = File(join(pluginExtractionDirPath.path, "logo.png"));
if (!logoFile.existsSync()) {
return null;

View 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!);
});

View 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';
}

View File

@ -5,6 +5,7 @@ import 'package:hetu_otp_util/hetu_otp_util.dart';
import 'package:hetu_script/hetu_script.dart';
import 'package:hetu_spotube_plugin/hetu_spotube_plugin.dart';
import 'package:hetu_std/hetu_std.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shared_preferences/shared_preferences.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/search.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';
const defaultMetadataLimit = "20";
class MetadataPlugin {
static final pluginApiVersion = Version.parse("1.0.0");
static Future<MetadataPlugin> create(
PluginConfiguration config,
Uint8List byteCode,
@ -101,6 +105,7 @@ class MetadataPlugin {
late final MetadataPluginPlaylistEndpoint playlist;
late final MetadataPluginTrackEndpoint track;
late final MetadataPluginUserEndpoint user;
late final MetadataPluginUpdaterEndpoint updater;
MetadataPlugin._(this.hetu) {
auth = MetadataAuthEndpoint(hetu);
@ -112,5 +117,6 @@ class MetadataPlugin {
playlist = MetadataPluginPlaylistEndpoint(hetu);
track = MetadataPluginTrackEndpoint(hetu);
user = MetadataPluginUserEndpoint(hetu);
updater = MetadataPluginUpdaterEndpoint(hetu);
}
}

View File

@ -1988,13 +1988,13 @@ packages:
source: hosted
version: "3.0.0"
pub_semver:
dependency: transitive
dependency: "direct main"
description:
name: pub_semver
sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd"
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
version: "2.2.0"
pubspec_parse:
dependency: "direct dev"
description:

View File

@ -157,6 +157,7 @@ dependencies:
ref: main
get_it: ^8.0.3
flutter_markdown_plus: ^1.0.3
pub_semver: ^2.2.0
dev_dependencies:
build_runner: ^2.4.13