From 933f8000dd9878be33fae9d20cb4bea7714383e7 Mon Sep 17 00:00:00 2001 From: lanyeeee Date: Wed, 11 Mar 2026 08:57:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=9B=E7=A8=8B=E5=86=85=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E5=BA=93=E6=8F=92=E4=BB=B6=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .prettierignore | 1 + src-plugin/.gitignore | 3 + src-plugin/Cargo.lock | 215 +++++++++++ src-plugin/Cargo.toml | 3 + src-plugin/plugin-api/Cargo.toml | 10 + src-plugin/plugin-api/src/lib.rs | 349 +++++++++++++++++ src-plugin/plugin-sdk/Cargo.toml | 14 + src-plugin/plugin-sdk/src/lib.rs | 191 ++++++++++ src-plugin/plugin-sdk/tests/macro_smoke.rs | 54 +++ src-tauri/Cargo.lock | 23 +- src-tauri/Cargo.toml | 3 +- src-tauri/src/commands.rs | 86 ++++- src-tauri/src/downloader/download_progress.rs | 83 +++-- src-tauri/src/events.rs | 9 + src-tauri/src/extensions.rs | 5 + src-tauri/src/lib.rs | 33 +- src-tauri/src/plugin.rs | 6 + src-tauri/src/plugin/hook_context.rs | 189 ++++++++++ src-tauri/src/plugin/host_api.rs | 67 ++++ src-tauri/src/plugin/plugin_executor.rs | 66 ++++ src-tauri/src/plugin/plugin_loader.rs | 78 ++++ src-tauri/src/plugin/plugin_manager.rs | 351 ++++++++++++++++++ src-tauri/src/plugin/plugin_types.rs | 45 +++ src-tauri/src/types.rs | 1 + src-tauri/src/types/plugin_info.rs | 131 +++++++ src/bindings.ts | 47 ++- 26 files changed, 2011 insertions(+), 52 deletions(-) create mode 100644 src-plugin/.gitignore create mode 100644 src-plugin/Cargo.lock create mode 100644 src-plugin/Cargo.toml create mode 100644 src-plugin/plugin-api/Cargo.toml create mode 100644 src-plugin/plugin-api/src/lib.rs create mode 100644 src-plugin/plugin-sdk/Cargo.toml create mode 100644 src-plugin/plugin-sdk/src/lib.rs create mode 100644 src-plugin/plugin-sdk/tests/macro_smoke.rs create mode 100644 src-tauri/src/plugin.rs create mode 100644 src-tauri/src/plugin/hook_context.rs create mode 100644 src-tauri/src/plugin/host_api.rs create mode 100644 src-tauri/src/plugin/plugin_executor.rs create mode 100644 src-tauri/src/plugin/plugin_loader.rs create mode 100644 src-tauri/src/plugin/plugin_manager.rs create mode 100644 src-tauri/src/plugin/plugin_types.rs create mode 100644 src-tauri/src/types/plugin_info.rs diff --git a/.prettierignore b/.prettierignore index 3f4da76..196c789 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,6 +7,7 @@ dist-ssr/ # Rust/Tauri backend src-tauri/ +src-plugin/ # Dependencies node_modules/ diff --git a/src-plugin/.gitignore b/src-plugin/.gitignore new file mode 100644 index 0000000..9be9ac0 --- /dev/null +++ b/src-plugin/.gitignore @@ -0,0 +1,3 @@ +# Generated by Cargo +# will have compiled files and executables +target/ diff --git a/src-plugin/Cargo.lock b/src-plugin/Cargo.lock new file mode 100644 index 0000000..9e0107d --- /dev/null +++ b/src-plugin/Cargo.lock @@ -0,0 +1,215 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bilibili-video-downloader-plugin-api" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "bilibili-video-downloader-plugin-sdk" +version = "0.1.0" +dependencies = [ + "bilibili-video-downloader-plugin-api", + "eyre", + "parking_lot", + "serde_json", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/src-plugin/Cargo.toml b/src-plugin/Cargo.toml new file mode 100644 index 0000000..6c7f8f3 --- /dev/null +++ b/src-plugin/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["plugin-api", "plugin-sdk"] +resolver = "3" diff --git a/src-plugin/plugin-api/Cargo.toml b/src-plugin/plugin-api/Cargo.toml new file mode 100644 index 0000000..26aad86 --- /dev/null +++ b/src-plugin/plugin-api/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "bilibili-video-downloader-plugin-api" +version = "0.1.0" +edition = "2024" + +[lib] +name = "bilibili_video_downloader_plugin_api" + +[dependencies] +serde = { version = "1", features = ["derive"] } diff --git a/src-plugin/plugin-api/src/lib.rs b/src-plugin/plugin-api/src/lib.rs new file mode 100644 index 0000000..c1d58b2 --- /dev/null +++ b/src-plugin/plugin-api/src/lib.rs @@ -0,0 +1,349 @@ +pub const SDK_API_VERSION_V1: u32 = 1; + +pub mod v1 { + use std::path::PathBuf; + + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] + pub enum HookPointV1 { + AfterPrepare, + BeforeVideoProcess, + OnCompleted, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + pub enum PluginFailurePolicy { + FailOpen, + FailClosed, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct PluginDescriptorV1 { + pub sdk_api_version: u32, + pub id: String, + pub name: String, + pub version: String, + pub hooks: Vec, + pub failure_policy: PluginFailurePolicy, + pub description: String, + } + + #[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct HookReadonlyMetaV1 { + pub app_version: String, + pub os: String, + pub arch: String, + pub process_id: u32, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + pub struct BeforeVideoProcessPayloadV1 { + pub progress: DownloadProgressV1, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + pub struct AfterPreparePayloadV1 { + pub progress: DownloadProgressV1, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + pub struct OnCompletedPayloadV1 { + pub progress: DownloadProgressV1, + } + + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] + pub enum HookPayloadV1 { + BeforeVideoProcess(BeforeVideoProcessPayloadV1), + AfterPrepare(AfterPreparePayloadV1), + OnCompleted(OnCompletedPayloadV1), + } + + impl Default for HookPayloadV1 { + fn default() -> Self { + Self::BeforeVideoProcess(BeforeVideoProcessPayloadV1::default()) + } + } + + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] + pub struct HookInputV1 { + pub hook_point: HookPointV1, + pub payload: HookPayloadV1, + pub readonly_meta: HookReadonlyMetaV1, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + pub struct HookOutputV1 { + pub payload: HookPayloadV1, + } + + pub type HostApiGetConfigJsonV1 = + unsafe extern "C" fn(out_ptr: *mut *mut u8, out_len: *mut usize) -> i32; + pub type HostApiFreeBufferV1 = unsafe extern "C" fn(ptr: *mut u8, len: usize); + + #[derive(Debug, Clone, Copy)] + #[repr(C)] + pub struct HostApiV1 { + pub get_config_json: HostApiGetConfigJsonV1, + pub free_buffer: HostApiFreeBufferV1, + } + + #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + pub enum ProxyModeV1 { + #[default] + NoProxy, + System, + Custom, + } + + #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + pub enum FileExistActionV1 { + #[default] + Overwrite, + Skip, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(default)] + pub struct CanvasConfigV1 { + pub duration: f64, + pub width: u32, + pub height: u32, + pub font: String, + pub font_size: u32, + pub width_ratio: f64, + pub horizontal_gap: f64, + pub lane_size: u32, + pub float_percentage: f64, + pub alpha: f64, + pub bold: bool, + pub outline: f64, + pub time_offset: f64, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(default)] + #[allow(clippy::struct_excessive_bools)] + #[allow(clippy::struct_field_names)] + pub struct HostConfigV1 { + pub download_dir: PathBuf, + pub enable_file_logger: bool, + pub sessdata: String, + pub video_quality_priority: Vec, + pub codec_type_priority: Vec, + pub audio_quality_priority: Vec, + pub download_video: bool, + pub download_audio: bool, + pub auto_merge: bool, + pub embed_chapter: bool, + pub embed_skip: bool, + pub download_xml_danmaku: bool, + pub download_ass_danmaku: bool, + pub download_json_danmaku: bool, + pub download_subtitle: bool, + pub download_cover: bool, + pub download_nfo: bool, + pub download_json: bool, + pub dir_fmt: String, + pub dir_fmt_for_part: String, + pub time_fmt: String, + pub proxy_mode: ProxyModeV1, + pub proxy_host: String, + pub proxy_port: u16, + pub task_concurrency: usize, + pub task_download_interval_sec: u64, + pub chunk_concurrency: usize, + pub chunk_download_interval_sec: u64, + pub danmaku_config: CanvasConfigV1, + pub file_exist_action: FileExistActionV1, + pub auto_start_download_task: bool, + } + + #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + pub enum EpisodeTypeV1 { + #[default] + Normal, + Bangumi, + Cheese, + } + + #[derive(Default, Debug, Clone, Copy, Hash, Eq, PartialEq, Serialize, Deserialize)] + #[repr(i64)] + pub enum VideoQualityV1 { + #[default] + Unknown = -1, + #[serde(rename = "240P")] + Video240P = 6, + #[serde(rename = "360P")] + Video360P = 16, + #[serde(rename = "480P")] + Video480P = 32, + #[serde(rename = "720P")] + Video720P = 64, + #[serde(rename = "720P60")] + Video720P60 = 74, + #[serde(rename = "1080P")] + Video1080P = 80, + #[serde(rename = "AiRepair")] + VideoAiRepair = 100, + #[serde(rename = "1080P+")] + Video1080PPlus = 112, + #[serde(rename = "1080P60")] + Video1080P60 = 116, + #[serde(rename = "4K")] + Video4K = 120, + #[serde(rename = "HDR")] + VideoHDR = 125, + #[serde(rename = "Dolby")] + VideoDolby = 126, + #[serde(rename = "8K")] + Video8K = 127, + } + + #[derive(Default, Debug, Clone, Copy, Hash, Eq, PartialEq, Serialize, Deserialize)] + #[repr(i64)] + pub enum AudioQualityV1 { + #[default] + Unknown = -1, + #[serde(rename = "64K")] + Audio64K = 30216, + #[serde(rename = "132K")] + Audio132K = 30232, + #[serde(rename = "192K")] + Audio192K = 30280, + #[serde(rename = "Dolby")] + AudioDolby = 30250, + #[serde(rename = "HiRes")] + AudioHiRes = 30251, + } + + #[derive(Default, Debug, Clone, Copy, Hash, Eq, PartialEq, Serialize, Deserialize)] + #[repr(i64)] + pub enum CodecTypeV1 { + #[default] + Unknown = -1, + Audio = 0, + AVC = 7, + HEVC = 12, + AV1 = 13, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + pub struct MediaChunkV1 { + pub start: u64, + pub end: u64, + pub completed: bool, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(default)] + pub struct VideoTaskV1 { + pub selected: bool, + pub url: String, + pub video_quality: VideoQualityV1, + pub codec_type: CodecTypeV1, + pub content_length: u64, + pub chunks: Vec, + pub completed: bool, + pub skipped: bool, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(default)] + pub struct AudioTaskV1 { + pub selected: bool, + pub url: String, + pub audio_quality: AudioQualityV1, + pub content_length: u64, + pub chunks: Vec, + pub completed: bool, + pub skipped: bool, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(default)] + #[allow(clippy::struct_excessive_bools)] + pub struct VideoProcessTaskV1 { + pub merge_selected: bool, + pub embed_chapter_selected: bool, + pub embed_skip_selected: bool, + pub completed: bool, + pub skipped: bool, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(default)] + pub struct SubtitleTaskV1 { + pub selected: bool, + pub completed: bool, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(default)] + #[allow(clippy::struct_excessive_bools)] + pub struct DanmakuTaskV1 { + pub xml_selected: bool, + pub ass_selected: bool, + pub json_selected: bool, + pub completed: bool, + pub skipped: bool, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(default)] + pub struct CoverTaskV1 { + pub selected: bool, + pub url: String, + pub completed: bool, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(default)] + pub struct NfoTaskV1 { + pub selected: bool, + pub completed: bool, + pub skipped: bool, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(default)] + pub struct JsonTaskV1 { + pub selected: bool, + pub completed: bool, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(default)] + pub struct DownloadProgressV1 { + pub task_id: String, + pub episode_type: EpisodeTypeV1, + pub aid: i64, + pub bvid: Option, + pub cid: i64, + pub ep_id: Option, + pub duration: u64, + pub pub_ts: i64, + pub collection_title: String, + pub part_title: Option, + pub part_order: Option, + pub episode_title: String, + pub episode_order: i64, + pub up_name: Option, + pub up_uid: Option, + pub up_avatar: Option, + pub episode_dir: PathBuf, + pub filename: String, + pub video_task: VideoTaskV1, + pub audio_task: AudioTaskV1, + pub video_process_task: VideoProcessTaskV1, + pub subtitle_task: SubtitleTaskV1, + pub danmaku_task: DanmakuTaskV1, + pub cover_task: CoverTaskV1, + pub nfo_task: NfoTaskV1, + pub json_task: JsonTaskV1, + pub create_ts: u64, + pub completed_ts: Option, + pub is_drm: bool, + pub is_preview: bool, + } +} diff --git a/src-plugin/plugin-sdk/Cargo.toml b/src-plugin/plugin-sdk/Cargo.toml new file mode 100644 index 0000000..fbd694a --- /dev/null +++ b/src-plugin/plugin-sdk/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "bilibili-video-downloader-plugin-sdk" +version = "0.1.0" +edition = "2024" + +[lib] +name = "bilibili_video_downloader_plugin_sdk" + +[dependencies] +bilibili-video-downloader-plugin-api = { path = "../plugin-api" } + +eyre = { version = "0.6.12" } +serde_json = { version = "1" } +parking_lot = { version = "0.12.5" } diff --git a/src-plugin/plugin-sdk/src/lib.rs b/src-plugin/plugin-sdk/src/lib.rs new file mode 100644 index 0000000..7901793 --- /dev/null +++ b/src-plugin/plugin-sdk/src/lib.rs @@ -0,0 +1,191 @@ +pub use bilibili_video_downloader_plugin_api::SDK_API_VERSION_V1 as SDK_API_VERSION; +pub use bilibili_video_downloader_plugin_api::v1::{ + AfterPreparePayloadV1, BeforeVideoProcessPayloadV1, CanvasConfigV1, DownloadProgressV1, + FileExistActionV1, HookInputV1, HookOutputV1, HookPayloadV1, HookPointV1, HostApiV1, + HostConfigV1, OnCompletedPayloadV1, PluginDescriptorV1, PluginFailurePolicy, ProxyModeV1, +}; +pub use eyre; +pub use parking_lot; +pub use serde_json; + +use std::sync::LazyLock; + +use parking_lot::Mutex; + +pub trait PluginV1: Default + Send + 'static { + fn descriptor(&self) -> PluginDescriptorV1; + #[allow(clippy::missing_errors_doc)] + fn on_hook(&mut self, input: HookInputV1) -> eyre::Result; +} + +static HOST_API_V1: LazyLock>> = LazyLock::new(|| Mutex::new(None)); + +#[doc(hidden)] +pub unsafe fn register_host_api_v1(api_ptr: *const HostApiV1) -> i32 { + if api_ptr.is_null() { + return 1; + } + + let api = unsafe { *api_ptr }; + *HOST_API_V1.lock() = Some(api); + + 0 +} + +fn get_host_api_v1() -> eyre::Result { + HOST_API_V1 + .lock() + .as_ref() + .copied() + .ok_or_else(|| eyre::eyre!("host api 未注册")) +} + +pub mod host { + use crate::HostConfigV1; + + #[allow(clippy::missing_errors_doc)] + pub fn get_config() -> eyre::Result { + let host_api = crate::get_host_api_v1()?; + + let mut output_ptr: *mut u8 = std::ptr::null_mut(); + let mut output_len: usize = 0; + let rc = unsafe { (host_api.get_config_json)(&raw mut output_ptr, &raw mut output_len) }; + if rc != 0 { + return Err(eyre::eyre!("host get_config_json 调用失败: rc={rc}")); + } + + if output_ptr.is_null() { + return Err(eyre::eyre!("host get_config_json 返回的缓冲区为空指针")); + } + + let output_bytes = unsafe { std::slice::from_raw_parts(output_ptr, output_len) }.to_vec(); + unsafe { + (host_api.free_buffer)(output_ptr, output_len); + } + + let host_config = serde_json::from_slice::(&output_bytes)?; + + Ok(host_config) + } +} + +#[macro_export] +macro_rules! export_plugin_v1 { + ($ty:ty) => { + use std::ffi::{CString, c_char}; + use std::panic::{AssertUnwindSafe, catch_unwind}; + use std::sync::LazyLock; + use $crate::parking_lot::Mutex; + + fn to_cstring_lossy(value: String) -> CString { + // Replacing NUL guarantees CString invariants and avoids fallible construction. + let sanitized = value.replace('\0', " "); + unsafe { CString::from_vec_unchecked(sanitized.into_bytes()) } + } + + static INSTANCE_V1: LazyLock> = LazyLock::new(|| Mutex::new(<$ty>::default())); + static DESCRIPTOR_JSON_V1: LazyLock = LazyLock::new(|| { + let instance = INSTANCE_V1.lock(); + let descriptor = instance.descriptor(); + let descriptor_json = match $crate::serde_json::to_string(&descriptor) { + Ok(json) => json, + Err(err) => format!("{{\"error\":\"序列化 descriptor 失败: {err}\"}}"), + }; + to_cstring_lossy(descriptor_json) + }); + static LAST_ERROR_V1: LazyLock> = + LazyLock::new(|| Mutex::new(to_cstring_lossy(String::new()))); + + fn set_last_error_v1(message: String) { + let mut guard = LAST_ERROR_V1.lock(); + *guard = to_cstring_lossy(message); + } + + #[unsafe(export_name = "bilibili_video_downloader_plugin_descriptor_v1")] + pub extern "C" fn descriptor_v1() -> *const c_char { + DESCRIPTOR_JSON_V1.as_ptr() + } + + #[unsafe(export_name = "bilibili_video_downloader_plugin_last_error_v1")] + pub extern "C" fn last_error_v1() -> *const c_char { + LAST_ERROR_V1.lock().as_ptr() + } + + #[unsafe(export_name = "bilibili_video_downloader_plugin_set_host_api_v1")] + pub unsafe extern "C" fn set_host_api_v1(api: *const $crate::HostApiV1) -> i32 { + let rc = unsafe { $crate::register_host_api_v1(api) }; + if rc != 0 { + set_last_error_v1("无效的 host api 指针".to_string()); + } + rc + } + + #[unsafe(export_name = "bilibili_video_downloader_plugin_on_hook_v1")] + pub unsafe extern "C" fn on_hook_v1( + input_ptr: *const u8, + input_len: usize, + out_ptr: *mut *mut u8, + out_len: *mut usize, + ) -> i32 { + if input_ptr.is_null() || out_ptr.is_null() || out_len.is_null() { + set_last_error_v1("参数里有空指针".to_string()); + return 1; + } + + let input_slice = unsafe { std::slice::from_raw_parts(input_ptr, input_len) }; + let hook_input: $crate::HookInputV1 = match $crate::serde_json::from_slice(input_slice) + { + Ok(input) => input, + Err(err) => { + set_last_error_v1(format!("解析 hook 输入失败: {err}")); + return 2; + } + }; + + let hook_output = match catch_unwind(AssertUnwindSafe(|| { + let mut plugin = INSTANCE_V1.lock(); + plugin.on_hook(hook_input) + })) { + Ok(Ok(output)) => output, + Ok(Err(err)) => { + set_last_error_v1(format!("{err:?}")); + return 3; + } + Err(_) => { + set_last_error_v1("处理 on_hook 时插件内部发生 panic".to_string()); + return 5; + } + }; + + let output_bytes = match $crate::serde_json::to_vec(&hook_output) { + Ok(bytes) => bytes, + Err(err) => { + set_last_error_v1(format!("序列化 hook 输出失败: {err}")); + return 4; + } + }; + + let boxed = output_bytes.into_boxed_slice(); + let len = boxed.len(); + let ptr = Box::into_raw(boxed) as *mut u8; + + unsafe { + *out_ptr = ptr; + *out_len = len; + } + + 0 + } + + #[unsafe(export_name = "bilibili_video_downloader_plugin_free_buffer_v1")] + pub unsafe extern "C" fn free_buffer_v1(ptr: *mut u8, len: usize) { + if ptr.is_null() || len == 0 { + return; + } + let raw_slice = std::ptr::slice_from_raw_parts_mut(ptr, len); + unsafe { + drop(Box::from_raw(raw_slice)); + } + } + }; +} diff --git a/src-plugin/plugin-sdk/tests/macro_smoke.rs b/src-plugin/plugin-sdk/tests/macro_smoke.rs new file mode 100644 index 0000000..d2711d3 --- /dev/null +++ b/src-plugin/plugin-sdk/tests/macro_smoke.rs @@ -0,0 +1,54 @@ +use bilibili_video_downloader_plugin_sdk::{ + AfterPreparePayloadV1, BeforeVideoProcessPayloadV1, HookInputV1, HookOutputV1, HookPayloadV1, + HookPointV1, OnCompletedPayloadV1, PluginDescriptorV1, PluginFailurePolicy, PluginV1, + SDK_API_VERSION, export_plugin_v1, eyre, host, +}; + +#[derive(Default)] +struct MacroSmokePlugin; + +impl PluginV1 for MacroSmokePlugin { + fn descriptor(&self) -> PluginDescriptorV1 { + PluginDescriptorV1 { + sdk_api_version: SDK_API_VERSION, + id: "macro-smoke".to_string(), + name: "Macro Smoke".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + hooks: vec![HookPointV1::BeforeVideoProcess], + failure_policy: PluginFailurePolicy::FailOpen, + description: "Compile-time macro smoke test".to_string(), + } + } + + fn on_hook(&mut self, input: HookInputV1) -> eyre::Result { + let payload = match input.payload { + HookPayloadV1::BeforeVideoProcess(payload) => { + HookPayloadV1::BeforeVideoProcess(BeforeVideoProcessPayloadV1 { + progress: payload.progress, + }) + } + HookPayloadV1::AfterPrepare(payload) => { + HookPayloadV1::AfterPrepare(AfterPreparePayloadV1 { + progress: payload.progress, + }) + } + HookPayloadV1::OnCompleted(payload) => { + HookPayloadV1::OnCompleted(OnCompletedPayloadV1 { + progress: payload.progress, + }) + } + }; + + Ok(HookOutputV1 { payload }) + } +} + +export_plugin_v1!(MacroSmokePlugin); + +#[test] +fn macro_smoke_builds() { + assert_eq!(SDK_API_VERSION, 1); + + let err = host::get_config().unwrap_err(); + assert!(err.to_string().contains("host api 未注册")); +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 56fb7f6..f03c14e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -286,9 +286,11 @@ name = "bilibili-video-downloader" version = "0.1.0" dependencies = [ "base64 0.22.1", + "bilibili-video-downloader-plugin-api", "byteorder", "bytes", "chrono", + "dlopen2 0.8.2", "eyre", "float-ord", "fs4", @@ -323,6 +325,13 @@ dependencies = [ "yaserde", ] +[[package]] +name = "bilibili-video-downloader-plugin-api" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -861,6 +870,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + [[package]] name = "dlopen2_derive" version = "0.4.1" @@ -4186,7 +4207,7 @@ dependencies = [ "core-graphics", "crossbeam-channel", "dispatch", - "dlopen2", + "dlopen2 0.7.0", "dpi", "gdkwayland-sys", "gdkx11-sys", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 100f28c..b6f67de 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,8 @@ tauri = { version = "2", features = [] } tauri-plugin-opener = "2" tauri-plugin-os = "2" tauri-plugin-dialog = "2" +dlopen2 = { version = "0.8.2" } +bilibili-video-downloader-plugin-api = { path = "../src-plugin/plugin-api" } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -63,4 +65,3 @@ strip = true lto = true codegen-units = 1 panic = "abort" - diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 358ecdd..8430d4b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -32,6 +32,7 @@ use crate::{ history_info::HistoryInfo, log_metadata::LogMetadata, normal_info::NormalInfo, + plugin_info::PluginInfo, qrcode_data::QrcodeData, qrcode_status::QrcodeStatus, restart_download_task_params::RestartDownloadTaskParams, @@ -64,15 +65,16 @@ pub fn save_config(app: AppHandle, config: Config) -> CommandResult<()> { let bili_client = app.get_bili_client(); let config_state = app.get_config(); - let proxy_changed = { - let config_state = config_state.read(); - config_state.proxy_mode != config.proxy_mode - || config_state.proxy_host != config.proxy_host - || config_state.proxy_port != config.proxy_port - }; - let enable_file_logger = config.enable_file_logger; - let file_logger_changed = config_state.read().enable_file_logger != enable_file_logger; + let (proxy_changed, file_logger_changed) = { + let current_config = config_state.read(); + ( + current_config.proxy_mode != config.proxy_mode + || current_config.proxy_host != config.proxy_host + || current_config.proxy_port != config.proxy_port, + current_config.enable_file_logger != enable_file_logger, + ) + }; { // 包裹在大括号中,以便自动释放写锁 @@ -506,3 +508,71 @@ pub fn open_log_file(path: &str) -> CommandResult> { Ok(logs) } + +#[allow(clippy::needless_pass_by_value)] +#[tauri::command(async)] +#[specta::specta] +#[instrument(level = "error", skip_all)] +pub fn get_plugin_infos(app: AppHandle) -> Vec { + app.get_plugin_manager().get_plugin_infos() +} + +#[allow(clippy::needless_pass_by_value)] +#[tauri::command(async)] +#[specta::specta] +#[instrument(level = "error", skip_all, fields(plugin_path = plugin_path))] +pub fn add_plugin(app: AppHandle, plugin_path: String) -> CommandResult<()> { + let plugin_manager = app.get_plugin_manager(); + + plugin_manager + .add_plugin(&plugin_path) + .map_err(|err| CommandError::from("加载插件失败", err))?; + + Ok(()) +} + +#[allow(clippy::needless_pass_by_value)] +#[tauri::command(async)] +#[specta::specta] +#[instrument(level = "error", skip_all, fields(plugin_path = plugin_path))] +pub fn uninstall_plugin(app: AppHandle, plugin_path: String) -> CommandResult<()> { + let plugin_manager = app.get_plugin_manager(); + + plugin_manager + .uninstall_plugin(&plugin_path) + .map_err(|err| CommandError::from("卸载插件失败", err))?; + + Ok(()) +} + +#[allow(clippy::needless_pass_by_value)] +#[tauri::command(async)] +#[specta::specta] +#[instrument(level = "error", skip_all, fields(plugin_path = plugin_path, enabled = enabled))] +pub fn set_plugin_enabled(app: AppHandle, plugin_path: String, enabled: bool) -> CommandResult<()> { + let plugin_manager = app.get_plugin_manager(); + + plugin_manager + .set_plugin_enabled(&plugin_path, enabled) + .map_err(|err| CommandError::from("设置插件启用状态失败", err))?; + + Ok(()) +} + +#[allow(clippy::needless_pass_by_value)] +#[tauri::command(async)] +#[specta::specta] +#[instrument(level = "error", skip_all, fields(plugin_path = plugin_path, priority = priority))] +pub fn set_plugin_priority( + app: AppHandle, + plugin_path: String, + priority: i32, +) -> CommandResult<()> { + let plugin_manager = app.get_plugin_manager(); + + plugin_manager + .set_plugin_priority(&plugin_path, priority) + .map_err(|err| CommandError::from("设置插件优先级失败", err))?; + + Ok(()) +} diff --git a/src-tauri/src/downloader/download_progress.rs b/src-tauri/src/downloader/download_progress.rs index 1725ca4..4cc7c3c 100644 --- a/src-tauri/src/downloader/download_progress.rs +++ b/src-tauri/src/downloader/download_progress.rs @@ -24,6 +24,9 @@ use crate::{ }, events::DownloadEvent, extensions::AppHandleExt, + plugin::hook_context::{ + AfterPrepareContext, BeforeVideoProcessContext, HookContext, OnCompletedContext, + }, types::{ audio_quality::AudioQuality, bangumi_info::BangumiInfo, @@ -200,15 +203,23 @@ impl DownloadProgress { } #[instrument(level = "error", skip_all)] + #[allow(clippy::too_many_lines)] pub async fn process(&mut self, download_task: &Arc) -> eyre::Result<()> { + let app = &download_task.app; let _ = DownloadEvent::ProgressPreparing { task_id: self.task_id.clone(), } - .emit(&download_task.app); + .emit(app); - self.prepare(&download_task.app) - .await - .wrap_err("准备下载失败")?; + self.prepare(app).await.wrap_err("准备下载失败")?; + + let progress_before_hook = self.clone(); + app.get_plugin_manager() + .run_hook(HookContext::AfterPrepare(AfterPrepareContext::new(self))) + .await?; + if *self != progress_before_hook { + download_task.update_progress(|p| *p = self.clone()); + } self.completed_ts = None; // 重置完成时间戳 download_task.update_progress(|p| *p = self.clone()); @@ -216,35 +227,36 @@ impl DownloadProgress { std::fs::create_dir_all(&self.episode_dir) .wrap_err(format!("创建目录`{}`失败", self.episode_dir.display()))?; - let video_task = &self.video_task; - let audio_task = &self.audio_task; - let video_process_task = &self.video_process_task; - let danmaku_task = &self.danmaku_task; - let subtitle_task = &self.subtitle_task; - let cover_task = &self.cover_task; - let nfo_task = &self.nfo_task; - let json_task = &self.json_task; - let mut player_info = None; let mut episode_info = None; - if !video_task.is_completed() && video_task.content_length != 0 { - video_task + if !self.video_task.is_completed() && self.video_task.content_length != 0 { + self.video_task .process(download_task, self) .await .wrap_err("下载视频文件失败")?; tracing::debug!("视频下载任务完成"); } - if !audio_task.is_completed() && audio_task.content_length != 0 { - audio_task + if !self.audio_task.is_completed() && self.audio_task.content_length != 0 { + self.audio_task .process(download_task, self) .await .wrap_err("下载音频文件失败")?; tracing::debug!("音频下载任务完成"); } - let video_process_task_is_completed = video_process_task.is_completed(); + let progress_before_hook = self.clone(); + app.get_plugin_manager() + .run_hook(HookContext::BeforeVideoProcess( + BeforeVideoProcessContext::new(self), + )) + .await?; + if *self != progress_before_hook { + download_task.update_progress(|p| *p = self.clone()); + } + + let video_process_task_is_completed = self.video_process_task.is_completed(); if self.is_drm && !video_process_task_is_completed { download_task.update_progress(|p| { p.video_process_task.skipped = true; @@ -252,47 +264,47 @@ impl DownloadProgress { }); tracing::debug!("受版权保护(DRM),无法处理,已跳过视频处理任务"); } else if !video_process_task_is_completed { - video_process_task + self.video_process_task .process(download_task, self, &mut player_info) .await .wrap_err("视频处理失败")?; tracing::debug!("视频处理任务完成"); } - if !danmaku_task.is_completed() { - danmaku_task + if !self.danmaku_task.is_completed() { + self.danmaku_task .process(download_task, self) .await .wrap_err("下载弹幕失败")?; tracing::debug!("弹幕下载任务完成"); } - if !subtitle_task.is_completed() { - subtitle_task + if !self.subtitle_task.is_completed() { + self.subtitle_task .process(download_task, self, &mut player_info) .await .wrap_err("下载字幕失败")?; tracing::debug!("字幕下载任务完成"); } - if !cover_task.is_completed() { - cover_task + if !self.cover_task.is_completed() { + self.cover_task .process(download_task, self) .await .wrap_err("下载封面失败")?; tracing::debug!("封面下载任务完成"); } - if !nfo_task.is_completed() { - nfo_task + if !self.nfo_task.is_completed() { + self.nfo_task .process(download_task, self, &mut episode_info) .await .wrap_err("下载NFO失败")?; tracing::debug!("NFO下载任务完成"); } - if !json_task.is_completed() { - json_task + if !self.json_task.is_completed() { + self.json_task .process(download_task, self, &mut episode_info) .await .wrap_err("下载JSON元数据失败")?; @@ -303,8 +315,17 @@ impl DownloadProgress { .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .ok(); - if completed_ts.is_some() { - download_task.update_progress(|p| p.completed_ts = completed_ts); + if let Some(completed_ts) = completed_ts { + self.completed_ts = Some(completed_ts); + download_task.update_progress(|p| p.completed_ts = Some(completed_ts)); + } + + let progress_before_hook = self.clone(); + app.get_plugin_manager() + .run_hook(HookContext::OnCompleted(OnCompletedContext::new(self))) + .await?; + if *self != progress_before_hook { + download_task.update_progress(|p| *p = self.clone()); } Ok(()) diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs index 9073232..a8af6e4 100644 --- a/src-tauri/src/events.rs +++ b/src-tauri/src/events.rs @@ -5,6 +5,7 @@ use tauri_specta::Event; use crate::downloader::{ download_progress::DownloadProgress, download_task_state::DownloadTaskState, }; +use crate::types::plugin_info::PluginInfo; #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] #[serde(rename_all = "camelCase")] @@ -46,3 +47,11 @@ pub enum DownloadEvent { progress: DownloadProgress, }, } + +#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] +#[serde(tag = "event", content = "data")] +pub enum PluginEvent { + Loaded { plugin_info: PluginInfo }, + Update { plugin_info: PluginInfo }, + Uninstall { plugin_path: String }, +} diff --git a/src-tauri/src/extensions.rs b/src-tauri/src/extensions.rs index a77873f..200fc75 100644 --- a/src-tauri/src/extensions.rs +++ b/src-tauri/src/extensions.rs @@ -7,6 +7,7 @@ use crate::{ bili_client::BiliClient, config::Config, downloader::{download_manager::DownloadManager, download_progress::DownloadProgress}, + plugin::plugin_manager::PluginManager, types::player_info::PlayerInfo, }; @@ -24,6 +25,7 @@ pub trait AppHandleExt { fn get_config(&self) -> State<'_, RwLock>; fn get_bili_client(&self) -> State<'_, BiliClient>; fn get_download_manager(&self) -> State<'_, DownloadManager>; + fn get_plugin_manager(&self) -> State<'_, PluginManager>; } impl AppHandleExt for AppHandle { @@ -36,6 +38,9 @@ impl AppHandleExt for AppHandle { fn get_download_manager(&self) -> State<'_, DownloadManager> { self.state::() } + fn get_plugin_manager(&self) -> State<'_, PluginManager> { + self.state::() + } } pub trait GetOrInitPlayerInfo { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0d2ab22..544a799 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod errors; mod events; mod extensions; mod logger; +mod plugin; mod types; mod utils; mod wbi; @@ -16,14 +17,14 @@ mod protobuf { } use commands::{ - create_download_tasks, delete_download_tasks, generate_qrcode, get_available_media_formats, - get_bangumi_follow_info, get_bangumi_info, get_config, get_fav_folders, get_fav_info, - get_history_info, get_logs_dir_size, get_normal_info, get_qrcode_status, get_skip_segments, - get_user_info, get_user_video_info, get_watch_later_info, pause_download_tasks, - restart_download_task, restart_download_tasks, restore_download_tasks, resume_download_tasks, - save_config, search, show_path_in_file_manager, + add_plugin, create_download_tasks, delete_download_tasks, generate_qrcode, + get_available_media_formats, get_bangumi_follow_info, get_bangumi_info, get_config, + get_fav_folders, get_fav_info, get_history_info, get_logs_dir_size, get_normal_info, + get_plugin_infos, get_qrcode_status, get_skip_segments, get_user_info, get_user_video_info, + get_watch_later_info, pause_download_tasks, restart_download_task, restart_download_tasks, + restore_download_tasks, resume_download_tasks, save_config, search, set_plugin_enabled, + set_plugin_priority, show_path_in_file_manager, uninstall_plugin, }; -use config::Config; use eyre::WrapErr; use parking_lot::RwLock; use tauri::{Manager, Wry}; @@ -31,9 +32,11 @@ use tauri::{Manager, Wry}; use crate::{ bili_client::BiliClient, commands::open_log_file, + config::Config, downloader::download_manager::DownloadManager, errors::install_custom_eyre_handler, - events::{DownloadEvent, LogEvent}, + events::{DownloadEvent, LogEvent, PluginEvent}, + plugin::plugin_manager::PluginManager, }; fn generate_context() -> tauri::Context { @@ -49,6 +52,7 @@ pub fn run() { .commands(tauri_specta::collect_commands![ get_config, save_config, + get_plugin_infos, generate_qrcode, get_qrcode_status, get_user_info, @@ -73,8 +77,16 @@ pub fn run() { get_skip_segments, get_available_media_formats, open_log_file, + add_plugin, + uninstall_plugin, + set_plugin_enabled, + set_plugin_priority, ]) - .events(tauri_specta::collect_events![LogEvent, DownloadEvent]); + .events(tauri_specta::collect_events![ + LogEvent, + DownloadEvent, + PluginEvent, + ]); #[cfg(debug_assertions)] builder @@ -122,6 +134,9 @@ pub fn run() { logger::init(app.handle())?; + let plugin_manager = PluginManager::new(app.handle())?; + app.manage(plugin_manager); + Ok(()) }) .run(generate_context()) diff --git a/src-tauri/src/plugin.rs b/src-tauri/src/plugin.rs new file mode 100644 index 0000000..182c35e --- /dev/null +++ b/src-tauri/src/plugin.rs @@ -0,0 +1,6 @@ +pub mod hook_context; +pub mod host_api; +pub mod plugin_executor; +pub mod plugin_loader; +pub mod plugin_manager; +pub mod plugin_types; diff --git a/src-tauri/src/plugin/hook_context.rs b/src-tauri/src/plugin/hook_context.rs new file mode 100644 index 0000000..7b05f42 --- /dev/null +++ b/src-tauri/src/plugin/hook_context.rs @@ -0,0 +1,189 @@ +use bilibili_video_downloader_plugin_api::v1::{ + AfterPreparePayloadV1, BeforeVideoProcessPayloadV1, DownloadProgressV1, HookInputV1, + HookOutputV1, HookPayloadV1, HookPointV1, HookReadonlyMetaV1, OnCompletedPayloadV1, +}; +use eyre::{WrapErr, eyre}; +use serde::{Serialize, de::DeserializeOwned}; + +use crate::downloader::download_progress::DownloadProgress; + +pub struct BeforeVideoProcessContext<'a> { + progress: &'a mut DownloadProgress, +} + +impl<'a> BeforeVideoProcessContext<'a> { + pub fn new(progress: &'a mut DownloadProgress) -> Self { + Self { progress } + } + + fn to_payload(&self) -> eyre::Result { + Ok(BeforeVideoProcessPayloadV1 { + progress: host_to_api_progress(self.progress)?, + }) + } + + fn apply_payload(&mut self, payload: BeforeVideoProcessPayloadV1) -> eyre::Result<()> { + validate_task_id_unchanged(self.progress, &payload.progress)?; + + let next_progress = api_to_host_progress(payload.progress)?; + + *self.progress = next_progress; + + Ok(()) + } +} + +pub struct OnCompletedContext<'a> { + progress: &'a mut DownloadProgress, +} + +impl<'a> OnCompletedContext<'a> { + pub fn new(progress: &'a mut DownloadProgress) -> Self { + Self { progress } + } + + fn to_payload(&self) -> eyre::Result { + Ok(OnCompletedPayloadV1 { + progress: host_to_api_progress(self.progress)?, + }) + } + + fn apply_payload(&mut self, payload: OnCompletedPayloadV1) -> eyre::Result<()> { + validate_task_id_unchanged(self.progress, &payload.progress)?; + + let next_progress = api_to_host_progress(payload.progress)?; + + *self.progress = next_progress; + + Ok(()) + } +} + +pub struct AfterPrepareContext<'a> { + progress: &'a mut DownloadProgress, +} + +impl<'a> AfterPrepareContext<'a> { + pub fn new(progress: &'a mut DownloadProgress) -> Self { + Self { progress } + } + + fn to_payload(&self) -> eyre::Result { + Ok(AfterPreparePayloadV1 { + progress: host_to_api_progress(self.progress)?, + }) + } + + fn apply_payload(&mut self, payload: AfterPreparePayloadV1) -> eyre::Result<()> { + validate_task_id_unchanged(self.progress, &payload.progress)?; + + let next_progress = api_to_host_progress(payload.progress)?; + + *self.progress = next_progress; + + Ok(()) + } +} + +pub enum HookContext<'a> { + BeforeVideoProcess(BeforeVideoProcessContext<'a>), + AfterPrepare(AfterPrepareContext<'a>), + OnCompleted(OnCompletedContext<'a>), +} + +impl HookContext<'_> { + pub fn hook_point(&self) -> HookPointV1 { + match self { + HookContext::BeforeVideoProcess(_) => HookPointV1::BeforeVideoProcess, + HookContext::AfterPrepare(_) => HookPointV1::AfterPrepare, + HookContext::OnCompleted(_) => HookPointV1::OnCompleted, + } + } + + pub fn to_input(&self, app_version: &str) -> eyre::Result { + let hook_point = self.hook_point(); + let payload = match self { + HookContext::BeforeVideoProcess(context) => { + HookPayloadV1::BeforeVideoProcess(context.to_payload()?) + } + HookContext::AfterPrepare(context) => { + HookPayloadV1::AfterPrepare(context.to_payload()?) + } + HookContext::OnCompleted(context) => HookPayloadV1::OnCompleted(context.to_payload()?), + }; + + let input = HookInputV1 { + hook_point, + payload, + readonly_meta: HookReadonlyMetaV1 { + app_version: app_version.to_string(), + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + process_id: std::process::id(), + }, + }; + + Ok(input) + } + + pub fn apply_output(&mut self, output: HookOutputV1) -> eyre::Result<()> { + let context_hook_point = self.hook_point(); + match (self, output.payload) { + ( + HookContext::BeforeVideoProcess(context), + HookPayloadV1::BeforeVideoProcess(payload), + ) => context.apply_payload(payload), + + (HookContext::AfterPrepare(context), HookPayloadV1::AfterPrepare(payload)) => { + context.apply_payload(payload) + } + + (HookContext::OnCompleted(context), HookPayloadV1::OnCompleted(payload)) => { + context.apply_payload(payload) + } + + (_, payload) => Err(eyre!( + "hook_point 与 payload 不匹配: hook_point={context_hook_point:?}, payload={payload:?}" + )), + } + } +} + +fn validate_task_id_unchanged( + current_progress: &DownloadProgress, + next_progress: &DownloadProgressV1, +) -> eyre::Result<()> { + if current_progress.task_id != next_progress.task_id { + return Err(eyre!("task_id 不可修改")); + } + Ok(()) +} + +fn host_to_api_progress(progress: &DownloadProgress) -> eyre::Result { + convert_via_json( + progress, + "序列化宿主 DownloadProgress 失败", + "反序列化为插件 DownloadProgressV1 失败", + ) +} + +fn api_to_host_progress(progress: DownloadProgressV1) -> eyre::Result { + convert_via_json( + progress, + "序列化插件 DownloadProgressV1 失败", + "反序列化为宿主 DownloadProgress 失败", + ) +} + +fn convert_via_json( + source: TSrc, + serialize_err: &str, + deserialize_err: &str, +) -> eyre::Result +where + TSrc: Serialize, + TDst: DeserializeOwned, +{ + let value = serde_json::to_value(source).wrap_err_with(|| serialize_err.to_string())?; + serde_json::from_value(value).wrap_err_with(|| deserialize_err.to_string()) +} diff --git a/src-tauri/src/plugin/host_api.rs b/src-tauri/src/plugin/host_api.rs new file mode 100644 index 0000000..f7341d0 --- /dev/null +++ b/src-tauri/src/plugin/host_api.rs @@ -0,0 +1,67 @@ +use std::sync::OnceLock; + +use bilibili_video_downloader_plugin_api::v1::{HostApiV1, HostConfigV1}; +use eyre::WrapErr; +use tauri::AppHandle; + +use crate::{config::Config, extensions::AppHandleExt}; + +static HOST_APP_HANDLE: OnceLock = OnceLock::new(); + +pub fn init(app: &AppHandle) { + HOST_APP_HANDLE.get_or_init(|| app.clone()); +} + +pub fn build_host_api_v1() -> HostApiV1 { + HostApiV1 { + get_config_json: host_get_config_json_v1, + free_buffer: host_free_buffer_v1, + } +} + +unsafe extern "C" fn host_get_config_json_v1(out_ptr: *mut *mut u8, out_len: *mut usize) -> i32 { + if out_ptr.is_null() || out_len.is_null() { + return 1; + } + + let Some(app) = HOST_APP_HANDLE.get() else { + return 2; + }; + + let host_config = app.get_config().read().clone(); + let Ok(host_config_v1) = to_host_config_v1(&host_config) else { + return 3; + }; + + let Ok(output_bytes) = serde_json::to_vec(&host_config_v1) else { + return 3; + }; + + let boxed = output_bytes.into_boxed_slice(); + let len = boxed.len(); + let ptr = Box::into_raw(boxed).cast::(); + + unsafe { + *out_ptr = ptr; + *out_len = len; + } + + 0 +} + +unsafe extern "C" fn host_free_buffer_v1(ptr: *mut u8, len: usize) { + if ptr.is_null() || len == 0 { + return; + } + + let raw_slice = std::ptr::slice_from_raw_parts_mut(ptr, len); + unsafe { + drop(Box::from_raw(raw_slice)); + } +} + +fn to_host_config_v1(config: &Config) -> eyre::Result { + let value = serde_json::to_value(config).wrap_err("序列化宿主 Config 失败")?; + let host_config = serde_json::from_value(value).wrap_err("反序列化为插件 HostConfigV1 失败")?; + Ok(host_config) +} diff --git a/src-tauri/src/plugin/plugin_executor.rs b/src-tauri/src/plugin/plugin_executor.rs new file mode 100644 index 0000000..5ae8ade --- /dev/null +++ b/src-tauri/src/plugin/plugin_executor.rs @@ -0,0 +1,66 @@ +use std::{ffi::CStr, sync::Arc}; + +use bilibili_video_downloader_plugin_api::v1::{HookInputV1, HookOutputV1}; +use dlopen2::wrapper::Container; +use eyre::eyre; +use tracing::instrument; + +use crate::plugin::plugin_types::{PluginDylibApi, PluginRuntime}; + +#[instrument(level = "error", skip_all, fields(plugin_name = plugin.display_name(), hook_point = ?input.hook_point))] +pub async fn execute_hook( + plugin: &PluginRuntime, + input: &HookInputV1, +) -> eyre::Result { + let input_bytes = serde_json::to_vec(input)?; + let api = plugin.api.clone(); + + let (tx, rx) = tokio::sync::oneshot::channel::>>(); + tauri::async_runtime::spawn_blocking(move || { + let result = call_on_hook_blocking(api, &input_bytes); + let _ = tx.send(result); + }); + + let output_bytes = rx.await??; + let output: HookOutputV1 = serde_json::from_slice(&output_bytes)?; + Ok(output) +} + +#[instrument(level = "error", skip_all)] +#[allow(clippy::needless_pass_by_value)] +fn call_on_hook_blocking( + api: Arc>, + input_bytes: &[u8], +) -> eyre::Result> { + let mut output_ptr: *mut u8 = std::ptr::null_mut(); + let mut output_len: usize = 0; + let rc = unsafe { + api.on_hook( + input_bytes.as_ptr(), + input_bytes.len(), + &raw mut output_ptr, + &raw mut output_len, + ) + }; + if rc != 0 { + let detail = get_last_error(&api); + return Err(eyre!("插件返回错误码: code={rc}, detail={detail}")); + } + if output_ptr.is_null() { + return Err(eyre!("插件返回空输出缓冲区")); + } + + let output_bytes = unsafe { std::slice::from_raw_parts(output_ptr, output_len) }.to_vec(); + unsafe { api.free_buffer(output_ptr, output_len) }; + Ok(output_bytes) +} + +fn get_last_error(api: &Arc>) -> String { + let error_ptr = unsafe { api.last_error() }; + if error_ptr.is_null() { + return "获取错误信息失败,error_ptr为null".to_string(); + } + + let error_cstr = unsafe { CStr::from_ptr(error_ptr) }; + error_cstr.to_string_lossy().to_string() +} diff --git a/src-tauri/src/plugin/plugin_loader.rs b/src-tauri/src/plugin/plugin_loader.rs new file mode 100644 index 0000000..f928d58 --- /dev/null +++ b/src-tauri/src/plugin/plugin_loader.rs @@ -0,0 +1,78 @@ +use std::{ffi::CStr, path::Path, sync::Arc}; + +use bilibili_video_downloader_plugin_api::{SDK_API_VERSION_V1, v1::PluginDescriptorV1}; +use dlopen2::wrapper::Container; +use eyre::{WrapErr, eyre}; +use tracing::instrument; + +use crate::plugin::{ + host_api, + plugin_types::{PluginDylibApi, PluginRuntime}, +}; + +#[instrument(level = "error", skip_all, fields(plugin_path = %plugin_path.display(), priority = priority, enabled = enabled))] +pub fn load_plugin_from_path( + plugin_path: &Path, + priority: i32, + enabled: bool, +) -> eyre::Result { + if !plugin_path.is_absolute() { + return Err(eyre!("插件路径必须是绝对路径: `{}`", plugin_path.display())); + } + if !plugin_path.exists() { + return Err(eyre!("插件动态库文件`{}`不存在", plugin_path.display())); + } + + let api = unsafe { Container::::load(plugin_path) } + .wrap_err(format!("加载插件动态库文件`{}`失败", plugin_path.display()))?; + + let descriptor_json = get_descriptor_json(&api).wrap_err("读取插件描述失败")?; + let descriptor: PluginDescriptorV1 = serde_json::from_str(&descriptor_json) + .wrap_err(format!("解析插件描述失败: {descriptor_json}"))?; + + if descriptor.sdk_api_version != SDK_API_VERSION_V1 { + return Err(eyre!( + "插件SDK版本不匹配: 期望版本={}, 实际版本={}", + SDK_API_VERSION_V1, + descriptor.sdk_api_version + )); + } + + if descriptor.id.trim().is_empty() { + return Err(eyre!("descriptor.id 为空")); + } + + if descriptor.hooks.is_empty() { + return Err(eyre!("插件未声明任何可执行 Hook")); + } + + let host_api = host_api::build_host_api_v1(); + let rc = unsafe { api.set_host_api(&raw const host_api) }; + if rc != 0 { + return Err(eyre!( + "注册宿主 Host API 失败: plugin_id={}, rc={rc}", + descriptor.id + )); + } + + Ok(PluginRuntime { + descriptor, + plugin_path: plugin_path.to_path_buf(), + enabled, + priority, + api: Arc::new(api), + }) +} + +#[instrument(level = "error", skip_all)] +fn get_descriptor_json(api: &Container) -> eyre::Result { + let descriptor_ptr = unsafe { api.descriptor() }; + if descriptor_ptr.is_null() { + return Err(eyre!("descriptor 指针为空")); + } + + let descriptor_cstr = unsafe { CStr::from_ptr(descriptor_ptr).to_str() } + .wrap_err("descriptor 非 UTF-8 字符串")?; + + Ok(descriptor_cstr.to_string()) +} diff --git a/src-tauri/src/plugin/plugin_manager.rs b/src-tauri/src/plugin/plugin_manager.rs new file mode 100644 index 0000000..b36e6b2 --- /dev/null +++ b/src-tauri/src/plugin/plugin_manager.rs @@ -0,0 +1,351 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use eyre::eyre; +use parking_lot::RwLock; +use tauri::{AppHandle, Manager}; +use tauri_specta::Event; +use tracing::instrument; + +use crate::{ + events::PluginEvent, + extensions::EyreReportToMessage, + types::plugin_info::{PluginDescriptorInfo, PluginInfo, PluginMetadata, PluginRuntimeStatus}, +}; + +use super::{ + hook_context::HookContext, host_api, plugin_executor, plugin_loader, + plugin_types::PluginRuntime, +}; + +pub struct PluginManager { + app: AppHandle, + infos: RwLock>, + runtimes: RwLock>, +} + +impl PluginManager { + #[instrument(level = "error", skip_all)] + pub fn new(app: &AppHandle) -> eyre::Result { + host_api::init(app); + + let app_data_dir = app.path().app_data_dir()?; + let plugin_json_path = app_data_dir.join("plugin.json"); + + let mut infos = HashMap::new(); + if plugin_json_path.exists() { + let json_string = std::fs::read_to_string(&plugin_json_path)?; + + let metadata_map: HashMap = + serde_json::from_str(&json_string).unwrap_or_default(); + + for (plugin_path, metadata) in metadata_map { + let status = PluginRuntimeStatus::Unknown; + let info = PluginInfo::from_metadata(metadata, status); + infos.insert(plugin_path, info); + } + } + + let mut runtimes = Vec::new(); + for info in infos.values_mut() { + if !info.enabled { + info.runtime_status = PluginRuntimeStatus::Disabled; + continue; + } + + match plugin_loader::load_plugin_from_path(&info.path, info.priority, true) { + Ok(runtime) => { + tracing::info!( + "插件加载成功: plugin_name={}, plugin_path={}", + runtime.display_name(), + runtime.plugin_path.display() + ); + + info.runtime_status = PluginRuntimeStatus::Loaded; + info.descriptor = PluginDescriptorInfo::from_descriptor(&runtime.descriptor); + + insert_runtime_by_priority(&mut runtimes, runtime); + } + Err(err) => { + let err_title = "某个插件加载失败,已跳过"; + let message = err.to_message(); + tracing::error!(err_title, message); + + info.runtime_status = PluginRuntimeStatus::LoadFailed; + } + } + } + + let plugin_manager = Self { + app: app.clone(), + infos: RwLock::new(infos), + runtimes: RwLock::new(runtimes), + }; + + plugin_manager.save_metadata()?; + + Ok(plugin_manager) + } + + #[instrument(level = "error", skip_all, fields(plugin_path = plugin_path))] + pub fn add_plugin(&self, plugin_path: &str) -> eyre::Result<()> { + let runtime = plugin_loader::load_plugin_from_path(&PathBuf::from(plugin_path), 0, true)?; + + let plugin_info = { + let mut infos = self.infos.write(); + if infos.contains_key(plugin_path) { + return Err(eyre!("插件已存在: {plugin_path}")); + } + let status = PluginRuntimeStatus::Loaded; + let metadata = PluginMetadata::from_plugin_runtime(&runtime); + let info = PluginInfo::from_metadata(metadata, status); + infos.insert(plugin_path.to_string(), info.clone()); + info + }; + + { + let mut runtimes = self.runtimes.write(); + insert_runtime_by_priority(&mut runtimes, runtime); + } + + self.save_metadata()?; + let _ = PluginEvent::Loaded { plugin_info }.emit(&self.app); + + Ok(()) + } + + #[instrument(level = "error", skip_all, fields(plugin_path = plugin_path))] + pub fn uninstall_plugin(&self, plugin_path: &str) -> eyre::Result<()> { + { + let mut infos = self.infos.write(); + if !infos.contains_key(plugin_path) { + return Err(eyre!("key中没有插件路径: {plugin_path}")); + } + infos.remove(plugin_path); + } + + { + let mut runtimes = self.runtimes.write(); + remove_runtime_by_path(&mut runtimes, Path::new(plugin_path)); + } + + let _ = PluginEvent::Uninstall { + plugin_path: plugin_path.to_string(), + } + .emit(&self.app); + + self.save_metadata()?; + + Ok(()) + } + + #[instrument(level = "error", skip_all, fields(plugin_path = plugin_path, enabled = enabled))] + pub fn set_plugin_enabled(&self, plugin_path: &str, enabled: bool) -> eyre::Result<()> { + if !enabled { + let plugin_info = { + let mut infos = self.infos.write(); + let Some(info) = infos.get_mut(plugin_path) else { + return Err(eyre!("key中没有插件路径: {plugin_path}")); + }; + if info.enabled == enabled { + return Ok(()); + } + info.enabled = false; + info.runtime_status = PluginRuntimeStatus::Disabled; + info.clone() + }; + + { + let mut runtimes = self.runtimes.write(); + remove_runtime_by_path(&mut runtimes, Path::new(plugin_path)); + } + + let _ = PluginEvent::Update { plugin_info }.emit(&self.app); + + self.save_metadata()?; + + return Ok(()); + } + + let (plugin_file_path, priority) = { + let mut infos = self.infos.write(); + let Some(info) = infos.get_mut(plugin_path) else { + return Err(eyre!("key中没有插件路径: {plugin_path}")); + }; + if info.enabled == enabled { + return Ok(()); + } + info.enabled = true; + (info.path.clone(), info.priority) + }; + + let plugin_info = + match plugin_loader::load_plugin_from_path(&plugin_file_path, priority, true) { + Ok(runtime) => { + { + let mut runtimes = self.runtimes.write(); + remove_runtime_by_path(&mut runtimes, &plugin_file_path); + insert_runtime_by_priority(&mut runtimes, runtime.clone()); + } + + let mut infos = self.infos.write(); + let Some(info) = infos.get_mut(plugin_path) else { + return Err(eyre!("key中没有插件路径: {plugin_path}")); + }; + info.runtime_status = PluginRuntimeStatus::Loaded; + info.descriptor = PluginDescriptorInfo::from_descriptor(&runtime.descriptor); + info.clone() + } + Err(err) => { + let err_title = "启用插件时加载失败"; + let message = err.to_message(); + tracing::error!(err_title, message); + + { + let mut runtimes = self.runtimes.write(); + remove_runtime_by_path(&mut runtimes, &plugin_file_path); + } + + let mut infos = self.infos.write(); + let Some(info) = infos.get_mut(plugin_path) else { + return Err(eyre!("key中没有插件路径: {plugin_path}")); + }; + info.runtime_status = PluginRuntimeStatus::LoadFailed; + info.clone() + } + }; + + let _ = PluginEvent::Update { plugin_info }.emit(&self.app); + + self.save_metadata()?; + + Ok(()) + } + + #[instrument( + level = "error", + skip_all, + fields(plugin_path = plugin_path, priority = priority) + )] + pub fn set_plugin_priority(&self, plugin_path: &str, priority: i32) -> eyre::Result<()> { + let plugin_info = { + let mut infos = self.infos.write(); + let Some(info) = infos.get_mut(plugin_path) else { + return Err(eyre!("key中没有插件路径: {plugin_path}")); + }; + if info.priority == priority { + return Ok(()); + } + info.priority = priority; + info.clone() + }; + + { + let mut runtimes = self.runtimes.write(); + if let Some(mut runtime) = remove_runtime_by_path(&mut runtimes, Path::new(plugin_path)) + { + runtime.priority = priority; + insert_runtime_by_priority(&mut runtimes, runtime); + } + } + + let _ = PluginEvent::Update { plugin_info }.emit(&self.app); + + self.save_metadata()?; + + Ok(()) + } + + pub fn get_plugin_infos(&self) -> Vec { + self.infos.read().values().cloned().collect() + } + + #[instrument(level = "error", skip_all)] + pub async fn run_hook(&self, mut context: HookContext<'_>) -> eyre::Result<()> { + let hook_point = context.hook_point(); + let runtimes = self.runtimes.read().clone(); + if runtimes.is_empty() { + return Ok(()); + } + + let app_version = self.app.package_info().version.to_string(); + + for runtime in &runtimes { + if !runtime.enabled || !runtime.should_run_hook(hook_point) { + continue; + } + + let input = context.to_input(&app_version)?; + let output = match plugin_executor::execute_hook(runtime, &input).await { + Ok(output) => output, + Err(err) => match runtime.descriptor.failure_policy { + bilibili_video_downloader_plugin_api::v1::PluginFailurePolicy::FailOpen => { + let err_title = "插件执行出错,按照 FailOpen 继续其他任务"; + let message = err.to_message(); + tracing::error!(err_title, message); + continue; + } + bilibili_video_downloader_plugin_api::v1::PluginFailurePolicy::FailClosed => { + let err = err.wrap_err("插件执行出错,按照 FailClosed 中断任务"); + return Err(err); + } + }, + }; + + if let Err(err) = context.apply_output(output) { + match runtime.descriptor.failure_policy { + bilibili_video_downloader_plugin_api::v1::PluginFailurePolicy::FailOpen => { + let err_title = "插件输出无效,按照 FailOpen 继续其他任务"; + let message = err.to_message(); + tracing::error!(err_title, message); + } + bilibili_video_downloader_plugin_api::v1::PluginFailurePolicy::FailClosed => { + let err = err.wrap_err("插件输出无效,按照 FailClosed 中断任务"); + return Err(err); + } + } + } + } + + Ok(()) + } + + #[instrument(level = "error", skip_all)] + fn save_metadata(&self) -> eyre::Result<()> { + let app_data_dir = self.app.path().app_data_dir()?; + let plugin_json_path = app_data_dir.join("plugin.json"); + + let metadata_by_path: HashMap = self + .infos + .read() + .clone() + .into_iter() + .map(|(plugin_path, info)| (plugin_path, info.into_metadata())) + .collect(); + let json_string = serde_json::to_string_pretty(&metadata_by_path)?; + + std::fs::write(plugin_json_path, json_string)?; + + Ok(()) + } +} + +fn insert_runtime_by_priority(runtimes: &mut Vec, runtime: PluginRuntime) { + let insert_idx = runtimes + .iter() + .position(|existing| existing.priority < runtime.priority) + .unwrap_or(runtimes.len()); + runtimes.insert(insert_idx, runtime); +} + +fn remove_runtime_by_path( + runtimes: &mut Vec, + plugin_path: &Path, +) -> Option { + let remove_idx = runtimes + .iter() + .position(|runtime| runtime.plugin_path == plugin_path)?; + Some(runtimes.remove(remove_idx)) +} diff --git a/src-tauri/src/plugin/plugin_types.rs b/src-tauri/src/plugin/plugin_types.rs new file mode 100644 index 0000000..b5dfd53 --- /dev/null +++ b/src-tauri/src/plugin/plugin_types.rs @@ -0,0 +1,45 @@ +use std::{ffi::c_char, path::PathBuf, sync::Arc}; + +use bilibili_video_downloader_plugin_api::v1::{HookPointV1, HostApiV1, PluginDescriptorV1}; +use dlopen2::wrapper::{Container, WrapperApi}; + +#[derive(WrapperApi)] +pub struct PluginDylibApi { + #[dlopen2_name = "bilibili_video_downloader_plugin_descriptor_v1"] + descriptor: unsafe extern "C" fn() -> *const c_char, + #[dlopen2_name = "bilibili_video_downloader_plugin_on_hook_v1"] + on_hook: unsafe extern "C" fn( + input_ptr: *const u8, + input_len: usize, + out_ptr: *mut *mut u8, + out_len: *mut usize, + ) -> i32, + #[dlopen2_name = "bilibili_video_downloader_plugin_free_buffer_v1"] + free_buffer: unsafe extern "C" fn(ptr: *mut u8, len: usize), + #[dlopen2_name = "bilibili_video_downloader_plugin_last_error_v1"] + last_error: unsafe extern "C" fn() -> *const c_char, + #[dlopen2_name = "bilibili_video_downloader_plugin_set_host_api_v1"] + set_host_api: unsafe extern "C" fn(api: *const HostApiV1) -> i32, +} + +#[derive(Clone)] +pub struct PluginRuntime { + pub descriptor: PluginDescriptorV1, + pub plugin_path: PathBuf, + pub enabled: bool, + pub priority: i32, + pub api: Arc>, +} + +impl PluginRuntime { + pub fn display_name(&self) -> String { + format!( + "{} ({}, v{})", + self.descriptor.name, self.descriptor.id, self.descriptor.version + ) + } + + pub fn should_run_hook(&self, hook: HookPointV1) -> bool { + self.descriptor.hooks.contains(&hook) + } +} diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index fd290e1..2292b83 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -23,6 +23,7 @@ pub mod log_metadata; pub mod normal_info; pub mod normal_media_url; pub mod player_info; +pub mod plugin_info; pub mod qrcode_data; pub mod qrcode_status; pub mod restart_download_task_params; diff --git a/src-tauri/src/types/plugin_info.rs b/src-tauri/src/types/plugin_info.rs new file mode 100644 index 0000000..972745b --- /dev/null +++ b/src-tauri/src/types/plugin_info.rs @@ -0,0 +1,131 @@ +use std::path::PathBuf; + +use bilibili_video_downloader_plugin_api::v1::{ + HookPointV1, PluginDescriptorV1, PluginFailurePolicy, +}; +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::plugin::plugin_types::PluginRuntime; + +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +pub enum PluginHookPoint { + #[default] + BeforeVideoProcess, + AfterPrepare, + OnCompleted, +} + +impl From for PluginHookPoint { + fn from(value: HookPointV1) -> Self { + match value { + HookPointV1::BeforeVideoProcess => Self::BeforeVideoProcess, + HookPointV1::AfterPrepare => Self::AfterPrepare, + HookPointV1::OnCompleted => Self::OnCompleted, + } + } +} + +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +pub enum PluginFailurePolicyInfo { + #[default] + FailOpen, + FailClosed, +} + +impl From for PluginFailurePolicyInfo { + fn from(value: PluginFailurePolicy) -> Self { + match value { + PluginFailurePolicy::FailOpen => Self::FailOpen, + PluginFailurePolicy::FailClosed => Self::FailClosed, + } + } +} + +#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct PluginDescriptorInfo { + pub sdk_api_version: u32, + pub id: String, + pub name: String, + pub version: String, + pub hooks: Vec, + pub failure_policy: PluginFailurePolicyInfo, + pub description: String, +} + +impl PluginDescriptorInfo { + pub fn from_descriptor(descriptor: &PluginDescriptorV1) -> Self { + Self { + sdk_api_version: descriptor.sdk_api_version, + id: descriptor.id.clone(), + name: descriptor.name.clone(), + version: descriptor.version.clone(), + hooks: descriptor + .hooks + .iter() + .copied() + .map(PluginHookPoint::from) + .collect(), + failure_policy: descriptor.failure_policy.into(), + description: descriptor.description.clone(), + } + } +} + +#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct PluginMetadata { + pub path: PathBuf, + pub enabled: bool, + pub priority: i32, + pub descriptor: PluginDescriptorInfo, +} + +impl PluginMetadata { + pub fn from_plugin_runtime(runtime: &PluginRuntime) -> Self { + Self { + path: runtime.plugin_path.clone(), + enabled: runtime.enabled, + priority: runtime.priority, + descriptor: PluginDescriptorInfo::from_descriptor(&runtime.descriptor), + } + } +} + +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +pub enum PluginRuntimeStatus { + #[default] + Unknown, + Loaded, + Disabled, + LoadFailed, +} + +#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct PluginInfo { + pub path: PathBuf, + pub enabled: bool, + pub priority: i32, + pub descriptor: PluginDescriptorInfo, + pub runtime_status: PluginRuntimeStatus, +} + +impl PluginInfo { + pub fn from_metadata(metadata: PluginMetadata, runtime_status: PluginRuntimeStatus) -> Self { + Self { + path: metadata.path, + enabled: metadata.enabled, + priority: metadata.priority, + descriptor: metadata.descriptor, + runtime_status, + } + } + + pub fn into_metadata(self) -> PluginMetadata { + PluginMetadata { + path: self.path, + enabled: self.enabled, + priority: self.priority, + descriptor: self.descriptor, + } + } +} diff --git a/src/bindings.ts b/src/bindings.ts index 286bf38..52f43ac 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -16,6 +16,9 @@ async saveConfig(config: Config) : Promise> { else return { status: "error", error: e as any }; } }, +async getPluginInfos() : Promise { + return await TAURI_INVOKE("get_plugin_infos"); +}, async generateQrcode() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("generate_qrcode") }; @@ -177,6 +180,38 @@ async openLogFile(path: string) : Promise> { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } +}, +async addPlugin(pluginPath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("add_plugin", { pluginPath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async uninstallPlugin(pluginPath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("uninstall_plugin", { pluginPath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async setPluginEnabled(pluginPath: string, enabled: boolean) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("set_plugin_enabled", { pluginPath, enabled }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async setPluginPriority(pluginPath: string, priority: number) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("set_plugin_priority", { pluginPath, priority }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} } } @@ -185,10 +220,12 @@ async openLogFile(path: string) : Promise> { export const events = __makeEvents__<{ downloadEvent: DownloadEvent, -logEvent: LogEvent +logEvent: LogEvent, +pluginEvent: PluginEvent }>({ downloadEvent: "download-event", -logEvent: "log-event" +logEvent: "log-event", +pluginEvent: "plugin-event" }) /** user-defined constants **/ @@ -371,6 +408,12 @@ export type PaymentInBangumi = { discount: number; pay_type: PayType; price: str export type PendantInCheese = { image: string; name: string; pid: number } export type PendantInUserInfo = { pid: number; name: string; image: string; expire: number; image_enhance: string; image_enhance_frame: string; n_pid: number } export type PlayStrategy = { strategies: string[] } +export type PluginDescriptorInfo = { sdk_api_version: number; id: string; name: string; version: string; hooks: PluginHookPoint[]; failure_policy: PluginFailurePolicyInfo; description: string } +export type PluginEvent = { event: "Loaded"; data: { plugin_info: PluginInfo } } | { event: "Update"; data: { plugin_info: PluginInfo } } | { event: "Uninstall"; data: { plugin_path: string } } +export type PluginFailurePolicyInfo = "FailOpen" | "FailClosed" +export type PluginHookPoint = "BeforeVideoProcess" | "AfterPrepare" | "OnCompleted" +export type PluginInfo = { path: string; enabled: boolean; priority: number; descriptor: PluginDescriptorInfo; runtime_status: PluginRuntimeStatus } +export type PluginRuntimeStatus = "Unknown" | "Loaded" | "Disabled" | "LoadFailed" export type Positive = { id: number; title: string } export type PreviewedPurchaseNote = { long_watch_text: string; pay_text: string; price_format: string; watch_text: string; watching_text: string } export type Producer = { mid: number; type: number; is_contribute: number | null; title: string }