Compare commits

...

3 Commits

Author SHA1 Message Date
Kingkor Roy Tirtho
fe83f50286 feat: implement webview api for plugin usage 2025-12-06 00:31:02 +06:00
Kingkor Roy Tirtho
fca0551032 feat: weird impl 2025-12-03 18:25:48 +06:00
Kingkor Roy Tirtho
7b0c49f565 feat: add auth state support 2025-12-02 19:06:03 +06:00
28 changed files with 5678 additions and 864 deletions

View File

@ -58,28 +58,6 @@ import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:yt_dlp_dart/yt_dlp_dart.dart'; import 'package:yt_dlp_dart/yt_dlp_dart.dart';
import 'package:flutter_new_pipe_extractor/flutter_new_pipe_extractor.dart'; import 'package:flutter_new_pipe_extractor/flutter_new_pipe_extractor.dart';
const pluginJS = """
function timeout(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
class CoreEndpoint {
async checkUpdate() {
console.log('Core checkUpdate');
await timeout(5000);
console.log('Core checkUpdate done. No updates!');
}
get support() {
return 'Metadata';
}
}
class TestingPlugin {
constructor() {
this.core = new CoreEndpoint();
}
}
""";
Future<void> main(List<String> rawArgs) async { Future<void> main(List<String> rawArgs) async {
if (rawArgs.contains("web_view_title_bar")) { if (rawArgs.contains("web_view_title_bar")) {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -121,25 +99,6 @@ Future<void> main(List<String> rawArgs) async {
await RustLib.init(); await RustLib.init();
final plugin = SpotubePlugin();
const config = PluginConfiguration(
entryPoint: "TestingPlugin",
abilities: [PluginAbility.metadata],
apis: [],
author: "KRTirtho",
description: "Testing Plugin",
name: "Testing Plugin",
pluginApiVersion: "2.0.0",
repository: null,
version: "0.1.0",
);
final sender = SpotubePlugin.newContext(
pluginScript: pluginJS,
pluginConfig: config,
);
await plugin.core.checkUpdate(mpscTx: sender, pluginConfig: config);
if (kIsDesktop) { if (kIsDesktop) {
await windowManager.setPreventClose(true); await windowManager.setPreventClose(true);
await YtDlp.instance await YtDlp.instance
@ -219,6 +178,58 @@ class Spotube extends HookConsumerWidget {
HomeWidget.registerInteractivityCallback(glanceBackgroundCallback); HomeWidget.registerInteractivityCallback(glanceBackgroundCallback);
} }
start() async {
final server = await ref.read(serverProvider.future);
final plugin = SpotubePlugin();
const pluginConfiguration = PluginConfiguration(
name: "Spotube Plugin",
description: "Spotube Plugin",
version: "1.0.0",
author: "Spotube",
entryPoint: "Plugin",
pluginApiVersion: "2.0.0",
apis: [PluginApi.localstorage, PluginApi.webview],
abilities: [PluginAbility.metadata],
);
final pluginContext = plugin.createContext(
serverEndpointUrl:
"http://${server.server.address.host}:${server.port}",
serverSecret: ref.read(serverRandomSecretProvider),
pluginScript: """
class AuthEndpoint {
}
class CoreEndpoint {
async checkUpdate() {
console.log(globalThis);
const webview = await WebView.create("https://spotube.krtirtho.dev");
webview.onUrlChange((url) => {
console.log("url_request: ", url);
if (url.includes("/about")) {
webview.close();
}
});
await webview.open();
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
class Plugin {
constructor() {
this.auth = new AuthEndpoint();
this.core = new CoreEndpoint();
}
}
""",
pluginConfig: pluginConfiguration,
);
await plugin.core.checkUpdate(
mpscTx: pluginContext, pluginConfig: pluginConfiguration);
}
start();
return () { return () {
/// For enabling hot reload for audio player /// For enabling hot reload for audio player
if (!kDebugMode) return; if (!kDebugMode) return;

View File

@ -3,10 +3,23 @@ import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart'; import 'package:shelf_router/shelf_router.dart';
import 'package:spotube/provider/server/routes/connect.dart'; import 'package:spotube/provider/server/routes/connect.dart';
import 'package:spotube/provider/server/routes/playback.dart'; import 'package:spotube/provider/server/routes/playback.dart';
import 'package:spotube/provider/server/routes/plugin_apis/webview.dart';
Handler pluginApiAuthMiddleware(Handler handler) {
return (Request request) {
final apiKey = request.headers['X-Plugin-Secret'] ??
request.url.queryParameters['secret'];
if (apiKey == null || apiKey != request.context['plugin_api_secret']) {
return Response.forbidden('Forbidden');
}
return handler(request);
};
}
final serverRouterProvider = Provider((ref) { final serverRouterProvider = Provider((ref) {
final playbackRoutes = ref.watch(serverPlaybackRoutesProvider); final playbackRoutes = ref.watch(serverPlaybackRoutesProvider);
final connectRoutes = ref.watch(serverConnectRoutesProvider); final connectRoutes = ref.watch(serverConnectRoutesProvider);
final webviewRoutes = ref.watch(serverWebviewRoutesProvider);
final router = Router(); final router = Router();
@ -19,7 +32,32 @@ final serverRouterProvider = Provider((ref) {
router.get("/playback/previous", playbackRoutes.previousTrack); router.get("/playback/previous", playbackRoutes.previousTrack);
router.get("/playback/next", playbackRoutes.nextTrack); router.get("/playback/next", playbackRoutes.nextTrack);
router.post(
"/plugin-api/webview/create",
pluginApiAuthMiddleware(webviewRoutes.postCreateWebview),
);
router.get(
"/plugin-api/webview/<uid>/on-url-request",
pluginApiAuthMiddleware(webviewRoutes.getOnUrlRequestStream),
);
router.post(
"/plugin-api/webview/open",
pluginApiAuthMiddleware(webviewRoutes.postOpenWebview),
);
router.post(
"/plugin-api/webview/close",
pluginApiAuthMiddleware(webviewRoutes.postCloseWebview),
);
router.post(
"/plugin-api/webview/cookies",
pluginApiAuthMiddleware(webviewRoutes.postGetWebviewCookies),
);
router.all("/ws", connectRoutes.websocket); router.all("/ws", connectRoutes.websocket);
ref.onDispose(() {
webviewRoutes.dispose();
});
return router; return router;
}); });

View File

@ -0,0 +1,134 @@
import 'dart:async';
import 'dart:convert';
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/src/plugin_api/webview/webview.dart';
import 'package:async/async.dart';
class ServerWebviewRoutes {
final Map<String, Webview> _webviews = {};
Future<Response> postCreateWebview(Request request) async {
final payload = jsonDecode(await request.readAsString());
final uri = Uri.parse(payload['url'] as String);
final webview = Webview(uri: uri.toString());
_webviews[webview.uid] = webview;
return Response.ok(
jsonEncode({'uid': webview.uid}),
encoding: utf8,
headers: {
'Content-Type': 'application/json',
},
);
}
Future<Response> getOnUrlRequestStream(Request request) async {
final uid = request.params["uid"];
final webview = _webviews[uid];
if (webview == null) {
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");
});
// 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,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // Disable buffering for nginx
},
encoding: utf8,
);
}
Future<Response> postOpenWebview(Request request) async {
final body = jsonDecode(await request.readAsString());
final uid = body['uid'] as String;
final webview = _webviews[uid];
if (webview == null) {
return Response.notFound('Webview with uid $uid not found');
}
await webview.open();
return Response.ok(null);
}
Future<Response> postCloseWebview(Request request) async {
final body = jsonDecode(await request.readAsString());
final uid = body['uid'] as String;
final webview = _webviews[uid];
if (webview == null) {
return Response.notFound('Webview with uid $uid not found');
}
await webview.close();
_webviews.remove(uid);
return Response.ok(null);
}
Future<Response> postGetWebviewCookies(Request request) async {
final body = jsonDecode(await request.readAsString());
final uid = body['uid'] as String;
final url = body['url'] as String;
final webview = _webviews[uid];
if (webview == null) {
return Response.notFound('Webview with uid $uid not found');
}
final cookies = await webview.getCookies(url);
return Response.ok(
jsonEncode(cookies),
encoding: utf8,
headers: {
'Content-Type': 'application/json',
},
);
}
Future<void> dispose() async {
for (final webview in _webviews.values) {
await webview.close();
}
_webviews.clear();
}
}
final serverWebviewRoutesProvider = Provider((ref) => ServerWebviewRoutes());

View File

@ -1,7 +1,9 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart'; import 'package:shelf/shelf_io.dart';
import 'package:spotube/provider/server/pipeline.dart'; import 'package:spotube/provider/server/pipeline.dart';
import 'package:spotube/provider/server/router.dart'; import 'package:spotube/provider/server/router.dart';
@ -9,8 +11,16 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/logger/logger.dart';
final serverRandomSecretProvider = Provider<String>(
(ref) {
final random = Random.secure();
final values = List<int>.generate(16, (i) => random.nextInt(256));
return base64Url.encode(values);
},
);
final serverProvider = FutureProvider( final serverProvider = FutureProvider(
(ref) async { (ref) async {
final randomSecret = ref.watch(serverRandomSecretProvider);
final enabledRemoteConnect = ref.watch( final enabledRemoteConnect = ref.watch(
userPreferencesProvider.select((value) => value.enableConnect), userPreferencesProvider.select((value) => value.enableConnect),
); );
@ -31,8 +41,21 @@ final serverProvider = FutureProvider(
SpotubeMedia.serverPort = connectPort; SpotubeMedia.serverPort = connectPort;
} }
final handler = pipeline.addMiddleware(logRequests()).addMiddleware(
(innerHandler) {
return (request) {
final updatedRequest = request.change(
context: {
'plugin_api_secret': randomSecret,
},
);
return innerHandler(updatedRequest);
};
},
).addHandler(router.call);
final server = await serve( final server = await serve(
pipeline.addHandler(router.call), handler,
enabledRemoteConnect enabledRemoteConnect
? InternetAddress.anyIPv4 ? InternetAddress.anyIPv4
: InternetAddress.loopbackIPv4, : InternetAddress.loopbackIPv4,

View File

@ -0,0 +1,118 @@
import 'dart:async';
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:desktop_webview_window/desktop_webview_window.dart';
import 'package:desktop_webview_window/desktop_webview_window.dart'
as webview_window;
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:random_user_agents/random_user_agents.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' hide join;
import 'package:spotube/collections/routes.dart';
import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/src/plugin_api/webview/webview_page.dart';
import 'package:uuid/uuid.dart';
class Webview {
final String uri;
final String uid;
Webview({
required this.uri,
}) : _onUrlRequestStreamController = StreamController<String>.broadcast(),
uid = const Uuid().v4();
StreamController<String>? _onUrlRequestStreamController;
Stream<String> get onUrlRequestStream =>
_onUrlRequestStreamController!.stream;
webview_window.Webview? _webview;
BuildContext? _pageContext;
Future<void> open() async {
if (Platform.isLinux) {
final applicationSupportDir = await getApplicationSupportDirectory();
final userDataFolder = Directory(
join(applicationSupportDir.path, "webview_window_Webview2"),
);
if (!await userDataFolder.exists()) {
await userDataFolder.create();
}
_webview = await WebviewWindow.create(
configuration: CreateConfiguration(
title: "Spotube Login",
windowHeight: 720,
windowWidth: 1280,
userDataFolderWindows: userDataFolder.path,
),
)
..setApplicationUserAgent(RandomUserAgents.random());
_webview!.setOnUrlRequestCallback((url) {
_onUrlRequestStreamController?.add(url);
return true;
});
_webview!.launch(uri);
return;
}
final route = WebviewPage(
uri: uri,
onLoad: (url) {
_onUrlRequestStreamController?.add(url.toString());
},
);
await rootNavigatorKey.currentContext?.router.pushWidget(
Builder(builder: (context) {
_pageContext = context;
return Scaffold(
headers: const [
TitleBar(
automaticallyImplyLeading: true,
)
],
child: route,
);
}),
);
}
Future<void> close() async {
_onUrlRequestStreamController?.close();
_onUrlRequestStreamController = null;
if (Platform.isLinux) {
_webview?.close();
_webview = null;
return;
}
await _pageContext?.maybePop();
}
Future<List<Cookie>> getCookies(String url) async {
if (Platform.isLinux) {
final cookies = await _webview?.getAllCookies() ?? [];
return cookies.map((cookie) {
return Cookie(
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
expiresDate: cookie.expires?.millisecondsSinceEpoch,
isHttpOnly: cookie.httpOnly,
isSecure: cookie.secure,
isSessionOnly: cookie.sessionOnly,
path: cookie.path,
);
}).toList();
}
return await CookieManager.instance(
// Created in [WebviewPage]. Custom WebViewEnvironment for Windows otherwise it installs
// in installation directory so permission exception occurs.
webViewEnvironment: await webViewEnvironment,
).getCookies(url: WebUri(url));
}
}

View File

@ -0,0 +1,62 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:fk_user_agent/fk_user_agent.dart';
Future<String?> getUserAgent() async {
if (Platform.isIOS || Platform.isAndroid) {
await FkUserAgent.init();
return FkUserAgent.userAgent;
}
return null;
}
final webViewEnvironment = Platform.isWindows
? getApplicationSupportDirectory().then((directory) async {
return await WebViewEnvironment.create(
settings: WebViewEnvironmentSettings(
userDataFolder: join(directory.path, 'inappwebview_data'),
),
);
})
: Future.value(null);
class WebviewPage extends StatelessWidget {
final String uri;
final void Function(String url)? onLoad;
const WebviewPage({super.key, required this.uri, this.onLoad});
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: Future.wait([webViewEnvironment, getUserAgent()]),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
return InAppWebView(
initialUrlRequest: URLRequest(url: WebUri(uri)),
webViewEnvironment: snapshot.data?[0] as WebViewEnvironment?,
initialSettings: InAppWebViewSettings(
userAgent: snapshot.data?[1] as String?,
),
onLoadStop: (controller, url) {
try {
if (onLoad != null && url != null) {
onLoad!(url.toString());
}
} catch (e, stack) {
debugPrint("[Webview][onLoad] Error: $e");
debugPrintStack(stackTrace: stack);
rethrow;
}
},
);
},
);
}
}

View File

@ -0,0 +1,34 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.11.1.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import '../../../frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_receiver_is_total_eq`, `clone`, `clone`, `cmp`, `eq`, `fmt`, `fmt`, `partial_cmp`
class AuthEventObject {
final AuthEventType eventType;
const AuthEventObject({
required this.eventType,
});
@override
int get hashCode => eventType.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AuthEventObject &&
runtimeType == other.runtimeType &&
eventType == other.eventType;
}
enum AuthEventType {
login,
refreshSession,
logout,
;
}

View File

@ -5,11 +5,12 @@
import '../../frb_generated.dart'; import '../../frb_generated.dart';
import '../../lib.dart'; import '../../lib.dart';
import 'models/auth.dart';
import 'models/core.dart'; import 'models/core.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'senders.dart'; import 'senders.dart';
// These functions are ignored because they are not marked as `pub`: `console_log`, `js_executor_thread`, `register_globals`, `set_timeout` // These functions are ignored because they are not marked as `pub`: `create_context`, `js_executor_thread`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `fmt` // These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `fmt`
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<OpaqueSender>> // Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<OpaqueSender>>
@ -19,69 +20,58 @@ abstract class OpaqueSender implements RustOpaqueInterface {
set sender(SenderPluginCommand sender); set sender(SenderPluginCommand sender);
} }
class SpotubePlugin { // Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<SpotubePlugin>>
final PluginArtistSender artist; abstract class SpotubePlugin implements RustOpaqueInterface {
final PluginAlbumSender album; Stream<AuthEventObject> authState();
final PluginAudioSourceSender audioSource;
final PluginAuthSender auth;
final PluginBrowseSender browse;
final PluginCoreSender core;
final PluginPlaylistSender playlist;
final PluginSearchSender search;
final PluginTrackSender track;
final PluginUserSender user;
const SpotubePlugin.raw({ PluginAlbumSender get album;
required this.artist,
required this.album,
required this.audioSource,
required this.auth,
required this.browse,
required this.core,
required this.playlist,
required this.search,
required this.track,
required this.user,
});
Future<void> dispose({required OpaqueSender tx}) => RustLib.instance.api PluginArtistSender get artist;
.crateApiPluginPluginSpotubePluginDispose(that: this, tx: tx);
PluginAudioSourceSender get audioSource;
PluginAuthSender get auth;
PluginBrowseSender get browse;
PluginCoreSender get core;
PluginPlaylistSender get playlist;
PluginSearchSender get search;
PluginTrackSender get track;
PluginUserSender get user;
set album(PluginAlbumSender album);
set artist(PluginArtistSender artist);
set audioSource(PluginAudioSourceSender audioSource);
set auth(PluginAuthSender auth);
set browse(PluginBrowseSender browse);
set core(PluginCoreSender core);
set playlist(PluginPlaylistSender playlist);
set search(PluginSearchSender search);
set track(PluginTrackSender track);
set user(PluginUserSender user);
Future<void> close({required OpaqueSender tx});
OpaqueSender createContext(
{required String pluginScript,
required PluginConfiguration pluginConfig,
required String serverEndpointUrl,
required String serverSecret});
factory SpotubePlugin() => factory SpotubePlugin() =>
RustLib.instance.api.crateApiPluginPluginSpotubePluginNew(); RustLib.instance.api.crateApiPluginPluginSpotubePluginNew();
static OpaqueSender newContext(
{required String pluginScript,
required PluginConfiguration pluginConfig}) =>
RustLib.instance.api.crateApiPluginPluginSpotubePluginNewContext(
pluginScript: pluginScript, pluginConfig: pluginConfig);
@override
int get hashCode =>
artist.hashCode ^
album.hashCode ^
audioSource.hashCode ^
auth.hashCode ^
browse.hashCode ^
core.hashCode ^
playlist.hashCode ^
search.hashCode ^
track.hashCode ^
user.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SpotubePlugin &&
runtimeType == other.runtimeType &&
artist == other.artist &&
album == other.album &&
audioSource == other.audioSource &&
auth == other.auth &&
browse == other.browse &&
core == other.core &&
playlist == other.playlist &&
search == other.search &&
track == other.track &&
user == other.user;
} }

View File

@ -18,6 +18,7 @@ import 'models/user.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'plugin.dart'; import 'plugin.dart';
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`
// These functions are ignored (category: IgnoreBecauseExplicitAttribute): `new`, `new`, `new`, `new`, `new`, `new`, `new`, `new`, `new`, `new` // These functions are ignored (category: IgnoreBecauseExplicitAttribute): `new`, `new`, `new`, `new`, `new`, `new`, `new`, `new`, `new`, `new`
class PluginAlbumSender { class PluginAlbumSender {

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@ import 'api/plugin/commands.dart';
import 'api/plugin/models/album.dart'; import 'api/plugin/models/album.dart';
import 'api/plugin/models/artist.dart'; import 'api/plugin/models/artist.dart';
import 'api/plugin/models/audio_source.dart'; import 'api/plugin/models/audio_source.dart';
import 'api/plugin/models/auth.dart';
import 'api/plugin/models/browse.dart'; import 'api/plugin/models/browse.dart';
import 'api/plugin/models/core.dart'; import 'api/plugin/models/core.dart';
import 'api/plugin/models/image.dart'; import 'api/plugin/models/image.dart';
@ -44,6 +45,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
get rust_arc_decrement_strong_count_SenderPluginCommandPtr => wire get rust_arc_decrement_strong_count_SenderPluginCommandPtr => wire
._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSenderPluginCommandPtr; ._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSenderPluginCommandPtr;
CrossPlatformFinalizerArg
get rust_arc_decrement_strong_count_SpotubePluginPtr => wire
._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePluginPtr;
@protected @protected
AnyhowException dco_decode_AnyhowException(dynamic raw); AnyhowException dco_decode_AnyhowException(dynamic raw);
@ -62,16 +67,31 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSenderPluginCommand( dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSenderPluginCommand(
dynamic raw); dynamic raw);
@protected
SpotubePlugin
dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePlugin(
dynamic raw);
@protected @protected
OpaqueSender OpaqueSender
dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOpaqueSender( dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOpaqueSender(
dynamic raw); dynamic raw);
@protected
SpotubePlugin
dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePlugin(
dynamic raw);
@protected @protected
OpaqueSender OpaqueSender
dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOpaqueSender( dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOpaqueSender(
dynamic raw); dynamic raw);
@protected
SpotubePlugin
dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePlugin(
dynamic raw);
@protected @protected
OpaqueSender OpaqueSender
dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOpaqueSender( dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOpaqueSender(
@ -87,9 +107,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSenderPluginCommand( dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSenderPluginCommand(
dynamic raw); dynamic raw);
@protected
SpotubePlugin
dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePlugin(
dynamic raw);
@protected
RustStreamSink<AuthEventObject> dco_decode_StreamSink_auth_event_object_Sse(
dynamic raw);
@protected @protected
String dco_decode_String(dynamic raw); String dco_decode_String(dynamic raw);
@protected
AuthEventObject dco_decode_auth_event_object(dynamic raw);
@protected
AuthEventType dco_decode_auth_event_type(dynamic raw);
@protected @protected
bool dco_decode_bool(dynamic raw); bool dco_decode_bool(dynamic raw);
@ -180,9 +215,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SpotubeFullPlaylistObject dco_decode_box_autoadd_spotube_full_playlist_object( SpotubeFullPlaylistObject dco_decode_box_autoadd_spotube_full_playlist_object(
dynamic raw); dynamic raw);
@protected
SpotubePlugin dco_decode_box_autoadd_spotube_plugin(dynamic raw);
@protected @protected
SpotubeSimpleAlbumObject dco_decode_box_autoadd_spotube_simple_album_object( SpotubeSimpleAlbumObject dco_decode_box_autoadd_spotube_simple_album_object(
dynamic raw); dynamic raw);
@ -414,9 +446,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SpotubePaginationResponseObjectItem SpotubePaginationResponseObjectItem
dco_decode_spotube_pagination_response_object_item(dynamic raw); dco_decode_spotube_pagination_response_object_item(dynamic raw);
@protected
SpotubePlugin dco_decode_spotube_plugin(dynamic raw);
@protected @protected
SpotubeSearchResponseObject dco_decode_spotube_search_response_object( SpotubeSearchResponseObject dco_decode_spotube_search_response_object(
dynamic raw); dynamic raw);
@ -471,16 +500,31 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSenderPluginCommand( sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSenderPluginCommand(
SseDeserializer deserializer); SseDeserializer deserializer);
@protected
SpotubePlugin
sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePlugin(
SseDeserializer deserializer);
@protected @protected
OpaqueSender OpaqueSender
sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOpaqueSender( sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOpaqueSender(
SseDeserializer deserializer); SseDeserializer deserializer);
@protected
SpotubePlugin
sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePlugin(
SseDeserializer deserializer);
@protected @protected
OpaqueSender OpaqueSender
sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOpaqueSender( sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOpaqueSender(
SseDeserializer deserializer); SseDeserializer deserializer);
@protected
SpotubePlugin
sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePlugin(
SseDeserializer deserializer);
@protected @protected
OpaqueSender OpaqueSender
sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOpaqueSender( sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOpaqueSender(
@ -496,9 +540,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSenderPluginCommand( sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSenderPluginCommand(
SseDeserializer deserializer); SseDeserializer deserializer);
@protected
SpotubePlugin
sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePlugin(
SseDeserializer deserializer);
@protected
RustStreamSink<AuthEventObject> sse_decode_StreamSink_auth_event_object_Sse(
SseDeserializer deserializer);
@protected @protected
String sse_decode_String(SseDeserializer deserializer); String sse_decode_String(SseDeserializer deserializer);
@protected
AuthEventObject sse_decode_auth_event_object(SseDeserializer deserializer);
@protected
AuthEventType sse_decode_auth_event_type(SseDeserializer deserializer);
@protected @protected
bool sse_decode_bool(SseDeserializer deserializer); bool sse_decode_bool(SseDeserializer deserializer);
@ -603,10 +662,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SpotubeFullPlaylistObject sse_decode_box_autoadd_spotube_full_playlist_object( SpotubeFullPlaylistObject sse_decode_box_autoadd_spotube_full_playlist_object(
SseDeserializer deserializer); SseDeserializer deserializer);
@protected
SpotubePlugin sse_decode_box_autoadd_spotube_plugin(
SseDeserializer deserializer);
@protected @protected
SpotubeSimpleAlbumObject sse_decode_box_autoadd_spotube_simple_album_object( SpotubeSimpleAlbumObject sse_decode_box_autoadd_spotube_simple_album_object(
SseDeserializer deserializer); SseDeserializer deserializer);
@ -870,9 +925,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
sse_decode_spotube_pagination_response_object_item( sse_decode_spotube_pagination_response_object_item(
SseDeserializer deserializer); SseDeserializer deserializer);
@protected
SpotubePlugin sse_decode_spotube_plugin(SseDeserializer deserializer);
@protected @protected
SpotubeSearchResponseObject sse_decode_spotube_search_response_object( SpotubeSearchResponseObject sse_decode_spotube_search_response_object(
SseDeserializer deserializer); SseDeserializer deserializer);
@ -931,16 +983,31 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSenderPluginCommand( sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSenderPluginCommand(
SenderPluginCommand self, SseSerializer serializer); SenderPluginCommand self, SseSerializer serializer);
@protected
void
sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePlugin(
SpotubePlugin self, SseSerializer serializer);
@protected @protected
void void
sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOpaqueSender( sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOpaqueSender(
OpaqueSender self, SseSerializer serializer); OpaqueSender self, SseSerializer serializer);
@protected
void
sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePlugin(
SpotubePlugin self, SseSerializer serializer);
@protected @protected
void void
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOpaqueSender( sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOpaqueSender(
OpaqueSender self, SseSerializer serializer); OpaqueSender self, SseSerializer serializer);
@protected
void
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePlugin(
SpotubePlugin self, SseSerializer serializer);
@protected @protected
void void
sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOpaqueSender( sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOpaqueSender(
@ -956,9 +1023,25 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSenderPluginCommand( sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSenderPluginCommand(
SenderPluginCommand self, SseSerializer serializer); SenderPluginCommand self, SseSerializer serializer);
@protected
void
sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePlugin(
SpotubePlugin self, SseSerializer serializer);
@protected
void sse_encode_StreamSink_auth_event_object_Sse(
RustStreamSink<AuthEventObject> self, SseSerializer serializer);
@protected @protected
void sse_encode_String(String self, SseSerializer serializer); void sse_encode_String(String self, SseSerializer serializer);
@protected
void sse_encode_auth_event_object(
AuthEventObject self, SseSerializer serializer);
@protected
void sse_encode_auth_event_type(AuthEventType self, SseSerializer serializer);
@protected @protected
void sse_encode_bool(bool self, SseSerializer serializer); void sse_encode_bool(bool self, SseSerializer serializer);
@ -1059,10 +1142,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
void sse_encode_box_autoadd_spotube_full_playlist_object( void sse_encode_box_autoadd_spotube_full_playlist_object(
SpotubeFullPlaylistObject self, SseSerializer serializer); SpotubeFullPlaylistObject self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_spotube_plugin(
SpotubePlugin self, SseSerializer serializer);
@protected @protected
void sse_encode_box_autoadd_spotube_simple_album_object( void sse_encode_box_autoadd_spotube_simple_album_object(
SpotubeSimpleAlbumObject self, SseSerializer serializer); SpotubeSimpleAlbumObject self, SseSerializer serializer);
@ -1323,9 +1402,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
void sse_encode_spotube_pagination_response_object_item( void sse_encode_spotube_pagination_response_object_item(
SpotubePaginationResponseObjectItem self, SseSerializer serializer); SpotubePaginationResponseObjectItem self, SseSerializer serializer);
@protected
void sse_encode_spotube_plugin(SpotubePlugin self, SseSerializer serializer);
@protected @protected
void sse_encode_spotube_search_response_object( void sse_encode_spotube_search_response_object(
SpotubeSearchResponseObject self, SseSerializer serializer); SpotubeSearchResponseObject self, SseSerializer serializer);
@ -1475,4 +1551,36 @@ class RustLibWire implements BaseWire {
late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSenderPluginCommand = late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSenderPluginCommand =
_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSenderPluginCommandPtr _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSenderPluginCommandPtr
.asFunction<void Function(ffi.Pointer<ffi.Void>)>(); .asFunction<void Function(ffi.Pointer<ffi.Void>)>();
void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePlugin(
ffi.Pointer<ffi.Void> ptr,
) {
return _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePlugin(
ptr,
);
}
late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePluginPtr =
_lookup<ffi.NativeFunction<ffi.Void Function(ffi.Pointer<ffi.Void>)>>(
'frbgen_spotube_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePlugin');
late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePlugin =
_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePluginPtr
.asFunction<void Function(ffi.Pointer<ffi.Void>)>();
void
rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePlugin(
ffi.Pointer<ffi.Void> ptr,
) {
return _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePlugin(
ptr,
);
}
late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePluginPtr =
_lookup<ffi.NativeFunction<ffi.Void Function(ffi.Pointer<ffi.Void>)>>(
'frbgen_spotube_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePlugin');
late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePlugin =
_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSpotubePluginPtr
.asFunction<void Function(ffi.Pointer<ffi.Void>)>();
} }

View File

@ -742,7 +742,7 @@ packages:
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
fk_user_agent: fk_user_agent:
dependency: transitive dependency: "direct main"
description: description:
path: "." path: "."
ref: master ref: master
@ -1935,13 +1935,13 @@ packages:
source: hosted source: hosted
version: "3.2.2" version: "3.2.2"
random_user_agents: random_user_agents:
dependency: transitive dependency: "direct main"
description: description:
name: random_user_agents name: random_user_agents
sha256: "95647149687167e82a7b39e1b4616fdebb574981b71b6f0cfca21b69f36293a8" sha256: "80dc025723a73f04797351aa6ef2fddb14836f86a752711a2f8a04e37c4ccdff"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.17" version: "1.0.18"
recase: recase:
dependency: transitive dependency: transitive
description: description:

View File

@ -42,6 +42,10 @@ dependencies:
envied: ^1.0.0 envied: ^1.0.0
file_picker: 10.3.3 file_picker: 10.3.3
file_selector: ^1.0.3 file_selector: ^1.0.3
fk_user_agent:
git:
url: https://github.com/TiffApps/fk_user_agent.git
ref: master
fluentui_system_icons: ^1.1.234 fluentui_system_icons: ^1.1.234
flutter: flutter:
sdk: flutter sdk: flutter
@ -157,6 +161,7 @@ dependencies:
path: rust_builder path: rust_builder
flutter_rust_bridge: 2.11.1 flutter_rust_bridge: 2.11.1
json_annotation: ^4.9.0 json_annotation: ^4.9.0
random_user_agents: ^1.0.18
dev_dependencies: dev_dependencies:
build_runner: ^2.4.13 build_runner: ^2.4.13

813
rust/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -15,13 +15,17 @@ flutter_rust_bridge = "=2.11.1"
anyhow = "1" anyhow = "1"
serde_json = "1" serde_json = "1"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
rquickjs = { version = "0", features = ["chrono", "futures"] } rquickjs = { version = "0", features = ["chrono", "futures", "macro", "classes", "bindgen"] }
tokio = { version = "1.48.0", features = ["full"] } tokio = { version = "1.48.0", features = ["full"] }
heck = "0.5.0" heck = "0.5.0"
llrt_modules = { git = "https://github.com/awslabs/llrt.git", rev = "7d749dd18cf26a2e51119094c3b945975ae57bd4", features = ["abort", "buffer", "console", "crypto", "events", "exceptions", "fetch", "navigator", "url", "timers"] } llrt_modules = { git = "https://github.com/awslabs/llrt.git", rev = "7d749dd18cf26a2e51119094c3b945975ae57bd4", features = ["abort", "buffer", "console", "crypto", "events", "exceptions", "fetch", "navigator", "url", "timers"] }
eventsource-client = "0.15.1"
reqwest = { version = "0.12", features = ["json"] }
[patch."https://github.com/DelSkayn/rquickjs"] [patch."https://github.com/DelSkayn/rquickjs"]
rquickjs = "0.10.0" rquickjs = "0.10.0"
[path.crates-io]
rquickjs = "0.10.0"
[lints.rust] [lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] } unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] }

View File

@ -0,0 +1,14 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AuthEventType {
Login,
RefreshSession,
Logout,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthEventObject {
pub event_type: AuthEventType,
}

View File

@ -9,3 +9,4 @@ pub mod track;
pub mod user; pub mod user;
pub mod pagination; pub mod pagination;
pub mod core; pub mod core;
pub mod auth;

View File

@ -3,20 +3,26 @@ use crate::api::plugin::executors::{
execute_albums, execute_artists, execute_audio_source, execute_auth, execute_browse, execute_albums, execute_artists, execute_audio_source, execute_auth, execute_browse,
execute_core, execute_playlist, execute_search, execute_track, execute_user, 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::PluginConfiguration;
use crate::api::plugin::senders::{ use crate::api::plugin::senders::{
PluginAlbumSender, PluginArtistSender, PluginAudioSourceSender, PluginAuthSender, PluginAlbumSender, PluginArtistSender, PluginAudioSourceSender, PluginAuthSender,
PluginBrowseSender, PluginCoreSender, PluginPlaylistSender, PluginSearchSender, PluginBrowseSender, PluginCoreSender, PluginPlaylistSender, PluginSearchSender,
PluginTrackSender, PluginUserSender, PluginTrackSender, PluginUserSender,
}; };
use crate::frb_generated::StreamSink;
use crate::internal::apis::webview;
use anyhow::anyhow; use anyhow::anyhow;
use flutter_rust_bridge::frb; use flutter_rust_bridge::{frb, Rust2DartSendError};
use llrt_modules::{abort, buffer, console, crypto, events, exceptions, fetch, navigator, timers, url, util};
use llrt_modules::module_builder::ModuleBuilder; use llrt_modules::module_builder::ModuleBuilder;
use rquickjs::{async_with, AsyncContext, AsyncRuntime, Error}; use llrt_modules::{
abort, buffer, console, crypto, events, exceptions, fetch, navigator, timers, url, util,
};
use rquickjs::prelude::Func;
use rquickjs::{async_with, AsyncContext, AsyncRuntime, CatchResultExt, Error, Object};
use std::thread; use std::thread;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::{Receiver, Sender};
use tokio::task; use tokio::task;
use tokio::task::LocalSet; use tokio::task::LocalSet;
@ -25,8 +31,11 @@ pub struct OpaqueSender {
pub sender: Sender<PluginCommand>, pub sender: Sender<PluginCommand>,
} }
#[frb(ignore)] // #[frb(ignore)]
async fn create_context() -> anyhow::Result<(AsyncContext, AsyncRuntime)> { async fn create_context(
server_endpoint_url: String,
server_secret: String,
) -> anyhow::Result<(AsyncContext, AsyncRuntime)> {
let runtime = AsyncRuntime::new().expect("Unable to create async runtime"); let runtime = AsyncRuntime::new().expect("Unable to create async runtime");
let mut module_builder = ModuleBuilder::new(); let mut module_builder = ModuleBuilder::new();
@ -42,8 +51,7 @@ async fn create_context() -> anyhow::Result<(AsyncContext, AsyncRuntime)> {
.with_global(navigator::init) .with_global(navigator::init)
.with_global(url::init) .with_global(url::init)
.with_global(timers::init) .with_global(timers::init)
.with_global(util::init) .with_global(util::init);
;
let (module_resolver, module_loader, global_attachment) = module_builder.build(); let (module_resolver, module_loader, global_attachment) = module_builder.build();
runtime runtime
@ -55,8 +63,9 @@ async fn create_context() -> anyhow::Result<(AsyncContext, AsyncRuntime)> {
.expect("Unable to create async context"); .expect("Unable to create async context");
async_with!(context => |ctx| { async_with!(context => |ctx| {
global_attachment.attach(&ctx)?; global_attachment.attach(&ctx).catch(&ctx).map_err(|e| anyhow!("Failed to attach global modules: {}", e))?;
Ok::<(), Error>(()) webview::init(&ctx, server_endpoint_url, server_secret).catch(&ctx).map_err(|e| anyhow!("Failed to initialize WebView API: {}", e))?;
anyhow::Ok(())
}) })
.await .await
.map_err(|e| anyhow!("Failed to register globals: {}", e))?; .map_err(|e| anyhow!("Failed to register globals: {}", e))?;
@ -65,8 +74,8 @@ async fn create_context() -> anyhow::Result<(AsyncContext, AsyncRuntime)> {
} }
#[frb(ignore)] #[frb(ignore)]
async fn js_executor_thread( async fn js_executor_thread(
rx: &mut mpsc::Receiver<PluginCommand>, rx: &mut Receiver<PluginCommand>,
ctx: &AsyncContext, context: &AsyncContext,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
while let Some(command) = rx.recv().await { while let Some(command) = rx.recv().await {
println!("JS Executor thread received command: {:?}", command); println!("JS Executor thread received command: {:?}", command);
@ -75,19 +84,21 @@ async fn js_executor_thread(
return anyhow::Ok(()); return anyhow::Ok(());
} }
let ctx = ctx.clone(); let context = context.clone();
task::spawn_local(async move { task::spawn_local(async move {
let result = match command { let result = match command {
PluginCommand::Artist(commands) => execute_artists(commands, &ctx).await, PluginCommand::Artist(commands) => execute_artists(commands, &context).await,
PluginCommand::Album(commands) => execute_albums(commands, &ctx).await, PluginCommand::Album(commands) => execute_albums(commands, &context).await,
PluginCommand::AudioSource(commands) => execute_audio_source(commands, &ctx).await, PluginCommand::AudioSource(commands) => {
PluginCommand::Auth(commands) => execute_auth(commands, &ctx).await, execute_audio_source(commands, &context).await
PluginCommand::Browse(commands) => execute_browse(commands, &ctx).await, }
PluginCommand::Core(commands) => execute_core(commands, &ctx).await, PluginCommand::Auth(commands) => execute_auth(commands, &context).await,
PluginCommand::Playlist(commands) => execute_playlist(commands, &ctx).await, PluginCommand::Browse(commands) => execute_browse(commands, &context).await,
PluginCommand::Search(commands) => execute_search(commands, &ctx).await, PluginCommand::Core(commands) => execute_core(commands, &context).await,
PluginCommand::Track(commands) => execute_track(commands, &ctx).await, PluginCommand::Playlist(commands) => execute_playlist(commands, &context).await,
PluginCommand::User(commands) => execute_user(commands, &ctx).await, PluginCommand::Search(commands) => execute_search(commands, &context).await,
PluginCommand::Track(commands) => execute_track(commands, &context).await,
PluginCommand::User(commands) => execute_user(commands, &context).await,
PluginCommand::Shutdown => unreachable!(), PluginCommand::Shutdown => unreachable!(),
}; };
@ -110,11 +121,15 @@ pub struct SpotubePlugin {
pub search: PluginSearchSender, pub search: PluginSearchSender,
pub track: PluginTrackSender, pub track: PluginTrackSender,
pub user: PluginUserSender, pub user: PluginUserSender,
event_tx: Sender<AuthEventObject>,
event_rx: Receiver<AuthEventObject>,
} }
impl SpotubePlugin { impl SpotubePlugin {
#[frb(sync)] #[frb(sync)]
pub fn new() -> Self { pub fn new() -> Self {
let (event_tx, event_rx) = mpsc::channel(32);
Self { Self {
artist: PluginArtistSender::new(), artist: PluginArtistSender::new(),
album: PluginAlbumSender::new(), album: PluginAlbumSender::new(),
@ -126,16 +141,30 @@ impl SpotubePlugin {
search: PluginSearchSender::new(), search: PluginSearchSender::new(),
track: PluginTrackSender::new(), track: PluginTrackSender::new(),
user: PluginUserSender::new(), user: PluginUserSender::new(),
event_tx,
event_rx,
} }
} }
#[frb(sync)] pub async fn auth_state(&mut self, sink: StreamSink<AuthEventObject>) -> anyhow::Result<()> {
pub fn new_context( while let Some(event) = self.event_rx.recv().await {
sink.add(event)
.map_err(|e: Rust2DartSendError| anyhow::anyhow!(e))?;
}
Ok(())
}
// #[frb(sync)]
pub fn create_context(
&self,
plugin_script: String, plugin_script: String,
plugin_config: PluginConfiguration, plugin_config: PluginConfiguration,
server_endpoint_url: String,
server_secret: String,
) -> anyhow::Result<OpaqueSender> { ) -> anyhow::Result<OpaqueSender> {
let (command_tx, mut command_rx) = mpsc::channel(32); let (command_tx, mut command_rx) = mpsc::channel(32);
let sender = self.event_tx.clone();
let _thread_handle = thread::spawn(move || { let _thread_handle = thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread() let rt = tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
@ -143,7 +172,10 @@ impl SpotubePlugin {
.unwrap(); .unwrap();
let local = LocalSet::new(); let local = LocalSet::new();
if let Err(e) = local.block_on(&rt, async { if let Err(e) = local.block_on(&rt, async {
let (ctx, runtime) = create_context().await?; let (ctx, _) = create_context(
server_endpoint_url,
server_secret,
).await?;
let injection = format!( let injection = format!(
"globalThis.pluginInstance = new {}();", "globalThis.pluginInstance = new {}();",
@ -151,7 +183,43 @@ impl SpotubePlugin {
); );
let script = format!("{}\n{}", plugin_script, injection); let script = format!("{}\n{}", plugin_script, injection);
ctx.with(|cx| cx.eval::<(), _>(script.as_str())).await?; async_with!(ctx => |cx| {
cx.eval::<(), _>(script.as_str())
.catch(&cx).map_err(|e| anyhow!("Failed to evaluate supplied plugin script: {}", e))
}).await?;
async_with!(ctx => |ctx|{
let globals = ctx.globals();
let callback = Func::new(move |event: Object| -> rquickjs::Result<()>{
let sender_clone = sender.clone();
let event_type_js: rquickjs::String = event.get("eventType")?;
let event_type = serde_json::from_value::<AuthEventType>(serde_json::Value::String(event_type_js.to_string()?));
if let Ok(event_type) = event_type {
tokio::spawn(async move{
if let Err(e) = sender_clone.send(AuthEventObject{event_type}).await {
eprintln!("Error sending auth event: {:?}", e);
}
});
Ok(())
} else {
Err(Error::FromJs{
from: "event.eventType",
to: "AuthEventType",
message: Some("Failed to deserialize eventType".to_string())
})
}
});
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}"))?;
if let Err(e) = js_executor_thread(&mut command_rx, &ctx).await { if let Err(e) = js_executor_thread(&mut command_rx, &ctx).await {
eprintln!("JS executor error: {}", e); eprintln!("JS executor error: {}", e);
@ -165,7 +233,7 @@ impl SpotubePlugin {
Ok(OpaqueSender { sender: command_tx }) Ok(OpaqueSender { sender: command_tx })
} }
pub async fn dispose(&self, tx: OpaqueSender) -> anyhow::Result<()> { pub async fn close(&self, tx: OpaqueSender) -> anyhow::Result<()> {
tx.sender.send(PluginCommand::Shutdown).await?; tx.sender.send(PluginCommand::Shutdown).await?;
Ok(()) Ok(())
} }

View File

@ -1,4 +1,3 @@
use std::backtrace::Backtrace;
use crate::api::plugin::commands::{ use crate::api::plugin::commands::{
AlbumCommands, ArtistCommands, AudioSourceCommands, AuthCommands, BrowseCommands, CoreCommands, AlbumCommands, ArtistCommands, AudioSourceCommands, AuthCommands, BrowseCommands, CoreCommands,
PlaylistCommands, PluginCommand, SearchCommands, TrackCommands, UserCommands, PlaylistCommands, PluginCommand, SearchCommands, TrackCommands, UserCommands,
@ -19,8 +18,10 @@ use crate::api::plugin::models::user::SpotubeUserObject;
use crate::api::plugin::plugin::OpaqueSender; use crate::api::plugin::plugin::OpaqueSender;
use anyhow::anyhow; use anyhow::anyhow;
use flutter_rust_bridge::frb; use flutter_rust_bridge::frb;
use std::backtrace::Backtrace;
use tokio::sync::oneshot; use tokio::sync::oneshot;
#[derive(Debug, Clone, Copy)]
pub struct PluginArtistSender {} pub struct PluginArtistSender {}
impl PluginArtistSender { impl PluginArtistSender {
@ -31,7 +32,7 @@ impl PluginArtistSender {
pub async fn get_artist( pub async fn get_artist(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
id: String, id: String,
) -> anyhow::Result<SpotubeFullArtistObject> { ) -> anyhow::Result<SpotubeFullArtistObject> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
@ -48,7 +49,7 @@ impl PluginArtistSender {
pub async fn top_tracks( pub async fn top_tracks(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
id: String, id: String,
offset: Option<u32>, offset: Option<u32>,
limit: Option<u32>, limit: Option<u32>,
@ -69,7 +70,7 @@ impl PluginArtistSender {
pub async fn albums( pub async fn albums(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
id: String, id: String,
offset: Option<u32>, offset: Option<u32>,
limit: Option<u32>, limit: Option<u32>,
@ -90,7 +91,7 @@ impl PluginArtistSender {
pub async fn related( pub async fn related(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
id: String, id: String,
offset: Option<u32>, offset: Option<u32>,
limit: Option<u32>, limit: Option<u32>,
@ -109,7 +110,7 @@ impl PluginArtistSender {
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o) rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
} }
pub async fn save(&self, mpsc_tx: OpaqueSender, ids: Vec<String>) -> anyhow::Result<()> { pub async fn save(&self, mpsc_tx: &OpaqueSender, ids: Vec<String>) -> anyhow::Result<()> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
mpsc_tx mpsc_tx
.sender .sender
@ -122,7 +123,7 @@ impl PluginArtistSender {
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o) rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
} }
pub async fn unsave(&self, mpsc_tx: OpaqueSender, ids: Vec<String>) -> anyhow::Result<()> { pub async fn unsave(&self, mpsc_tx: &OpaqueSender, ids: Vec<String>) -> anyhow::Result<()> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
mpsc_tx mpsc_tx
.sender .sender
@ -136,6 +137,7 @@ impl PluginArtistSender {
} }
} }
#[derive(Debug, Clone, Copy)]
pub struct PluginAlbumSender {} pub struct PluginAlbumSender {}
impl PluginAlbumSender { impl PluginAlbumSender {
@ -146,7 +148,7 @@ impl PluginAlbumSender {
pub async fn get_album( pub async fn get_album(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
id: String, id: String,
) -> anyhow::Result<SpotubeFullAlbumObject> { ) -> anyhow::Result<SpotubeFullAlbumObject> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
@ -163,7 +165,7 @@ impl PluginAlbumSender {
pub async fn tracks( pub async fn tracks(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
id: String, id: String,
offset: Option<u32>, offset: Option<u32>,
limit: Option<u32>, limit: Option<u32>,
@ -184,7 +186,7 @@ impl PluginAlbumSender {
pub async fn releases( pub async fn releases(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
offset: Option<u32>, offset: Option<u32>,
limit: Option<u32>, limit: Option<u32>,
) -> anyhow::Result<SpotubePaginationResponseObject> { ) -> anyhow::Result<SpotubePaginationResponseObject> {
@ -201,7 +203,7 @@ impl PluginAlbumSender {
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o) rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
} }
pub async fn save(&self, mpsc_tx: OpaqueSender, ids: Vec<String>) -> anyhow::Result<()> { pub async fn save(&self, mpsc_tx: &OpaqueSender, ids: Vec<String>) -> anyhow::Result<()> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
mpsc_tx mpsc_tx
.sender .sender
@ -214,7 +216,7 @@ impl PluginAlbumSender {
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o) rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
} }
pub async fn unsave(&self, mpsc_tx: OpaqueSender, ids: Vec<String>) -> anyhow::Result<()> { pub async fn unsave(&self, mpsc_tx: &OpaqueSender, ids: Vec<String>) -> anyhow::Result<()> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
mpsc_tx mpsc_tx
.sender .sender
@ -228,6 +230,7 @@ impl PluginAlbumSender {
} }
} }
#[derive(Debug, Clone, Copy)]
pub struct PluginAudioSourceSender {} pub struct PluginAudioSourceSender {}
impl PluginAudioSourceSender { impl PluginAudioSourceSender {
@ -238,7 +241,7 @@ impl PluginAudioSourceSender {
pub async fn matches( pub async fn matches(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
track: SpotubeTrackObject, track: SpotubeTrackObject,
) -> anyhow::Result<Vec<SpotubeAudioSourceMatchObject>> { ) -> anyhow::Result<Vec<SpotubeAudioSourceMatchObject>> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
@ -255,7 +258,7 @@ impl PluginAudioSourceSender {
pub async fn streams( pub async fn streams(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
matched: SpotubeAudioSourceMatchObject, matched: SpotubeAudioSourceMatchObject,
) -> anyhow::Result<Vec<SpotubeAudioSourceStreamObject>> { ) -> anyhow::Result<Vec<SpotubeAudioSourceStreamObject>> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
@ -271,6 +274,7 @@ impl PluginAudioSourceSender {
} }
} }
#[derive(Debug, Clone, Copy)]
pub struct PluginAuthSender {} pub struct PluginAuthSender {}
impl PluginAuthSender { impl PluginAuthSender {
@ -279,7 +283,7 @@ impl PluginAuthSender {
Self {} Self {}
} }
pub async fn authenticate(&self, mpsc_tx: OpaqueSender) -> anyhow::Result<()> { pub async fn authenticate(&self, mpsc_tx: &OpaqueSender) -> anyhow::Result<()> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
mpsc_tx mpsc_tx
.sender .sender
@ -291,7 +295,7 @@ impl PluginAuthSender {
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o) rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
} }
pub async fn logout(&self, mpsc_tx: OpaqueSender) -> anyhow::Result<()> { pub async fn logout(&self, mpsc_tx: &OpaqueSender) -> anyhow::Result<()> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
mpsc_tx mpsc_tx
.sender .sender
@ -303,7 +307,7 @@ impl PluginAuthSender {
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o) rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
} }
pub async fn is_authenticated(&self, mpsc_tx: OpaqueSender) -> anyhow::Result<bool> { pub async fn is_authenticated(&self, mpsc_tx: &OpaqueSender) -> anyhow::Result<bool> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
mpsc_tx mpsc_tx
.sender .sender
@ -316,6 +320,7 @@ impl PluginAuthSender {
} }
} }
#[derive(Debug, Clone, Copy)]
pub struct PluginBrowseSender {} pub struct PluginBrowseSender {}
impl PluginBrowseSender { impl PluginBrowseSender {
@ -326,7 +331,7 @@ impl PluginBrowseSender {
pub async fn sections( pub async fn sections(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
offset: Option<u32>, offset: Option<u32>,
limit: Option<u32>, limit: Option<u32>,
) -> anyhow::Result<SpotubePaginationResponseObject> { ) -> anyhow::Result<SpotubePaginationResponseObject> {
@ -345,7 +350,7 @@ impl PluginBrowseSender {
pub async fn section_items( pub async fn section_items(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
id: String, id: String,
offset: Option<u32>, offset: Option<u32>,
limit: Option<u32>, limit: Option<u32>,
@ -365,6 +370,7 @@ impl PluginBrowseSender {
} }
} }
#[derive(Debug, Clone, Copy)]
pub struct PluginCoreSender {} pub struct PluginCoreSender {}
impl PluginCoreSender { impl PluginCoreSender {
@ -375,10 +381,9 @@ impl PluginCoreSender {
pub async fn check_update( pub async fn check_update(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
plugin_config: PluginConfiguration, plugin_config: PluginConfiguration,
) -> anyhow::Result<Option<PluginUpdateAvailable>> { ) -> anyhow::Result<Option<PluginUpdateAvailable>> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
mpsc_tx mpsc_tx
.sender .sender
@ -388,14 +393,16 @@ impl PluginCoreSender {
})) }))
.await?; .await?;
rx.await.map_err(|e| { rx.await
.map_err(|e| {
eprintln!("RecvError: {}", e); eprintln!("RecvError: {}", e);
eprintln!("Stack trace:\n{:?}", Backtrace::capture()); eprintln!("Stack trace:\n{:?}", Backtrace::capture());
anyhow!("{e}") anyhow!("{e}")
}).and_then(|o| o) })
.and_then(|o| o)
} }
pub async fn support(&self, mpsc_tx: OpaqueSender) -> anyhow::Result<String> { pub async fn support(&self, mpsc_tx: &OpaqueSender) -> anyhow::Result<String> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
mpsc_tx mpsc_tx
.sender .sender
@ -409,7 +416,7 @@ impl PluginCoreSender {
pub async fn scrobble( pub async fn scrobble(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
details: ScrobbleDetails, details: ScrobbleDetails,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
@ -425,6 +432,7 @@ impl PluginCoreSender {
} }
} }
#[derive(Debug, Clone, Copy)]
pub struct PluginPlaylistSender {} pub struct PluginPlaylistSender {}
impl PluginPlaylistSender { impl PluginPlaylistSender {
@ -435,7 +443,7 @@ impl PluginPlaylistSender {
pub async fn get_playlist( pub async fn get_playlist(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
id: String, id: String,
) -> anyhow::Result<SpotubeFullPlaylistObject> { ) -> anyhow::Result<SpotubeFullPlaylistObject> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
@ -452,7 +460,7 @@ impl PluginPlaylistSender {
pub async fn tracks( pub async fn tracks(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
id: String, id: String,
offset: Option<u32>, offset: Option<u32>,
limit: Option<u32>, limit: Option<u32>,
@ -473,7 +481,7 @@ impl PluginPlaylistSender {
pub async fn create_playlist( pub async fn create_playlist(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
user_id: String, user_id: String,
name: String, name: String,
description: Option<String>, description: Option<String>,
@ -498,7 +506,7 @@ impl PluginPlaylistSender {
pub async fn update_playlist( pub async fn update_playlist(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
playlist_id: String, playlist_id: String,
name: Option<String>, name: Option<String>,
description: Option<String>, description: Option<String>,
@ -523,7 +531,7 @@ impl PluginPlaylistSender {
pub async fn delete_playlist( pub async fn delete_playlist(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
playlist_id: String, playlist_id: String,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
@ -540,7 +548,7 @@ impl PluginPlaylistSender {
pub async fn add_tracks( pub async fn add_tracks(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
playlist_id: String, playlist_id: String,
track_ids: Vec<String>, track_ids: Vec<String>,
position: Option<u32>, position: Option<u32>,
@ -561,7 +569,7 @@ impl PluginPlaylistSender {
pub async fn remove_tracks( pub async fn remove_tracks(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
playlist_id: String, playlist_id: String,
track_ids: Vec<String>, track_ids: Vec<String>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
@ -578,7 +586,7 @@ impl PluginPlaylistSender {
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o) rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
} }
pub async fn save(&self, mpsc_tx: OpaqueSender, playlist_id: String) -> anyhow::Result<()> { pub async fn save(&self, mpsc_tx: &OpaqueSender, playlist_id: String) -> anyhow::Result<()> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
mpsc_tx mpsc_tx
.sender .sender
@ -591,7 +599,7 @@ impl PluginPlaylistSender {
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o) rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
} }
pub async fn unsave(&self, mpsc_tx: OpaqueSender, playlist_id: String) -> anyhow::Result<()> { pub async fn unsave(&self, mpsc_tx: &OpaqueSender, playlist_id: String) -> anyhow::Result<()> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
mpsc_tx mpsc_tx
.sender .sender
@ -605,6 +613,7 @@ impl PluginPlaylistSender {
} }
} }
#[derive(Debug, Clone, Copy)]
pub struct PluginSearchSender {} pub struct PluginSearchSender {}
impl PluginSearchSender { impl PluginSearchSender {
@ -613,7 +622,7 @@ impl PluginSearchSender {
Self {} Self {}
} }
pub async fn chips(&self, mpsc_tx: OpaqueSender) -> anyhow::Result<Vec<String>> { pub async fn chips(&self, mpsc_tx: &OpaqueSender) -> anyhow::Result<Vec<String>> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
mpsc_tx mpsc_tx
.sender .sender
@ -627,7 +636,7 @@ impl PluginSearchSender {
pub async fn all( pub async fn all(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
query: String, query: String,
) -> anyhow::Result<SpotubeSearchResponseObject> { ) -> anyhow::Result<SpotubeSearchResponseObject> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
@ -644,7 +653,7 @@ impl PluginSearchSender {
pub async fn tracks( pub async fn tracks(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
query: String, query: String,
offset: Option<u32>, offset: Option<u32>,
limit: Option<u32>, limit: Option<u32>,
@ -665,7 +674,7 @@ impl PluginSearchSender {
pub async fn albums( pub async fn albums(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
query: String, query: String,
offset: Option<u32>, offset: Option<u32>,
limit: Option<u32>, limit: Option<u32>,
@ -686,7 +695,7 @@ impl PluginSearchSender {
pub async fn artists( pub async fn artists(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
query: String, query: String,
offset: Option<u32>, offset: Option<u32>,
limit: Option<u32>, limit: Option<u32>,
@ -707,7 +716,7 @@ impl PluginSearchSender {
pub async fn playlists( pub async fn playlists(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
query: String, query: String,
offset: Option<u32>, offset: Option<u32>,
limit: Option<u32>, limit: Option<u32>,
@ -727,6 +736,7 @@ impl PluginSearchSender {
} }
} }
#[derive(Debug, Clone, Copy)]
pub struct PluginTrackSender {} pub struct PluginTrackSender {}
impl PluginTrackSender { impl PluginTrackSender {
@ -737,7 +747,7 @@ impl PluginTrackSender {
pub async fn get_track( pub async fn get_track(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
id: String, id: String,
) -> anyhow::Result<SpotubeTrackObject> { ) -> anyhow::Result<SpotubeTrackObject> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
@ -752,7 +762,7 @@ impl PluginTrackSender {
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o) rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
} }
pub async fn save(&self, mpsc_tx: OpaqueSender, ids: Vec<String>) -> anyhow::Result<()> { pub async fn save(&self, mpsc_tx: &OpaqueSender, ids: Vec<String>) -> anyhow::Result<()> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
mpsc_tx mpsc_tx
.sender .sender
@ -765,7 +775,7 @@ impl PluginTrackSender {
rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o) rx.await.map_err(|e| anyhow!("{e}")).and_then(|o| o)
} }
pub async fn unsave(&self, mpsc_tx: OpaqueSender, ids: Vec<String>) -> anyhow::Result<()> { pub async fn unsave(&self, mpsc_tx: &OpaqueSender, ids: Vec<String>) -> anyhow::Result<()> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
mpsc_tx mpsc_tx
.sender .sender
@ -780,7 +790,7 @@ impl PluginTrackSender {
pub async fn radio( pub async fn radio(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
id: String, id: String,
) -> anyhow::Result<Vec<SpotubeTrackObject>> { ) -> anyhow::Result<Vec<SpotubeTrackObject>> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
@ -796,6 +806,7 @@ impl PluginTrackSender {
} }
} }
#[derive(Debug, Clone, Copy)]
pub struct PluginUserSender {} pub struct PluginUserSender {}
impl PluginUserSender { impl PluginUserSender {
@ -804,7 +815,7 @@ impl PluginUserSender {
Self {} Self {}
} }
pub async fn me(&self, mpsc_tx: OpaqueSender) -> anyhow::Result<SpotubeUserObject> { pub async fn me(&self, mpsc_tx: &OpaqueSender) -> anyhow::Result<SpotubeUserObject> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
mpsc_tx mpsc_tx
.sender .sender
@ -816,7 +827,7 @@ impl PluginUserSender {
pub async fn saved_tracks( pub async fn saved_tracks(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
offset: Option<u32>, offset: Option<u32>,
limit: Option<u32>, limit: Option<u32>,
) -> anyhow::Result<SpotubePaginationResponseObject> { ) -> anyhow::Result<SpotubePaginationResponseObject> {
@ -835,7 +846,7 @@ impl PluginUserSender {
pub async fn saved_albums( pub async fn saved_albums(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
offset: Option<u32>, offset: Option<u32>,
limit: Option<u32>, limit: Option<u32>,
) -> anyhow::Result<SpotubePaginationResponseObject> { ) -> anyhow::Result<SpotubePaginationResponseObject> {
@ -854,7 +865,7 @@ impl PluginUserSender {
pub async fn saved_artists( pub async fn saved_artists(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
offset: Option<u32>, offset: Option<u32>,
limit: Option<u32>, limit: Option<u32>,
) -> anyhow::Result<SpotubePaginationResponseObject> { ) -> anyhow::Result<SpotubePaginationResponseObject> {
@ -873,7 +884,7 @@ impl PluginUserSender {
pub async fn saved_playlists( pub async fn saved_playlists(
&self, &self,
mpsc_tx: OpaqueSender, mpsc_tx: &OpaqueSender,
offset: Option<u32>, offset: Option<u32>,
limit: Option<u32>, limit: Option<u32>,
) -> anyhow::Result<SpotubePaginationResponseObject> { ) -> anyhow::Result<SpotubePaginationResponseObject> {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,167 @@
use eventsource_client::{ClientBuilder, Client, SSE};
use flutter_rust_bridge::for_generated::futures::StreamExt;
use rquickjs::function::Func;
use rquickjs::{CatchResultExt, Ctx, Error as JsError, Function, Object, Value};
use tokio::sync::mpsc;
fn connect_sse<'js>(ctx: Ctx<'js>, config: Object<'js>) -> rquickjs::Result<Object<'js>> {
let url: String = config.get("url")?;
let on_connecting: Function = config.get("onConnecting")?;
let on_open: Function = config.get("onOpen")?;
let on_message: Function = config.get("onMessage")?;
let on_error: Function = config.get("onError")?;
let (close_tx, mut close_rx) = mpsc::unbounded_channel::<()>();
if let Err(e) = on_connecting.call::<_, ()>(()).catch(&ctx) {
eprintln!("Error in onConnecting callback: {}", e);
}
// Spawn the SSE background task using Ctx::spawn
let _ = ctx.clone().spawn(async move {
let client = ClientBuilder::for_url(&url);
if let Err(err) = client {
eprintln!("Error in ClientBuilder::for_url: {}", err);
return;
}
let client = client.unwrap().build();
// Notify "open"
if let Err(e) = on_open.call::<(), ()>(()) {
eprintln!("Error in onOpen callback: {}", e);
}
// Now listen to SSE events OR close signal
let mut stream = Box::pin(client.stream());
loop {
tokio::select! {
// Check for close signal first
_ = close_rx.recv() => {
// Close requested — drop stream and exit
drop(stream);
break;
}
event = stream.next() => {
match event {
Some(Ok(SSE::Event(msg))) => {
let data = msg.data.clone();
if let Err(e) = on_message.call::<_, ()>((data,)) {
eprintln!("Error in onMessage callback: {}", e);
}
}
Some(Ok(SSE::Connected(details))) => {
println!("SSE Connected: {:?}", details);
}
Some(Ok(SSE::Comment(comment))) => {
println!("SSE Comment: {}", comment);
}
Some(Err(err)) => {
if let Err(e) = on_error.call::<_, ()>((err.to_string(),)) {
eprintln!("Error in onError callback: {}", e);
}
break;
}
None => {
println!("SSE Stream ended gracefully");
break;
}
}
}
}
}
});
// Create the close function that sends signal via channel
let close_fn = Function::new(ctx.clone(), move |_ctx: Ctx<'_>| {
// Send close signal — ignore errors if receiver is gone
let _ = close_tx.send(());
Ok::<(), JsError>(())
})?;
// Return { close: () => void }
let result = Object::new(ctx)?;
result.set("close", close_fn)?;
Ok(result)
}
pub fn init(ctx: &Ctx) -> rquickjs::Result<()> {
let globals = ctx.globals();
globals.set("__connectSSE", Func::new(connect_sse))?;
ctx.eval::<Value, _>(
r#"
globalThis.EventSource = class EventSource {
#listeners = {};
constructor(url, options) {
this.url = url;
this.options = options;
this.close = __connectSSE({
url: this.url,
onConnecting: this.#onConnecting.bind(this),
onOpen: this.#onOpen.bind(this),
onMessage: this.#onMessage.bind(this),
onError: this.#onError.bind(this),
}).close;
}
#onMessage(data) {
console.log("Received message:", data);
if (this.onmessage) {
this.onmessage(data);
}
const eventLines = data.split('\n');
if(eventLines.length === 0) return;
const eventNameChunks = eventLines[0].split("event:");
if(eventNameChunks.length === 0) return;
const eventName = eventNameChunks[1].trim();
if (!this.#listeners[eventName]) return;
const eventDataChunks = eventLines[1].split("data:");
if(eventDataChunks.length === 0) return;
const eventData = eventDataChunks[1].trim();
if (!eventData) return;
this.#listeners[eventName](eventData);
}
#onConnecting() {
this.readyState = 0;
}
#onOpen() {
this.readyState = 1;
if (this.onopen) {
this.onopen();
}
}
#onError(error) {
this.readyState = 2;
if (this.onerror) {
this.onerror(error);
}
}
addEventListener(event, callback) {
this.#listeners[event] ??= [];
this.#listeners[event].push(callback);
}
}
"#,
)?;
Ok(())
}

View File

@ -1 +1,2 @@
pub mod fetcher; pub mod event_source;
pub mod webview;

View File

@ -0,0 +1,215 @@
use eventsource_client::{Client as EventSourceClient, ClientBuilder};
use flutter_rust_bridge::for_generated::futures::StreamExt;
use rquickjs::{class::Trace, Class, Ctx, Function, JsLifetime};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Serialize, Deserialize)]
struct WebViewURLRequest {
url: String,
}
#[derive(Serialize, Deserialize)]
struct WebViewUIDRequest {
uid: String,
}
#[derive(Serialize, Deserialize)]
struct WebViewCookiesRequest {
url: String,
uid: String,
}
#[derive(Serialize, Deserialize)]
struct WebViewResponse {
uid: String,
}
#[derive(Clone, Trace, JsLifetime)]
#[rquickjs::class]
pub struct WebView<'js> {
pub url: String,
pub uid: String,
#[qjs(skip_trace)]
endpoint_url: String,
#[qjs(skip_trace)]
secret: String,
#[qjs(skip_trace)]
callbacks: Vec<Function<'js>>,
}
#[rquickjs::methods]
impl<'js> WebView<'js> {
#[qjs(constructor)]
pub fn new(url: String, uid: String, endpoint_url: String, secret: String) -> Self {
Self {
url,
uid,
endpoint_url,
secret,
callbacks: Vec::new(),
}
}
#[qjs(static)]
pub async fn create(ctx: Ctx<'js>, url: String) -> rquickjs::Result<Class<'js, WebView<'js>>> {
let endpoint_url: String = ctx.globals().get("__webviewUrl").unwrap_or_default();
let secret: String = ctx.globals().get("__webviewSecret").unwrap_or_default();
let client = reqwest::Client::new();
let endpoint = format!("{}/plugin-api/webview/create", endpoint_url.clone());
let response = client
.post(&endpoint)
.header("Content-Type", "application/json")
.header("X-Plugin-Secret", &secret)
.json(&WebViewURLRequest { url: url.clone() })
.send()
.await
.map_err(|e| {
rquickjs::Error::new_from_js_message("reqwest", "Error", &e.to_string())
})?;
let data: WebViewResponse = response.json().await.map_err(|e| {
rquickjs::Error::new_from_js_message("reqwest", "Error", &e.to_string())
})?;
let webview = WebView::new(url, data.uid, endpoint_url, secret);
Class::instance(ctx, webview)
}
pub async fn open(&self) -> rquickjs::Result<()> {
let client = reqwest::Client::new();
let endpoint = format!("{}/plugin-api/webview/open", self.endpoint_url);
client
.post(&endpoint)
.header("Content-Type", "application/json")
.header("X-Plugin-Secret", &self.secret)
.json(&WebViewUIDRequest {
uid: self.uid.clone(),
})
.send()
.await
.map_err(|e| {
rquickjs::Error::new_from_js_message("reqwest", "Error", &e.to_string())
})?;
self.url_change_task().await;
Ok(())
}
pub async fn cookies(&self, ctx: Ctx<'js>) -> rquickjs::Result<rquickjs::Value<'js>> {
let client = reqwest::Client::new();
let endpoint = format!("{}/plugin-api/webview/cookies", self.endpoint_url);
let response = client
.post(&endpoint)
.header("Content-Type", "application/json")
.header("X-Plugin-Secret", &self.secret)
.json(&WebViewCookiesRequest {
url: self.url.clone(),
uid: self.uid.clone(),
})
.send()
.await
.map_err(|e| {
rquickjs::Error::new_from_js_message("reqwest", "Error", &e.to_string())
})?;
let data: serde_json::Value = response.json().await.map_err(|e| {
rquickjs::Error::new_from_js_message("reqwest", "Error", &e.to_string())
})?;
let value = ctx.json_parse(data.to_string())?;
Ok(value)
}
pub async fn close(&self) -> rquickjs::Result<()> {
let client = reqwest::Client::new();
let endpoint = format!("{}/plugin-api/webview/close", self.endpoint_url);
client
.post(&endpoint)
.header("Content-Type", "application/json")
.header("X-Plugin-Secret", &self.secret)
.json(&WebViewUIDRequest {
uid: self.uid.clone(),
})
.send()
.await
.map_err(|e| {
rquickjs::Error::new_from_js_message("reqwest", "Error", &e.to_string())
})?;
Ok(())
}
#[qjs(rename = "onUrlChange")]
pub fn on_url_change(&mut self, callback: Function<'js>) -> rquickjs::Result<()> {
self.callbacks.push(callback);
Ok(())
}
async fn url_change_task(&self) {
let endpoint = format!(
"{}/plugin-api/webview/{}/on-url-request",
self.endpoint_url, self.uid
);
let secret = self.secret.clone();
let mut backoff = 1u64;
const MAX_BACKOFF: u64 = 60;
loop {
let client = ClientBuilder::for_url(&endpoint)
.expect("Failed to create EventSourceClient")
.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" {
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();
for callback in self.callbacks.iter() {
if let Err(e) = callback.call::<_, ()>((url.clone(),)) {
eprintln!("Error in onUrlChange callback: {}", e);
}
}
} else {
eprintln!("Failed to parse event data: {}", msg.data);
}
}
Ok(_) => {}
Err(err) => {
eprintln!("Error in EventSource stream: {}", err);
}
}
}
eprintln!("EventSource disconnected. Reconnecting in {}s...", backoff);
tokio::time::sleep(tokio::time::Duration::from_secs(backoff)).await;
backoff = (backoff * 2).min(MAX_BACKOFF);
}
}
}
pub fn init(ctx: &Ctx, endpoint_url: String, secret: String) -> rquickjs::Result<()> {
// Store config in globals for access in static methods
ctx.globals().set("__webviewUrl", endpoint_url)?;
ctx.globals().set("__webviewSecret", secret)?;
// Register the WebView class
Class::<WebView>::define(&ctx.globals())?;
Ok(())
}

View File

@ -2,7 +2,6 @@ use crate::api::plugin::models::core::{
PluginConfiguration, PluginUpdateAvailable, ScrobbleDetails, PluginConfiguration, PluginUpdateAvailable, ScrobbleDetails,
}; };
use crate::internal::utils::{js_invoke_async_method_to_json, js_invoke_method_to_json}; use crate::internal::utils::{js_invoke_async_method_to_json, js_invoke_method_to_json};
use anyhow::anyhow;
use flutter_rust_bridge::frb; use flutter_rust_bridge::frb;
use rquickjs::{async_with, AsyncContext}; use rquickjs::{async_with, AsyncContext};

View File

@ -1,5 +1,4 @@
pub mod album; pub mod album;
pub mod apis;
pub mod artist; pub mod artist;
pub mod audio_source; pub mod audio_source;
pub mod browse; pub mod browse;
@ -9,5 +8,6 @@ pub mod search;
pub mod track; pub mod track;
pub mod user; pub mod user;
pub mod auth; pub mod auth;
mod utils; pub(crate) mod utils;
pub(crate) mod apis;
// Export Context // Export Context

View File

@ -1,8 +1,8 @@
use anyhow::anyhow; use anyhow::anyhow;
use rquickjs::function::Args; use rquickjs::function::Args;
use rquickjs::{Array, CatchResultExt, Ctx, Filter, FromJs, Function, IntoJs, Object, Promise}; use rquickjs::{Array, CatchResultExt, Ctx, Filter, Function, IntoJs, Object, Promise};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde::{Deserialize, Deserializer, Serialize}; use serde::Serialize;
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use std::collections::HashMap; use std::collections::HashMap;
@ -120,7 +120,7 @@ where
.map_err(|e| anyhow!("{e}"))?; .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).map_err(|e| anyhow!("{e}"))?;
let mut args_js = Args::new(ctx.clone(), args.len() as usize); let mut args_js = Args::new(ctx.clone(), args.len() as usize);
for (i, arg) in args.iter().enumerate() { for arg in args.iter() {
let arg_value = serde_json::to_value(arg).map_err(|e| anyhow!("{e}"))?; 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}"))?; 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}"))?; args_js.push_arg(arg_js).map_err(|e| anyhow!("{e}"))?;
@ -161,7 +161,7 @@ where
.map_err(|e| anyhow!("{e}"))?; .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).map_err(|e| anyhow!("{e}"))?;
let mut args_js = Args::new(ctx.clone(), args.len() as usize); let mut args_js = Args::new(ctx.clone(), args.len() as usize);
for (i, arg) in args.iter().enumerate() { for arg in args.iter().enumerate() {
let arg_value = serde_json::to_value(arg).map_err(|e| anyhow!("{e}"))?; 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}"))?; 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}"))?; args_js.push_arg(arg_js).map_err(|e| anyhow!("{e}"))?;

View File

@ -1,4 +1,5 @@
mod api; mod api;
mod frb_generated;
mod internal; mod internal;
use rquickjs::function::{Async, Func}; use rquickjs::function::{Async, Func};
@ -67,9 +68,7 @@ function sleep(ms) {
class Core { class Core {
async checkUpdate() { async checkUpdate() {
console.log('Core checkUpdate'); console.log('Core checkUpdate');
const response = await fetch('https://api.github.com/repos/KRTirtho/spotube/releases/latest'); await sleep(1000);
const data = await response.json();
console.log(data);
console.log('No update available'); console.log('No update available');
} }
support() { support() {
@ -77,9 +76,12 @@ class Core {
} }
} }
class Auth {}
class TestingPlugin { class TestingPlugin {
constructor() { constructor() {
this.core = new Core(); this.core = new Core();
this.auth = new Auth();
} }
} }
"; ";
@ -98,10 +100,10 @@ async fn plugin() -> anyhow::Result<()> {
repository: None, repository: None,
version: "0.1.0".to_string(), version: "0.1.0".to_string(),
}; };
let sender = SpotubePlugin::new_context(PLUGIN_JS.to_string(), config.clone())?; let sender = plugin.create_context(PLUGIN_JS.to_string(), config.clone(), "".to_string(), "".to_string())?;
let (r1, r2) = tokio::join!( let (r1, r2) = tokio::join!(
plugin.core.check_update(sender.clone(), config.clone()), plugin.core.check_update(&sender, config.clone()),
plugin.core.check_update(sender.clone(), config.clone()) plugin.core.check_update(&sender, config.clone())
); );
r1?; r1?;
r2?; r2?;