diff --git a/lib/main.dart b/lib/main.dart index e871ae26..e5751788 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -210,19 +210,21 @@ class CoreEndpoint { // } // }); // await webview.open(); - const res = await SpotubeForm.show("Hello", [ - { - objectType: "input", - id: "email", - variant: "text", - placeholder: "Enter your email", - defaultValue: null, - required: true, - regex: null, - } - ]) + // const res = await SpotubeForm.show("Hello", [ + // { + // objectType: "input", + // id: "email", + // variant: "text", + // placeholder: "Enter your email", + // defaultValue: null, + // required: true, + // regex: null, + // } + // ]) + // console.log("Form Result: ", res); - console.log("Form Result: ", res); + console.log("LocalStorage Value: ", localStorage.getItem("test_key")); + localStorage.setItem("test_key", "test_value"); } } class Plugin { diff --git a/lib/provider/server/router.dart b/lib/provider/server/router.dart index 8ad786d2..6cc626f8 100644 --- a/lib/provider/server/router.dart +++ b/lib/provider/server/router.dart @@ -4,6 +4,7 @@ import 'package:shelf_router/shelf_router.dart'; import 'package:spotube/provider/server/routes/connect.dart'; import 'package:spotube/provider/server/routes/playback.dart'; import 'package:spotube/provider/server/routes/plugin_apis/form.dart'; +import 'package:spotube/provider/server/routes/plugin_apis/path_provider.dart'; import 'package:spotube/provider/server/routes/plugin_apis/webview.dart'; Handler pluginApiAuthMiddleware(Handler handler) { @@ -58,6 +59,10 @@ final serverRouterProvider = Provider((ref) { "/plugin-api/form/show", pluginApiAuthMiddleware(formRoutes.showForm), ); + router.get( + "/plugin/localstorage/directories", + pluginApiAuthMiddleware(ServerPathProviderRoutes.getDirectories), + ); router.all("/ws", connectRoutes.websocket); diff --git a/lib/provider/server/routes/plugin_apis/path_provider.dart b/lib/provider/server/routes/plugin_apis/path_provider.dart new file mode 100644 index 00000000..55f1ad8b --- /dev/null +++ b/lib/provider/server/routes/plugin_apis/path_provider.dart @@ -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 getDirectories(Request request) async { + final directories = { + 'temporary': await Future.value(pp.getTemporaryDirectory()) + .catchError((e) => null), + 'applicationDocuments': + await Future.value(pp.getApplicationDocumentsDirectory()) + .catchError((e) => null), + 'applicationSupport': + await Future.value(pp.getApplicationSupportDirectory()) + .catchError((e) => null), + 'library': await Future.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'}, + ); + } +} diff --git a/rust/Cargo.lock b/rust/Cargo.lock index a1db558d..71a8f46f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -404,6 +404,19 @@ dependencies = [ "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]] name = "console_error_panic_hook" version = "0.1.7" @@ -721,6 +734,17 @@ dependencies = [ "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]] name = "event-listener" version = "5.4.0" @@ -1558,9 +1582,9 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" @@ -2898,6 +2922,7 @@ name = "rust_lib_spotube" version = "0.1.0" dependencies = [ "anyhow", + "confy", "eventsource-client", "flutter_rust_bridge", "heck", @@ -3163,6 +3188,15 @@ dependencies = [ "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]] name = "serde_urlencoded" version = "0.7.1" @@ -3373,6 +3407,26 @@ dependencies = [ "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]] name = "threadpool" version = "1.8.1" @@ -3474,6 +3528,21 @@ dependencies = [ "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]] name = "toml_datetime" version = "0.7.3" @@ -3504,6 +3573,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" + [[package]] name = "tower" version = "0.5.2" @@ -3884,6 +3959,15 @@ dependencies = [ "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]] name = "windows-sys" version = "0.60.2" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 0b67eab7..d66ee23f 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -21,6 +21,7 @@ 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"] } eventsource-client = "0.15.1" reqwest = { version = "0.12", features = ["json"] } +confy = "2.0.0" [patch."https://github.com/DelSkayn/rquickjs"] rquickjs = "0.10.0" diff --git a/rust/src/api/plugin/plugin.rs b/rust/src/api/plugin/plugin.rs index d6116c8b..9874b121 100644 --- a/rust/src/api/plugin/plugin.rs +++ b/rust/src/api/plugin/plugin.rs @@ -1,4 +1,3 @@ -use std::fmt::format; use crate::api::plugin::commands::PluginCommand; use crate::api::plugin::executors::{ execute_albums, execute_artists, execute_audio_source, execute_auth, execute_browse, @@ -13,7 +12,7 @@ use crate::api::plugin::senders::{ }; use crate::frb_generated::StreamSink; use crate::internal::apis; -use crate::internal::apis::{form, webview}; +use crate::internal::apis::{form, get_platform_directories, webview}; use anyhow::anyhow; use flutter_rust_bridge::{frb, Rust2DartSendError}; use llrt_modules::module_builder::ModuleBuilder; @@ -37,6 +36,7 @@ pub struct OpaqueSender { async fn create_context( server_endpoint_url: String, server_secret: String, + plugin_slug: String, ) -> anyhow::Result<(AsyncContext, AsyncRuntime)> { let runtime = AsyncRuntime::new().expect("Unable to create async runtime"); @@ -66,13 +66,19 @@ async fn create_context( .await .expect("Unable to create async context"); + let directories = + get_platform_directories(server_endpoint_url.clone(), server_secret.clone()).await?; + let local_storage_dir = directories + .application_support + .ok_or_else(|| anyhow!("Application support directory not found"))?; + async_with!(context => |ctx| { - apis::init(&ctx, server_endpoint_url, server_secret)?; + 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))?; anyhow::Ok(()) }) - .await - .map_err(|e| anyhow!("Failed to register globals: {}", e))?; + .await?; Ok((context, runtime)) } @@ -159,7 +165,7 @@ impl SpotubePlugin { Ok(()) } - // #[frb(sync)] + #[frb(sync)] pub fn create_context( &self, plugin_script: String, @@ -179,6 +185,7 @@ impl SpotubePlugin { let (ctx, _) = create_context( server_endpoint_url, server_secret, + plugin_config.slug(), ).await?; let injection = format!( diff --git a/rust/src/internal/apis/event_source.rs b/rust/src/internal/apis/event_source.rs deleted file mode 100644 index 615f0b06..00000000 --- a/rust/src/internal/apis/event_source.rs +++ /dev/null @@ -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> { - 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::( - 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(()) -} diff --git a/rust/src/internal/apis/local_storage.rs b/rust/src/internal/apis/local_storage.rs new file mode 100644 index 00000000..8b941d59 --- /dev/null +++ b/rust/src/internal/apis/local_storage.rs @@ -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, +} + +/// 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>, +} + +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 { + 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> { + 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::::define(&global)?; + + ctx.eval::(format!( + "globalThis.localStorage = new LocalStorage('{}', '{}');", + prefix, directory + ))?; + Ok(()) +} diff --git a/rust/src/internal/apis/mod.rs b/rust/src/internal/apis/mod.rs index 6c02f88b..46a0b1d8 100644 --- a/rust/src/internal/apis/mod.rs +++ b/rust/src/internal/apis/mod.rs @@ -1,7 +1,8 @@ use rquickjs::Ctx; +use serde::{Deserialize, Serialize}; -pub mod event_source; pub mod form; +pub mod local_storage; pub mod webview; pub fn init(ctx: &Ctx, endpoint_url: String, secret: String) -> rquickjs::Result<()> { @@ -9,3 +10,28 @@ pub fn init(ctx: &Ctx, endpoint_url: String, secret: String) -> rquickjs::Result ctx.globals().set("__serverSecret", secret)?; Ok(()) } + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct DirectoriesResponse { + pub temporary: Option, + pub application_documents: Option, + pub application_support: Option, + pub library: Option, + pub external_storage: Option, + pub downloads: Option, +} + +pub async fn get_platform_directories( + server_url: String, + server_secret: String, +) -> anyhow::Result { + 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::() + .await?) +}