feat: 进程内动态库插件系统

This commit is contained in:
lanyeeee
2026-03-11 08:57:43 +08:00
parent 6be3289d93
commit 933f8000dd
26 changed files with 2011 additions and 52 deletions
+78 -8
View File
@@ -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<Vec<LogMetadata>> {
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<PluginInfo> {
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(())
}
+52 -31
View File
@@ -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<DownloadTask>) -> 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(())
+9
View File
@@ -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 },
}
+5
View File
@@ -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<Config>>;
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::<DownloadManager>()
}
fn get_plugin_manager(&self) -> State<'_, PluginManager> {
self.state::<PluginManager>()
}
}
pub trait GetOrInitPlayerInfo {
+24 -9
View File
@@ -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<Wry> {
@@ -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())
+6
View File
@@ -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;
+189
View File
@@ -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<BeforeVideoProcessPayloadV1> {
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<OnCompletedPayloadV1> {
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<AfterPreparePayloadV1> {
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<HookInputV1> {
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<DownloadProgressV1> {
convert_via_json(
progress,
"序列化宿主 DownloadProgress 失败",
"反序列化为插件 DownloadProgressV1 失败",
)
}
fn api_to_host_progress(progress: DownloadProgressV1) -> eyre::Result<DownloadProgress> {
convert_via_json(
progress,
"序列化插件 DownloadProgressV1 失败",
"反序列化为宿主 DownloadProgress 失败",
)
}
fn convert_via_json<TSrc, TDst>(
source: TSrc,
serialize_err: &str,
deserialize_err: &str,
) -> eyre::Result<TDst>
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())
}
+67
View File
@@ -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<AppHandle> = 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::<u8>();
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<HostConfigV1> {
let value = serde_json::to_value(config).wrap_err("序列化宿主 Config 失败")?;
let host_config = serde_json::from_value(value).wrap_err("反序列化为插件 HostConfigV1 失败")?;
Ok(host_config)
}
+66
View File
@@ -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<HookOutputV1> {
let input_bytes = serde_json::to_vec(input)?;
let api = plugin.api.clone();
let (tx, rx) = tokio::sync::oneshot::channel::<eyre::Result<Vec<u8>>>();
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<Container<PluginDylibApi>>,
input_bytes: &[u8],
) -> eyre::Result<Vec<u8>> {
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<Container<PluginDylibApi>>) -> 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()
}
+78
View File
@@ -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<PluginRuntime> {
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::<PluginDylibApi>::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<PluginDylibApi>) -> eyre::Result<String> {
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())
}
+351
View File
@@ -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<HashMap<String, PluginInfo>>,
runtimes: RwLock<Vec<PluginRuntime>>,
}
impl PluginManager {
#[instrument(level = "error", skip_all)]
pub fn new(app: &AppHandle) -> eyre::Result<PluginManager> {
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<String, PluginMetadata> =
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<PluginInfo> {
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<String, PluginMetadata> = 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<PluginRuntime>, 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<PluginRuntime>,
plugin_path: &Path,
) -> Option<PluginRuntime> {
let remove_idx = runtimes
.iter()
.position(|runtime| runtime.plugin_path == plugin_path)?;
Some(runtimes.remove(remove_idx))
}
+45
View File
@@ -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<Container<PluginDylibApi>>,
}
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)
}
}
+1
View File
@@ -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;
+131
View File
@@ -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<HookPointV1> 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<PluginFailurePolicy> 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<PluginHookPoint>,
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,
}
}
}