fix: stuck because of authState running in main thread and sse no url-request event captured

This commit is contained in:
Kingkor Roy Tirtho 2025-12-10 16:45:44 +06:00
parent bd2275a89f
commit 1e1f2ca82c
27 changed files with 680 additions and 317 deletions

2
.vscode/launch.json vendored
View File

@ -52,7 +52,7 @@
"--flavor",
"dev"
]
}
},
],
"compounds": []
}

View File

@ -632,7 +632,7 @@ class SettingsMetadataProviderFormRoute
SettingsMetadataProviderFormRoute({
_i44.Key? key,
required String title,
required List<void> fields,
required List<dynamic> fields,
List<_i41.PageRouteInfo>? children,
}) : super(
SettingsMetadataProviderFormRoute.name,
@ -670,7 +670,7 @@ class SettingsMetadataProviderFormRouteArgs {
final String title;
final List<void> fields;
final List<dynamic> fields;
@override
String toString() {

View File

@ -7,7 +7,7 @@ part of 'track_sources.dart';
// **************************************************************************
BasicSourcedTrack _$BasicSourcedTrackFromJson(Map json) => BasicSourcedTrack(
query: SpotubeTrackObject.fromJson(
query: SpotubeFullTrackObject.fromJson(
Map<String, dynamic>.from(json['query'] as Map)),
source: json['source'] as String,
info: SpotubeAudioSourceMatchObject.fromJson(

View File

@ -13,7 +13,7 @@ import 'package:spotube/models/metadata/metadata.dart';
@RoutePage()
class SettingsMetadataProviderFormPage extends HookConsumerWidget {
final String title;
final List<MetadataFormFieldObject> fields;
final List fields;
const SettingsMetadataProviderFormPage({
super.key,
required this.title,

View File

@ -19,16 +19,19 @@ class MetadataPluginAuthenticatedNotifier extends AsyncNotifier<bool> {
return false;
}
/// `authState` can be called once in the SpotubePlugin's lifetime.
final sub = defaultPlugin.authState().listen((event) async {
state = AsyncData(await defaultPlugin.auth
.isAuthenticated(mpscTx: defaultPlugin.sender));
state = AsyncData(
await defaultPlugin.auth.isAuthenticated(mpscTx: defaultPlugin.sender),
);
});
ref.onDispose(() {
sub.cancel();
});
return defaultPlugin.auth.isAuthenticated(mpscTx: defaultPlugin.sender);
return await defaultPlugin.auth
.isAuthenticated(mpscTx: defaultPlugin.sender);
}
}

View File

@ -16,6 +16,7 @@ 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/src/rust/api/plugin/plugin.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:archive/archive.dart';
import 'package:pub_semver/pub_semver.dart';
@ -598,11 +599,16 @@ final _pluginProvider =
final pluginsNotifier = ref.read(metadataPluginsProvider.notifier);
final pluginSourceCode = await pluginsNotifier.getPluginSourceCode(config);
final spotubePlugin = SpotubePlugin();
final plugin = MetadataPlugin(
plugin: spotubePlugin,
sender: await spotubePlugin.createContext(
pluginScript: pluginSourceCode,
pluginConfig: config,
serverEndpointUrl: "http://${server.address.host}:$port",
serverSecret: serverSecret,
localStorageDir: (await getApplicationSupportDirectory()).path,
),
);
ref.onDispose(() {

View File

@ -1,10 +1,13 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.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);
try {
final metadataPluginConfigs =
await ref.watch(metadataPluginsProvider.future);
final metadataPlugin = await ref.watch(metadataPluginProvider.future);
if (metadataPlugin == null ||
@ -12,14 +15,21 @@ final metadataPluginUpdateCheckerProvider =
return null;
}
return metadataPlugin.core.checkUpdate(
final res = await metadataPlugin.core.checkUpdate(
pluginConfig: metadataPluginConfigs.defaultMetadataPluginConfig!,
mpscTx: metadataPlugin.sender,
);
return res;
} catch (e) {
debugPrint('Error checking metadata plugin update: $e');
rethrow;
}
});
final audioSourcePluginUpdateCheckerProvider =
FutureProvider<PluginUpdateAvailable?>((ref) async {
try {
final audioSourcePluginConfigs =
await ref.watch(metadataPluginsProvider.future);
final audioSourcePlugin = await ref.watch(audioSourcePluginProvider.future);
@ -29,8 +39,14 @@ final audioSourcePluginUpdateCheckerProvider =
return null;
}
return audioSourcePlugin.core.checkUpdate(
final res = await audioSourcePlugin.core.checkUpdate(
pluginConfig: audioSourcePluginConfigs.defaultAudioSourcePluginConfig!,
mpscTx: audioSourcePlugin.sender,
);
return res;
} catch (e) {
debugPrint('Error checking audio source plugin update: $e');
rethrow;
}
});

View File

@ -0,0 +1,201 @@
import "dart:async";
import "package:collection/collection.dart";
import "package:logging/logging.dart" as log;
/// Just a simple [Sink] implementation that proxies the [add] and [close]
/// methods.
class ProxySink<T> implements Sink<T> {
void Function(T) onAdd;
void Function() onClose;
ProxySink({required this.onAdd, required this.onClose});
@override
void add(t) => onAdd(t);
@override
void close() => onClose();
}
class EventCache {
final int? cacheCapacity;
final bool comparableIds;
final Map<String, List<Event>> _caches = {};
EventCache({this.cacheCapacity, this.comparableIds = true});
void replay(Sink<Event> sink, String lastEventId, [String channel = ""]) {
List<Event>? cache = _caches[channel];
if (cache == null || cache.isEmpty) {
// nothing to replay
return;
}
// find the location of lastEventId in the queue
int index;
if (comparableIds) {
// if comparableIds, we can use binary search
index = binarySearch(cache, lastEventId);
} else {
// otherwise, we starts from the last one and look one by one
index = cache.length - 1;
while (index > 0 && cache[index].id != lastEventId) {
index--;
}
}
if (index >= 0) {
// add them all to the sink
cache.sublist(index).forEach(sink.add);
}
}
/// Add a new [Event] to the cache(s) of the specified channel(s).
/// Please note that we assume events are added with increasing values of
/// [Event.id].
void add(Event event, [Iterable<String> channels = const [""]]) {
for (String channel in channels) {
List<Event> cache = _caches.putIfAbsent(channel, () => []);
if (cacheCapacity != null && cache.length >= cacheCapacity!) {
cache.removeAt(0);
}
cache.add(event);
}
}
void clear([Iterable<String> channels = const [""]]) {
channels.forEach(_caches.remove);
}
void clearAll() {
_caches.clear();
}
}
class Event implements Comparable<Event> {
/// An identifier that can be used to allow a client to replay
/// missed Events by returning the Last-Event-Id header.
/// Return empty string if not required.
String? id;
/// The name of the event. Return empty string if not required.
String? event;
/// The payload of the event.
String? data;
Event({this.id, this.event, this.data});
Event.message({this.id, this.data}) : event = "message";
@override
int compareTo(Event other) => id!.compareTo(other.id!);
}
/// An EventSource publisher. It can manage different channels of events.
/// This class forms the backbone of an EventSource server. To actually serve
/// a web server, use this together with [shelf_eventsource] or another server
/// implementation.
class EventSourcePublisher implements Sink<Event> {
log.Logger? logger;
EventCache? _cache;
/// Create a new EventSource server.
///
/// When using a cache, for efficient replaying, it is advisable to use a
/// custom Event implementation that overrides the `Event.compareTo` method.
/// F.e. if integer events are used, sorting should be done on integers and
/// not on the string representations of them.
/// If your Event's id properties are not incremental using
/// [Comparable.compare], set [comparableIds] to false.
EventSourcePublisher({
int cacheCapacity = 0,
bool comparableIds = false,
bool enableLogging = true,
}) {
if (cacheCapacity > 0) {
_cache = EventCache(cacheCapacity: cacheCapacity);
}
if (enableLogging) {
logger = log.Logger("EventSourceServer");
}
}
final Map<String, List<ProxySink>> _subsByChannel = {};
/// Creates a Sink for the specified channel.
/// The `add` and `remove` methods of this channel are equivalent to the
/// respective methods of this class with the specific channel passed along.
Sink<Event> channel(String channel) => ProxySink(
onAdd: (e) => add(e, channels: [channel]),
onClose: () => close(channels: [channel]));
/// Add a publication to the specified channels.
/// By default, only adds to the default channel.
@override
void add(Event event, {Iterable<String> channels = const [""]}) {
for (String channel in channels) {
List<ProxySink>? subs = _subsByChannel[channel];
if (subs == null) {
continue;
}
_logFiner(
"Sending event on channel $channel to ${subs.length} subscribers.");
for (var sub in subs) {
sub.add(event);
}
}
_cache?.add(event, channels);
}
/// Close the specified channels.
/// All the connections with the subscribers to this channels will be closed.
/// By default only closes the default channel.
@override
void close({Iterable<String> channels = const [""]}) {
for (String channel in channels) {
List<ProxySink>? subs = _subsByChannel[channel];
if (subs == null) {
continue;
}
_logInfo("Closing channel $channel with ${subs.length} subscribers.");
for (var sub in subs) {
sub.close();
}
}
_cache?.clear(channels);
}
/// Close all the open channels.
void closeAllChannels() => close(channels: _subsByChannel.keys);
/// Initialize a new subscription and replay when possible.
/// Should not be used by the user directly.
void newSubscription({
required void Function(Event) onEvent,
required void Function() onClose,
required String channel,
String? lastEventId,
}) {
_logFine("New subscriber on channel $channel.");
// create a sink for the subscription
ProxySink<Event> sub = ProxySink(onAdd: onEvent, onClose: onClose);
// save the subscription
_subsByChannel.putIfAbsent(channel, () => []).add(sub);
// replay past events
if (_cache != null && lastEventId != null) {
scheduleMicrotask(() {
_logFine("Replaying events on channel $channel from id $lastEventId.");
_cache!.replay(sub, lastEventId, channel);
});
}
}
void _logInfo(message) {
logger?.log(log.Level.INFO, message);
}
void _logFine(message) {
logger?.log(log.Level.FINE, message);
}
void _logFiner(message) {
logger?.log(log.Level.FINER, message);
}
}

View File

@ -0,0 +1,106 @@
import "dart:convert";
import "dart:io";
import "package:shelf/shelf.dart";
import "package:spotube/provider/server/libs/eventsource_publisher.dart";
class EventSourceEncoder extends Converter<Event, List<int>> {
final bool compressed;
const EventSourceEncoder({this.compressed = false});
static final Map<String, Function> _fields = {
"id: ": (e) => e.id,
"event: ": (e) => e.event,
"data: ": (e) => e.data,
};
@override
List<int> convert(Event event) {
String payload = convertToString(event);
List<int> bytes = utf8.encode(payload);
if (compressed) {
bytes = gzip.encode(bytes);
}
return bytes;
}
String convertToString(Event event) {
String payload = "";
for (String prefix in _fields.keys) {
String? value = _fields[prefix]?.call(event);
if (value == null || value.isEmpty) {
continue;
}
// multi-line values need the field prefix on every line
value = value.replaceAll("\n", "\n$prefix");
payload += "$prefix$value\n";
}
payload += "\n";
return payload;
}
@override
Sink<Event> startChunkedConversion(Sink<List<int>> sink) {
Sink<dynamic> inputSink = sink;
if (compressed) {
inputSink =
gzip.encoder.startChunkedConversion(inputSink as Sink<List<int>>);
}
inputSink =
utf8.encoder.startChunkedConversion(inputSink as Sink<List<int>>);
return new ProxySink(
onAdd: (Event event) => inputSink.add(convertToString(event)),
onClose: () => inputSink.close());
}
}
/// Create a shelf handler for the specified channel.
/// This handler can be passed to the [shelf.serve] method.
Handler eventSourceHandler(
EventSourcePublisher publisher, {
String channel = "",
bool gzip = false,
}) {
// define the handler
Response shelfHandler(Request request) {
if (request.method != "GET") {
return Response.notFound(null);
}
if (!request.canHijack) {
throw ArgumentError("eventSourceHandler may only be used with a "
"server that supports request hijacking.");
}
// set content encoding to gzip if we allow it and the request supports it
bool useGzip =
gzip && (request.headers["Accept-Encoding"] ?? "").contains("gzip");
// hijack the raw underlying channel
request.hijack((untypedChannel) {
var socketChannel = (untypedChannel).cast<List<int>>();
// create a regular UTF8 sink to write headers
var sink = utf8.encoder.startChunkedConversion(socketChannel.sink);
// write headers
sink.add("HTTP/1.1 200 OK\r\n"
"Content-Type: text/event-stream; charset=utf-8\r\n"
"Cache-Control: no-cache, no-store, must-revalidate\r\n"
"Connection: keep-alive\r\n");
if (useGzip) sink.add("Content-Encoding: gzip\r\n");
sink.add("\r\n");
// create encoder for this connection
var encodedSink = EventSourceEncoder(compressed: useGzip)
.startChunkedConversion(socketChannel.sink);
// initialize the new subscription
publisher.newSubscription(
onEvent: encodedSink.add,
onClose: encodedSink.close,
channel: channel,
lastEventId: request.headers["Last-Event-ID"]);
});
}
return shelfHandler;
}

View File

@ -1,12 +1,13 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import 'package:spotube/provider/server/libs/shelf_eventsource.dart';
import 'package:spotube/provider/server/routes/connect.dart';
import 'package:spotube/provider/server/routes/playback.dart';
import 'package:spotube/provider/server/routes/plugin_apis/form.dart';
import 'package:spotube/provider/server/routes/plugin_apis/path_provider.dart';
import 'package:spotube/provider/server/routes/plugin_apis/webview.dart';
import 'package:spotube/provider/server/routes/plugin_apis/yt_engine.dart';
import 'package:spotube/provider/server/sse_publisher.dart';
Handler pluginApiAuthMiddleware(Handler handler) {
return (Request request) {
@ -25,6 +26,8 @@ final serverRouterProvider = Provider((ref) {
final webviewRoutes = ref.watch(serverWebviewRoutesProvider);
final formRoutes = ref.watch(serverFormRoutesProvider);
final ytEngineRoutes = ref.watch(serverYTEngineRoutesProvider);
final publisher = ref.watch(ssePublisherProvider);
final sseHandler = eventSourceHandler(publisher);
final router = Router();
@ -42,8 +45,8 @@ final serverRouterProvider = Provider((ref) {
pluginApiAuthMiddleware(webviewRoutes.postCreateWebview),
);
router.get(
"/plugin-api/webview/<uid>/on-url-request",
pluginApiAuthMiddleware(webviewRoutes.getOnUrlRequestStream),
"/plugin-api/webview/events",
pluginApiAuthMiddleware(sseHandler),
);
router.post(
"/plugin-api/webview/open",
@ -61,10 +64,6 @@ final serverRouterProvider = Provider((ref) {
"/plugin-api/form/show",
pluginApiAuthMiddleware(formRoutes.showForm),
);
router.get(
"/plugin/localstorage/directories",
pluginApiAuthMiddleware(ServerPathProviderRoutes.getDirectories),
);
router.get(
"/plugin-api/yt-engine/search",

View File

@ -1,29 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart' as pp;
import 'package:shelf/shelf.dart';
class ServerPathProviderRoutes {
static Future<Response> getDirectories(Request request) async {
final directories = <String, Directory?>{
'temporary': await Future<Directory?>.value(pp.getTemporaryDirectory())
.catchError((e) => null),
'applicationDocuments':
await Future<Directory?>.value(pp.getApplicationDocumentsDirectory())
.catchError((e) => null),
'applicationSupport':
await Future<Directory?>.value(pp.getApplicationSupportDirectory())
.catchError((e) => null),
'library': await Future<Directory?>.value(pp.getLibraryDirectory())
.catchError((e) => null),
'externalStorage':
await pp.getExternalStorageDirectory().catchError((e) => null),
'downloads': await pp.getDownloadsDirectory().catchError((e) => null),
}.map((key, value) => MapEntry(key, value?.path));
return Response.ok(
jsonEncode(directories),
headers: {'Content-Type': 'application/json'},
);
}
}

View File

@ -7,9 +7,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import 'package:spotube/provider/server/libs/eventsource_publisher.dart';
import 'package:spotube/provider/server/server.dart';
import 'package:spotube/provider/server/sse_publisher.dart';
import 'package:spotube/src/plugin_api/webview/webview.dart';
import 'package:async/async.dart';
import 'package:encrypt/encrypt.dart' as encrypt;
class ServerWebviewRoutes {
@ -17,6 +18,7 @@ class ServerWebviewRoutes {
ServerWebviewRoutes({required this.ref});
final Map<String, Webview> _webviews = {};
final Map<String, StreamSubscription> _eventSubscriptions = {};
String _encryptCookies(dynamic cookies, String secret) {
final keyBytes = base64.decode(secret);
@ -39,6 +41,16 @@ class ServerWebviewRoutes {
final webview = Webview(uri: uri.toString());
_webviews[webview.uid] = webview;
_eventSubscriptions[webview.uid] = webview.onUrlRequestStream.listen((url) {
ref.read(ssePublisherProvider).add(
Event(
event: "url-request",
data: jsonEncode({'uid': webview.uid, 'url': url}),
),
);
});
return Response.ok(
jsonEncode({'uid': webview.uid}),
encoding: utf8,
@ -56,40 +68,16 @@ class ServerWebviewRoutes {
return Response.notFound('Webview with uid $uid not found');
}
// Create a stream that merges URL events with keepalive pings
final controller = StreamController<List<int>>();
// Send keepalive comment every 15 seconds to prevent connection timeout
final keepaliveTimer = Stream.periodic(
const Duration(seconds: 15),
(_) => utf8.encode(": keepalive\n\n"),
);
final urlStream = webview.onUrlRequestStream.map((url) {
return utf8.encode("event: url-request\n"
"data: ${jsonEncode({'url': url})}\n\n");
final payload = "event: url-request\n"
"data: ${jsonEncode({'url': url})}\n\n";
debugPrint('[server][webview] sending:\n$payload');
return utf8.encode(payload);
});
// Merge both streams
final subscription = StreamGroup.merge([keepaliveTimer, urlStream]).listen(
(data) {
if (!controller.isClosed) {
controller.add(data);
}
},
onDone: () {
controller.close();
},
);
// Clean up when client disconnects
controller.onCancel = () {
debugPrint('Webview $uid client disconnected');
subscription.cancel();
};
return Response.ok(
controller.stream,
urlStream,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
@ -117,11 +105,14 @@ class ServerWebviewRoutes {
final uid = body['uid'] as String;
final webview = _webviews[uid];
if (webview == null) {
final subscription = _eventSubscriptions[uid];
if (webview == null || subscription == null) {
return Response.notFound('Webview with uid $uid not found');
}
subscription.cancel();
await webview.close();
_eventSubscriptions.remove(uid);
_webviews.remove(uid);
return Response.ok(null);
}
@ -149,6 +140,10 @@ class ServerWebviewRoutes {
}
Future<void> dispose() async {
for (final subscription in _eventSubscriptions.values) {
await subscription.cancel();
}
_eventSubscriptions.clear();
for (final webview in _webviews.values) {
await webview.close();
}

View File

@ -0,0 +1,14 @@
import 'package:riverpod/riverpod.dart';
import 'package:spotube/provider/server/libs/eventsource_publisher.dart';
final ssePublisherProvider = Provider<EventSourcePublisher>(
(ref) {
final publisher = EventSourcePublisher(cacheCapacity: 100);
ref.onDispose(() {
publisher.close();
});
return publisher;
},
);

View File

@ -1,5 +1,4 @@
import 'package:spotube/src/rust/api/plugin/models/auth.dart';
import 'package:spotube/src/rust/api/plugin/models/core.dart';
import 'package:spotube/src/rust/api/plugin/plugin.dart';
import 'package:spotube/src/rust/api/plugin/senders.dart';
@ -7,19 +6,7 @@ class MetadataPlugin {
final SpotubePlugin plugin;
late final OpaqueSender sender;
MetadataPlugin({
required String pluginScript,
required PluginConfiguration pluginConfig,
required String serverEndpointUrl,
required String serverSecret,
}) : plugin = SpotubePlugin() {
sender = plugin.createContext(
pluginScript: pluginScript,
pluginConfig: pluginConfig,
serverEndpointUrl: serverEndpointUrl,
serverSecret: serverSecret,
);
}
MetadataPlugin({required this.sender, required this.plugin});
Stream<AuthEventObject> authState() => plugin.authState();

View File

@ -66,11 +66,12 @@ abstract class SpotubePlugin implements RustOpaqueInterface {
Future<void> close({required OpaqueSender tx});
OpaqueSender createContext(
Future<OpaqueSender> createContext(
{required String pluginScript,
required PluginConfiguration pluginConfig,
required String serverEndpointUrl,
required String serverSecret});
required String serverSecret,
required String localStorageDir});
factory SpotubePlugin() =>
RustLib.instance.api.crateApiPluginPluginSpotubePluginNew();

View File

@ -170,12 +170,13 @@ abstract class RustLibApi extends BaseApi {
Future<void> crateApiPluginPluginSpotubePluginClose(
{required SpotubePlugin that, required OpaqueSender tx});
OpaqueSender crateApiPluginPluginSpotubePluginCreateContext(
Future<OpaqueSender> crateApiPluginPluginSpotubePluginCreateContext(
{required SpotubePlugin that,
required String pluginScript,
required PluginConfiguration pluginConfig,
required String serverEndpointUrl,
required String serverSecret});
required String serverSecret,
required String localStorageDir});
SpotubePlugin crateApiPluginPluginSpotubePluginNew();
@ -1204,14 +1205,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
);
@override
OpaqueSender crateApiPluginPluginSpotubePluginCreateContext(
Future<OpaqueSender> crateApiPluginPluginSpotubePluginCreateContext(
{required SpotubePlugin that,
required String pluginScript,
required PluginConfiguration pluginConfig,
required String serverEndpointUrl,
required String serverSecret}) {
return handler.executeSync(SyncTask(
callFfi: () {
required String serverSecret,
required String localStorageDir}) {
return handler.executeNormal(NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePlugin(
that, serializer);
@ -1219,7 +1221,9 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_box_autoadd_plugin_configuration(pluginConfig, serializer);
sse_encode_String(serverEndpointUrl, serializer);
sse_encode_String(serverSecret, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 25)!;
sse_encode_String(localStorageDir, serializer);
pdeCallFfi(generalizedFrbRustBinding, serializer,
funcId: 25, port: port_);
},
codec: SseCodec(
decodeSuccessData:
@ -1232,7 +1236,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pluginScript,
pluginConfig,
serverEndpointUrl,
serverSecret
serverSecret,
localStorageDir
],
apiImpl: this,
));
@ -1246,7 +1251,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
"pluginScript",
"pluginConfig",
"serverEndpointUrl",
"serverSecret"
"serverSecret",
"localStorageDir"
],
);
@ -6951,15 +6957,17 @@ class SpotubePluginImpl extends RustOpaque implements SpotubePlugin {
Future<void> close({required OpaqueSender tx}) => RustLib.instance.api
.crateApiPluginPluginSpotubePluginClose(that: this, tx: tx);
OpaqueSender createContext(
Future<OpaqueSender> createContext(
{required String pluginScript,
required PluginConfiguration pluginConfig,
required String serverEndpointUrl,
required String serverSecret}) =>
required String serverSecret,
required String localStorageDir}) =>
RustLib.instance.api.crateApiPluginPluginSpotubePluginCreateContext(
that: this,
pluginScript: pluginScript,
pluginConfig: pluginConfig,
serverEndpointUrl: serverEndpointUrl,
serverSecret: serverSecret);
serverSecret: serverSecret,
localStorageDir: localStorageDir);
}

View File

@ -11,14 +11,11 @@ pub enum PluginApi {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PluginAbility {
#[serde(rename = "authentication")]
Authentication,
#[serde(rename = "scrobbling")]
Scrobbling,
#[serde(rename = "metadata")]
Metadata,
#[serde(rename = "audio-source")]
AudioSource,
}

View File

@ -4,7 +4,7 @@ use crate::api::plugin::executors::{
execute_core, execute_playlist, execute_search, execute_track, execute_user,
};
use crate::api::plugin::models::auth::{AuthEventObject, AuthEventType};
use crate::api::plugin::models::core::PluginConfiguration;
use crate::api::plugin::models::core::{PluginAbility, PluginConfiguration};
use crate::api::plugin::senders::{
PluginAlbumSender, PluginArtistSender, PluginAudioSourceSender, PluginAuthSender,
PluginBrowseSender, PluginCoreSender, PluginPlaylistSender, PluginSearchSender,
@ -12,9 +12,9 @@ use crate::api::plugin::senders::{
};
use crate::frb_generated::StreamSink;
use crate::internal::apis;
use crate::internal::apis::{form, get_platform_directories, timezone, webview, yt_engine};
use crate::internal::apis::{form, timezone, webview, yt_engine};
use anyhow::anyhow;
use flutter_rust_bridge::{frb, Rust2DartSendError};
use flutter_rust_bridge::frb;
use llrt_modules::module_builder::ModuleBuilder;
use llrt_modules::{
abort, buffer, console, crypto, events, exceptions, fetch, navigator, timers, url, util,
@ -37,8 +37,9 @@ async fn create_context(
server_endpoint_url: String,
server_secret: String,
plugin_slug: String,
local_storage_dir: String,
) -> anyhow::Result<(AsyncContext, AsyncRuntime)> {
let runtime = AsyncRuntime::new().expect("Unable to create async runtime");
let runtime = AsyncRuntime::new()?;
let mut module_builder = ModuleBuilder::new();
@ -64,15 +65,7 @@ async fn create_context(
.set_loader((module_resolver,), (module_loader,))
.await;
let context = AsyncContext::full(&runtime)
.await
.expect("Unable to create async context");
let directories =
get_platform_directories(server_endpoint_url.clone(), server_secret.clone()).await?;
let local_storage_dir = directories
.application_support
.ok_or_else(|| anyhow!("Application support directory not found"))?;
let context = AsyncContext::full(&runtime).await?;
async_with!(context => |ctx| {
apis::init(&ctx, server_endpoint_url, server_secret).catch(&ctx).map_err(|e| anyhow!("Failed to initialize APIs: {}", e))?;
@ -90,9 +83,7 @@ async fn js_executor_thread(
context: &AsyncContext,
) -> anyhow::Result<()> {
while let Some(command) = rx.recv().await {
println!("JS Executor thread received command: {:?}", command);
if let PluginCommand::Shutdown = command {
println!("JS Executor thread shutting down.");
return anyhow::Ok(());
}
@ -134,7 +125,7 @@ pub struct SpotubePlugin {
pub track: PluginTrackSender,
pub user: PluginUserSender,
event_tx: Sender<AuthEventObject>,
event_rx: Receiver<AuthEventObject>,
event_rx: Option<Receiver<AuthEventObject>>,
}
impl SpotubePlugin {
@ -154,29 +145,40 @@ impl SpotubePlugin {
track: PluginTrackSender::new(),
user: PluginUserSender::new(),
event_tx,
event_rx,
event_rx: Some(event_rx),
}
}
pub async fn auth_state(&mut self, sink: StreamSink<AuthEventObject>) -> anyhow::Result<()> {
while let Some(event) = self.event_rx.recv().await {
sink.add(event)
.map_err(|e: Rust2DartSendError| anyhow::anyhow!(e))?;
let mut receiver = self
.event_rx
.take()
.ok_or_else(|| anyhow::anyhow!("Receiver already consumed"))?;
tokio::spawn(async move {
while let Some(event) = receiver.recv().await {
if let Err(e) = sink.add(event) {
eprintln!("Failed to send auth event to stream sink: {:?}", e);
break;
}
}
});
Ok(())
}
#[frb(sync)]
pub fn create_context(
pub async fn create_context(
&self,
plugin_script: String,
plugin_config: PluginConfiguration,
server_endpoint_url: String,
server_secret: String,
local_storage_dir: String,
) -> anyhow::Result<OpaqueSender> {
let (command_tx, mut command_rx) = mpsc::channel(32);
let (init_tx, init_rx) = tokio::sync::oneshot::channel::<anyhow::Result<()>>();
let sender = self.event_tx.clone();
let _thread_handle = thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
@ -184,24 +186,36 @@ impl SpotubePlugin {
.unwrap();
let local = LocalSet::new();
if let Err(e) = local.block_on(&rt, async {
let (ctx, _) = create_context(
let ctx_res = create_context(
server_endpoint_url,
server_secret,
plugin_config.slug(),
).await?;
local_storage_dir,
).await;
let injection = format!(
"globalThis.pluginInstance = new {}();",
plugin_config.entry_point
);
let script = format!("{}\n{}", plugin_script, injection);
if let Err(e) = ctx_res {
let _ = init_tx.send(Err(e));
return anyhow::Ok(());
}
async_with!(ctx => |cx| {
let (ctx, _runtime) = ctx_res.unwrap();
let begin_injection = "globalThis.module = {exports: {}};";
let end_injection = "globalThis.pluginInstance = new module.exports.default();";
let script = format!("{}\n{}\n{}", begin_injection, plugin_script, end_injection);
let script_eval_res = async_with!(ctx => |cx| {
cx.eval::<(), _>(script.as_str())
.catch(&cx).map_err(|e| anyhow!("Failed to evaluate supplied plugin script: {}", e))
}).await?;
}).await;
async_with!(ctx => |ctx|{
if let Err(e) = script_eval_res {
let _ = init_tx.send(Err(e));
return anyhow::Ok(());
}
let on_auth_event_res = async_with!(ctx => |ctx|{
let globals = ctx.globals();
let callback = Func::new(move |event: Object| -> rquickjs::Result<()>{
let sender_clone = sender.clone();
@ -223,26 +237,39 @@ impl SpotubePlugin {
}
});
if plugin_config.abilities.contains(&PluginAbility::Authentication) {
if let Err(e) = globals.get::<_, Object>("pluginInstance")?.get::<_, Object>("auth")?.set(
"onAuthEvent", callback
) {
eprintln!("Error setting auth event handler: {:?}", e);
}
}
Ok::<(), Error>(())
})
.await
.map_err(|e| anyhow!("[onAuthEvent] {e}"))?;
.map_err(|e| anyhow!("[onAuthEvent] {e}"));
if let Err(e) = on_auth_event_res {
let _ = init_tx.send(Err(e));
return anyhow::Ok(());
}
let _ = init_tx.send(Ok(()));
if let Err(e) = js_executor_thread(&mut command_rx, &ctx).await {
eprintln!("JS executor error: {}", e);
}
anyhow::Ok(())
}) {
eprintln!("JS Executor thread error: {}", e);
eprintln!("[PluginInitializationError]: {}", e);
}
});
init_rx
.await
.map_err(|e| anyhow!("Failed to receive initialization result: {}", e))??;
Ok(OpaqueSender { sender: command_tx })
}

View File

@ -45,7 +45,7 @@ impl PluginArtistSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn top_tracks(
@ -66,7 +66,7 @@ impl PluginArtistSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn albums(
@ -87,7 +87,7 @@ impl PluginArtistSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn related(
@ -108,7 +108,7 @@ impl PluginArtistSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn save(&self, mpsc_tx: &OpaqueSender, ids: Vec<String>) -> anyhow::Result<()> {
@ -121,7 +121,7 @@ impl PluginArtistSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn unsave(&self, mpsc_tx: &OpaqueSender, ids: Vec<String>) -> anyhow::Result<()> {
@ -134,7 +134,7 @@ impl PluginArtistSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
}
@ -161,7 +161,7 @@ impl PluginAlbumSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn tracks(
@ -182,7 +182,7 @@ impl PluginAlbumSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn releases(
@ -201,7 +201,7 @@ impl PluginAlbumSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn save(&self, mpsc_tx: &OpaqueSender, ids: Vec<String>) -> anyhow::Result<()> {
@ -214,7 +214,7 @@ impl PluginAlbumSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn unsave(&self, mpsc_tx: &OpaqueSender, ids: Vec<String>) -> anyhow::Result<()> {
@ -227,7 +227,7 @@ impl PluginAlbumSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
}
@ -252,7 +252,7 @@ impl PluginAudioSourceSender {
))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn matches(
@ -269,7 +269,7 @@ impl PluginAudioSourceSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn streams(
@ -286,7 +286,7 @@ impl PluginAudioSourceSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
}
@ -308,7 +308,7 @@ impl PluginAuthSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn logout(&self, mpsc_tx: &OpaqueSender) -> anyhow::Result<()> {
@ -320,7 +320,7 @@ impl PluginAuthSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn is_authenticated(&self, mpsc_tx: &OpaqueSender) -> anyhow::Result<bool> {
@ -332,7 +332,7 @@ impl PluginAuthSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
}
@ -361,7 +361,7 @@ impl PluginBrowseSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn section_items(
@ -382,7 +382,7 @@ impl PluginBrowseSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
}
@ -409,13 +409,7 @@ impl PluginCoreSender {
}))
.await?;
rx.await
.map_err(|e| {
eprintln!("RecvError: {}", e);
eprintln!("Stack trace:\n{:?}", Backtrace::capture());
anyhow!("{e}")
})
.and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn support(&self, mpsc_tx: &OpaqueSender) -> anyhow::Result<String> {
@ -427,7 +421,7 @@ impl PluginCoreSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn scrobble(
@ -444,7 +438,7 @@ impl PluginCoreSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
}
@ -471,7 +465,7 @@ impl PluginPlaylistSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn tracks(
@ -492,7 +486,7 @@ impl PluginPlaylistSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn create_playlist(
@ -517,7 +511,7 @@ impl PluginPlaylistSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn update_playlist(
@ -542,7 +536,7 @@ impl PluginPlaylistSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn delete_playlist(
@ -559,7 +553,7 @@ impl PluginPlaylistSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn add_tracks(
@ -580,7 +574,7 @@ impl PluginPlaylistSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn remove_tracks(
@ -599,7 +593,7 @@ impl PluginPlaylistSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn save(&self, mpsc_tx: &OpaqueSender, playlist_id: String) -> anyhow::Result<()> {
@ -612,7 +606,7 @@ impl PluginPlaylistSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn unsave(&self, mpsc_tx: &OpaqueSender, playlist_id: String) -> anyhow::Result<()> {
@ -625,7 +619,7 @@ impl PluginPlaylistSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
}
@ -647,7 +641,7 @@ impl PluginSearchSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn all(
@ -664,7 +658,7 @@ impl PluginSearchSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn tracks(
@ -685,7 +679,7 @@ impl PluginSearchSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn albums(
@ -706,7 +700,7 @@ impl PluginSearchSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn artists(
@ -727,7 +721,7 @@ impl PluginSearchSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn playlists(
@ -748,7 +742,7 @@ impl PluginSearchSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
}
@ -775,7 +769,7 @@ impl PluginTrackSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn save(&self, mpsc_tx: &OpaqueSender, ids: Vec<String>) -> anyhow::Result<()> {
@ -788,7 +782,7 @@ impl PluginTrackSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn unsave(&self, mpsc_tx: &OpaqueSender, ids: Vec<String>) -> anyhow::Result<()> {
@ -801,7 +795,7 @@ impl PluginTrackSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn radio(
@ -818,7 +812,7 @@ impl PluginTrackSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
}
@ -838,7 +832,7 @@ impl PluginUserSender {
.send(PluginCommand::User(UserCommands::Me { response_tx: tx }))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn saved_tracks(
@ -857,7 +851,7 @@ impl PluginUserSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn saved_albums(
@ -876,7 +870,7 @@ impl PluginUserSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn saved_artists(
@ -895,7 +889,7 @@ impl PluginUserSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
pub async fn saved_playlists(
@ -914,6 +908,6 @@ impl PluginUserSender {
}))
.await?;
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
rx.await.map_err(|e| anyhow!("{e}"))?
}
}

View File

@ -1253,15 +1253,16 @@ fn wire__crate__api__plugin__plugin__SpotubePlugin_close_impl(
)
}
fn wire__crate__api__plugin__plugin__SpotubePlugin_create_context_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::<flutter_rust_bridge::for_generated::SseCodec, _>(
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "SpotubePlugin_create_context",
port: None,
mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync,
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
@ -1283,19 +1284,24 @@ fn wire__crate__api__plugin__plugin__SpotubePlugin_create_context_impl(
);
let api_server_endpoint_url = <String>::sse_decode(&mut deserializer);
let api_server_secret = <String>::sse_decode(&mut deserializer);
let api_local_storage_dir = <String>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>(
(move || {
(move || async move {
let mut api_that_guard = None;
let decode_indices_ =
flutter_rust_bridge::for_generated::lockable_compute_decode_order(vec![
flutter_rust_bridge::for_generated::LockableOrderInfo::new(
flutter_rust_bridge::for_generated::lockable_compute_decode_order(
vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new(
&api_that, 0, false,
),
]);
)],
);
for i in decode_indices_ {
match i {
0 => api_that_guard = Some(api_that.lockable_decode_sync_ref()),
0 => {
api_that_guard =
Some(api_that.lockable_decode_async_ref().await)
}
_ => unreachable!(),
}
}
@ -1306,10 +1312,14 @@ fn wire__crate__api__plugin__plugin__SpotubePlugin_create_context_impl(
api_plugin_config,
api_server_endpoint_url,
api_server_secret,
)?;
Ok(output_ok)
})(),
api_local_storage_dir,
)
.await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
@ -5824,6 +5834,7 @@ fn pde_ffi_dispatcher_primary_impl(
match func_id {
3 => wire__crate__api__plugin__plugin__SpotubePlugin_auth_state_impl(port, ptr, rust_vec_len, data_len),
24 => wire__crate__api__plugin__plugin__SpotubePlugin_close_impl(port, ptr, rust_vec_len, data_len),
25 => wire__crate__api__plugin__plugin__SpotubePlugin_create_context_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__api__init_app_impl(port, ptr, rust_vec_len, data_len),
28 => wire__crate__api__plugin__senders__plugin_album_sender_get_album_impl(port, ptr, rust_vec_len, data_len),
29 => wire__crate__api__plugin__senders__plugin_album_sender_releases_impl(port, ptr, rust_vec_len, data_len),
@ -5906,7 +5917,6 @@ fn pde_ffi_dispatcher_sync_impl(
21 => wire__crate__api__plugin__plugin__SpotubePlugin_auto_accessor_set_search_impl(ptr, rust_vec_len, data_len),
22 => wire__crate__api__plugin__plugin__SpotubePlugin_auto_accessor_set_track_impl(ptr, rust_vec_len, data_len),
23 => wire__crate__api__plugin__plugin__SpotubePlugin_auto_accessor_set_user_impl(ptr, rust_vec_len, data_len),
25 => wire__crate__api__plugin__plugin__SpotubePlugin_create_context_impl(ptr, rust_vec_len, data_len),
26 => wire__crate__api__plugin__plugin__SpotubePlugin_new_impl(ptr, rust_vec_len, data_len),
47 => wire__crate__api__plugin__models__core__plugin_configuration_slug_impl(ptr, rust_vec_len, data_len),
75 => wire__crate__api__plugin__models__audio_source__spotube_audio_lossless_container_quality_to_string_fmt_impl(ptr, rust_vec_len, data_len),

View File

@ -1,10 +1,9 @@
use rquickjs::Ctx;
use serde::{Deserialize, Serialize};
pub mod form;
pub mod local_storage;
pub mod webview;
pub mod timezone;
pub mod webview;
pub mod yt_engine;
pub fn init(ctx: &Ctx, endpoint_url: String, secret: String) -> rquickjs::Result<()> {
@ -13,28 +12,3 @@ pub fn init(ctx: &Ctx, endpoint_url: String, secret: String) -> rquickjs::Result
Ok(())
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DirectoriesResponse {
pub temporary: Option<String>,
pub application_documents: Option<String>,
pub application_support: Option<String>,
pub library: Option<String>,
pub external_storage: Option<String>,
pub downloads: Option<String>,
}
pub async fn get_platform_directories(
server_url: String,
server_secret: String,
) -> anyhow::Result<DirectoriesResponse> {
let client = reqwest::Client::new();
Ok(client
.get(format!("{}/plugin/localstorage/directories", server_url).as_str())
.header("X-Plugin-Secret", server_secret.as_str())
.send()
.await?
.json::<DirectoriesResponse>()
.await?)
}

View File

@ -1,5 +1,5 @@
use rquickjs::prelude::Func;
use rquickjs::{Class, Ctx, Object};
use rquickjs::{Ctx, Object};
pub fn get_local_timezone() -> rquickjs::Result<String> {
let timezone = iana_time_zone::get_timezone()

View File

@ -185,10 +185,7 @@ impl<'js> WebView<'js> {
}
async fn url_change_task(&self, ctx: Ctx<'js>) {
let endpoint = format!(
"{}/plugin-api/webview/{}/on-url-request",
self.endpoint_url, self.uid
);
let endpoint = format!("{}/plugin-api/webview/events", self.endpoint_url);
let secret = self.secret.clone();
@ -201,22 +198,39 @@ impl<'js> WebView<'js> {
.header("X-Plugin-Secret", &secret)
.expect("Failed to set header for EventSourceClient")
.build();
let mut stream = client.stream();
while let Some(event) = stream.next().await {
match event {
Ok(eventsource_client::SSE::Event(msg)) => {
if msg.event_type != "url-request" {
eprintln!(
"[rust][webview] Not expected event-type: {}",
msg.event_type
);
continue;
}
backoff = 1;
if let Ok(data) = serde_json::from_str::<HashMap<String, String>>(&msg.data)
{
let url = data.get("url").cloned().unwrap_or_default();
let uid = data.get("uid").cloned().unwrap_or_default();
if uid != self.uid {
println!(
"[rust][webview] Ignored event for different uid: {}",
uid
);
continue;
}
for callback in self.callbacks.iter() {
match callback.call::<_, Value>((url.clone(),)) {
Ok(res) => {
if let Some(promise) = res.into_promise() {
if let Err(e) = promise.into_future::<()>().await.catch(&ctx) {
if let Err(e) =
promise.into_future::<()>().await.catch(&ctx)
{
eprintln!("Error in onUrlChange promise: {}", e);
}
}
@ -230,7 +244,9 @@ impl<'js> WebView<'js> {
eprintln!("Failed to parse event data: {}", msg.data);
}
}
Ok(_) => {}
Ok(e) => {
eprintln!("[rust][webview] Ignored non-event message: {:?}", e);
}
Err(err) => {
eprintln!("Error in EventSource stream: {}", err);
}

View File

@ -1,4 +1,4 @@
use crate::internal::utils::js_invoke_async_method_to_json;
use crate::internal::utils::{js_invoke_async_method_to_json, js_invoke_method_to_json};
use flutter_rust_bridge::frb;
use rquickjs::{async_with, AsyncContext};
@ -28,16 +28,14 @@ impl<'a> PluginAuthEndpoint<'a> {
pub async fn is_authenticated(&self) -> anyhow::Result<bool> {
async_with!(self.0 => |ctx| {
Ok(
js_invoke_async_method_to_json::<(), bool>(
let s = js_invoke_method_to_json::<(), bool>(
ctx.clone(),
"auth",
"is_authenticated",
"isAuthenticated",
&[]
)
.await?
.expect("[hey][smartypants] auth.is_authenticated should return a boolean")
)
)?.expect("[hey][smartypants] auth.isAuthenticated should return a boolean");
Ok(s)
})
.await
}

View File

@ -5,9 +5,10 @@ use serde::de::DeserializeOwned;
use serde::Serialize;
use serde_json::{Map, Value};
use std::collections::HashMap;
use std::fmt::Debug;
/// Convert a `serde_json::Value` into a Boa `JsValue`
pub fn json_value_to_js<'a>(value: &Value, ctx: Ctx<'a>) -> anyhow::Result<rquickjs::Value<'a>> {
pub fn json_value_to_js<'a>(value: &Value, ctx: Ctx<'a>) -> rquickjs::Result<rquickjs::Value<'a>> {
match value {
Value::Null => Ok(rquickjs::Value::new_null(ctx)),
Value::Bool(b) => Ok(rquickjs::Value::new_bool(ctx, *b)),
@ -30,7 +31,7 @@ pub fn json_value_to_js<'a>(value: &Value, ctx: Ctx<'a>) -> anyhow::Result<rquic
let js_val = json_value_to_js(item, ctx.clone())?;
js_arr.push(js_val);
}
js_arr.into_js(&ctx).map_err(|e| anyhow!(e))
js_arr.into_js(&ctx)
}
Value::Object(obj) => {
let mut js_obj = HashMap::<String, rquickjs::Value>::with_capacity(obj.len());
@ -40,12 +41,12 @@ pub fn json_value_to_js<'a>(value: &Value, ctx: Ctx<'a>) -> anyhow::Result<rquic
js_obj.insert(key.clone(), js_val);
}
js_obj.into_js(&ctx).map_err(|e| anyhow!(e))
js_obj.into_js(&ctx)
}
}
}
/// Convert a Boa `JsValue` into a `serde_json::Value`
/// Convert a `Value` into a `serde_json::Value`
pub fn js_value_to_json<'a>(value: rquickjs::Value<'a>, ctx: Ctx<'a>) -> anyhow::Result<Value> {
if value.is_null() || value.is_undefined() {
return Ok(Value::Null);
@ -70,7 +71,9 @@ pub fn js_value_to_json<'a>(value: rquickjs::Value<'a>, ctx: Ctx<'a>) -> anyhow:
// Array?
if obj.is_array() {
let obj: Array = Array::from_value(obj.into_value()).map_err(|e| anyhow!("{}", e))?;
let obj: Array = Array::from_value(obj.into_value())
.catch(&ctx)
.map_err(|e| anyhow!("{}", e))?;
let length = obj.len();
let mut json_arr = Vec::<Value>::with_capacity(length);
@ -111,34 +114,53 @@ pub async fn js_invoke_async_method_to_json<'b, T, R>(
) -> anyhow::Result<Option<R>>
where
T: Serialize,
R: DeserializeOwned,
R: DeserializeOwned + Debug,
{
let global = ctx.globals();
let plugin_instance: Object<'b> = global.get("pluginInstance").map_err(|e| anyhow!("{e}"))?;
let plugin_instance: Object<'b> = global
.get("pluginInstance")
.catch(&ctx)
.map_err(|e| anyhow!("{e}"))?;
let core_val: Object<'b> = plugin_instance
.get(endpoint_name)
.catch(&ctx)
.map_err(|e| anyhow!("{e}"))?;
let js_fn: Function<'b> = core_val.get(name).map_err(|e| anyhow!("{e}"))?;
let js_fn: Function<'b> = core_val.get(name).catch(&ctx).map_err(|e| anyhow!("{e}"))?;
let mut args_js = Args::new(ctx.clone(), args.len() as usize);
args_js
.this(core_val)
.catch(&ctx)
.map_err(|e| anyhow!("{e}"))?;
for arg in args.iter() {
let arg_value = serde_json::to_value(arg).map_err(|e| anyhow!("{e}"))?;
let arg_js = json_value_to_js(&arg_value, ctx.clone()).map_err(|e| anyhow!("{e}"))?;
args_js.push_arg(arg_js).map_err(|e| anyhow!("{e}"))?;
let arg_js = json_value_to_js(&arg_value, ctx.clone())
.catch(&ctx)
.map_err(|e| anyhow!("{e}"))?;
args_js
.push_arg(arg_js)
.catch(&ctx)
.map_err(|e| anyhow!("{e}"))?;
}
let result_promise: Promise = js_fn.call_arg(args_js).map_err(|e| anyhow!("{e}"))?;
let result_promise: Promise = js_fn
.call_arg(args_js)
.catch(&ctx)
.map_err(|e| anyhow!("{e}"))?;
println!("Sync Result: {:?}", result_promise);
let result_future: rquickjs::Value = result_promise
.into_future()
.await
.catch(&ctx)
.map_err(|e| anyhow!("{e}"))?;
let value = js_value_to_json(result_future, ctx.clone()).map_err(|e| anyhow!("{e}"))?;
let value = js_value_to_json(result_future, ctx.clone())?;
if value.is_null() {
return Ok(None);
}
Ok(Some(
serde_json::from_value::<R>(value).map_err(|e| anyhow!("{e}"))?,
))
@ -155,20 +177,38 @@ where
R: DeserializeOwned,
{
let global = ctx.globals();
let plugin_instance: Object<'b> = global.get("pluginInstance").map_err(|e| anyhow!("{e}"))?;
let plugin_instance: Object<'b> = global
.get("pluginInstance")
.catch(&ctx)
.map_err(|e| anyhow!("{e}"))?;
let core_val: Object<'b> = plugin_instance
.get(endpoint_name)
.catch(&ctx)
.map_err(|e| anyhow!("{e}"))?;
let js_fn: Function<'b> = core_val.get(name).map_err(|e| anyhow!("{e}"))?;
let js_fn: Function<'b> = core_val.get(name).catch(&ctx).map_err(|e| anyhow!("{e}"))?;
let mut args_js = Args::new(ctx.clone(), args.len() as usize);
args_js
.this(core_val)
.catch(&ctx)
.map_err(|e| anyhow!("{e}"))?;
for arg in args.iter().enumerate() {
let arg_value = serde_json::to_value(arg).map_err(|e| anyhow!("{e}"))?;
let arg_js = json_value_to_js(&arg_value, ctx.clone()).map_err(|e| anyhow!("{e}"))?;
args_js.push_arg(arg_js).map_err(|e| anyhow!("{e}"))?;
let arg_js = json_value_to_js(&arg_value, ctx.clone())
.catch(&ctx)
.map_err(|e| anyhow!("{e}"))?;
args_js
.push_arg(arg_js)
.catch(&ctx)
.map_err(|e| anyhow!("{e}"))?;
}
let result: rquickjs::Value = js_fn.call_arg(args_js).map_err(|e| anyhow!("{e}"))?;
let value = js_value_to_json(result, ctx.clone()).map_err(|e| anyhow!("{e}"))?;
let result: rquickjs::Value = js_fn
.call_arg(args_js)
.catch(&ctx)
.map_err(|e| anyhow!("{e}"))?;
let value = js_value_to_json(result, ctx.clone())?;
if value.is_null() {
return Ok(None);

View File

@ -100,7 +100,7 @@ async fn plugin() -> anyhow::Result<()> {
repository: None,
version: "0.1.0".to_string(),
};
let sender = plugin.create_context(PLUGIN_JS.to_string(), config.clone(), "".to_string(), "".to_string())?;
let sender = plugin.create_context(PLUGIN_JS.to_string(), config.clone(), "".to_string(), "".to_string(), "".into()).await?;
let (r1, r2) = tokio::join!(
plugin.core.check_update(&sender, config.clone()),
plugin.core.check_update(&sender, config.clone())