mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-02-03 23:52:52 +00:00
Compare commits
4 Commits
fe83f50286
...
2b9c5730c9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b9c5730c9 | ||
|
|
4129a61d85 | ||
|
|
949519aa61 | ||
|
|
6da7fb7ac3 |
@ -45,8 +45,6 @@ import 'package:spotube/services/kv_store/encrypted_kv_store.dart';
|
|||||||
import 'package:spotube/services/kv_store/kv_store.dart';
|
import 'package:spotube/services/kv_store/kv_store.dart';
|
||||||
import 'package:spotube/services/logger/logger.dart';
|
import 'package:spotube/services/logger/logger.dart';
|
||||||
import 'package:spotube/services/wm_tools/wm_tools.dart';
|
import 'package:spotube/services/wm_tools/wm_tools.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/frb_generated.dart';
|
import 'package:spotube/src/rust/frb_generated.dart';
|
||||||
import 'package:spotube/utils/migrations/sandbox.dart';
|
import 'package:spotube/utils/migrations/sandbox.dart';
|
||||||
import 'package:spotube/utils/platform.dart';
|
import 'package:spotube/utils/platform.dart';
|
||||||
@ -178,58 +176,6 @@ 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;
|
||||||
|
|||||||
@ -3,7 +3,10 @@ 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/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/webview.dart';
|
||||||
|
import 'package:spotube/provider/server/routes/plugin_apis/yt_engine.dart';
|
||||||
|
|
||||||
Handler pluginApiAuthMiddleware(Handler handler) {
|
Handler pluginApiAuthMiddleware(Handler handler) {
|
||||||
return (Request request) {
|
return (Request request) {
|
||||||
@ -20,6 +23,8 @@ 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 webviewRoutes = ref.watch(serverWebviewRoutesProvider);
|
||||||
|
final formRoutes = ref.watch(serverFormRoutesProvider);
|
||||||
|
final ytEngineRoutes = ref.watch(serverYTEngineRoutesProvider);
|
||||||
|
|
||||||
final router = Router();
|
final router = Router();
|
||||||
|
|
||||||
@ -52,6 +57,27 @@ final serverRouterProvider = Provider((ref) {
|
|||||||
"/plugin-api/webview/cookies",
|
"/plugin-api/webview/cookies",
|
||||||
pluginApiAuthMiddleware(webviewRoutes.postGetWebviewCookies),
|
pluginApiAuthMiddleware(webviewRoutes.postGetWebviewCookies),
|
||||||
);
|
);
|
||||||
|
router.post(
|
||||||
|
"/plugin-api/form/show",
|
||||||
|
pluginApiAuthMiddleware(formRoutes.showForm),
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/plugin/localstorage/directories",
|
||||||
|
pluginApiAuthMiddleware(ServerPathProviderRoutes.getDirectories),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/plugin-api/yt-engine/search",
|
||||||
|
pluginApiAuthMiddleware(ytEngineRoutes.search),
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/plugin-api/yt-engine/video",
|
||||||
|
pluginApiAuthMiddleware(ytEngineRoutes.getVideo),
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/plugin-api/yt-engine/stream-manifest",
|
||||||
|
pluginApiAuthMiddleware(ytEngineRoutes.streamManifest),
|
||||||
|
);
|
||||||
|
|
||||||
router.all("/ws", connectRoutes.websocket);
|
router.all("/ws", connectRoutes.websocket);
|
||||||
|
|
||||||
|
|||||||
30
lib/provider/server/routes/plugin_apis/form.dart
Normal file
30
lib/provider/server/routes/plugin_apis/form.dart
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:riverpod/riverpod.dart';
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:spotube/collections/routes.dart';
|
||||||
|
import 'package:spotube/collections/routes.gr.dart';
|
||||||
|
import 'package:spotube/models/metadata/metadata.dart';
|
||||||
|
|
||||||
|
class ServerFormRoutes {
|
||||||
|
Future<Response> showForm(Request request) async {
|
||||||
|
final body = jsonDecode(await request.readAsString());
|
||||||
|
final res = await rootNavigatorKey.currentContext!.router
|
||||||
|
.push<List<Map<String, dynamic>>?>(
|
||||||
|
SettingsMetadataProviderFormRoute(
|
||||||
|
title: body["title"],
|
||||||
|
fields: (body["fields"] as List)
|
||||||
|
.map((e) => MetadataFormFieldObject.fromJson(e))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Response.ok(
|
||||||
|
jsonEncode(res),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final serverFormRoutesProvider = Provider((ref) => ServerFormRoutes());
|
||||||
29
lib/provider/server/routes/plugin_apis/path_provider.dart
Normal file
29
lib/provider/server/routes/plugin_apis/path_provider.dart
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
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'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,16 +1,38 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
import 'package:shelf_router/shelf_router.dart';
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
import 'package:spotube/provider/server/server.dart';
|
||||||
import 'package:spotube/src/plugin_api/webview/webview.dart';
|
import 'package:spotube/src/plugin_api/webview/webview.dart';
|
||||||
import 'package:async/async.dart';
|
import 'package:async/async.dart';
|
||||||
|
import 'package:encrypt/encrypt.dart' as encrypt;
|
||||||
|
|
||||||
class ServerWebviewRoutes {
|
class ServerWebviewRoutes {
|
||||||
|
final Ref ref;
|
||||||
|
ServerWebviewRoutes({required this.ref});
|
||||||
|
|
||||||
final Map<String, Webview> _webviews = {};
|
final Map<String, Webview> _webviews = {};
|
||||||
|
|
||||||
|
String _encryptCookies(dynamic cookies, String secret) {
|
||||||
|
final keyBytes = base64.decode(secret);
|
||||||
|
final key = encrypt.Key(keyBytes);
|
||||||
|
final ivBytes = List<int>.generate(16, (_) => Random.secure().nextInt(256));
|
||||||
|
final iv = encrypt.IV(Uint8List.fromList(ivBytes));
|
||||||
|
|
||||||
|
final encrypter = encrypt.Encrypter(
|
||||||
|
encrypt.AES(key, mode: encrypt.AESMode.cbc, padding: 'PKCS7'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final encrypted = encrypter.encrypt(jsonEncode(cookies), iv: iv);
|
||||||
|
final combined = iv.bytes + encrypted.bytes;
|
||||||
|
return base64.encode(combined);
|
||||||
|
}
|
||||||
|
|
||||||
Future<Response> postCreateWebview(Request request) async {
|
Future<Response> postCreateWebview(Request request) async {
|
||||||
final payload = jsonDecode(await request.readAsString());
|
final payload = jsonDecode(await request.readAsString());
|
||||||
final uri = Uri.parse(payload['url'] as String);
|
final uri = Uri.parse(payload['url'] as String);
|
||||||
@ -105,6 +127,8 @@ class ServerWebviewRoutes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Response> postGetWebviewCookies(Request request) async {
|
Future<Response> postGetWebviewCookies(Request request) async {
|
||||||
|
final secret = ref.read(serverRandomSecretProvider);
|
||||||
|
|
||||||
final body = jsonDecode(await request.readAsString());
|
final body = jsonDecode(await request.readAsString());
|
||||||
final uid = body['uid'] as String;
|
final uid = body['uid'] as String;
|
||||||
final url = body['url'] as String;
|
final url = body['url'] as String;
|
||||||
@ -114,8 +138,9 @@ class ServerWebviewRoutes {
|
|||||||
return Response.notFound('Webview with uid $uid not found');
|
return Response.notFound('Webview with uid $uid not found');
|
||||||
}
|
}
|
||||||
final cookies = await webview.getCookies(url);
|
final cookies = await webview.getCookies(url);
|
||||||
|
final encryptedCookies = _encryptCookies(cookies, secret);
|
||||||
return Response.ok(
|
return Response.ok(
|
||||||
jsonEncode(cookies),
|
jsonEncode({'data': encryptedCookies}),
|
||||||
encoding: utf8,
|
encoding: utf8,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@ -131,4 +156,5 @@ class ServerWebviewRoutes {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final serverWebviewRoutesProvider = Provider((ref) => ServerWebviewRoutes());
|
final serverWebviewRoutesProvider =
|
||||||
|
Provider((ref) => ServerWebviewRoutes(ref: ref));
|
||||||
|
|||||||
114
lib/provider/server/routes/plugin_apis/yt_engine.dart
Normal file
114
lib/provider/server/routes/plugin_apis/yt_engine.dart
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:spotube/provider/youtube_engine/youtube_engine.dart';
|
||||||
|
import 'package:spotube/services/youtube_engine/youtube_engine.dart';
|
||||||
|
|
||||||
|
class ServerYTEngineRoutes {
|
||||||
|
final Ref ref;
|
||||||
|
ServerYTEngineRoutes({required this.ref});
|
||||||
|
YouTubeEngine get youtubeEngine => ref.read(youtubeEngineProvider);
|
||||||
|
|
||||||
|
Future<Response> search(Request request) async {
|
||||||
|
final query = request.url.queryParameters["query"];
|
||||||
|
|
||||||
|
if (query == null || query.isEmpty) {
|
||||||
|
return Response.badRequest(
|
||||||
|
body: 'Query parameter "query" is required',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await youtubeEngine.searchVideos(query);
|
||||||
|
final mappedResult = result
|
||||||
|
.map((video) => {
|
||||||
|
'id': video.id.value,
|
||||||
|
'title': video.title,
|
||||||
|
'author': video.author,
|
||||||
|
'duration': video.duration?.inSeconds,
|
||||||
|
'description': video.description,
|
||||||
|
'uploadDate': video.uploadDate?.toIso8601String(),
|
||||||
|
'viewCount': video.engagement.viewCount,
|
||||||
|
'likeCount': video.engagement.likeCount,
|
||||||
|
'isLive': video.isLive,
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return Response.ok(
|
||||||
|
jsonEncode(mappedResult),
|
||||||
|
encoding: utf8,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> getVideo(Request request) async {
|
||||||
|
final videoId = request.url.queryParameters["videoId"];
|
||||||
|
|
||||||
|
if (videoId == null || videoId.isEmpty) {
|
||||||
|
return Response.badRequest(
|
||||||
|
body: 'Query parameter "videoId" is required',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final video = await youtubeEngine.getVideo(videoId);
|
||||||
|
final res = {
|
||||||
|
'id': video.id.value,
|
||||||
|
'title': video.title,
|
||||||
|
'author': video.author,
|
||||||
|
'duration': video.duration?.inSeconds,
|
||||||
|
'description': video.description,
|
||||||
|
'uploadDate': video.uploadDate?.toIso8601String(),
|
||||||
|
'viewCount': video.engagement.viewCount,
|
||||||
|
'likeCount': video.engagement.likeCount,
|
||||||
|
'isLive': video.isLive,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Response.ok(
|
||||||
|
jsonEncode(res),
|
||||||
|
encoding: utf8,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> streamManifest(Request request) async {
|
||||||
|
final videoId = request.url.queryParameters["videoId"];
|
||||||
|
|
||||||
|
if (videoId == null || videoId.isEmpty) {
|
||||||
|
return Response.badRequest(
|
||||||
|
body: 'Query parameter "videoId" is required',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final streamManifest =
|
||||||
|
await youtubeEngine.getStreamManifest(videoId).then((manifest) {
|
||||||
|
final streams = manifest.audioOnly
|
||||||
|
.map(
|
||||||
|
(stream) => {
|
||||||
|
'url': stream.url.toString(),
|
||||||
|
'quality': stream.qualityLabel,
|
||||||
|
'bitrate': stream.bitrate.bitsPerSecond,
|
||||||
|
'container': stream.container.name,
|
||||||
|
'videoId': stream.videoId,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
return streams;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.ok(
|
||||||
|
jsonEncode(streamManifest),
|
||||||
|
encoding: utf8,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final serverYTEngineRoutesProvider = Provider<ServerYTEngineRoutes>(
|
||||||
|
(ref) => ServerYTEngineRoutes(ref: ref),
|
||||||
|
);
|
||||||
@ -15,7 +15,7 @@ final serverRandomSecretProvider = Provider<String>(
|
|||||||
(ref) {
|
(ref) {
|
||||||
final random = Random.secure();
|
final random = Random.secure();
|
||||||
final values = List<int>.generate(16, (i) => random.nextInt(256));
|
final values = List<int>.generate(16, (i) => random.nextInt(256));
|
||||||
return base64Url.encode(values);
|
return base64.encode(values);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
final serverProvider = FutureProvider(
|
final serverProvider = FutureProvider(
|
||||||
|
|||||||
@ -2,10 +2,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:spotube/models/database/database.dart';
|
import 'package:spotube/models/database/database.dart';
|
||||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||||
import 'package:spotube/services/youtube_engine/newpipe_engine.dart';
|
import 'package:spotube/services/youtube_engine/newpipe_engine.dart';
|
||||||
|
import 'package:spotube/services/youtube_engine/youtube_engine.dart';
|
||||||
import 'package:spotube/services/youtube_engine/youtube_explode_engine.dart';
|
import 'package:spotube/services/youtube_engine/youtube_explode_engine.dart';
|
||||||
import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart';
|
import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart';
|
||||||
|
|
||||||
final youtubeEngineProvider = Provider((ref) {
|
final youtubeEngineProvider = Provider<YouTubeEngine>((ref) {
|
||||||
final engineMode = ref.watch(
|
final engineMode = ref.watch(
|
||||||
userPreferencesProvider.select((value) => value.youtubeClientEngine),
|
userPreferencesProvider.select((value) => value.youtubeClientEngine),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -82,7 +82,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.6.5"
|
version: "1.6.5"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: async
|
name: async
|
||||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||||
|
|||||||
@ -162,6 +162,7 @@ dependencies:
|
|||||||
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
|
random_user_agents: ^1.0.18
|
||||||
|
async: ^2.13.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
build_runner: ^2.4.13
|
build_runner: ^2.4.13
|
||||||
|
|||||||
128
rust/Cargo.lock
generated
128
rust/Cargo.lock
generated
@ -364,6 +364,16 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chrono-tz"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"phf 0.12.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cipher"
|
name = "cipher"
|
||||||
version = "0.5.0-rc.2"
|
version = "0.5.0-rc.2"
|
||||||
@ -404,6 +414,19 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "confy"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8807c397789cbe02bbdb1a27ea5f345584132808697b2a3f957c829829ee4814"
|
||||||
|
dependencies = [
|
||||||
|
"etcetera",
|
||||||
|
"lazy_static",
|
||||||
|
"serde",
|
||||||
|
"thiserror",
|
||||||
|
"toml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "console_error_panic_hook"
|
name = "console_error_panic_hook"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@ -721,6 +744,17 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "etcetera"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26c7b13d0780cb82722fd59f6f57f925e143427e4a75313a6c77243bf5326ae6"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"home",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "event-listener"
|
name = "event-listener"
|
||||||
version = "5.4.0"
|
version = "5.4.0"
|
||||||
@ -1558,9 +1592,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.4.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
@ -1788,7 +1822,7 @@ dependencies = [
|
|||||||
"hex-simd",
|
"hex-simd",
|
||||||
"llrt_build",
|
"llrt_build",
|
||||||
"memchr",
|
"memchr",
|
||||||
"phf",
|
"phf 0.13.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2432,6 +2466,15 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
|
||||||
|
dependencies = [
|
||||||
|
"phf_shared 0.12.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf"
|
name = "phf"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@ -2439,7 +2482,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_macros",
|
"phf_macros",
|
||||||
"phf_shared",
|
"phf_shared 0.13.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2449,7 +2492,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"phf_shared",
|
"phf_shared 0.13.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2459,12 +2502,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_generator",
|
"phf_generator",
|
||||||
"phf_shared",
|
"phf_shared 0.13.1",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_shared"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
|
||||||
|
dependencies = [
|
||||||
|
"siphasher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf_shared"
|
name = "phf_shared"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@ -2898,10 +2950,15 @@ name = "rust_lib_spotube"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"chrono-tz",
|
||||||
|
"confy",
|
||||||
"eventsource-client",
|
"eventsource-client",
|
||||||
"flutter_rust_bridge",
|
"flutter_rust_bridge",
|
||||||
"heck",
|
"heck",
|
||||||
|
"iana-time-zone",
|
||||||
"llrt_modules",
|
"llrt_modules",
|
||||||
|
"openssl",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rquickjs",
|
"rquickjs",
|
||||||
"serde",
|
"serde",
|
||||||
@ -3163,6 +3220,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@ -3373,6 +3439,26 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "threadpool"
|
name = "threadpool"
|
||||||
version = "1.8.1"
|
version = "1.8.1"
|
||||||
@ -3474,6 +3560,21 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.9.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde_core",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@ -3504,6 +3605,12 @@ dependencies = [
|
|||||||
"winnow",
|
"winnow",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_writer"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@ -3884,6 +3991,15 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.59.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
|
|||||||
@ -21,6 +21,11 @@ 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"
|
eventsource-client = "0.15.1"
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
confy = "2.0.0"
|
||||||
|
chrono-tz = "0.10"
|
||||||
|
iana-time-zone = "0.1"
|
||||||
|
base64 = "0.22.1"
|
||||||
|
openssl = "0.10.75"
|
||||||
|
|
||||||
[patch."https://github.com/DelSkayn/rquickjs"]
|
[patch."https://github.com/DelSkayn/rquickjs"]
|
||||||
rquickjs = "0.10.0"
|
rquickjs = "0.10.0"
|
||||||
|
|||||||
@ -11,7 +11,8 @@ use crate::api::plugin::senders::{
|
|||||||
PluginTrackSender, PluginUserSender,
|
PluginTrackSender, PluginUserSender,
|
||||||
};
|
};
|
||||||
use crate::frb_generated::StreamSink;
|
use crate::frb_generated::StreamSink;
|
||||||
use crate::internal::apis::webview;
|
use crate::internal::apis;
|
||||||
|
use crate::internal::apis::{form, get_platform_directories, timezone, webview, yt_engine};
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
use flutter_rust_bridge::{frb, Rust2DartSendError};
|
use flutter_rust_bridge::{frb, Rust2DartSendError};
|
||||||
use llrt_modules::module_builder::ModuleBuilder;
|
use llrt_modules::module_builder::ModuleBuilder;
|
||||||
@ -35,6 +36,7 @@ pub struct OpaqueSender {
|
|||||||
async fn create_context(
|
async fn create_context(
|
||||||
server_endpoint_url: String,
|
server_endpoint_url: String,
|
||||||
server_secret: String,
|
server_secret: String,
|
||||||
|
plugin_slug: String,
|
||||||
) -> anyhow::Result<(AsyncContext, AsyncRuntime)> {
|
) -> anyhow::Result<(AsyncContext, AsyncRuntime)> {
|
||||||
let runtime = AsyncRuntime::new().expect("Unable to create async runtime");
|
let runtime = AsyncRuntime::new().expect("Unable to create async runtime");
|
||||||
|
|
||||||
@ -51,7 +53,11 @@ async fn create_context(
|
|||||||
.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)
|
||||||
|
.with_global(form::init)
|
||||||
|
.with_global(webview::init)
|
||||||
|
.with_global(yt_engine::init)
|
||||||
|
.with_global(timezone::init);
|
||||||
|
|
||||||
let (module_resolver, module_loader, global_attachment) = module_builder.build();
|
let (module_resolver, module_loader, global_attachment) = module_builder.build();
|
||||||
runtime
|
runtime
|
||||||
@ -62,13 +68,19 @@ async fn create_context(
|
|||||||
.await
|
.await
|
||||||
.expect("Unable to create async context");
|
.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"))?;
|
||||||
|
|
||||||
async_with!(context => |ctx| {
|
async_with!(context => |ctx| {
|
||||||
|
apis::init(&ctx, server_endpoint_url, server_secret).catch(&ctx).map_err(|e| anyhow!("Failed to initialize APIs: {}", e))?;
|
||||||
|
apis::local_storage::init(&ctx, plugin_slug, local_storage_dir).catch(&ctx).map_err(|e| anyhow!("Failed to initialize LocalStorage API: {}", e))?;
|
||||||
global_attachment.attach(&ctx).catch(&ctx).map_err(|e| anyhow!("Failed to attach global modules: {}", e))?;
|
global_attachment.attach(&ctx).catch(&ctx).map_err(|e| anyhow!("Failed to attach global modules: {}", e))?;
|
||||||
webview::init(&ctx, server_endpoint_url, server_secret).catch(&ctx).map_err(|e| anyhow!("Failed to initialize WebView API: {}", e))?;
|
|
||||||
anyhow::Ok(())
|
anyhow::Ok(())
|
||||||
})
|
})
|
||||||
.await
|
.await?;
|
||||||
.map_err(|e| anyhow!("Failed to register globals: {}", e))?;
|
|
||||||
|
|
||||||
Ok((context, runtime))
|
Ok((context, runtime))
|
||||||
}
|
}
|
||||||
@ -155,7 +167,7 @@ impl SpotubePlugin {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// #[frb(sync)]
|
#[frb(sync)]
|
||||||
pub fn create_context(
|
pub fn create_context(
|
||||||
&self,
|
&self,
|
||||||
plugin_script: String,
|
plugin_script: String,
|
||||||
@ -175,6 +187,7 @@ impl SpotubePlugin {
|
|||||||
let (ctx, _) = create_context(
|
let (ctx, _) = create_context(
|
||||||
server_endpoint_url,
|
server_endpoint_url,
|
||||||
server_secret,
|
server_secret,
|
||||||
|
plugin_config.slug(),
|
||||||
).await?;
|
).await?;
|
||||||
|
|
||||||
let injection = format!(
|
let injection = format!(
|
||||||
|
|||||||
@ -1,167 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
25
rust/src/internal/apis/form.rs
Normal file
25
rust/src/internal/apis/form.rs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
use rquickjs::{Ctx, Value};
|
||||||
|
|
||||||
|
pub fn init(ctx: &Ctx) -> rquickjs::Result<()> {
|
||||||
|
ctx.eval::<Value, _>(
|
||||||
|
r#"
|
||||||
|
globalThis.SpotubeForm = class SpotubeForm {
|
||||||
|
static async show(title, fields) {
|
||||||
|
return await fetch(
|
||||||
|
`${__serverUrl}/plugin-api/form/show`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Plugin-Secret': __serverSecret,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ title, fields }),
|
||||||
|
}
|
||||||
|
).then(res=>res.json());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
115
rust/src/internal/apis/local_storage.rs
Normal file
115
rust/src/internal/apis/local_storage.rs
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
use rquickjs::class::Trace;
|
||||||
|
use rquickjs::{Class, Ctx, JsLifetime, Value};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
/// All values stored as strings; we convert at the edges.
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
|
||||||
|
struct LocalStorageConfig {
|
||||||
|
map: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LocalStorage backed by `confy`.
|
||||||
|
#[derive(Clone, JsLifetime, Trace)]
|
||||||
|
#[rquickjs::class]
|
||||||
|
pub struct LocalStorage {
|
||||||
|
#[qjs(skip_trace)]
|
||||||
|
prefix: String,
|
||||||
|
#[qjs(skip_trace)]
|
||||||
|
path: String,
|
||||||
|
#[qjs(skip_trace)]
|
||||||
|
state: Arc<Mutex<LocalStorageConfig>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_prefix(prefix: String, key: String) -> String {
|
||||||
|
format!("{}->{}", prefix, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rquickjs::methods]
|
||||||
|
impl LocalStorage {
|
||||||
|
#[qjs(constructor)]
|
||||||
|
pub fn new(prefix: String, directory: String) -> rquickjs::Result<Self> {
|
||||||
|
let path = Path::new(&directory).join("plugin_configs.toml");
|
||||||
|
let cfg: LocalStorageConfig = confy::load_path(path.clone()).map_err(|e| {
|
||||||
|
rquickjs::Error::new_from_js_message(
|
||||||
|
"local_storage",
|
||||||
|
"PersistenceError",
|
||||||
|
&e.to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Self {
|
||||||
|
prefix,
|
||||||
|
path: path.to_string_lossy().to_string(),
|
||||||
|
state: Arc::new(Mutex::new(cfg)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist current state to disk.
|
||||||
|
fn persist(&self) -> rquickjs::Result<()> {
|
||||||
|
let cfg = self.state.lock().unwrap().clone();
|
||||||
|
confy::store_path(self.path.clone(), cfg).map_err(|e| {
|
||||||
|
rquickjs::Error::new_from_js_message(
|
||||||
|
"local_storage",
|
||||||
|
"PersistenceError",
|
||||||
|
&e.to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[qjs(rename = "setItem")]
|
||||||
|
pub fn set_item(&self, key: String, value: String) -> rquickjs::Result<()> {
|
||||||
|
{
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
state
|
||||||
|
.map
|
||||||
|
.insert(merge_prefix(self.prefix.clone(), key), value);
|
||||||
|
}
|
||||||
|
self.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[qjs(rename = "getItem")]
|
||||||
|
pub fn get_item(&self, key: String) -> rquickjs::Result<Option<String>> {
|
||||||
|
let state = self.state.lock().map_err(|e| {
|
||||||
|
rquickjs::Error::new_from_js_message("local_storage", "LockError", &e.to_string())
|
||||||
|
})?;
|
||||||
|
let key = merge_prefix(self.prefix.clone(), key.clone());
|
||||||
|
Ok(state.map.get(key.as_str()).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[qjs(rename = "removeItem")]
|
||||||
|
pub fn remove_item(&self, key: String) -> rquickjs::Result<()> {
|
||||||
|
{
|
||||||
|
let mut state = self.state.lock().map_err(|e| {
|
||||||
|
rquickjs::Error::new_from_js_message("local_storage", "LockError", &e.to_string())
|
||||||
|
})?;
|
||||||
|
state
|
||||||
|
.map
|
||||||
|
.remove(merge_prefix(self.prefix.clone(), key).as_str());
|
||||||
|
}
|
||||||
|
self.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&self) -> rquickjs::Result<()> {
|
||||||
|
{
|
||||||
|
let mut state = self.state.lock().map_err(|e| {
|
||||||
|
rquickjs::Error::new_from_js_message("local_storage", "LockError", &e.to_string())
|
||||||
|
})?;
|
||||||
|
state.map.clear();
|
||||||
|
}
|
||||||
|
self.persist()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init(ctx: &Ctx, prefix: String, directory: String) -> rquickjs::Result<()> {
|
||||||
|
let global = ctx.globals();
|
||||||
|
Class::<LocalStorage>::define(&global)?;
|
||||||
|
|
||||||
|
ctx.eval::<Value, _>(format!(
|
||||||
|
"globalThis.localStorage = new LocalStorage('{}', '{}');",
|
||||||
|
prefix, directory
|
||||||
|
))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@ -1,2 +1,40 @@
|
|||||||
pub mod event_source;
|
use rquickjs::Ctx;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub mod form;
|
||||||
|
pub mod local_storage;
|
||||||
pub mod webview;
|
pub mod webview;
|
||||||
|
pub mod timezone;
|
||||||
|
pub mod yt_engine;
|
||||||
|
|
||||||
|
pub fn init(ctx: &Ctx, endpoint_url: String, secret: String) -> rquickjs::Result<()> {
|
||||||
|
ctx.globals().set("__serverUrl", endpoint_url)?;
|
||||||
|
ctx.globals().set("__serverSecret", secret)?;
|
||||||
|
|
||||||
|
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?)
|
||||||
|
}
|
||||||
|
|||||||
27
rust/src/internal/apis/timezone.rs
Normal file
27
rust/src/internal/apis/timezone.rs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
use rquickjs::prelude::Func;
|
||||||
|
use rquickjs::{Class, Ctx, Object};
|
||||||
|
|
||||||
|
pub fn get_local_timezone() -> rquickjs::Result<String> {
|
||||||
|
let timezone = iana_time_zone::get_timezone()
|
||||||
|
.map_err(|e| rquickjs::Error::new_from_js_message("Timezone", "Error", &e.to_string()))?;
|
||||||
|
Ok(timezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_available_timezones() -> rquickjs::Result<Vec<String>> {
|
||||||
|
let timezones: Vec<String> = chrono_tz::TZ_VARIANTS
|
||||||
|
.iter()
|
||||||
|
.map(|tz| tz.name().to_string())
|
||||||
|
.collect();
|
||||||
|
Ok(timezones)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init(ctx: &Ctx) -> rquickjs::Result<()> {
|
||||||
|
let globals = ctx.globals();
|
||||||
|
let timezone_obj = Object::new(ctx.clone())?;
|
||||||
|
timezone_obj.set("getLocalTimezone", Func::new(get_local_timezone))?;
|
||||||
|
timezone_obj.set("getAvailableTimezones", Func::new(get_available_timezones))?;
|
||||||
|
|
||||||
|
globals.set("Timezone", timezone_obj)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||||
use eventsource_client::{Client as EventSourceClient, ClientBuilder};
|
use eventsource_client::{Client as EventSourceClient, ClientBuilder};
|
||||||
use flutter_rust_bridge::for_generated::futures::StreamExt;
|
use flutter_rust_bridge::for_generated::futures::StreamExt;
|
||||||
use rquickjs::{class::Trace, Class, Ctx, Function, JsLifetime};
|
use openssl::symm::{decrypt, Cipher};
|
||||||
|
use rquickjs::{class::Trace, CatchResultExt, Class, Ctx, Function, JsLifetime, Value};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@ -52,8 +54,8 @@ impl<'js> WebView<'js> {
|
|||||||
|
|
||||||
#[qjs(static)]
|
#[qjs(static)]
|
||||||
pub async fn create(ctx: Ctx<'js>, url: String) -> rquickjs::Result<Class<'js, WebView<'js>>> {
|
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 endpoint_url: String = ctx.globals().get("__serverUrl").unwrap_or_default();
|
||||||
let secret: String = ctx.globals().get("__webviewSecret").unwrap_or_default();
|
let secret: String = ctx.globals().get("__serverSecret").unwrap_or_default();
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let endpoint = format!("{}/plugin-api/webview/create", endpoint_url.clone());
|
let endpoint = format!("{}/plugin-api/webview/create", endpoint_url.clone());
|
||||||
@ -78,7 +80,7 @@ impl<'js> WebView<'js> {
|
|||||||
Class::instance(ctx, webview)
|
Class::instance(ctx, webview)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn open(&self) -> rquickjs::Result<()> {
|
pub async fn open(&self, ctx: Ctx<'js>) -> rquickjs::Result<()> {
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let endpoint = format!("{}/plugin-api/webview/open", self.endpoint_url);
|
let endpoint = format!("{}/plugin-api/webview/open", self.endpoint_url);
|
||||||
|
|
||||||
@ -95,12 +97,13 @@ impl<'js> WebView<'js> {
|
|||||||
rquickjs::Error::new_from_js_message("reqwest", "Error", &e.to_string())
|
rquickjs::Error::new_from_js_message("reqwest", "Error", &e.to_string())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
self.url_change_task().await;
|
self.url_change_task(ctx).await;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn cookies(&self, ctx: Ctx<'js>) -> rquickjs::Result<rquickjs::Value<'js>> {
|
pub async fn cookies(&self, ctx: Ctx<'js>) -> rquickjs::Result<Value<'js>> {
|
||||||
|
let secret: String = ctx.globals().get("__serverSecret").unwrap_or_default();
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let endpoint = format!("{}/plugin-api/webview/cookies", self.endpoint_url);
|
let endpoint = format!("{}/plugin-api/webview/cookies", self.endpoint_url);
|
||||||
|
|
||||||
@ -122,7 +125,36 @@ impl<'js> WebView<'js> {
|
|||||||
rquickjs::Error::new_from_js_message("reqwest", "Error", &e.to_string())
|
rquickjs::Error::new_from_js_message("reqwest", "Error", &e.to_string())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let value = ctx.json_parse(data.to_string())?;
|
let enc = data.get("data").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||||
|
rquickjs::Error::new_from_js_message("cookies", "Error", "missing encrypted data")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let combined = STANDARD.decode(enc.trim()).map_err(|e| {
|
||||||
|
rquickjs::Error::new_from_js_message("cookies", "Error", &format!("b64 decode: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if combined.len() < 16 {
|
||||||
|
return Err(rquickjs::Error::new_from_js_message(
|
||||||
|
"cookies",
|
||||||
|
"Error",
|
||||||
|
"invalid payload (too short)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (iv, cipher_bytes) = combined.split_at(16);
|
||||||
|
let key = STANDARD.decode(secret.trim()).map_err(|e| {
|
||||||
|
rquickjs::Error::new_from_js_message("cookies", "Error", &format!("key decode: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let plain = decrypt(Cipher::aes_128_cbc(), &key, Some(iv), cipher_bytes).map_err(|e| {
|
||||||
|
rquickjs::Error::new_from_js_message("cookies", "Error", &format!("decrypt: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let cookies_json: serde_json::Value = serde_json::from_slice(&plain).map_err(|e| {
|
||||||
|
rquickjs::Error::new_from_js_message("cookies", "Error", &format!("json decode: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let value = ctx.json_parse(cookies_json.to_string())?;
|
||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,7 +184,7 @@ impl<'js> WebView<'js> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn url_change_task(&self) {
|
async fn url_change_task(&self, ctx: Ctx<'js>) {
|
||||||
let endpoint = format!(
|
let endpoint = format!(
|
||||||
"{}/plugin-api/webview/{}/on-url-request",
|
"{}/plugin-api/webview/{}/on-url-request",
|
||||||
self.endpoint_url, self.uid
|
self.endpoint_url, self.uid
|
||||||
@ -181,8 +213,17 @@ impl<'js> WebView<'js> {
|
|||||||
{
|
{
|
||||||
let url = data.get("url").cloned().unwrap_or_default();
|
let url = data.get("url").cloned().unwrap_or_default();
|
||||||
for callback in self.callbacks.iter() {
|
for callback in self.callbacks.iter() {
|
||||||
if let Err(e) = callback.call::<_, ()>((url.clone(),)) {
|
match callback.call::<_, Value>((url.clone(),)) {
|
||||||
eprintln!("Error in onUrlChange callback: {}", e);
|
Ok(res) => {
|
||||||
|
if let Some(promise) = res.into_promise() {
|
||||||
|
if let Err(e) = promise.into_future::<()>().await.catch(&ctx) {
|
||||||
|
eprintln!("Error in onUrlChange promise: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error in onUrlChange callback: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -203,12 +244,7 @@ impl<'js> WebView<'js> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init(ctx: &Ctx, endpoint_url: String, secret: String) -> rquickjs::Result<()> {
|
pub fn init(ctx: &Ctx) -> 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())?;
|
Class::<WebView>::define(&ctx.globals())?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
36
rust/src/internal/apis/yt_engine.rs
Normal file
36
rust/src/internal/apis/yt_engine.rs
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
use rquickjs::{Ctx, Value};
|
||||||
|
|
||||||
|
pub fn init(ctx: &Ctx) -> rquickjs::Result<()> {
|
||||||
|
ctx.eval::<Value, _>(
|
||||||
|
r#"
|
||||||
|
globalThis.YouTubeEngine = class YouTubeEngine {
|
||||||
|
async request(endpoint, qName, qValue) {
|
||||||
|
return await fetch(
|
||||||
|
`${__serverUrl}/plugin-api/yt-engine/${endpoint}?${qName}=${encodeURIComponent(qValue)}`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Plugin-Secret': __serverSecret,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
).then(res=>res.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(query) {
|
||||||
|
return await this.request('search', 'query', query);
|
||||||
|
}
|
||||||
|
|
||||||
|
async video(videoId) {
|
||||||
|
return await this.request('video', 'videoId', videoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async streamManifest(videoId) {
|
||||||
|
return await this.request('stream-manifest', 'videoId', videoId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user