diff --git a/.gitignore b/.gitignore index 385fc940..c4d779a2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ lyrics/ /index.css /startup_script.js /worker_script.js +/vendor + .DS_Store target/ .yarn/cache/ @@ -19,3 +21,4 @@ target/ *.tsbuildinfo .million/ .opencode +AGENTS.md 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-tauri/build.rs b/packages/player/src-tauri/build.rs index 261851f6..b2fba3a2 100644 --- a/packages/player/src-tauri/build.rs +++ b/packages/player/src-tauri/build.rs @@ -1,3 +1,20 @@ 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_mark_ready", + "extension_window_get_current", + "extension_window_get_current_extension_files", + ])); + 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..ee23c324 --- /dev/null +++ b/packages/player/src-tauri/capabilities/extension-window.toml @@ -0,0 +1,46 @@ +"$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" + +[[permissions]] +identifier = "allow-extension-window-get-current-extension-files" + +[[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" +[[permissions]] +identifier = "allow-extension-window-mark-ready" 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_get_current_extension_files.toml b/packages/player/src-tauri/permissions/autogenerated/extension_window_get_current_extension_files.toml new file mode 100644 index 00000000..d772d9ee --- /dev/null +++ b/packages/player/src-tauri/permissions/autogenerated/extension_window_get_current_extension_files.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-extension-window-get-current-extension-files" +description = "Enables the extension_window_get_current_extension_files command without any pre-configured scope." +commands.allow = ["extension_window_get_current_extension_files"] + +[[permission]] +identifier = "deny-extension-window-get-current-extension-files" +description = "Denies the extension_window_get_current_extension_files command without any pre-configured scope." +commands.deny = ["extension_window_get_current_extension_files"] 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_mark_ready.toml b/packages/player/src-tauri/permissions/autogenerated/extension_window_mark_ready.toml new file mode 100644 index 00000000..f6e188e1 --- /dev/null +++ b/packages/player/src-tauri/permissions/autogenerated/extension_window_mark_ready.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-extension-window-mark-ready" +description = "Enables the extension_window_mark_ready command without any pre-configured scope." +commands.allow = ["extension_window_mark_ready"] + +[[permission]] +identifier = "deny-extension-window-mark-ready" +description = "Denies the extension_window_mark_ready command without any pre-configured scope." +commands.deny = ["extension_window_mark_ready"] 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..7b360270 --- /dev/null +++ b/packages/player/src-tauri/src/extension_window.rs @@ -0,0 +1,931 @@ +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use tauri::{ + AppHandle, LogicalPosition, LogicalSize, LogicalUnit, Manager, PixelUnit, State, WebviewUrl, + WebviewWindow, WebviewWindowBuilder, WindowSizeConstraints, path::BaseDirectory, +}; + +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, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionScriptFile { + pub file_name: String, + pub script_data: String, +} + +struct ExtensionScriptSource { + file_name: String, + script_data: String, + id: Option, + dependency: Vec, +} + +#[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>, + visibility_by_label: HashMap, +} + +#[derive(Clone, Debug)] +struct ExtensionWindowVisibility { + ready: bool, + should_show: bool, + should_focus: bool, +} + +impl ExtensionWindowVisibility { + fn ready() -> Self { + Self { + ready: true, + should_show: true, + should_focus: false, + } + } + + fn pending(should_show: bool) -> Self { + Self { + ready: false, + should_show, + should_focus: should_show, + } + } +} + +impl ExtensionWindowState { + fn insert(&self, info: ExtensionWindowInfo) { + self.insert_with_visibility(info, ExtensionWindowVisibility::ready()); + } + + fn insert_pending(&self, info: ExtensionWindowInfo, should_show: bool) { + self.insert_with_visibility(info, ExtensionWindowVisibility::pending(should_show)); + } + + fn insert_with_visibility( + &self, + info: ExtensionWindowInfo, + visibility: ExtensionWindowVisibility, + ) { + let mut maps = self.windows.lock().unwrap(); + maps.by_extension + .entry(info.extension_id.clone()) + .or_default() + .insert(info.label.clone()); + maps.visibility_by_label + .insert(info.label.clone(), visibility); + 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)?; + maps.visibility_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 visibility(&self, label: &str) -> ExtensionWindowVisibility { + self.windows + .lock() + .unwrap() + .visibility_by_label + .get(label) + .cloned() + .unwrap_or_else(ExtensionWindowVisibility::ready) + } + + fn request_show(&self, label: &str) -> ExtensionWindowVisibility { + let mut maps = self.windows.lock().unwrap(); + let visibility = maps + .visibility_by_label + .entry(label.to_string()) + .or_insert_with(ExtensionWindowVisibility::ready); + visibility.should_show = true; + visibility.clone() + } + + fn request_show_and_focus(&self, label: &str) -> ExtensionWindowVisibility { + let mut maps = self.windows.lock().unwrap(); + let visibility = maps + .visibility_by_label + .entry(label.to_string()) + .or_insert_with(ExtensionWindowVisibility::ready); + visibility.should_show = true; + visibility.should_focus = true; + visibility.clone() + } + + fn request_hide(&self, label: &str) -> ExtensionWindowVisibility { + let mut maps = self.windows.lock().unwrap(); + let visibility = maps + .visibility_by_label + .entry(label.to_string()) + .or_insert_with(ExtensionWindowVisibility::ready); + visibility.should_show = false; + visibility.should_focus = false; + visibility.clone() + } + + fn mark_ready(&self, label: &str) -> Option<(bool, ExtensionWindowVisibility)> { + let mut maps = self.windows.lock().unwrap(); + maps.by_label.get(label)?; + let visibility = maps + .visibility_by_label + .entry(label.to_string()) + .or_insert_with(ExtensionWindowVisibility::ready); + let was_ready = visibility.ready; + visibility.ready = true; + Some((was_ready, visibility.clone())) + } + + fn clear_focus_request(&self, label: &str) { + if let Some(visibility) = self + .windows + .lock() + .unwrap() + .visibility_by_label + .get_mut(label) + { + visibility.should_focus = false; + } + } +} + +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 parse_extension_script_source(file_name: String, script_data: String) -> ExtensionScriptSource { + let mut source = ExtensionScriptSource { + file_name, + script_data, + id: None, + dependency: Vec::new(), + }; + + for line in source.script_data.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let Some(comment) = trimmed.strip_prefix("//") else { + break; + }; + let Some(meta_line) = comment.trim_start().strip_prefix('@') else { + break; + }; + let mut parts = meta_line.splitn(2, char::is_whitespace); + let key = parts.next().unwrap_or_default(); + let value = parts.next().unwrap_or_default().trim(); + if key.is_empty() || value.is_empty() { + break; + } + + match key { + "id" if source.id.is_none() => source.id = Some(value.to_string()), + "dependency" => source.dependency.push(value.to_string()), + _ => {} + } + } + + source +} + +fn collect_extension_file_indices( + extension_id: &str, + sources: &[ExtensionScriptSource], + sources_by_id: &HashMap>, + visiting: &mut HashSet, + collected_indices: &mut HashSet, +) -> Result<(), String> { + if !visiting.insert(extension_id.to_string()) { + return Err(format!("circular extension dependency: {extension_id}")); + } + + if let Some(indices) = sources_by_id.get(extension_id) { + for index in indices { + if let Some(source) = sources.get(*index) { + for dependency_id in &source.dependency { + collect_extension_file_indices( + dependency_id, + sources, + sources_by_id, + visiting, + collected_indices, + )?; + } + collected_indices.insert(*index); + } + } + } + + visiting.remove(extension_id); + Ok(()) +} + +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 = if let Some(info) = state.get_by_label(&label) { + info + } else { + let info = ExtensionWindowInfo { + extension_id: extension_id.to_string(), + window_id: window_id.to_string(), + label, + }; + state.insert(info.clone()); + info + }; + Ok(Some((info, win))) + } else { + state.remove_label(&label); + Ok(None) + } +} + +fn apply_window_visibility( + app: &AppHandle, + state: &ExtensionWindowState, + label: &str, +) -> Result<(), String> { + let visibility = state.visibility(label); + if !visibility.ready { + return Ok(()); + } + + let Some(win) = app.get_webview_window(label) else { + return Ok(()); + }; + + if visibility.should_show { + win.show() + .map_err(|err| format!("failed to show extension window: {err}"))?; + if visibility.should_focus { + win.set_focus() + .map_err(|err| format!("failed to focus extension window: {err}"))?; + state.clear_focus_request(label); + } + } else { + win.hide() + .map_err(|err| format!("failed to hide extension window: {err}"))?; + } + + Ok(()) +} + +#[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; + state.request_show_and_focus(&info.label); + apply_window_visibility(&app, &state, &info.label)?; + 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; + state.request_show_and_focus(&info.label); + apply_window_visibility(&app, &state, &info.label)?; + 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(false); + + 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}"))?; + + let info = ExtensionWindowInfo { + extension_id, + window_id, + label, + }; + let _ = win; + state.insert_pending(info.clone(), visible); + 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)? { + let label = win.label().to_string(); + state.request_show(&label); + apply_window_visibility(&app, &state, &label)?; + } + 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)? { + let label = win.label().to_string(); + state.request_hide(&label); + apply_window_visibility(&app, &state, &label)?; + } + 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)? { + let label = win.label().to_string(); + state.request_show_and_focus(&label); + apply_window_visibility(&app, &state, &label)?; + } + 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_mark_ready( + app: AppHandle, + caller: WebviewWindow, + state: State<'_, ExtensionWindowState>, +) -> Result<(), String> { + let label = caller.label(); + if !is_extension_window_label(label) { + return Err("current window is not an extension window".to_string()); + } + + let Some((was_ready, _)) = state.mark_ready(label) else { + return Err("extension window ownership is not registered".to_string()); + }; + if !was_ready { + apply_window_visibility(&app, &state, label)?; + } + + 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()) +} + +#[tauri::command] +pub fn extension_window_get_current_extension_files( + app: AppHandle, + caller: WebviewWindow, + state: State<'_, ExtensionWindowState>, +) -> Result, String> { + let current = extension_window_get_current(caller, state)?; + + let extension_dir = app + .path() + .resolve("extensions", BaseDirectory::AppData) + .map_err(|err| format!("failed to resolve extension directory: {err}"))?; + if !extension_dir.exists() { + return Ok(Vec::new()); + } + + let entries = fs::read_dir(&extension_dir) + .map_err(|err| format!("failed to read extension directory: {err}"))?; + let mut sources = Vec::new(); + + for entry in entries { + let entry = entry.map_err(|err| format!("failed to read extension entry: {err}"))?; + let file_type = entry + .file_type() + .map_err(|err| format!("failed to read extension entry type: {err}"))?; + if !file_type.is_file() { + continue; + } + + let file_name = entry.file_name().to_string_lossy().into_owned(); + if !(file_name.ends_with(".js") || file_name.ends_with(".js.disabled")) { + continue; + } + + let script_data = fs::read_to_string(entry.path()) + .map_err(|err| format!("failed to read extension script {file_name}: {err}"))?; + sources.push(parse_extension_script_source(file_name, script_data)); + } + + sources.sort_by(|a, b| a.file_name.cmp(&b.file_name)); + + let mut sources_by_id: HashMap> = HashMap::new(); + for (index, source) in sources.iter().enumerate() { + if let Some(id) = &source.id { + sources_by_id.entry(id.clone()).or_default().push(index); + } + } + + if !sources_by_id.contains_key(¤t.extension_id) { + return Err(format!( + "missing extension script for current extension: {}", + current.extension_id + )); + } + + let mut collected_indices = HashSet::new(); + collect_extension_file_indices( + ¤t.extension_id, + &sources, + &sources_by_id, + &mut HashSet::new(), + &mut collected_indices, + )?; + + let mut collected_sources = collected_indices.into_iter().collect::>(); + collected_sources.sort_by(|a, b| sources[*a].file_name.cmp(&sources[*b].file_name)); + + Ok(collected_sources + .into_iter() + .filter_map(|index| sources.get(index)) + .map(|source| ExtensionScriptFile { + file_name: source.file_name.clone(), + script_data: source.script_data.clone(), + }) + .collect()) +} + +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 a29ce3d3..96c464bd 100644 --- a/packages/player/src-tauri/src/lib.rs +++ b/packages/player/src-tauri/src/lib.rs @@ -30,6 +30,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,34 @@ 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_mark_ready, + #[cfg(desktop)] + extension_window::extension_window_get_current, + #[cfg(desktop)] + extension_window::extension_window_get_current_extension_files, #[cfg(target_os = "windows")] set_window_always_on_top, #[cfg(target_os = "windows")] @@ -579,6 +610,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 +623,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" diff --git a/packages/player/src/components/ExtensionContext/ext-ctx.ts b/packages/player/src/components/ExtensionContext/ext-ctx.ts index 7fbe7963..d5bc25b3 100644 --- a/packages/player/src/components/ExtensionContext/ext-ctx.ts +++ b/packages/player/src/components/ExtensionContext/ext-ctx.ts @@ -1,54 +1,11 @@ +import { invoke } from "@tauri-apps/api/core"; 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()]; -} +import { createExtensionWindowsApi } from "./windows.ts"; export class PlayerExtensionContext extends EventTarget @@ -60,6 +17,11 @@ export class PlayerExtensionContext registeredInjectPointComponent: { [injectPointName: string]: ComponentType | undefined; } = {}; + registeredWindowComponent: { + [windowId: string]: ComponentType | undefined; + } = {}; + private active = true; + readonly windows: ExtensionEnv.ExtensionWindowsApi; constructor( readonly playerStates: ExtensionEnv.PlayerStates, readonly amllStates: ExtensionEnv.AMLLStates, @@ -69,10 +31,31 @@ export class PlayerExtensionContext readonly lyric: typeof import("@applemusic-like-lyrics/lyric"), readonly playerDB: typeof db, readonly http: typeof TauriHttp, + readonly runtime: ExtensionEnv.ExtensionRuntimeInfo = { + kind: "main", + }, + readonly window?: ExtensionEnv.ExtensionWindowRuntimeInfo, ) { super(); + this.windows = createExtensionWindowsApi( + extensionMeta.id, + () => this.active, + ); + } + extensionApiNumber = 2; + deactivate() { + this.active = false; + } + async dispose() { + if (!this.active) return; + try { + await invoke("extension_window_close_all", { + extensionId: this.extensionMeta.id, + }); + } finally { + this.deactivate(); + } } - extensionApiNumber = 1; registerLocale(localeData: { [langId: string]: T }) { for (const [lng, data] of Object.entries(localeData)) { i18n.addResourceBundle(lng, this.extensionMeta.id, data); @@ -81,6 +64,9 @@ export class PlayerExtensionContext registerComponent(injectPointName: string, injectComponent: ComponentType) { this.registeredInjectPointComponent[injectPointName] = injectComponent; } + registerWindowComponent(windowId: string, component: ComponentType) { + this.registeredWindowComponent[windowId] = component; + } registerPlayerSource(_idPrefix: string) { console.warn("Unimplemented"); } diff --git a/packages/player/src/components/ExtensionContext/index.tsx b/packages/player/src/components/ExtensionContext/index.tsx index 20a0b5f1..e66b1576 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,7 +41,17 @@ class Notify { } } -const LOG_TAG = chalk.bgHex("#00AAFF").hex("#FFFFFF")(" EXTENSION "); +async function closeExtensionWindows( + context: PlayerExtensionContext, + extensionId: string, +) { + try { + await context.dispose(); + } catch (err) { + console.warn(EXTENSION_LOG_TAG, "关闭扩展程序窗口失败", extensionId, err); + context.deactivate(); + } +} const SingleExtensionContext: FC<{ extensionMeta: ExtensionMetaState; @@ -97,87 +91,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, @@ -191,6 +128,7 @@ const SingleExtensionContext: FC<{ cancelRef.current = notify; (async () => { context.dispatchEvent(new Event("extension-unload")); + await closeExtensionWindows(context, extensionMeta.id); setLoadedExtension((v) => v.filter((e) => e !== loadedExt)); notify.notify(); })(); 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/components/ExtensionContext/windows.ts b/packages/player/src/components/ExtensionContext/windows.ts new file mode 100644 index 00000000..5b32712d --- /dev/null +++ b/packages/player/src/components/ExtensionContext/windows.ts @@ -0,0 +1,106 @@ +import { type InvokeArgs, invoke } from "@tauri-apps/api/core"; +import type ExtensionEnv from "../../extension-env.ts"; + +type NativeExtensionWindowInfo = { + extensionId: string; + windowId: string; + label: string; +}; + +function buildWindowCommandArgs( + extensionId: string, + windowId?: string, + extra?: Record, +): InvokeArgs { + return { + extensionId, + ...(typeof windowId === "string" ? { windowId } : {}), + ...extra, + }; +} + +function toExtensionWindowHandle( + info: NativeExtensionWindowInfo, + extensionId: string, + isActive: () => boolean, +): ExtensionEnv.ExtensionWindowHandle { + const callWindowCommand = ( + command: string, + extra?: Record, + ) => { + if (!isActive()) { + return Promise.reject( + new Error(`Extension ${extensionId} has already been unloaded`), + ); + } + + return invoke( + command, + buildWindowCommandArgs(extensionId, info.windowId, extra), + ); + }; + + return { + id: info.windowId, + label: info.label, + close: () => callWindowCommand("extension_window_close"), + show: () => callWindowCommand("extension_window_show"), + hide: () => callWindowCommand("extension_window_hide"), + focus: () => callWindowCommand("extension_window_focus"), + center: () => callWindowCommand("extension_window_center"), + setTitle: (title) => + callWindowCommand("extension_window_set_title", { title }), + setSize: (width, height) => + callWindowCommand("extension_window_set_size", { width, height }), + setPosition: (x, y) => + callWindowCommand("extension_window_set_position", { x, y }), + }; +} + +export function createExtensionWindowsApi( + extensionId: string, + isActive: () => boolean = () => true, +): ExtensionEnv.ExtensionWindowsApi { + const invokeWindowCommand = ( + command: string, + windowId?: string, + extra?: Record, + ) => { + if (!isActive()) { + return Promise.reject( + new Error(`Extension ${extensionId} has already been unloaded`), + ); + } + + return invoke( + command, + buildWindowCommandArgs(extensionId, windowId, extra), + ); + }; + + return { + async create(id, options) { + const info = await invokeWindowCommand( + "extension_window_create", + id, + options ? { options } : undefined, + ); + return toExtensionWindowHandle(info, extensionId, isActive); + }, + async get(id) { + const info = await invokeWindowCommand( + "extension_window_get", + id, + ); + return info + ? toExtensionWindowHandle(info, extensionId, isActive) + : undefined; + }, + close(id) { + return invokeWindowCommand("extension_window_close", id); + }, + closeAll() { + return invokeWindowCommand("extension_window_close_all"); + }, + }; +} diff --git a/packages/player/src/extension-env.d.ts b/packages/player/src/extension-env.d.ts index ac2d9f20..523507a5 100644 --- a/packages/player/src/extension-env.d.ts +++ b/packages/player/src/extension-env.d.ts @@ -44,6 +44,75 @@ declare interface NetworkSongData extends AnySongData { headers?: Record; } +declare interface ExtensionRuntimeInfo { + /** + * 当前扩展程序运行所在的宿主环境 + */ + kind: "main" | "extension-window"; +} + +declare interface ExtensionWindowRuntimeInfo { + /** + * 当前扩展窗口在宿主中的唯一 windowId + */ + id: string; + /** + * 当前扩展窗口对应的受控 label + */ + 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; +} + declare interface ExtensionContextEventMap { /** * 当所有扩展程序都完成了初步脚本加载的操作时触发 @@ -69,6 +138,18 @@ declare interface ExtensionContext extends EventTarget { * 扩展程序接口的版本号,会随着扩展接口更新而递增数字 */ extensionApiNumber: number; + /** + * 当前扩展脚本运行时信息,可用于区分主窗口和扩展窗口宿主 + */ + runtime: ExtensionRuntimeInfo; + /** + * 当前扩展窗口信息,仅在 `runtime.kind === "extension-window"` 时存在 + */ + window?: ExtensionWindowRuntimeInfo; + /** + * 当前扩展可用的受控窗口管理 API + */ + windows: ExtensionWindowsApi; jotaiStore: ReturnType; /** * 将扩展程序的本地化字段数据注册到 AMLL Player 的国际化上下文中 @@ -93,6 +174,10 @@ declare interface ExtensionContext extends EventTarget { injectPointName: string, injectComponent: ComponentType, ): void; + /** + * 为指定 windowId 注册扩展窗口组件,供 extension-window 宿主渲染 + */ + registerWindowComponent(windowId: string, component: ComponentType): void; /** * 注册一个音频源 * @@ -142,6 +227,11 @@ export type { AnySongData, ExtensionContext, ExtensionContextEventMap, + ExtensionRuntimeInfo, + ExtensionWindowHandle, + ExtensionWindowOptions, + ExtensionWindowRuntimeInfo, + ExtensionWindowsApi, LocalSongData, NetworkSongData, PlayerStates, diff --git a/packages/player/src/extension-window.tsx b/packages/player/src/extension-window.tsx new file mode 100644 index 00000000..cc1996bd --- /dev/null +++ b/packages/player/src/extension-window.tsx @@ -0,0 +1,370 @@ +import * as lyric from "@applemusic-like-lyrics/lyric"; +import * as amllStates from "@applemusic-like-lyrics/react-full"; +import { Theme } from "@radix-ui/themes"; +import "@radix-ui/themes/styles.css"; +import { invoke } from "@tauri-apps/api/core"; +import * as http from "@tauri-apps/plugin-http"; +import { Provider, useStore } from "jotai"; +import { + type ComponentType, + type CSSProperties, + useEffect, + useState, +} from "react"; +import { createRoot } from "react-dom/client"; +import { ErrorBoundary, type FallbackProps } from "react-error-boundary"; +import { useTranslation } from "react-i18next"; +import { PlayerExtensionContext } from "./components/ExtensionContext/ext-ctx.ts"; +import { + EXTENSION_LOG_TAG, + runExtensionScript, +} from "./components/ExtensionContext/runtime.ts"; +import { db } from "./dexie.ts"; +import "./i18n"; +import * as appAtoms from "./states/appAtoms.ts"; +import * as extensionsAtoms from "./states/extensionsAtoms.ts"; +import { + ExtensionLoadResult, + type ExtensionMetaState, +} from "./states/extensionsAtoms.ts"; +import { toError } from "./utils/error.ts"; +import { + type ExtensionScriptFile, + loadExtensionMetasFromFiles, +} from "./utils/extension-loader.ts"; + +type ExtensionWindowInfo = { + extensionId: string; + windowId: string; + label: string; +}; + +type LoadedWindowComponent = { + current: ExtensionWindowInfo; + contexts: PlayerExtensionContext[]; + WindowComponent: ComponentType; +}; + +type HostState = + | { status: "loading" } + | { status: "ready"; component: LoadedWindowComponent } + | { status: "error"; error: Error }; + +function WindowReadySignal() { + useEffect(() => { + const timer = window.setTimeout(() => { + void invoke("extension_window_mark_ready").catch((err) => { + console.warn(EXTENSION_LOG_TAG, "标记扩展窗口就绪失败", err); + }); + }, 0); + return () => window.clearTimeout(timer); + }, []); + return null; +} + +async function cleanupLoadedContexts(contexts: PlayerExtensionContext[]) { + for (let index = contexts.length - 1; 0 <= index; index -= 1) { + const context = contexts[index]; + if (!context) continue; + context.dispatchEvent(new Event("extension-unload")); + try { + await context.dispose(); + } catch (err) { + console.warn( + EXTENSION_LOG_TAG, + "关闭扩展窗口失败", + context.extensionMeta.id, + err, + ); + context.deactivate(); + } + } +} + +function getLoadableExtensionMeta( + extensionMetaById: Map, + extensionId: string, +) { + const extensionMeta = extensionMetaById.get(extensionId); + if (!extensionMeta) { + throw new Error(`Missing extension metadata: ${extensionId}`); + } + if (extensionMeta.loadResult !== ExtensionLoadResult.Loadable) { + throw new Error( + `Extension ${extensionId} is not loadable: ${extensionMeta.loadResult}`, + ); + } + return extensionMeta; +} + +function resolveExtensionLoadOrder( + extensionMetas: ExtensionMetaState[], + targetExtensionId: string, +) { + const extensionMetaById = new Map( + extensionMetas.map((meta) => [meta.id, meta]), + ); + const loaded = new Set(); + const loading = new Set(); + const orderedMetas: ExtensionMetaState[] = []; + + const visit = (extensionId: string) => { + if (loaded.has(extensionId)) return; + if (loading.has(extensionId)) { + throw new Error(`Circular extension dependency: ${extensionId}`); + } + const extensionMeta = getLoadableExtensionMeta( + extensionMetaById, + extensionId, + ); + loading.add(extensionId); + for (const dependencyId of extensionMeta.dependency) { + visit(dependencyId); + } + loading.delete(extensionId); + loaded.add(extensionId); + orderedMetas.push(extensionMeta); + }; + + visit(targetExtensionId); + return orderedMetas; +} + +async function loadWindowComponent( + current: ExtensionWindowInfo, + store: ReturnType, + i18n: ReturnType["i18n"], +): Promise { + const extensionFiles = await invoke( + "extension_window_get_current_extension_files", + ); + const extensionMetas = loadExtensionMetasFromFiles(extensionFiles); + const orderedMetas = resolveExtensionLoadOrder( + extensionMetas, + current.extensionId, + ); + const loadedExtensionIds = new Set(); + const playerStatesObject = Object.freeze({ + ...appAtoms, + ...extensionsAtoms, + }); + const amllStatesObject = Object.freeze({ ...amllStates }); + const contexts: PlayerExtensionContext[] = []; + const waitForDependency = async (extensionId: string) => { + if (!loadedExtensionIds.has(extensionId)) { + throw new Error(`Missing Dependency: ${extensionId}`); + } + }; + + try { + for (const extensionMeta of orderedMetas) { + const extI18n = i18n.cloneInstance({ + ns: extensionMeta.id, + }); + const context = new PlayerExtensionContext( + playerStatesObject, + amllStatesObject, + extI18n, + store, + extensionMeta, + lyric, + db, + http, + { kind: "extension-window" }, + { id: current.windowId, label: current.label }, + ); + + console.log( + EXTENSION_LOG_TAG, + "正在加载扩展窗口扩展程序", + extensionMeta.id, + extensionMeta.fileName, + ); + + await runExtensionScript({ + extensionMeta, + context, + waitForDependency, + }); + context.dispatchEvent(new Event("extension-load")); + loadedExtensionIds.add(extensionMeta.id); + contexts.push(context); + + console.log( + EXTENSION_LOG_TAG, + "扩展窗口扩展程序", + extensionMeta.id, + extensionMeta.fileName, + "加载完成", + ); + } + } catch (err) { + await cleanupLoadedContexts(contexts); + throw err; + } + + const targetContext = contexts.find( + (context) => context.extensionMeta.id === current.extensionId, + ); + const WindowComponent = + targetContext?.registeredWindowComponent[current.windowId]; + if (!WindowComponent) { + await cleanupLoadedContexts(contexts); + throw new Error( + `Extension ${current.extensionId} did not register window component: ${current.windowId}`, + ); + } + + return { + current, + contexts: [...contexts], + WindowComponent, + }; +} + +const pageStyle = { + display: "flex", + minHeight: "100vh", + boxSizing: "border-box", + padding: "24px", + alignItems: "center", + justifyContent: "center", + color: "#f8fafc", + background: + "radial-gradient(circle at 20% 20%, rgba(56, 189, 248, 0.28), transparent 32%), linear-gradient(135deg, #111827, #020617)", +} satisfies CSSProperties; + +const cardStyle = { + width: "min(720px, 100%)", + boxSizing: "border-box", + padding: "24px", + borderRadius: "18px", + background: "rgba(15, 23, 42, 0.82)", + boxShadow: "0 20px 80px rgba(0, 0, 0, 0.32)", + border: "1px solid rgba(148, 163, 184, 0.22)", +} satisfies CSSProperties; + +const codeStyle = { + display: "block", + marginTop: "14px", + padding: "14px", + maxHeight: "40vh", + overflow: "auto", + whiteSpace: "pre-wrap", + borderRadius: "12px", + background: "rgba(2, 6, 23, 0.72)", + color: "#cbd5e1", +} satisfies CSSProperties; + +function InfoPage({ + title, + detail, + error, +}: { + title: string; + detail?: string; + error?: Error; +}) { + return ( +
+
+

{title}

+ {detail &&

{detail}

} + {error && ( + + {error.message} + {error.stack ? `\n\n${error.stack}` : ""} + + )} +
+
+ ); +} + +function ComponentErrorPage({ + error, + current, +}: FallbackProps & { + current: ExtensionWindowInfo; +}) { + const normalizedError = toError(error); + return ( + + ); +} + +const ExtensionWindowApp = () => { + const store = useStore(); + const { i18n } = useTranslation(); + const [state, setState] = useState({ status: "loading" }); + + useEffect(() => { + let canceled = false; + let loadedContexts: PlayerExtensionContext[] = []; + + (async () => { + try { + const current = await invoke( + "extension_window_get_current", + ); + const component = await loadWindowComponent(current, store, i18n); + loadedContexts = component.contexts; + if (canceled) { + await cleanupLoadedContexts(loadedContexts); + return; + } + setState({ status: "ready", component }); + } catch (err) { + if (canceled) return; + setState({ status: "error", error: toError(err) }); + } + })(); + + return () => { + canceled = true; + const contextsToCleanup = loadedContexts; + loadedContexts = []; + void cleanupLoadedContexts(contextsToCleanup); + }; + }, [i18n, store]); + + if (state.status === "loading") { + return ; + } + + if (state.status === "error") { + return ( + <> + + + + ); + } + + const { WindowComponent, current } = state.component; + return ( + + ( + <> + + + + )} + > + + + + + ); +}; + +createRoot(document.getElementById("root") as HTMLElement).render( + + + , +); 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..cfdb8a30 --- /dev/null +++ b/packages/player/src/utils/extension-loader.ts @@ -0,0 +1,178 @@ +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 interface ExtensionScriptFile { + fileName: string; + scriptData: string; +} + +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]), + ); + } + } + } +} + +function parseExtensionMetaFile({ + fileName, + scriptData, +}: ExtensionScriptFile): ExtensionMetaState { + const extensionMeta = createEmptyExtensionMeta(fileName); + if (fileName.endsWith(".js.disabled") || fileName.endsWith(".js")) { + if (fileName.endsWith(".js.disabled")) { + extensionMeta.loadResult = ExtensionLoadResult.Disabled; + } + for (const line of scriptData.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 = scriptData; + + 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); +} + +async function loadExtensionMeta( + extensionDir: string, + fileName: string, +): Promise { + const scriptData = + fileName.endsWith(".js.disabled") || fileName.endsWith(".js") + ? await readTextFile(await join(extensionDir, fileName)) + : ""; + return parseExtensionMetaFile({ fileName, scriptData }); +} + +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; +} + +export function loadExtensionMetasFromFiles( + extensionFiles: ExtensionScriptFile[], +) { + const extensionMetas = extensionFiles.map(parseExtensionMetaFile); + applyExtensionLoadResults(extensionMetas); + sortExtensionMetas(extensionMetas); + return extensionMetas; +} diff --git a/packages/player/vite.config.ts b/packages/player/vite.config.ts index d9f8c607..cc543cf3 100644 --- a/packages/player/vite.config.ts +++ b/packages/player/vite.config.ts @@ -93,6 +93,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"), },