From 91a11282d845e3269d41d624fa21479c589886ae Mon Sep 17 00:00:00 2001 From: xiaowumin-mark Date: Wed, 20 May 2026 23:29:38 +0800 Subject: [PATCH 01/14] feat(player): add plugin extension window management --- .gitignore | 1 + packages/player/src-tauri/build.rs | 17 +- .../capabilities/extension-window.toml | 18 + .../src-tauri/capabilities/migrated.toml | 27 +- .../extension_window_center.toml | 11 + .../autogenerated/extension_window_close.toml | 11 + .../extension_window_close_all.toml | 11 + .../extension_window_create.toml | 11 + .../autogenerated/extension_window_focus.toml | 11 + .../autogenerated/extension_window_get.toml | 11 + .../extension_window_get_current.toml | 11 + .../autogenerated/extension_window_hide.toml | 11 + .../extension_window_set_position.toml | 11 + .../extension_window_set_size.toml | 11 + .../extension_window_set_title.toml | 11 + .../autogenerated/extension_window_show.toml | 11 + .../player/src-tauri/src/extension_window.rs | 607 ++++++++++++++++++ packages/player/src-tauri/src/lib.rs | 38 ++ 18 files changed, 837 insertions(+), 3 deletions(-) create mode 100644 packages/player/src-tauri/capabilities/extension-window.toml create mode 100644 packages/player/src-tauri/permissions/autogenerated/extension_window_center.toml create mode 100644 packages/player/src-tauri/permissions/autogenerated/extension_window_close.toml create mode 100644 packages/player/src-tauri/permissions/autogenerated/extension_window_close_all.toml create mode 100644 packages/player/src-tauri/permissions/autogenerated/extension_window_create.toml create mode 100644 packages/player/src-tauri/permissions/autogenerated/extension_window_focus.toml create mode 100644 packages/player/src-tauri/permissions/autogenerated/extension_window_get.toml create mode 100644 packages/player/src-tauri/permissions/autogenerated/extension_window_get_current.toml create mode 100644 packages/player/src-tauri/permissions/autogenerated/extension_window_hide.toml create mode 100644 packages/player/src-tauri/permissions/autogenerated/extension_window_set_position.toml create mode 100644 packages/player/src-tauri/permissions/autogenerated/extension_window_set_size.toml create mode 100644 packages/player/src-tauri/permissions/autogenerated/extension_window_set_title.toml create mode 100644 packages/player/src-tauri/permissions/autogenerated/extension_window_show.toml create mode 100644 packages/player/src-tauri/src/extension_window.rs diff --git a/.gitignore b/.gitignore index 385fc940..3a3cc106 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ target/ *.tsbuildinfo .million/ .opencode +/vendor \ No newline at end of file diff --git a/packages/player/src-tauri/build.rs b/packages/player/src-tauri/build.rs index 261851f6..4140638f 100644 --- a/packages/player/src-tauri/build.rs +++ b/packages/player/src-tauri/build.rs @@ -1,3 +1,18 @@ fn main() { - tauri_build::build(); + let attrs = + tauri_build::Attributes::new().app_manifest(tauri_build::AppManifest::new().commands(&[ + "extension_window_create", + "extension_window_get", + "extension_window_close", + "extension_window_close_all", + "extension_window_show", + "extension_window_hide", + "extension_window_focus", + "extension_window_center", + "extension_window_set_title", + "extension_window_set_size", + "extension_window_set_position", + "extension_window_get_current", + ])); + tauri_build::try_build(attrs).expect("failed to run tauri build script"); } diff --git a/packages/player/src-tauri/capabilities/extension-window.toml b/packages/player/src-tauri/capabilities/extension-window.toml new file mode 100644 index 00000000..65114c2c --- /dev/null +++ b/packages/player/src-tauri/capabilities/extension-window.toml @@ -0,0 +1,18 @@ +"$schema" = "../gen/schemas/desktop-schema.json" + +identifier = "extension-window" +local = true +windows = ["extension-window/*"] +platforms = ["macOS", "windows", "linux"] + +[[permissions]] +identifier = "core:event:allow-listen" +[[permissions]] +identifier = "core:event:allow-emit" +[[permissions]] +identifier = "core:event:allow-emit-to" +[[permissions]] +identifier = "core:event:allow-unlisten" + +[[permissions]] +identifier = "allow-extension-window-get-current" diff --git a/packages/player/src-tauri/capabilities/migrated.toml b/packages/player/src-tauri/capabilities/migrated.toml index 5436d781..618bc229 100644 --- a/packages/player/src-tauri/capabilities/migrated.toml +++ b/packages/player/src-tauri/capabilities/migrated.toml @@ -61,5 +61,28 @@ identifier = "fs:allow-stat" identifier = "fs:read-app-specific-dirs-recursive" [[permissions]] identifier = "fs:create-app-specific-dirs" -[[permissions]] -identifier = "dialog:default" +[[permissions]] +identifier = "dialog:default" + +[[permissions]] +identifier = "allow-extension-window-create" +[[permissions]] +identifier = "allow-extension-window-get" +[[permissions]] +identifier = "allow-extension-window-close" +[[permissions]] +identifier = "allow-extension-window-close-all" +[[permissions]] +identifier = "allow-extension-window-show" +[[permissions]] +identifier = "allow-extension-window-hide" +[[permissions]] +identifier = "allow-extension-window-focus" +[[permissions]] +identifier = "allow-extension-window-center" +[[permissions]] +identifier = "allow-extension-window-set-title" +[[permissions]] +identifier = "allow-extension-window-set-size" +[[permissions]] +identifier = "allow-extension-window-set-position" diff --git a/packages/player/src-tauri/permissions/autogenerated/extension_window_center.toml b/packages/player/src-tauri/permissions/autogenerated/extension_window_center.toml new file mode 100644 index 00000000..e34d11df --- /dev/null +++ b/packages/player/src-tauri/permissions/autogenerated/extension_window_center.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-extension-window-center" +description = "Enables the extension_window_center command without any pre-configured scope." +commands.allow = ["extension_window_center"] + +[[permission]] +identifier = "deny-extension-window-center" +description = "Denies the extension_window_center command without any pre-configured scope." +commands.deny = ["extension_window_center"] diff --git a/packages/player/src-tauri/permissions/autogenerated/extension_window_close.toml b/packages/player/src-tauri/permissions/autogenerated/extension_window_close.toml new file mode 100644 index 00000000..b93b84d1 --- /dev/null +++ b/packages/player/src-tauri/permissions/autogenerated/extension_window_close.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-extension-window-close" +description = "Enables the extension_window_close command without any pre-configured scope." +commands.allow = ["extension_window_close"] + +[[permission]] +identifier = "deny-extension-window-close" +description = "Denies the extension_window_close command without any pre-configured scope." +commands.deny = ["extension_window_close"] diff --git a/packages/player/src-tauri/permissions/autogenerated/extension_window_close_all.toml b/packages/player/src-tauri/permissions/autogenerated/extension_window_close_all.toml new file mode 100644 index 00000000..0e2928f4 --- /dev/null +++ b/packages/player/src-tauri/permissions/autogenerated/extension_window_close_all.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-extension-window-close-all" +description = "Enables the extension_window_close_all command without any pre-configured scope." +commands.allow = ["extension_window_close_all"] + +[[permission]] +identifier = "deny-extension-window-close-all" +description = "Denies the extension_window_close_all command without any pre-configured scope." +commands.deny = ["extension_window_close_all"] diff --git a/packages/player/src-tauri/permissions/autogenerated/extension_window_create.toml b/packages/player/src-tauri/permissions/autogenerated/extension_window_create.toml new file mode 100644 index 00000000..09169f05 --- /dev/null +++ b/packages/player/src-tauri/permissions/autogenerated/extension_window_create.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-extension-window-create" +description = "Enables the extension_window_create command without any pre-configured scope." +commands.allow = ["extension_window_create"] + +[[permission]] +identifier = "deny-extension-window-create" +description = "Denies the extension_window_create command without any pre-configured scope." +commands.deny = ["extension_window_create"] diff --git a/packages/player/src-tauri/permissions/autogenerated/extension_window_focus.toml b/packages/player/src-tauri/permissions/autogenerated/extension_window_focus.toml new file mode 100644 index 00000000..a9562877 --- /dev/null +++ b/packages/player/src-tauri/permissions/autogenerated/extension_window_focus.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-extension-window-focus" +description = "Enables the extension_window_focus command without any pre-configured scope." +commands.allow = ["extension_window_focus"] + +[[permission]] +identifier = "deny-extension-window-focus" +description = "Denies the extension_window_focus command without any pre-configured scope." +commands.deny = ["extension_window_focus"] diff --git a/packages/player/src-tauri/permissions/autogenerated/extension_window_get.toml b/packages/player/src-tauri/permissions/autogenerated/extension_window_get.toml new file mode 100644 index 00000000..e2d08638 --- /dev/null +++ b/packages/player/src-tauri/permissions/autogenerated/extension_window_get.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-extension-window-get" +description = "Enables the extension_window_get command without any pre-configured scope." +commands.allow = ["extension_window_get"] + +[[permission]] +identifier = "deny-extension-window-get" +description = "Denies the extension_window_get command without any pre-configured scope." +commands.deny = ["extension_window_get"] diff --git a/packages/player/src-tauri/permissions/autogenerated/extension_window_get_current.toml b/packages/player/src-tauri/permissions/autogenerated/extension_window_get_current.toml new file mode 100644 index 00000000..b17ee0d0 --- /dev/null +++ b/packages/player/src-tauri/permissions/autogenerated/extension_window_get_current.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-extension-window-get-current" +description = "Enables the extension_window_get_current command without any pre-configured scope." +commands.allow = ["extension_window_get_current"] + +[[permission]] +identifier = "deny-extension-window-get-current" +description = "Denies the extension_window_get_current command without any pre-configured scope." +commands.deny = ["extension_window_get_current"] diff --git a/packages/player/src-tauri/permissions/autogenerated/extension_window_hide.toml b/packages/player/src-tauri/permissions/autogenerated/extension_window_hide.toml new file mode 100644 index 00000000..f23ccf64 --- /dev/null +++ b/packages/player/src-tauri/permissions/autogenerated/extension_window_hide.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-extension-window-hide" +description = "Enables the extension_window_hide command without any pre-configured scope." +commands.allow = ["extension_window_hide"] + +[[permission]] +identifier = "deny-extension-window-hide" +description = "Denies the extension_window_hide command without any pre-configured scope." +commands.deny = ["extension_window_hide"] diff --git a/packages/player/src-tauri/permissions/autogenerated/extension_window_set_position.toml b/packages/player/src-tauri/permissions/autogenerated/extension_window_set_position.toml new file mode 100644 index 00000000..4793af7f --- /dev/null +++ b/packages/player/src-tauri/permissions/autogenerated/extension_window_set_position.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-extension-window-set-position" +description = "Enables the extension_window_set_position command without any pre-configured scope." +commands.allow = ["extension_window_set_position"] + +[[permission]] +identifier = "deny-extension-window-set-position" +description = "Denies the extension_window_set_position command without any pre-configured scope." +commands.deny = ["extension_window_set_position"] diff --git a/packages/player/src-tauri/permissions/autogenerated/extension_window_set_size.toml b/packages/player/src-tauri/permissions/autogenerated/extension_window_set_size.toml new file mode 100644 index 00000000..46d18e85 --- /dev/null +++ b/packages/player/src-tauri/permissions/autogenerated/extension_window_set_size.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-extension-window-set-size" +description = "Enables the extension_window_set_size command without any pre-configured scope." +commands.allow = ["extension_window_set_size"] + +[[permission]] +identifier = "deny-extension-window-set-size" +description = "Denies the extension_window_set_size command without any pre-configured scope." +commands.deny = ["extension_window_set_size"] diff --git a/packages/player/src-tauri/permissions/autogenerated/extension_window_set_title.toml b/packages/player/src-tauri/permissions/autogenerated/extension_window_set_title.toml new file mode 100644 index 00000000..544b1e1a --- /dev/null +++ b/packages/player/src-tauri/permissions/autogenerated/extension_window_set_title.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-extension-window-set-title" +description = "Enables the extension_window_set_title command without any pre-configured scope." +commands.allow = ["extension_window_set_title"] + +[[permission]] +identifier = "deny-extension-window-set-title" +description = "Denies the extension_window_set_title command without any pre-configured scope." +commands.deny = ["extension_window_set_title"] diff --git a/packages/player/src-tauri/permissions/autogenerated/extension_window_show.toml b/packages/player/src-tauri/permissions/autogenerated/extension_window_show.toml new file mode 100644 index 00000000..4ad20add --- /dev/null +++ b/packages/player/src-tauri/permissions/autogenerated/extension_window_show.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-extension-window-show" +description = "Enables the extension_window_show command without any pre-configured scope." +commands.allow = ["extension_window_show"] + +[[permission]] +identifier = "deny-extension-window-show" +description = "Denies the extension_window_show command without any pre-configured scope." +commands.deny = ["extension_window_show"] diff --git a/packages/player/src-tauri/src/extension_window.rs b/packages/player/src-tauri/src/extension_window.rs new file mode 100644 index 00000000..b60041f7 --- /dev/null +++ b/packages/player/src-tauri/src/extension_window.rs @@ -0,0 +1,607 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use tauri::{ + AppHandle, LogicalPosition, LogicalSize, LogicalUnit, Manager, PixelUnit, State, WebviewUrl, + WebviewWindow, WebviewWindowBuilder, WindowSizeConstraints, +}; + +const EXTENSION_WINDOW_LABEL_PREFIX: &str = "extension-window/"; +const EXTENSION_WINDOW_ENTRY: &str = "extension-window.html"; +const DEFAULT_WINDOW_WIDTH: f64 = 800.0; +const DEFAULT_WINDOW_HEIGHT: f64 = 600.0; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionWindowInfo { + pub extension_id: String, + pub window_id: String, + pub label: String, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionWindowOptions { + pub title: Option, + pub width: Option, + pub height: Option, + pub x: Option, + pub y: Option, + pub center: Option, + pub resizable: Option, + pub decorations: Option, + pub visible: Option, + pub min_width: Option, + pub min_height: Option, + pub max_width: Option, + pub max_height: Option, +} + +#[derive(Default)] +pub struct ExtensionWindowState { + windows: Mutex, +} + +#[derive(Default)] +struct ExtensionWindowMaps { + by_label: HashMap, + by_extension: HashMap>, +} + +impl ExtensionWindowState { + fn insert(&self, info: ExtensionWindowInfo) { + let mut maps = self.windows.lock().unwrap(); + maps.by_extension + .entry(info.extension_id.clone()) + .or_default() + .insert(info.label.clone()); + maps.by_label.insert(info.label.clone(), info); + } + + fn get_by_label(&self, label: &str) -> Option { + self.windows.lock().unwrap().by_label.get(label).cloned() + } + + fn get_by_owner( + &self, + extension_id: &str, + window_id: &str, + ) -> Result, String> { + let label = make_extension_window_label(extension_id, window_id)?; + Ok(self.get_by_label(&label)) + } + + fn labels_for_extension(&self, extension_id: &str) -> Vec { + self.windows + .lock() + .unwrap() + .by_extension + .get(extension_id) + .map(|labels| labels.iter().cloned().collect()) + .unwrap_or_default() + } + + fn all_labels(&self) -> Vec { + self.windows + .lock() + .unwrap() + .by_label + .keys() + .cloned() + .collect() + } + + fn remove_label(&self, label: &str) -> Option { + let mut maps = self.windows.lock().unwrap(); + let info = maps.by_label.remove(label)?; + let should_remove_extension = + if let Some(labels) = maps.by_extension.get_mut(&info.extension_id) { + labels.remove(label); + labels.is_empty() + } else { + false + }; + if should_remove_extension { + maps.by_extension.remove(&info.extension_id); + } + Some(info) + } +} + +fn validate_extension_id(extension_id: &str) -> Result<(), String> { + if extension_id.trim().is_empty() { + Err("extensionId must not be empty".to_string()) + } else { + Ok(()) + } +} + +fn validate_window_id(window_id: &str) -> Result<(), String> { + let len = window_id.len(); + if !(1..=64).contains(&len) { + return Err("windowId length must be between 1 and 64".to_string()); + } + if !window_id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + { + return Err("windowId may only contain ASCII letters, digits, '_' and '-'".to_string()); + } + Ok(()) +} + +fn validate_positive_number(value: f64, name: &str) -> Result { + if value.is_finite() && value > 0.0 { + Ok(value) + } else { + Err(format!("{name} must be a positive finite number")) + } +} + +fn validate_finite_number(value: f64, name: &str) -> Result { + if value.is_finite() { + Ok(value) + } else { + Err(format!("{name} must be a finite number")) + } +} + +fn extension_id_hash(extension_id: &str) -> String { + format!("{:x}", md5::compute(extension_id.as_bytes())) +} + +fn make_extension_window_label(extension_id: &str, window_id: &str) -> Result { + validate_extension_id(extension_id)?; + validate_window_id(window_id)?; + Ok(format!( + "{EXTENSION_WINDOW_LABEL_PREFIX}{}/{window_id}", + extension_id_hash(extension_id) + )) +} + +fn is_extension_window_label(label: &str) -> bool { + label.starts_with(EXTENSION_WINDOW_LABEL_PREFIX) +} + +fn parse_extension_window_label(label: &str) -> Option<(&str, &str)> { + let rest = label.strip_prefix(EXTENSION_WINDOW_LABEL_PREFIX)?; + let (extension_hash, window_id) = rest.split_once('/')?; + if extension_hash.is_empty() || window_id.is_empty() { + None + } else { + Some((extension_hash, window_id)) + } +} + +fn ensure_command_owner( + caller: &WebviewWindow, + extension_id: &str, + state: &ExtensionWindowState, +) -> Result<(), String> { + if caller.label() == "main" { + return Ok(()); + } + + let caller_label = caller.label(); + if !is_extension_window_label(caller_label) { + return Err( + "extension window commands can only be called from main or extension windows" + .to_string(), + ); + } + + if let Some(info) = state.get_by_label(caller_label) { + if info.extension_id == extension_id { + return Ok(()); + } + return Err("caller does not own this extension window".to_string()); + } + + let Some((extension_hash, _)) = parse_extension_window_label(caller_label) else { + return Err("invalid extension window label".to_string()); + }; + + if extension_hash == extension_id_hash(extension_id) { + Ok(()) + } else { + Err("caller does not own this extension window".to_string()) + } +} + +fn extension_window_url(app: &AppHandle) -> Result { + #[cfg(debug_assertions)] + { + let dev_url = app + .config() + .build + .dev_url + .clone() + .ok_or_else(|| "devUrl is not configured".to_string())?; + dev_url + .join(EXTENSION_WINDOW_ENTRY) + .map(WebviewUrl::External) + .map_err(|err| format!("failed to create extension window URL: {err}")) + } + + #[cfg(not(debug_assertions))] + { + let _ = app; + Ok(WebviewUrl::App(EXTENSION_WINDOW_ENTRY.into())) + } +} + +fn logical_pixel_unit(value: f64) -> PixelUnit { + PixelUnit::Logical(LogicalUnit::new(value)) +} + +fn resolve_size_constraints( + options: &ExtensionWindowOptions, +) -> Result { + if let (Some(min_width), Some(max_width)) = (options.min_width, options.max_width) { + if validate_positive_number(min_width, "minWidth")? + > validate_positive_number(max_width, "maxWidth")? + { + return Err("minWidth must be less than or equal to maxWidth".to_string()); + } + } + if let (Some(min_height), Some(max_height)) = (options.min_height, options.max_height) { + if validate_positive_number(min_height, "minHeight")? + > validate_positive_number(max_height, "maxHeight")? + { + return Err("minHeight must be less than or equal to maxHeight".to_string()); + } + } + + Ok(WindowSizeConstraints { + min_width: options + .min_width + .map(|value| validate_positive_number(value, "minWidth").map(logical_pixel_unit)) + .transpose()?, + min_height: options + .min_height + .map(|value| validate_positive_number(value, "minHeight").map(logical_pixel_unit)) + .transpose()?, + max_width: options + .max_width + .map(|value| validate_positive_number(value, "maxWidth").map(logical_pixel_unit)) + .transpose()?, + max_height: options + .max_height + .map(|value| validate_positive_number(value, "maxHeight").map(logical_pixel_unit)) + .transpose()?, + }) +} + +fn has_size_constraints(options: &ExtensionWindowOptions) -> bool { + options.min_width.is_some() + || options.min_height.is_some() + || options.max_width.is_some() + || options.max_height.is_some() +} + +fn get_window_for_owner( + app: &AppHandle, + state: &ExtensionWindowState, + extension_id: &str, + window_id: &str, +) -> Result, String> { + let label = make_extension_window_label(extension_id, window_id)?; + if let Some(win) = app.get_webview_window(&label) { + let info = state + .get_by_label(&label) + .unwrap_or_else(|| ExtensionWindowInfo { + extension_id: extension_id.to_string(), + window_id: window_id.to_string(), + label, + }); + state.insert(info.clone()); + Ok(Some((info, win))) + } else { + state.remove_label(&label); + Ok(None) + } +} + +#[tauri::command] +pub async fn extension_window_create( + app: AppHandle, + caller: WebviewWindow, + state: State<'_, ExtensionWindowState>, + extension_id: String, + window_id: String, + options: Option, +) -> Result { + ensure_command_owner(&caller, &extension_id, &state)?; + + let label = make_extension_window_label(&extension_id, &window_id)?; + if let Some(info) = state.get_by_owner(&extension_id, &window_id)? { + if let Some(win) = app.get_webview_window(&info.label) { + let _ = win.show(); + let _ = win.set_focus(); + return Ok(info); + } + state.remove_label(&info.label); + } else if let Some(win) = app.get_webview_window(&label) { + let info = ExtensionWindowInfo { + extension_id, + window_id, + label, + }; + state.insert(info.clone()); + let _ = win.show(); + let _ = win.set_focus(); + return Ok(info); + } + + let options = options.unwrap_or_default(); + let width = validate_positive_number(options.width.unwrap_or(DEFAULT_WINDOW_WIDTH), "width")?; + let height = + validate_positive_number(options.height.unwrap_or(DEFAULT_WINDOW_HEIGHT), "height")?; + let visible = options.visible.unwrap_or(true); + let title = options + .title + .clone() + .unwrap_or_else(|| "AMLL Player Extension".to_string()); + let should_center = options + .center + .unwrap_or(options.x.is_none() && options.y.is_none()); + + let mut builder = WebviewWindowBuilder::new(&app, &label, extension_window_url(&app)?) + .title(title) + .inner_size(width, height) + .resizable(options.resizable.unwrap_or(true)) + .decorations(options.decorations.unwrap_or(true)) + .visible(visible); + + if has_size_constraints(&options) { + builder = builder.inner_size_constraints(resolve_size_constraints(&options)?); + } + + if should_center { + builder = builder.center(); + } else if options.x.is_some() || options.y.is_some() { + let x = validate_finite_number( + options + .x + .ok_or_else(|| "x and y must be provided together".to_string())?, + "x", + )?; + let y = validate_finite_number( + options + .y + .ok_or_else(|| "x and y must be provided together".to_string())?, + "y", + )?; + builder = builder.position(x, y); + } + + let win = builder + .build() + .map_err(|err| format!("failed to create extension window: {err}"))?; + + if visible { + let _ = win.set_focus(); + } + + let info = ExtensionWindowInfo { + extension_id, + window_id, + label, + }; + state.insert(info.clone()); + Ok(info) +} + +#[tauri::command] +pub fn extension_window_get( + app: AppHandle, + caller: WebviewWindow, + state: State<'_, ExtensionWindowState>, + extension_id: String, + window_id: String, +) -> Result, String> { + ensure_command_owner(&caller, &extension_id, &state)?; + Ok(get_window_for_owner(&app, &state, &extension_id, &window_id)?.map(|(info, _)| info)) +} + +#[tauri::command] +pub fn extension_window_close( + app: AppHandle, + caller: WebviewWindow, + state: State<'_, ExtensionWindowState>, + extension_id: String, + window_id: String, +) -> Result<(), String> { + ensure_command_owner(&caller, &extension_id, &state)?; + if let Some((_, win)) = get_window_for_owner(&app, &state, &extension_id, &window_id)? { + win.close() + .map_err(|err| format!("failed to close extension window: {err}"))?; + } + Ok(()) +} + +#[tauri::command] +pub fn extension_window_close_all( + app: AppHandle, + caller: WebviewWindow, + state: State<'_, ExtensionWindowState>, + extension_id: String, +) -> Result<(), String> { + ensure_command_owner(&caller, &extension_id, &state)?; + for label in state.labels_for_extension(&extension_id) { + if let Some(win) = app.get_webview_window(&label) { + win.close() + .map_err(|err| format!("failed to close extension window: {err}"))?; + } else { + state.remove_label(&label); + } + } + Ok(()) +} + +#[tauri::command] +pub fn extension_window_show( + app: AppHandle, + caller: WebviewWindow, + state: State<'_, ExtensionWindowState>, + extension_id: String, + window_id: String, +) -> Result<(), String> { + ensure_command_owner(&caller, &extension_id, &state)?; + if let Some((_, win)) = get_window_for_owner(&app, &state, &extension_id, &window_id)? { + win.show() + .map_err(|err| format!("failed to show extension window: {err}"))?; + } + Ok(()) +} + +#[tauri::command] +pub fn extension_window_hide( + app: AppHandle, + caller: WebviewWindow, + state: State<'_, ExtensionWindowState>, + extension_id: String, + window_id: String, +) -> Result<(), String> { + ensure_command_owner(&caller, &extension_id, &state)?; + if let Some((_, win)) = get_window_for_owner(&app, &state, &extension_id, &window_id)? { + win.hide() + .map_err(|err| format!("failed to hide extension window: {err}"))?; + } + Ok(()) +} + +#[tauri::command] +pub fn extension_window_focus( + app: AppHandle, + caller: WebviewWindow, + state: State<'_, ExtensionWindowState>, + extension_id: String, + window_id: String, +) -> Result<(), String> { + ensure_command_owner(&caller, &extension_id, &state)?; + if let Some((_, win)) = get_window_for_owner(&app, &state, &extension_id, &window_id)? { + win.set_focus() + .map_err(|err| format!("failed to focus extension window: {err}"))?; + } + Ok(()) +} + +#[tauri::command] +pub fn extension_window_center( + app: AppHandle, + caller: WebviewWindow, + state: State<'_, ExtensionWindowState>, + extension_id: String, + window_id: String, +) -> Result<(), String> { + ensure_command_owner(&caller, &extension_id, &state)?; + if let Some((_, win)) = get_window_for_owner(&app, &state, &extension_id, &window_id)? { + win.center() + .map_err(|err| format!("failed to center extension window: {err}"))?; + } + Ok(()) +} + +#[tauri::command] +pub fn extension_window_set_title( + app: AppHandle, + caller: WebviewWindow, + state: State<'_, ExtensionWindowState>, + extension_id: String, + window_id: String, + title: String, +) -> Result<(), String> { + ensure_command_owner(&caller, &extension_id, &state)?; + if let Some((_, win)) = get_window_for_owner(&app, &state, &extension_id, &window_id)? { + win.set_title(&title) + .map_err(|err| format!("failed to set extension window title: {err}"))?; + } + Ok(()) +} + +#[tauri::command] +pub fn extension_window_set_size( + app: AppHandle, + caller: WebviewWindow, + state: State<'_, ExtensionWindowState>, + extension_id: String, + window_id: String, + width: f64, + height: f64, +) -> Result<(), String> { + ensure_command_owner(&caller, &extension_id, &state)?; + let width = validate_positive_number(width, "width")?; + let height = validate_positive_number(height, "height")?; + if let Some((_, win)) = get_window_for_owner(&app, &state, &extension_id, &window_id)? { + win.set_size(LogicalSize::new(width, height)) + .map_err(|err| format!("failed to set extension window size: {err}"))?; + } + Ok(()) +} + +#[tauri::command] +pub fn extension_window_set_position( + app: AppHandle, + caller: WebviewWindow, + state: State<'_, ExtensionWindowState>, + extension_id: String, + window_id: String, + x: f64, + y: f64, +) -> Result<(), String> { + ensure_command_owner(&caller, &extension_id, &state)?; + let x = validate_finite_number(x, "x")?; + let y = validate_finite_number(y, "y")?; + if let Some((_, win)) = get_window_for_owner(&app, &state, &extension_id, &window_id)? { + win.set_position(LogicalPosition::new(x, y)) + .map_err(|err| format!("failed to set extension window position: {err}"))?; + } + Ok(()) +} + +#[tauri::command] +pub fn extension_window_get_current( + caller: WebviewWindow, + state: State<'_, ExtensionWindowState>, +) -> Result { + let label = caller.label(); + if !is_extension_window_label(label) { + return Err("current window is not an extension window".to_string()); + } + state + .get_by_label(label) + .ok_or_else(|| "extension window ownership is not registered".to_string()) +} + +pub fn cleanup_destroyed_window(app: &AppHandle, label: &str) { + if is_extension_window_label(label) { + if let Some(state) = app.try_state::() { + state.remove_label(label); + } + } +} + +pub fn destroy_all_extension_windows(app: &AppHandle) { + let mut labels = Vec::new(); + + if let Some(state) = app.try_state::() { + labels.extend(state.all_labels()); + } + + for label in app.webview_windows().keys() { + if is_extension_window_label(label) && !labels.iter().any(|known| known == label) { + labels.push(label.clone()); + } + } + + for label in labels { + if let Some(win) = app.get_webview_window(&label) { + let _ = win.destroy(); + } + if let Some(state) = app.try_state::() { + state.remove_label(&label); + } + } +} diff --git a/packages/player/src-tauri/src/lib.rs b/packages/player/src-tauri/src/lib.rs index 8f5cabf8..54b00533 100644 --- a/packages/player/src-tauri/src/lib.rs +++ b/packages/player/src-tauri/src/lib.rs @@ -27,6 +27,9 @@ mod screen_capture; mod server; mod ttml_db; +#[cfg(desktop)] +mod extension_window; + #[cfg(target_os = "windows")] mod taskbar_lyric; #[cfg(target_os = "windows")] @@ -509,6 +512,30 @@ pub fn run() { sync_lyrics, search_lyrics, get_lyric_detail, + #[cfg(desktop)] + extension_window::extension_window_create, + #[cfg(desktop)] + extension_window::extension_window_get, + #[cfg(desktop)] + extension_window::extension_window_close, + #[cfg(desktop)] + extension_window::extension_window_close_all, + #[cfg(desktop)] + extension_window::extension_window_show, + #[cfg(desktop)] + extension_window::extension_window_hide, + #[cfg(desktop)] + extension_window::extension_window_focus, + #[cfg(desktop)] + extension_window::extension_window_center, + #[cfg(desktop)] + extension_window::extension_window_set_title, + #[cfg(desktop)] + extension_window::extension_window_set_size, + #[cfg(desktop)] + extension_window::extension_window_set_position, + #[cfg(desktop)] + extension_window::extension_window_get_current, #[cfg(target_os = "windows")] set_window_always_on_top, #[cfg(target_os = "windows")] @@ -579,6 +606,9 @@ pub fn run() { app.manage(ttml_db::create_shared_reader()); + #[cfg(desktop)] + app.manage(extension_window::ExtensionWindowState::default()); + app.manage::(RwLock::new(AMLLWebSocketServer::new( app.handle().clone(), ))); @@ -589,6 +619,14 @@ pub fn run() { Ok(()) }) .on_window_event(|window, event| { + #[cfg(desktop)] + if let tauri::WindowEvent::Destroyed = event { + extension_window::cleanup_destroyed_window(window.app_handle(), window.label()); + if window.label() == "main" { + extension_window::destroy_all_extension_windows(window.app_handle()); + } + } + #[cfg(target_os = "windows")] if let tauri::WindowEvent::Destroyed = event && window.label() == "main" From 3a8913d5467da29838e68f7eb581785048e203a5 Mon Sep 17 00:00:00 2001 From: xiaowumin-mark Date: Wed, 20 May 2026 23:38:35 +0800 Subject: [PATCH 02/14] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/player/extension-window.html | 31 ++++++++++++++++++++++++ packages/player/src/extension-window.tsx | 7 ++++++ packages/player/vite.config.ts | 1 + 3 files changed, 39 insertions(+) create mode 100644 packages/player/extension-window.html create mode 100644 packages/player/src/extension-window.tsx diff --git a/packages/player/extension-window.html b/packages/player/extension-window.html new file mode 100644 index 00000000..ec091303 --- /dev/null +++ b/packages/player/extension-window.html @@ -0,0 +1,31 @@ + + + + + + AMLL Player Extension Window + + + +
+ + + diff --git a/packages/player/src/extension-window.tsx b/packages/player/src/extension-window.tsx new file mode 100644 index 00000000..4bb68cfa --- /dev/null +++ b/packages/player/src/extension-window.tsx @@ -0,0 +1,7 @@ +import { createRoot } from "react-dom/client"; + +const ExtensionWindowApp = () => null; + +createRoot(document.getElementById("root") as HTMLElement).render( + , +); diff --git a/packages/player/vite.config.ts b/packages/player/vite.config.ts index 1bbaeff6..b6c1dc08 100644 --- a/packages/player/vite.config.ts +++ b/packages/player/vite.config.ts @@ -86,6 +86,7 @@ export default defineConfig({ shimMissingExports: true, input: { index: resolve(__dirname, "index.html"), + "extension-window": resolve(__dirname, "extension-window.html"), screenshot: resolve(__dirname, "screenshot.html"), "taskbar-lyric": resolve(__dirname, "taskbar-lyric.html"), }, From 26d81fd9efbbc82d572791abb1b96c05624a39d3 Mon Sep 17 00:00:00 2001 From: xiaowumin-mark Date: Wed, 20 May 2026 23:58:56 +0800 Subject: [PATCH 03/14] =?UTF-8?q?feat(player):=20=E6=8A=BD=E5=87=BA?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/ExtensionContext/ext-ctx.ts | 45 ----- .../src/components/ExtensionContext/index.tsx | 99 ++--------- .../components/ExtensionContext/runtime.ts | 165 ++++++++++++++++++ packages/player/src/states/extension.ts | 128 +------------- packages/player/src/utils/extension-loader.ts | 156 +++++++++++++++++ 5 files changed, 339 insertions(+), 254 deletions(-) create mode 100644 packages/player/src/components/ExtensionContext/runtime.ts create mode 100644 packages/player/src/utils/extension-loader.ts diff --git a/packages/player/src/components/ExtensionContext/ext-ctx.ts b/packages/player/src/components/ExtensionContext/ext-ctx.ts index 7fbe7963..191efa67 100644 --- a/packages/player/src/components/ExtensionContext/ext-ctx.ts +++ b/packages/player/src/components/ExtensionContext/ext-ctx.ts @@ -1,55 +1,10 @@ import type * as TauriHttp from "@tauri-apps/plugin-http"; -import { fromObject, fromSource, removeComments } from "convert-source-map"; import type { ComponentType } from "react"; -import { SourceMapConsumer, SourceMapGenerator } from "source-map-js"; import type { db } from "../../dexie.ts"; import type ExtensionEnv from "../../extension-env.ts"; import i18n from "../../i18n.ts"; import type { ExtensionMetaState } from "../../states/extensionsAtoms.ts"; -export async function sourceMapOffsetLines( - code: string, - sourceRoot: string, - lineOffset: number, -): Promise<[string, string]> { - const incomingSourceConv = fromSource(code); - if (!incomingSourceConv) return [code, ""]; - const incomingSourceMap = incomingSourceConv.toObject(); - const consumer = await new SourceMapConsumer(incomingSourceMap); - const generator = new SourceMapGenerator({ - file: incomingSourceMap.file, - sourceRoot: sourceRoot, - }); - consumer.eachMapping((m) => { - // skip invalid (not-connected) mapping - // refs: https://github.com/mozilla/source-map/blob/182f4459415de309667845af2b05716fcf9c59ad/lib/source-map-generator.js#L268-L275 - if ( - typeof m.originalLine === "number" && - 0 < m.originalLine && - typeof m.originalColumn === "number" && - 0 <= m.originalColumn && - m.source - ) { - generator.addMapping({ - source: - m.source && - `${location.origin}/extensions/${sourceRoot}/${m.source.replace(/^(\.*\/)+/, "")}`, - name: m.name, - original: { line: m.originalLine, column: m.originalColumn }, - generated: { - line: m.generatedLine + lineOffset, - column: m.generatedColumn, - }, - }); - } - }); - const outgoingSourceMap = JSON.parse(generator.toString()); - if (typeof incomingSourceMap.sourcesContent !== "undefined") { - outgoingSourceMap.sourcesContent = incomingSourceMap.sourcesContent; - } - return [removeComments(code), fromObject(outgoingSourceMap).toComment()]; -} - export class PlayerExtensionContext extends EventTarget implements ExtensionEnv.ExtensionContext diff --git a/packages/player/src/components/ExtensionContext/index.tsx b/packages/player/src/components/ExtensionContext/index.tsx index 20a0b5f1..b6174f05 100644 --- a/packages/player/src/components/ExtensionContext/index.tsx +++ b/packages/player/src/components/ExtensionContext/index.tsx @@ -1,12 +1,9 @@ import * as lyric from "@applemusic-like-lyrics/lyric"; import * as amllStates from "@applemusic-like-lyrics/react-full"; import * as http from "@tauri-apps/plugin-http"; -import chalk from "chalk"; import { useAtomValue, useSetAtom, useStore } from "jotai"; import { type FC, useCallback, useEffect, useMemo, useRef } from "react"; -import type * as JSXRuntime from "react/jsx-runtime"; import { useTranslation } from "react-i18next"; -import { uid } from "uid"; import { db } from "../../dexie.ts"; import * as appAtoms from "../../states/appAtoms"; import { extensionMetaAtom } from "../../states/extension.ts"; @@ -16,21 +13,8 @@ import type { LoadedExtension, } from "../../states/extensionsAtoms.ts"; import { ExtensionLoadResult } from "../../states/extensionsAtoms.ts"; -import { PlayerExtensionContext, sourceMapOffsetLines } from "./ext-ctx.ts"; - -const AsyncFunction: FunctionConstructor = Object.getPrototypeOf( - async () => {}, -).constructor; - -declare global { - interface Window { - React: typeof React; - ReactDOM: typeof ReactDOM; - Jotai: typeof Jotai; - RadixTheme: typeof RadixTheme; - JSXRuntime: typeof JSXRuntime; - } -} +import { PlayerExtensionContext } from "./ext-ctx.ts"; +import { EXTENSION_LOG_TAG, runExtensionScript } from "./runtime.ts"; class Notify { promise: Promise; @@ -57,8 +41,6 @@ class Notify { } } -const LOG_TAG = chalk.bgHex("#00AAFF").hex("#FFFFFF")(" EXTENSION "); - const SingleExtensionContext: FC<{ extensionMeta: ExtensionMetaState; waitForDependency: (extensionId: string) => Promise; @@ -97,87 +79,30 @@ const SingleExtensionContext: FC<{ }; (async () => { - const [React, ReactDOM, Jotai, RadixTheme, JSXRuntime] = - await Promise.all([ - import("react"), - import("react-dom"), - import("jotai"), - import("@radix-ui/themes"), - import("react/jsx-runtime"), - ]); - window.React = React; - window.ReactDOM = ReactDOM; - window.Jotai = Jotai; - window.RadixTheme = RadixTheme; - window.JSXRuntime = JSXRuntime; - const cancelNotify = cancelRef.current; if (cancelNotify) { await cancelNotify.wait(); } if (canceled) return; console.log( - LOG_TAG, + EXTENSION_LOG_TAG, "正在加载扩展程序", extensionMeta.id, extensionMeta.fileName, ); - const genFuncName = () => `__amll_internal_${uid()}`; - const resolveFuncName = genFuncName(); - const rejectFuncName = genFuncName(); - const waitForDependencyFuncName = genFuncName(); - const wrapperScript: string[] = []; - wrapperScript.push('"use strict";'); - wrapperScript.push("try {"); - - for (const dependencyId of extensionMeta.dependency) { - wrapperScript.push( - `await ${waitForDependencyFuncName}(${JSON.stringify(dependencyId)})`, - ); - } - - let comment = ""; - const offsetLines = wrapperScript.length + 2; - - try { - // 修正源映射表的行数,方便调试 - const [code, sourceMapComment] = await sourceMapOffsetLines( - extensionMeta.scriptData, - extensionMeta.id, - offsetLines, - ); - if (canceled) return; - wrapperScript.push(code); - comment = sourceMapComment; - } catch (err) { - console.log( - LOG_TAG, - "无法转换源映射表,可能是扩展程序并不包含源映射表", - err, - ); - wrapperScript.push(extensionMeta.scriptData); - } - - wrapperScript.push(`${resolveFuncName}();`); - wrapperScript.push("} catch (err) {"); - wrapperScript.push(`${rejectFuncName}(err);`); - wrapperScript.push("}"); - wrapperScript.push(comment); - - const extensionFunc: () => Promise = new AsyncFunction( - "extensionContext", - resolveFuncName, - rejectFuncName, - waitForDependencyFuncName, - wrapperScript.join("\n"), - ).bind(context, context, extPromise[1], extPromise[2], waitForDependency); - + await runExtensionScript({ + extensionMeta, + context, + waitForDependency, + resolveExtensionLoad: extPromise[1], + rejectExtensionLoad: extPromise[2], + isCanceled: () => canceled, + }); if (canceled) return; - await extensionFunc(); context.dispatchEvent(new Event("extension-load")); console.log( - LOG_TAG, + EXTENSION_LOG_TAG, "扩展程序", extensionMeta.id, extensionMeta.fileName, diff --git a/packages/player/src/components/ExtensionContext/runtime.ts b/packages/player/src/components/ExtensionContext/runtime.ts new file mode 100644 index 00000000..0aa2f092 --- /dev/null +++ b/packages/player/src/components/ExtensionContext/runtime.ts @@ -0,0 +1,165 @@ +import chalk from "chalk"; +import { fromObject, fromSource, removeComments } from "convert-source-map"; +import { SourceMapConsumer, SourceMapGenerator } from "source-map-js"; +import { uid } from "uid"; +import type { ExtensionMetaState } from "../../states/extensionsAtoms.ts"; +import type { PlayerExtensionContext } from "./ext-ctx.ts"; + +const AsyncFunction: FunctionConstructor = Object.getPrototypeOf( + async () => {}, +).constructor; + +export const EXTENSION_LOG_TAG = chalk.bgHex("#00AAFF").hex("#FFFFFF")( + " EXTENSION ", +); + +export type ExtensionDependencyWaiter = (extensionId: string) => Promise; + +export interface RunExtensionScriptOptions { + extensionMeta: ExtensionMetaState; + context: PlayerExtensionContext; + waitForDependency: ExtensionDependencyWaiter; + resolveExtensionLoad?: () => void; + rejectExtensionLoad?: (err: Error) => void; + isCanceled?: () => boolean; +} + +declare global { + interface Window { + React: typeof import("react"); + ReactDOM: typeof import("react-dom"); + Jotai: typeof import("jotai"); + RadixTheme: typeof import("@radix-ui/themes"); + JSXRuntime: typeof import("react/jsx-runtime"); + } +} + +export async function exposeExtensionGlobals() { + const [React, ReactDOM, Jotai, RadixTheme, JSXRuntime] = await Promise.all([ + import("react"), + import("react-dom"), + import("jotai"), + import("@radix-ui/themes"), + import("react/jsx-runtime"), + ]); + window.React = React; + window.ReactDOM = ReactDOM; + window.Jotai = Jotai; + window.RadixTheme = RadixTheme; + window.JSXRuntime = JSXRuntime; +} + +export async function sourceMapOffsetLines( + code: string, + sourceRoot: string, + lineOffset: number, +): Promise<[string, string]> { + const incomingSourceConv = fromSource(code); + if (!incomingSourceConv) return [code, ""]; + const incomingSourceMap = incomingSourceConv.toObject(); + const consumer = await new SourceMapConsumer(incomingSourceMap); + const generator = new SourceMapGenerator({ + file: incomingSourceMap.file, + sourceRoot: sourceRoot, + }); + consumer.eachMapping((m) => { + // skip invalid (not-connected) mapping + // refs: https://github.com/mozilla/source-map/blob/182f4459415de309667845af2b05716fcf9c59ad/lib/source-map-generator.js#L268-L275 + if ( + typeof m.originalLine === "number" && + 0 < m.originalLine && + typeof m.originalColumn === "number" && + 0 <= m.originalColumn && + m.source + ) { + generator.addMapping({ + source: + m.source && + `${location.origin}/extensions/${sourceRoot}/${m.source.replace(/^(\.*\/)+/, "")}`, + name: m.name, + original: { line: m.originalLine, column: m.originalColumn }, + generated: { + line: m.generatedLine + lineOffset, + column: m.generatedColumn, + }, + }); + } + }); + const outgoingSourceMap = JSON.parse(generator.toString()); + if (typeof incomingSourceMap.sourcesContent !== "undefined") { + outgoingSourceMap.sourcesContent = incomingSourceMap.sourcesContent; + } + return [removeComments(code), fromObject(outgoingSourceMap).toComment()]; +} + +export async function runExtensionScript({ + extensionMeta, + context, + waitForDependency, + resolveExtensionLoad = () => {}, + rejectExtensionLoad = (err) => { + throw err; + }, + isCanceled = () => false, +}: RunExtensionScriptOptions) { + await exposeExtensionGlobals(); + if (isCanceled()) return; + + const genFuncName = () => `__amll_internal_${uid()}`; + const resolveFuncName = genFuncName(); + const rejectFuncName = genFuncName(); + const waitForDependencyFuncName = genFuncName(); + const wrapperScript: string[] = []; + wrapperScript.push('"use strict";'); + wrapperScript.push("try {"); + + for (const dependencyId of extensionMeta.dependency) { + wrapperScript.push( + `await ${waitForDependencyFuncName}(${JSON.stringify(dependencyId)})`, + ); + } + + let comment = ""; + const offsetLines = wrapperScript.length + 2; + + try { + const [code, sourceMapComment] = await sourceMapOffsetLines( + extensionMeta.scriptData, + extensionMeta.id, + offsetLines, + ); + if (isCanceled()) return; + wrapperScript.push(code); + comment = sourceMapComment; + } catch (err) { + console.log( + EXTENSION_LOG_TAG, + "无法转换源映射表,可能是扩展程序并不包含源映射表", + err, + ); + wrapperScript.push(extensionMeta.scriptData); + } + + wrapperScript.push(`${resolveFuncName}();`); + wrapperScript.push("} catch (err) {"); + wrapperScript.push(`${rejectFuncName}(err);`); + wrapperScript.push("}"); + wrapperScript.push(comment); + + const extensionFunc: () => Promise = new AsyncFunction( + "extensionContext", + resolveFuncName, + rejectFuncName, + waitForDependencyFuncName, + wrapperScript.join("\n"), + ).bind( + context, + context, + resolveExtensionLoad, + rejectExtensionLoad, + waitForDependency, + ); + + if (isCanceled()) return; + await extensionFunc(); +} diff --git a/packages/player/src/states/extension.ts b/packages/player/src/states/extension.ts index 8bc1061e..a4f2089c 100644 --- a/packages/player/src/states/extension.ts +++ b/packages/player/src/states/extension.ts @@ -1,135 +1,19 @@ -import { appDataDir, join } from "@tauri-apps/api/path"; -import { mkdir, readDir, readTextFile } from "@tauri-apps/plugin-fs"; import { atom } from "jotai"; -import i18n from "../i18n.ts"; import { - ExtensionLoadResult, - type ExtensionMetaState, - reloadExtensionMetaAtom, -} from "./extensionsAtoms.ts"; + getExtensionDir, + loadExtensionMetas, +} from "../utils/extension-loader.ts"; +import { reloadExtensionMetaAtom } from "./extensionsAtoms.ts"; export const extensionDirAtom = atom(async () => { - const appDir = await appDataDir(); - return await join(appDir, "extensions"); + return await getExtensionDir(); }); export const extensionMetaAtom = atom( async (get) => { get(reloadExtensionMetaAtom); const extensionDir = await get(extensionDirAtom); - await mkdir(extensionDir, { recursive: true }); - const extensions = await readDir(extensionDir); - const META_REGEX = /^\/\/\s*@(\S+)\s*(.+)$/; - const extensionMetas = await Promise.all( - extensions - .filter((v) => v.isFile) - .map(async (extensionEntry) => { - const extensionMeta: ExtensionMetaState = { - loadResult: ExtensionLoadResult.Loadable, - id: "", - fileName: extensionEntry.name, - scriptData: "", - dependency: [], - }; - if ( - extensionEntry.name.endsWith(".js.disabled") || - extensionEntry.name.endsWith(".js") - ) { - if (extensionEntry.name.endsWith(".js.disabled")) - extensionMeta.loadResult = ExtensionLoadResult.Disabled; - const extensionData = await readTextFile( - await join(extensionDir, extensionEntry.name), - ); - for (const line of extensionData.split("\n")) { - const trimmed = line.trim(); - if (trimmed.length > 0) { - const matched = META_REGEX.exec(trimmed); - if (matched) { - if (matched[1] in extensionMeta) { - if (Array.isArray(extensionMeta[matched[1]])) { - (extensionMeta[matched[1]] as string[]).push(matched[2]); - } else if (extensionMeta[matched[1]]) { - extensionMeta[matched[1]] = [ - extensionMeta[matched[1]] as string, - matched[2], - ]; - } else { - extensionMeta[matched[1]] = matched[2]; - } - } else { - extensionMeta[matched[1]] = matched[2]; - } - } else { - break; - } - } - } - extensionMeta.fileName = extensionEntry.name; - extensionMeta.scriptData = extensionData; - - for (const key of ["id", "version", "icon"]) { - if (!(key in extensionMeta)) { - extensionMeta.loadResult = ExtensionLoadResult.MissingMetadata; - break; - } - } - - for (const localeKey of ["name", "description"]) { - for (const key in extensionMeta) { - if (key.startsWith(`${localeKey}:`)) { - const [, lng] = key.split(":", 2); - i18n.addResource( - lng, - extensionMeta.id, - localeKey, - String(extensionMeta[key]), - ); - } - } - } - } else { - extensionMeta.loadResult = ExtensionLoadResult.InvaildExtensionFile; - } - - return Object.seal(extensionMeta); - }), - ); - const extensionIds = new Set(); - const conflitsIds = new Set(); - for (const extensionMeta of extensionMetas) { - if (extensionIds.has(extensionMeta.id)) { - conflitsIds.add(extensionMeta.id); - } else { - extensionIds.add(extensionMeta.id); - } - } - for (const extensionMeta of extensionMetas) { - for (const d of extensionMeta.dependency) { - if (!extensionIds.has(d)) { - extensionMeta.loadResult = ExtensionLoadResult.MissingDependency; - break; - } - } - } - for (const extensionMeta of extensionMetas) { - if ( - extensionMeta.loadResult === ExtensionLoadResult.Loadable && - conflitsIds.has(extensionMeta.id) - ) { - extensionMeta.loadResult = ExtensionLoadResult.ExtensionIdConflict; - } - } - extensionMetas.sort((a, b) => { - if (a.loadResult === b.loadResult) - return a.fileName.localeCompare(b.fileName); - - if (a.loadResult === ExtensionLoadResult.Loadable) return -1; - if (b.loadResult === ExtensionLoadResult.Loadable) return 1; - if (a.loadResult === ExtensionLoadResult.Disabled) return -1; - if (b.loadResult === ExtensionLoadResult.Disabled) return 1; - return 0; - }); - return extensionMetas; + return await loadExtensionMetas(extensionDir); }, (_get, set) => { set(reloadExtensionMetaAtom, (c) => c + 1); diff --git a/packages/player/src/utils/extension-loader.ts b/packages/player/src/utils/extension-loader.ts new file mode 100644 index 00000000..4dd524d8 --- /dev/null +++ b/packages/player/src/utils/extension-loader.ts @@ -0,0 +1,156 @@ +import { appDataDir, join } from "@tauri-apps/api/path"; +import { mkdir, readDir, readTextFile } from "@tauri-apps/plugin-fs"; +import i18n from "../i18n.ts"; +import { + ExtensionLoadResult, + type ExtensionMetaState, +} from "../states/extensionsAtoms.ts"; + +const META_REGEX = /^\/\/\s*@(\S+)\s*(.+)$/; + +export async function getExtensionDir() { + const appDir = await appDataDir(); + return await join(appDir, "extensions"); +} + +function createEmptyExtensionMeta(fileName: string): ExtensionMetaState { + return { + loadResult: ExtensionLoadResult.Loadable, + id: "", + fileName, + scriptData: "", + dependency: [], + }; +} + +function applyExtensionMetaLine( + extensionMeta: ExtensionMetaState, + key: string, + value: string, +) { + if (key in extensionMeta) { + if (Array.isArray(extensionMeta[key])) { + (extensionMeta[key] as string[]).push(value); + } else if (extensionMeta[key]) { + extensionMeta[key] = [extensionMeta[key] as string, value]; + } else { + extensionMeta[key] = value; + } + } else { + extensionMeta[key] = value; + } +} + +function registerExtensionLocale(extensionMeta: ExtensionMetaState) { + for (const localeKey of ["name", "description"]) { + for (const key in extensionMeta) { + if (key.startsWith(`${localeKey}:`)) { + const [, lng] = key.split(":", 2); + i18n.addResource( + lng, + extensionMeta.id, + localeKey, + String(extensionMeta[key]), + ); + } + } + } +} + +async function loadExtensionMeta( + extensionDir: string, + fileName: string, +): Promise { + const extensionMeta = createEmptyExtensionMeta(fileName); + if (fileName.endsWith(".js.disabled") || fileName.endsWith(".js")) { + if (fileName.endsWith(".js.disabled")) { + extensionMeta.loadResult = ExtensionLoadResult.Disabled; + } + const extensionData = await readTextFile( + await join(extensionDir, fileName), + ); + for (const line of extensionData.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length > 0) { + const matched = META_REGEX.exec(trimmed); + if (matched) { + applyExtensionMetaLine(extensionMeta, matched[1], matched[2]); + } else { + break; + } + } + } + extensionMeta.fileName = fileName; + extensionMeta.scriptData = extensionData; + + for (const key of ["id", "version", "icon"]) { + if (!(key in extensionMeta)) { + extensionMeta.loadResult = ExtensionLoadResult.MissingMetadata; + break; + } + } + + registerExtensionLocale(extensionMeta); + } else { + extensionMeta.loadResult = ExtensionLoadResult.InvaildExtensionFile; + } + + return Object.seal(extensionMeta); +} + +function applyExtensionLoadResults(extensionMetas: ExtensionMetaState[]) { + const extensionIds = new Set(); + const conflitsIds = new Set(); + for (const extensionMeta of extensionMetas) { + if (extensionIds.has(extensionMeta.id)) { + conflitsIds.add(extensionMeta.id); + } else { + extensionIds.add(extensionMeta.id); + } + } + for (const extensionMeta of extensionMetas) { + for (const d of extensionMeta.dependency) { + if (!extensionIds.has(d)) { + extensionMeta.loadResult = ExtensionLoadResult.MissingDependency; + break; + } + } + } + for (const extensionMeta of extensionMetas) { + if ( + extensionMeta.loadResult === ExtensionLoadResult.Loadable && + conflitsIds.has(extensionMeta.id) + ) { + extensionMeta.loadResult = ExtensionLoadResult.ExtensionIdConflict; + } + } +} + +function sortExtensionMetas(extensionMetas: ExtensionMetaState[]) { + extensionMetas.sort((a, b) => { + if (a.loadResult === b.loadResult) + return a.fileName.localeCompare(b.fileName); + + if (a.loadResult === ExtensionLoadResult.Loadable) return -1; + if (b.loadResult === ExtensionLoadResult.Loadable) return 1; + if (a.loadResult === ExtensionLoadResult.Disabled) return -1; + if (b.loadResult === ExtensionLoadResult.Disabled) return 1; + return 0; + }); +} + +export async function loadExtensionMetas(extensionDir?: string) { + const resolvedExtensionDir = extensionDir ?? (await getExtensionDir()); + await mkdir(resolvedExtensionDir, { recursive: true }); + const extensions = await readDir(resolvedExtensionDir); + const extensionMetas = await Promise.all( + extensions + .filter((v) => v.isFile) + .map((extensionEntry) => + loadExtensionMeta(resolvedExtensionDir, extensionEntry.name), + ), + ); + applyExtensionLoadResults(extensionMetas); + sortExtensionMetas(extensionMetas); + return extensionMetas; +} From 7bfaaa7cf3d25527aaa7df2868bb3a73611ea8e0 Mon Sep 17 00:00:00 2001 From: xiaowumin-mark Date: Thu, 21 May 2026 20:57:59 +0800 Subject: [PATCH 04/14] =?UTF-8?q?feat:=E6=9C=80=E5=90=8E=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=EF=BC=8C=E6=96=B0=E5=BB=BAnext=E5=88=86=E6=94=AF=20=E6=96=B9?= =?UTF-8?q?=E4=BE=BF=E5=AF=B9=E9=BD=90pr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + AGENTS.md | 281 ++++++++++++++++++ .../components/ExtensionContext/ext-ctx.ts | 15 +- .../components/ExtensionContext/windows.ts | 84 ++++++ packages/player/src/extension-env.d.ts | 57 ++++ 5 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md create mode 100644 packages/player/src/components/ExtensionContext/windows.ts diff --git a/.gitignore b/.gitignore index 3a3cc106..4f2af0f5 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ lyrics/ /index.css /startup_script.js /worker_script.js +/vendor .DS_Store target/ .yarn/cache/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..382cf7f5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,281 @@ +# AGENTS.md — 受控插件窗口系统 + +## 目标 + +为 AMLL Player 插件系统提供宿主管理的扩展窗口 API,允许插件创建、渲染和控制属于自己的独立窗口。 + +## 当前变更记录 + +- 2026-05-20:修复本地启动时 `@applemusic-like-lyrics/core@0.5.0` 不再导出 `DomSlimLyricPlayer` 导致的页面语法错误。 +- 处理方式:从 `packages/player/src/pages/settings/player.tsx` 移除 `DomSlimLyricPlayer` 导入、DOM Slim 菜单项和旧实现切换分支,仅保留 `DomLyricPlayer`。 +- 背景:`@applemusic-like-lyrics/react-full@0.3.2` 仍依赖 `core@0.4.2`,但 player 直接依赖 `core@0.5.0`;页面代码应避免从直接依赖中使用已删除导出。 +- 2026-05-20:完成 Phase 1 Rust 窗口管理模块。 +- 处理方式:新增 `packages/player/src-tauri/src/extension_window.rs`,实现插件窗口 state、受控 label 生成、create/get/close/show/hide/focus/center/setTitle/setSize/setPosition/getCurrent 等 Tauri 命令;在 `lib.rs` 注册模块、state、invoke handler 和窗口销毁清理;更新 `build.rs` 生成 app command 权限和 `permissions/autogenerated/*` 权限文件;新增 `capabilities/extension-window.toml` 并更新 `capabilities/migrated.toml`,为 `extension-window/*` 和 `main` 配置最小调用权限。 +- 背景:后续 Phase 2+ 会补前端 `extension-window.html` 宿主入口和 `ExtensionContext` API,对接当前 Rust 命令。 +- 2026-05-20:完成 Phase 2 前端构建入口。 +- 处理方式:新增 `packages/player/extension-window.html` 和 `packages/player/src/extension-window.tsx`,提供独立的空白 React root;更新 `packages/player/vite.config.ts`,在 Rollup input 中加入 `extension-window` 入口。 +- 背景:Rust 侧新建插件窗口时会加载 `extension-window.html`;当前阶段仅保证入口可被 Vite/Tauri 加载,插件 runtime 抽取、组件注册和渲染留到 Phase 3+。 +- 2026-05-20:完成 Phase 3 插件 runtime 与 loader 抽取。 +- 处理方式:将扩展脚本包装、依赖等待、全局模块注入和源映射偏移逻辑抽到 `packages/player/src/components/ExtensionContext/runtime.ts`;将扩展目录解析、元数据加载、语言包注册和加载结果归一化抽到 `packages/player/src/utils/extension-loader.ts`;`packages/player/src/components/ExtensionContext/index.tsx` 与 `packages/player/src/states/extension.ts` 改为调用新模块,主窗口插件加载行为保持不变。 +- 背景:Phase 4 会在此基础上扩展 `ExtensionContext` API,加入窗口运行态与受控窗口操作;本阶段只做共享逻辑拆分,不改变现有插件加载语义。 +- 2026-05-20:完成 Phase 4 扩展 `ExtensionContext` API。 +- 处理方式:在 `packages/player/src/extension-env.d.ts` 中新增 `runtime`、`window`、`windows`、`registerWindowComponent` 类型定义并将 `extensionApiNumber` 提升到 2;在 `packages/player/src/components/ExtensionContext/ext-ctx.ts` 中为 `PlayerExtensionContext` 增加 `runtime/window/windows` 数据与 `registerWindowComponent()` 实现,同时将 `windows` API 绑定到当前插件 ID,供主窗口和未来窗口宿主复用。 +- 背景:Phase 5 将在 `extension-window.tsx` 中开始消费这些新增 API,实现插件窗口的宿主渲染与组件注册;当前阶段只扩展上下文能力,不改动旧插件的主窗口执行流程。 +- 2026-05-20:记录 Windows 本地启动/编译 FFmpeg 准备流程。 +- 处理方式:新增“本地启动/编译方式”章节,说明下载 `ffmpeg-8.0.1-windows-x64.zip`、解压到 `vendor/ffmpeg`、设置 `FFMPEG_DIR` 和 `PKG_CONFIG_PATH` 后运行 `pnpm -F player tauri dev`。 +- 背景:`cargo check -p amll-player` / `pnpm -F player tauri dev` 需要使用项目内 vendor FFmpeg,避免 MSVC 误用 MSYS2 MinGW 头文件导致 `ffmpeg-sys-next` 编译失败。 +## 本地启动/编译方式 + +Windows 下先准备项目内 FFmpeg vendor,再启动或编译 player: + +```powershell +$packageName = "ffmpeg-8.0.1-windows-x64.zip" +$downloadUrl = "https://github.com/apoint123/ffmpeg-builder/releases/latest/download/$packageName" +Invoke-WebRequest -Uri $downloadUrl -OutFile $packageName +mkdir vendor/ffmpeg +Expand-Archive -Path $packageName -DestinationPath vendor/ffmpeg_temp -Force +Move-Item -Path vendor/ffmpeg_temp/include -Destination vendor/ffmpeg/include +Move-Item -Path vendor/ffmpeg_temp/lib -Destination vendor/ffmpeg/lib +Remove-Item -Recurse -Force vendor/ffmpeg_temp +Remove-Item $packageName +$env:FFMPEG_DIR = "$PWD/vendor/ffmpeg" +$env:PKG_CONFIG_PATH = "$PWD/vendor/ffmpeg/lib/pkgconfig" +pnpm -F player tauri dev +``` + +- 如果只做 Rust 检查,同一个 PowerShell 会话里设置完 `FFMPEG_DIR` 和 `PKG_CONFIG_PATH` 后运行 `cargo check -p amll-player`。 +- 不要让 MSVC 编译链误用 `C:\msys64\mingw64\include` 下的头文件,否则 `ffmpeg-sys-next` 可能因 MinGW 专用语法编译失败。 + +## 核心思路 + +- 插件调用 `extensionContext.windows.create("panel", options)`。 +- Rust 侧创建一个受控 label 的 Tauri WebviewWindow,例如 `extension-window/{hash}/{windowId}`。 +- 新窗口打开 `extension-window.html` 宿主页面。 +- 宿主页面根据当前窗口 label 查询所属插件和 windowId。 +- 新窗口加载目标插件及其依赖,执行插件脚本。 +- 插件通过 `registerWindowComponent(windowId, Component)` 注册窗口组件。 +- 宿主页面只渲染当前 windowId 对应的组件。 +- 插件卸载/禁用时,自动关闭其所有窗口。 + +## 设计原则 + +- 不让插件直接调用 Tauri 原生 `WebviewWindow`,所有窗口操作走受控命令。 +- label 由 Rust 统一生成,插件不能自己传 label。 +- 窗口归属关系由 Rust 侧维护,前端 token 配合校验。 +- 插件窗口是最小权限窗口,不给 fs/shell/dialog/http 等能力。 +- 保守 MVP:不支持置顶、透明、全屏、任意外部 URL。 + +## PR 范围 + +### MVP 包含 + +| 能力 | 说明 | +|------|------| +| create | 创建插件窗口,指定 id 和选项 | +| close | 关闭指定窗口 | +| show / hide | 显示/隐藏 | +| focus | 聚焦窗口 | +| setTitle | 改标题 | +| setSize / setPosition | 改尺寸/位置 | +| center | 居中 | +| React 组件渲染 | 窗口内渲染插件注册的组件 | +| 依赖加载 | 插件窗口加载目标插件及其传递依赖 | +| 生命周期回收 | 卸载/禁用/主窗口关闭时自动清理 | + +### MVP 暂缓 + +- 任意 URL 加载 +- 全屏、置顶、透明穿透 +- 控制主窗口或其他插件窗口 +- 任务栏歌词级别的原生嵌入 +- 跨窗口状态同步(后续用 event 桥接) + +## 窗口 label 设计 + +格式:`extension-window/{extensionIdHash}/{windowId}` + +- `windowId` 只允许 `[a-zA-Z0-9_-]`,长度 1–64 +- `extensionId` 用 md5 映射 +- label 只在 Rust 侧生成 + +## API 设计 + +### 类型定义(extension-env.d.ts) + +```ts +declare interface ExtensionRuntimeInfo { + kind: "main" | "extension-window"; +} + +declare interface ExtensionWindowRuntimeInfo { + id: string; + label: string; +} + +declare interface ExtensionWindowOptions { + title?: string; + width?: number; + height?: number; + x?: number; + y?: number; + center?: boolean; + resizable?: boolean; + decorations?: boolean; + visible?: boolean; + minWidth?: number; + minHeight?: number; + maxWidth?: number; + maxHeight?: number; +} + +declare interface ExtensionWindowHandle { + id: string; + label: string; + close(): Promise; + show(): Promise; + hide(): Promise; + focus(): Promise; + center(): Promise; + setTitle(title: string): Promise; + setSize(width: number, height: number): Promise; + setPosition(x: number, y: number): Promise; +} + +declare interface ExtensionWindowsApi { + create( + id: string, + options?: ExtensionWindowOptions, + ): Promise; + get(id: string): Promise; + close(id: string): Promise; + closeAll(): Promise; +} +``` + +### ExtensionContext 新增 + +```ts +extensionApiNumber: 2; +runtime: ExtensionRuntimeInfo; +window?: ExtensionWindowRuntimeInfo; +windows: ExtensionWindowsApi; +registerWindowComponent( + windowId: string, + component: ComponentType, +): void; +``` + +## 技术难点与解决 + +### 1. 插件身份识别 +- **难点**:Rust 只能知道调用来自哪个窗口 label,不能天然知道是哪个插件。 +- **解决**:`windows.create()` 在 `PlayerExtensionContext` 内部闭包绑定当前插件 ID;后续操作由闭包携带内部 token。Rust 侧用窗口归属表校验。 + +### 2. 窗口 label 设计 +- **难点**:label 只能包含有限字符,且不能冲突。 +- **解决**:Rust 侧统一生成 `extension-window/{md5(extensionId)}/{safeWindowId}`,不让插件传入任意 label。 + +### 3. 动态窗口 capability +- **难点**:新窗口 label 在编译时未知,无法预配 capability。 +- **解决**:新增 `capabilities/extension-window.toml`,`windows = ["extension-window/*"]`,只给最小权限。 + +### 4. 新窗口加载插件 +- **难点**:新 WebView 不能复用主窗口的 React 组件、闭包、Jotai store。 +- **解决**:新窗口重新执行目标插件脚本,插件通过 `runtime.kind` 判断当前环境。 + +### 5. 重复执行副作用 +- **难点**:插件脚本在新窗口重新执行,可能触发重复注册、请求等副作用。 +- **解决**:给 `runtime.kind`,插件作者可判别环境;宿主在窗口模式下让 `registerPlayerSource()` 等 API no-op。 + +### 6. 生命周期回收 +- **难点**:插件禁用、卸载后,其窗口可能还开着。 +- **解决**:Rust 维护 `extensionId -> Vec