use crate::host::{CacheLevel, HostApiError, OpenTarget};
use crate::platform::capabilities::PlatformCapabilities;
use crate::services::app::{AppEnumerator, AppInfo, AppLauncher};
use crate::services::clipboard::ClipboardManager;
use crate::services::focus_monitor::{FocusCallback, FocusMonitor};
use crate::services::hotkey::types::{HotkeyCallback, HotkeyEventFilter};
use crate::services::hotkey::HotkeyManager;
use crate::services::icon::icon_cache::IconCacheService;
use crate::services::icon::icon_extractor::IconExtractor;
use crate::services::installation_monitor::types::InstallationCallback;
use crate::services::installation_monitor::InstallationMonitor;
use crate::services::model::{
ModelChatRequest, ModelChatResponse, ModelEmbeddingRequest, ModelEmbeddingResponse, ModelError,
ModelInfo, ModelService, ModelSimilarityRequest, ModelSimilarityResponse,
};
use crate::services::parameter::resolver::ParameterResolver;
use crate::services::parameter::types::ParameterSnapshot;
use crate::services::path::path_resolver::{KnownPath, PathResolver};
use crate::services::resource::AppResourceService;
use crate::services::shell::lnk_resolver::LnkResolver;
use crate::services::shell::resource_loader::ResourceLoader;
use crate::services::shell::ShellExecutor;
use crate::services::storage::storage_service::StorageService;
use crate::services::theme::{Theme, ThemeProvider};
use crate::services::timer::types::{TimerCallback, TimerId, TimerMode};
use crate::services::timer::TimerManager;
use crate::services::window::WindowManager;
use crate::services::IconRequest;
use parking_lot::RwLock;
use std::sync::Arc;
use super::sdk_config::PluginSdkConfig;
pub struct PluginHandle {
plugin_id: String,
config: RwLock<PluginSdkConfig>,
capabilities: PlatformCapabilities,
icon_extractor: Arc<dyn IconExtractor>,
icon_cache: Arc<IconCacheService>,
shell_executor: Arc<dyn ShellExecutor>,
window_manager: Arc<dyn WindowManager>,
path_resolver: Arc<dyn PathResolver>,
app_enumerator: Arc<dyn AppEnumerator>,
app_launcher: Arc<dyn AppLauncher>,
lnk_resolver: Arc<dyn LnkResolver>,
resource_loader: Arc<dyn ResourceLoader>,
parameter_resolver: Arc<dyn ParameterResolver>,
timer_manager: Arc<dyn TimerManager>,
app_resource: Arc<AppResourceService>,
storage: Arc<RwLock<Arc<dyn StorageService>>>,
hotkey_manager: Arc<dyn HotkeyManager>,
installation_monitor: Arc<dyn InstallationMonitor>,
focus_monitor: Arc<dyn FocusMonitor>,
clipboard_manager: Arc<dyn ClipboardManager>,
theme_provider: Arc<dyn ThemeProvider>,
theme_mode: Arc<RwLock<String>>,
model_service: Arc<dyn ModelService>,
}
impl PluginHandle {
#[allow(clippy::too_many_arguments)]
pub fn new(
plugin_id: String,
config: PluginSdkConfig,
capabilities: PlatformCapabilities,
theme_provider: Arc<dyn ThemeProvider>,
theme_mode: Arc<RwLock<String>>,
icon_extractor: Arc<dyn IconExtractor>,
icon_cache: Arc<IconCacheService>,
shell_executor: Arc<dyn ShellExecutor>,
window_manager: Arc<dyn WindowManager>,
path_resolver: Arc<dyn PathResolver>,
app_enumerator: Arc<dyn AppEnumerator>,
app_launcher: Arc<dyn AppLauncher>,
lnk_resolver: Arc<dyn LnkResolver>,
resource_loader: Arc<dyn ResourceLoader>,
parameter_resolver: Arc<dyn ParameterResolver>,
timer_manager: Arc<dyn TimerManager>,
app_resource: Arc<AppResourceService>,
storage: Arc<RwLock<Arc<dyn StorageService>>>,
hotkey_manager: Arc<dyn HotkeyManager>,
installation_monitor: Arc<dyn InstallationMonitor>,
focus_monitor: Arc<dyn FocusMonitor>,
clipboard_manager: Arc<dyn ClipboardManager>,
model_service: Arc<dyn ModelService>,
) -> Self {
Self {
plugin_id,
config: RwLock::new(config),
capabilities,
theme_provider,
theme_mode,
icon_extractor,
icon_cache,
shell_executor,
window_manager,
path_resolver,
app_enumerator,
app_launcher,
lnk_resolver,
resource_loader,
parameter_resolver,
timer_manager,
app_resource,
storage,
hotkey_manager,
installation_monitor,
focus_monitor,
clipboard_manager,
model_service,
}
}
pub fn plugin_id(&self) -> &str {
&self.plugin_id
}
fn icon_cache_level(&self) -> CacheLevel {
self.config.read().icon_cache_level.unwrap_or_default()
}
pub async fn get_icon(&self, request: IconRequest) -> Result<Vec<u8>, HostApiError> {
let level = self.icon_cache_level();
self.icon_extractor
.get_icon(&self.icon_cache, &request, level)
.await
}
pub async fn get_icon_or_default(&self, request: IconRequest) -> Vec<u8> {
let level = self.icon_cache_level();
match self
.icon_extractor
.get_icon(&self.icon_cache, &request, level)
.await
{
Ok(data) if !data.is_empty() => data,
_ => {
tracing::warn!("图标提取失败,使用默认图标: {:?}", request);
self.icon_extractor.load_default_icon(&request).await
}
}
}
pub async fn get_icon_and_update_cache(
&self,
request: IconRequest,
) -> Result<Vec<u8>, HostApiError> {
let level = self.icon_cache_level();
self.icon_extractor
.get_icon_and_update_cache(&self.icon_cache, &request, level)
.await
}
pub async fn override_icon_cache(
&self,
original_request: &IconRequest,
custom_icon_path: &str,
) -> Result<(), HostApiError> {
let hash_key = original_request.get_hash_string() + ".webp";
let custom_request = IconRequest::Path(custom_icon_path.to_string());
let data = self
.icon_extractor
.extract_and_process(&custom_request)
.await?;
self.icon_cache.set_l1(&hash_key, data.clone());
self.icon_cache.set_l2(&hash_key, data).await;
Ok(())
}
pub async fn shell_open(&self, target: OpenTarget) -> Result<(), HostApiError> {
self.shell_executor.shell_open(&target).await
}
pub async fn shell_open_folder(&self, path: &str) -> Result<(), HostApiError> {
self.shell_executor.shell_open_folder(path).await
}
pub async fn shell_execute_elevation(&self, path: &str) -> Result<(), HostApiError> {
self.shell_executor.shell_execute_elevation(path).await
}
pub async fn shell_execute_command(&self, command: &str) -> Result<(), HostApiError> {
self.shell_executor.shell_execute_command(command).await
}
pub async fn activate_window_by_process(
&self,
process_name: &str,
) -> Result<bool, HostApiError> {
self.window_manager
.activate_window_by_process(process_name)
.await
}
pub async fn activate_window_by_title(&self, title: &str) -> Result<bool, HostApiError> {
self.window_manager.activate_window_by_title(title).await
}
pub async fn activate_window_by_pid(&self, pid: u32) -> Result<bool, HostApiError> {
self.window_manager.activate_window_by_pid(pid).await
}
pub fn resolve_path(&self, path: KnownPath) -> Result<String, HostApiError> {
self.path_resolver.resolve_path(path)
}
pub fn set_clipboard_text(&self, text: &str) -> Result<(), HostApiError> {
self.clipboard_manager.set_text(text)
}
pub async fn enumerate_apps(&self) -> Vec<AppInfo> {
self.app_enumerator.enumerate_apps().await
}
pub async fn launch_app(
&self,
app_id: &str,
args: Option<&[String]>,
) -> Result<u32, HostApiError> {
self.app_launcher.launch_app(app_id, args).await
}
pub fn get_app_icon_path(&self, name: &str) -> Option<String> {
self.app_resource.get_icon_path(name)
}
pub fn resolve_lnk_target(&self, lnk_path: &str) -> Option<String> {
self.lnk_resolver.resolve_lnk_target(lnk_path)
}
pub fn parse_localized_names_from_dir(
&self,
dir_path: &std::path::Path,
) -> std::collections::HashMap<String, String> {
self.resource_loader
.parse_localized_names_from_dir(dir_path)
}
pub fn update_config(&self, config: PluginSdkConfig) {
*self.config.write() = config;
}
pub fn capabilities(&self) -> &PlatformCapabilities {
&self.capabilities
}
pub fn get_theme(&self) -> Result<Theme, HostApiError> {
match self.theme_mode.read().as_str() {
"light" => Ok(Theme::Light),
"dark" => Ok(Theme::Dark),
_ => self.theme_provider.current_system_theme(),
}
}
pub fn get_system_theme(&self) -> Result<Theme, HostApiError> {
self.theme_provider.current_system_theme()
}
pub fn model_list(&self) -> Vec<ModelInfo> {
self.model_service.list_models()
}
pub async fn model_chat(&self, req: ModelChatRequest) -> Result<ModelChatResponse, ModelError> {
self.model_service.chat(req).await
}
pub async fn model_embedding(
&self,
req: ModelEmbeddingRequest,
) -> Result<ModelEmbeddingResponse, ModelError> {
self.model_service.embedding(req).await
}
pub async fn model_similarity(
&self,
req: ModelSimilarityRequest,
) -> Result<ModelSimilarityResponse, ModelError> {
self.model_service.similarity(req).await
}
pub async fn resolve_parameters(
&self,
template: &str,
user_args: &[String],
snapshot: &ParameterSnapshot,
) -> Result<String, HostApiError> {
self.parameter_resolver
.resolve(template, user_args, snapshot)
.await
.map_err(|e| HostApiError::ParameterResolutionFailed {
reason: e.to_string(),
})
}
pub fn count_user_parameters(&self, template: &str) -> usize {
self.parameter_resolver.count_user_parameters(template)
}
pub fn has_system_parameters(&self, template: &str) -> bool {
self.parameter_resolver.has_system_parameters(template)
}
pub async fn set_timeout(
&self,
delay: std::time::Duration,
callback: TimerCallback,
) -> Result<TimerId, HostApiError> {
self.timer_manager
.set_timer(delay, TimerMode::OneShot, callback)
.await
}
pub async fn set_interval(
&self,
interval: std::time::Duration,
callback: TimerCallback,
) -> Result<TimerId, HostApiError> {
self.timer_manager
.set_timer(interval, TimerMode::Interval, callback)
.await
}
pub async fn cancel_timer(&self, id: TimerId) -> Result<(), HostApiError> {
self.timer_manager.cancel_timer(id).await
}
pub async fn cancel_all_timers(&self) -> Result<(), HostApiError> {
self.timer_manager.cancel_all().await
}
pub async fn resource_upload(
&self,
resource_id: &str,
file_path: &str,
max_size: Option<u64>,
) -> Result<String, HostApiError> {
let path = std::path::Path::new(file_path);
if let Some(limit) = max_size {
let metadata = tokio::fs::metadata(path).await.map_err(|e| {
HostApiError::StorageOperationFailed {
file: file_path.to_string(),
reason: format!("读取文件元数据失败: {}", e),
}
})?;
if metadata.len() > limit {
return Err(HostApiError::StorageOperationFailed {
file: file_path.to_string(),
reason: format!("文件大小 {} 超过限制 {} 字节", metadata.len(), limit),
});
}
}
let data =
tokio::fs::read(path)
.await
.map_err(|e| HostApiError::StorageOperationFailed {
file: file_path.to_string(),
reason: format!("读取文件失败: {}", e),
})?;
let storage_path = build_resource_path(&self.plugin_id, Some(resource_id))?;
let storage = self.storage.read().clone();
storage.upload(&storage_path, &data).await.map_err(|e| {
HostApiError::StorageOperationFailed {
file: storage_path,
reason: e.to_string(),
}
})?;
Ok(resource_id.to_string())
}
pub async fn resource_put(&self, resource_id: &str, data: &[u8]) -> Result<(), HostApiError> {
let storage_path = build_resource_path(&self.plugin_id, Some(resource_id))?;
let storage: Arc<dyn StorageService> = self.storage.read().clone();
storage.upload(&storage_path, data).await.map_err(|e| {
HostApiError::StorageOperationFailed {
file: storage_path,
reason: e.to_string(),
}
})
}
pub async fn resource_get(&self, resource_id: &str) -> Result<Vec<u8>, HostApiError> {
let path = build_resource_path(&self.plugin_id, Some(resource_id))?;
let storage = self.storage.read().clone();
storage
.download(&path)
.await
.map_err(|e| HostApiError::StorageOperationFailed {
file: path,
reason: e.to_string(),
})?
.ok_or_else(|| HostApiError::ResourceNotFound {
id: resource_id.to_string(),
})
}
pub async fn resource_delete(&self, resource_id: &str) -> Result<(), HostApiError> {
let path = build_resource_path(&self.plugin_id, Some(resource_id))?;
let storage = self.storage.read().clone();
storage
.delete(&path)
.await
.map_err(|e| HostApiError::StorageOperationFailed {
file: path,
reason: e.to_string(),
})
}
pub async fn resource_list(&self) -> Result<Vec<String>, HostApiError> {
let prefix = build_resource_path(&self.plugin_id, None)?;
let storage = self.storage.read().clone();
storage
.list(&prefix)
.await
.map_err(|e| HostApiError::StorageOperationFailed {
file: prefix,
reason: e.to_string(),
})
}
pub async fn cache_put(
&self,
domain: &str,
key: &str,
data: &[u8],
) -> Result<(), HostApiError> {
let cache_root = self.resolve_path(KnownPath::AppCacheDir)?;
let path = build_cache_path(&cache_root, &self.plugin_id, domain, key)?;
if let Some(parent) = std::path::Path::new(&path).parent() {
tokio::fs::create_dir_all(parent).await.map_err(|e| {
HostApiError::StorageOperationFailed {
file: path.clone(),
reason: format!("创建缓存目录失败: {}", e),
}
})?;
}
tokio::fs::write(&path, data)
.await
.map_err(|e| HostApiError::StorageOperationFailed {
file: path,
reason: format!("写入缓存文件失败: {}", e),
})?;
Ok(())
}
pub async fn cache_get(
&self,
domain: &str,
key: &str,
) -> Result<Option<Vec<u8>>, HostApiError> {
let cache_root = self.resolve_path(KnownPath::AppCacheDir)?;
let path = build_cache_path(&cache_root, &self.plugin_id, domain, key)?;
match tokio::fs::read(&path).await {
Ok(data) => Ok(Some(data)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(HostApiError::StorageOperationFailed {
file: path,
reason: format!("读取缓存文件失败: {}", e),
}),
}
}
pub async fn cache_delete(&self, domain: &str, key: &str) -> Result<(), HostApiError> {
let cache_root = self.resolve_path(KnownPath::AppCacheDir)?;
let path = build_cache_path(&cache_root, &self.plugin_id, domain, key)?;
match tokio::fs::remove_file(&path).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(HostApiError::StorageOperationFailed {
file: path,
reason: format!("删除缓存文件失败: {}", e),
}),
}
}
pub async fn cache_cleanup(
&self,
domain: &str,
max_entries: usize,
) -> Result<(), HostApiError> {
let cache_root = self.resolve_path(KnownPath::AppCacheDir)?;
let domain_dir = std::path::Path::new(&cache_root)
.join(&self.plugin_id)
.join(domain);
if !domain_dir.exists() {
return Ok(());
}
let mut entries: Vec<(std::time::SystemTime, std::path::PathBuf)> = Vec::new();
for sub in
std::fs::read_dir(&domain_dir).map_err(|e| HostApiError::StorageOperationFailed {
file: domain_dir.to_string_lossy().to_string(),
reason: format!("读取缓存目录失败: {}", e),
})?
{
let sub = sub.map_err(|e| HostApiError::StorageOperationFailed {
file: domain_dir.to_string_lossy().to_string(),
reason: format!("读取缓存子目录失败: {}", e),
})?;
if !sub.path().is_dir() {
continue;
}
for file in
std::fs::read_dir(sub.path()).map_err(|e| HostApiError::StorageOperationFailed {
file: sub.path().to_string_lossy().to_string(),
reason: format!("读取缓存分片目录失败: {}", e),
})?
{
let file = file.map_err(|e| HostApiError::StorageOperationFailed {
file: sub.path().to_string_lossy().to_string(),
reason: format!("读取缓存条目失败: {}", e),
})?;
let meta = file
.metadata()
.map_err(|e| HostApiError::StorageOperationFailed {
file: file.path().to_string_lossy().to_string(),
reason: format!("读取缓存条目元数据失败: {}", e),
})?;
if meta.is_file() {
entries.push((
meta.modified().unwrap_or(std::time::UNIX_EPOCH),
file.path(),
));
}
}
}
if entries.len() <= max_entries {
return Ok(());
}
entries.sort_by_key(|(mtime, _)| *mtime);
let excess = entries.len() - max_entries;
for (_, path) in entries.into_iter().take(excess) {
let _ = std::fs::remove_file(&path);
}
Ok(())
}
fn prefix_callback_id(&self, id: &str) -> String {
format!("{}:{}", self.plugin_id, id)
}
pub fn register_hotkey_callback(
&self,
id: &str,
filter: HotkeyEventFilter,
callback: HotkeyCallback,
) {
let prefixed = self.prefix_callback_id(id);
self.hotkey_manager
.register_callback(&prefixed, filter, callback);
}
pub fn unregister_hotkey_callback(&self, id: &str) {
let prefixed = self.prefix_callback_id(id);
self.hotkey_manager.unregister_callback(&prefixed);
}
pub fn register_installation_callback(&self, id: &str, callback: InstallationCallback) {
let prefixed = self.prefix_callback_id(id);
self.installation_monitor
.register_callback(&prefixed, callback);
}
pub fn unregister_installation_callback(&self, id: &str) {
let prefixed = self.prefix_callback_id(id);
self.installation_monitor.unregister_callback(&prefixed);
}
pub fn register_focus_callback(&self, id: &str, callback: FocusCallback) {
let prefixed = self.prefix_callback_id(id);
self.focus_monitor.register_callback(&prefixed, callback);
}
pub fn unregister_focus_callback(&self, id: &str) {
let prefixed = self.prefix_callback_id(id);
self.focus_monitor.unregister_callback(&prefixed);
}
}
fn build_resource_path(plugin_id: &str, filename: Option<&str>) -> Result<String, HostApiError> {
let base = std::path::PathBuf::from_iter(["resources", plugin_id]);
let base_normalized = normalize_path(&base);
let mut path = base.clone();
if let Some(name) = filename {
if name.is_empty() || name == "." || name == ".." {
return Err(HostApiError::PathTraversalRejected {
path: name.to_string(),
});
}
path.push(name);
let normalized = normalize_path(&path);
let is_valid = normalized == base_normalized || normalized.starts_with(&base_normalized);
if !is_valid {
return Err(HostApiError::PathTraversalRejected {
path: name.to_string(),
});
}
}
Ok(path.to_string_lossy().replace('\\', "/"))
}
fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
let mut result = std::path::PathBuf::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
result.pop();
}
std::path::Component::CurDir => {
}
other => {
result.push(other);
}
}
}
result
}
fn build_cache_path(
cache_root: &str,
plugin_id: &str,
domain: &str,
key: &str,
) -> Result<String, HostApiError> {
for segment in [domain, key] {
if segment.is_empty() || segment == "." || segment == ".." {
return Err(HostApiError::PathTraversalRejected {
path: segment.to_string(),
});
}
}
let base = std::path::Path::new(cache_root)
.join(plugin_id)
.join(domain);
let base_normalized = normalize_path(&base);
let mut path = base;
path.push(key);
let normalized = normalize_path(&path);
if normalized != base_normalized && !normalized.starts_with(&base_normalized) {
return Err(HostApiError::PathTraversalRejected {
path: key.to_string(),
});
}
Ok(path.to_string_lossy().replace('\\', "/"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_path_removes_cur_dir() {
let input = std::path::Path::new("a/./b/./c");
let result = normalize_path(input);
assert_eq!(result, std::path::PathBuf::from("a/b/c"));
}
#[test]
fn normalize_path_resolves_parent_dir() {
let input = std::path::Path::new("a/b/../c");
let result = normalize_path(input);
assert_eq!(result, std::path::PathBuf::from("a/c"));
}
#[test]
fn normalize_path_handles_leading_dotdot() {
let input = std::path::Path::new("../../../etc/passwd");
let result = normalize_path(input);
assert_eq!(result, std::path::PathBuf::from("etc/passwd"));
}
#[test]
fn build_resource_path_rejects_parent_dir_traversal() {
let result = build_resource_path("test-plugin", Some("../../../secret"));
assert!(result.is_err());
match result {
Err(HostApiError::PathTraversalRejected { path }) => {
assert!(path.contains(".."));
}
_ => panic!("expected PathTraversalRejected"),
}
}
#[test]
fn build_resource_path_rejects_cross_plugin_traversal() {
let result = build_resource_path("test", Some("../test_evil/secret.txt"));
assert!(matches!(
result,
Err(HostApiError::PathTraversalRejected { .. })
));
}
#[test]
fn build_resource_path_rejects_dot_literal() {
let result = build_resource_path("test-plugin", Some("."));
assert!(matches!(
result,
Err(HostApiError::PathTraversalRejected { .. })
));
}
#[test]
fn build_resource_path_rejects_dotdot_literal() {
let result = build_resource_path("test-plugin", Some(".."));
assert!(matches!(
result,
Err(HostApiError::PathTraversalRejected { .. })
));
}
#[test]
fn build_resource_path_accepts_valid_filename() {
let result = build_resource_path("test-plugin", Some("icon.png"));
assert!(result.is_ok());
let path = result.unwrap();
assert!(path.starts_with("resources/test-plugin/"));
assert!(path.ends_with("icon.png"));
}
#[test]
fn build_cache_path_accepts_valid_segments() {
let result = build_cache_path(
"C:/mock/zl-cache",
"test-plugin",
"model-embedding",
"ab/abc123.bin",
);
assert!(result.is_ok());
let path = result.unwrap();
assert!(path.starts_with("C:/mock/zl-cache/test-plugin/model-embedding/"));
assert!(path.ends_with("abc123.bin"));
}
#[test]
fn build_cache_path_rejects_traversal() {
for (domain, key) in [
("..", "a.bin"),
("a", "../../x.bin"),
("a", "../b/x.bin"),
("a", ".."),
("a", "."),
("a", ""),
] {
assert!(
build_cache_path("C:/mock/zl-cache", "test-plugin", domain, key).is_err(),
"应拒绝 domain={domain:?} key={key:?}"
);
}
}
#[test]
fn build_resource_path_accepts_none_filename() {
let result = build_resource_path("test-plugin", None);
assert!(result.is_ok());
let path = result.unwrap();
assert_eq!(path, "resources/test-plugin");
}
#[test]
fn build_resource_path_rejects_empty_filename() {
let result = build_resource_path("test-plugin", Some(""));
assert!(matches!(
result,
Err(HostApiError::PathTraversalRejected { .. })
));
}
#[test]
fn starts_with_component_boundary_prevents_false_prefix_match() {
let base = std::path::Path::new("resources/test");
let evil = std::path::Path::new("resources/test_evil/secret.txt");
assert!(!evil.starts_with(base));
}
#[test]
fn build_resource_path_rejects_same_prefix_traversal() {
let result = build_resource_path("test", Some("../test_evil/secret.txt"));
assert!(matches!(
result,
Err(HostApiError::PathTraversalRejected { .. })
));
}
#[test]
fn build_resource_path_allows_subdirectory_with_same_prefix() {
let result = build_resource_path("test", Some("test_data.txt"));
assert!(result.is_ok());
let path = result.unwrap();
assert_eq!(path, "resources/test/test_data.txt");
}
#[test]
fn build_resource_path_allows_nested_subdir() {
let result = build_resource_path("test", Some("subdir/file.png"));
assert!(result.is_ok());
let path = result.unwrap();
assert_eq!(path, "resources/test/subdir/file.png");
}
#[test]
fn pathbuf_push_empty_is_functionally_noop() {
let mut with_trailing = std::path::PathBuf::from("resources/test");
with_trailing.push("");
let without_trailing = std::path::PathBuf::from("resources/test");
assert_eq!(with_trailing, without_trailing);
let child = std::path::Path::new("resources/test/icon.png");
assert!(child.starts_with(&with_trailing));
assert!(child.starts_with(&without_trailing));
let unrelated = std::path::Path::new("resources/test_evil/secret.txt");
assert!(!unrelated.starts_with(&with_trailing));
assert!(!unrelated.starts_with(&without_trailing));
}
}