feat: support Quicklink on Rust side (#760)

This commit implements the support for Quicklink on Rust side. We still
need the frontend part to make this complete.
This commit is contained in:
SteveLauC
2025-07-29 16:30:12 +08:00
committed by GitHub
parent 232166eb89
commit a553ebd593
7 changed files with 852 additions and 21 deletions

25
src-tauri/Cargo.lock generated
View File

@@ -862,6 +862,7 @@ dependencies = [
"hostname",
"http 1.3.1",
"hyper 0.14.32",
"indexmap 2.10.0",
"lazy_static",
"log",
"meval",
@@ -2486,7 +2487,7 @@ dependencies = [
"futures-core",
"futures-sink",
"http 1.3.1",
"indexmap 2.9.0",
"indexmap 2.10.0",
"slab",
"tokio",
"tokio-util",
@@ -2955,9 +2956,9 @@ dependencies = [
[[package]]
name = "indexmap"
version = "2.9.0"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
dependencies = [
"equivalent",
"hashbrown 0.15.3",
@@ -4588,7 +4589,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac26e981c03a6e53e0aee43c113e3202f5581d5360dae7bd2c70e800dd0451d"
dependencies = [
"base64 0.22.1",
"indexmap 2.9.0",
"indexmap 2.10.0",
"quick-xml 0.32.0",
"serde",
"time",
@@ -5552,7 +5553,7 @@ version = "1.0.140"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
dependencies = [
"indexmap 2.9.0",
"indexmap 2.10.0",
"itoa 1.0.15",
"memchr",
"ryu",
@@ -5610,7 +5611,7 @@ dependencies = [
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.9.0",
"indexmap 2.10.0",
"serde",
"serde_derive",
"serde_json",
@@ -6662,7 +6663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8d321dbc6f998d825ab3f0d62673e810c861aac2d0de2cc2c395328f1d113b4"
dependencies = [
"embed-resource",
"indexmap 2.9.0",
"indexmap 2.10.0",
"toml",
]
@@ -6929,7 +6930,7 @@ version = "0.19.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
dependencies = [
"indexmap 2.9.0",
"indexmap 2.10.0",
"toml_datetime",
"winnow 0.5.40",
]
@@ -6940,7 +6941,7 @@ version = "0.20.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81"
dependencies = [
"indexmap 2.9.0",
"indexmap 2.10.0",
"toml_datetime",
"winnow 0.5.40",
]
@@ -6951,7 +6952,7 @@ version = "0.22.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e"
dependencies = [
"indexmap 2.9.0",
"indexmap 2.10.0",
"serde",
"serde_spanned",
"toml_datetime",
@@ -8565,7 +8566,7 @@ dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"indexmap 2.9.0",
"indexmap 2.10.0",
"memchr",
]
@@ -8584,7 +8585,7 @@ dependencies = [
"flate2",
"getrandom 0.3.2",
"hmac",
"indexmap 2.9.0",
"indexmap 2.10.0",
"liblzma",
"memchr",
"pbkdf2",

View File

@@ -105,6 +105,7 @@ camino = "1.1.10"
tokio-stream = { version = "0.1.17", features = ["io-util"] }
cfg-if = "1.0.1"
sysinfo = "0.35.2"
indexmap = { version = "2.10.0", features = ["serde"] }
[target."cfg(target_os = \"macos\")".dependencies]
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }

View File

@@ -1,7 +1,6 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tauri::AppHandle;
use tauri::Runtime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RichLabel {
@@ -42,6 +41,15 @@ pub(crate) enum OnOpened {
Command {
action: crate::extension::CommandAction,
},
// NOTE that this variant has the same definition as `struct Quicklink`, but we
// cannot use it directly, its `link` field should be deserialized/serialized
// from/to a string, but we need a JSON object here.
//
// See also the comments in `struct Quicklink`.
Quicklink {
link: crate::extension::QuicklinkLink,
open_with: Option<String>,
},
}
impl OnOpened {
@@ -59,28 +67,37 @@ impl OnOpened {
ret
}
// Currently, our URL is static and does not support dynamic parameters.
// The URL of a quicklink is nearly useless without such dynamic user
// inputs, so until we have dynamic URL support, we just use "N/A".
Self::Quicklink { .. } => String::from("N/A"),
}
}
}
#[tauri::command]
pub(crate) async fn open<R: Runtime>(
tauri_app_handle: AppHandle<R>,
pub(crate) async fn open(
tauri_app_handle: AppHandle,
on_opened: OnOpened,
extra_args: Option<HashMap<String, String>>,
) -> Result<(), String> {
log::debug!("open({})", on_opened.url());
use crate::util::open as homemade_tauri_shell_open;
use std::process::Command;
match on_opened {
OnOpened::Application { app_path } => {
log::debug!("open application [{}]", app_path);
homemade_tauri_shell_open(tauri_app_handle.clone(), app_path).await?
}
OnOpened::Document { url } => {
log::debug!("open document [{}]", url);
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
}
OnOpened::Command { action } => {
log::debug!("open (execute) command [{:?}]", action);
let mut cmd = Command::new(action.exec);
if let Some(args) = action.args {
cmd.args(args);
@@ -107,6 +124,39 @@ pub(crate) async fn open<R: Runtime>(
));
}
}
OnOpened::Quicklink {
link,
open_with: opt_open_with,
} => {
let url = link.concatenate_url(&extra_args);
log::debug!("open quicklink [{}] with [{:?}]", url, opt_open_with);
cfg_if::cfg_if! {
// The `open_with` functionality is only supported on macOS, provided
// by the `open -a` command.
if #[cfg(target_os = "macos")] {
let mut cmd = Command::new("open");
if let Some(ref open_with) = opt_open_with {
cmd.arg("-a");
cmd.arg(open_with.as_str());
}
cmd.arg(&url);
let output = cmd.output().map_err(|e| format!("failed to spawn [open] due to error [{}]", e))?;
if !output.status.success() {
return Err(format!(
"failed to open with app {:?}: {}",
opt_open_with,
String::from_utf8_lossy(&output.stderr)
));
}
} else {
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
}
}
}
}
Ok(())

View File

@@ -7,9 +7,11 @@ use crate::util::platform::Platform;
use anyhow::Context;
use borrowme::{Borrow, ToOwned};
use derive_more::Display;
use indexmap::IndexMap;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as Json;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use tauri::{AppHandle, Manager, Runtime};
@@ -193,8 +195,19 @@ impl Extension {
ExtensionType::Application => Some(OnOpened::Application {
app_path: self.id.clone(),
}),
ExtensionType::Quicklink => {
let quicklink = self.quicklink.clone().unwrap_or_else(|| {
panic!(
"Quicklink extension [{}]'s [quicklink] field is not set, something wrong with your extension validity check", self.id
)
});
Some(OnOpened::Quicklink{
link: quicklink.link,
open_with: quicklink.open_with,
})
}
ExtensionType::Script => todo!("not supported yet"),
ExtensionType::Quicklink => todo!("not supported yet"),
ExtensionType::Setting => todo!("not supported yet"),
ExtensionType::Calculator => None,
ExtensionType::AiExtension => None,
@@ -270,7 +283,173 @@ pub(crate) struct CommandAction {
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Quicklink {
link: String,
// NOTE that `struct QuicklinkLink` (not `struct Quicklink`) has its own
// derived `Deserialize/Serialize` impl, which deserializes/serializes
// it from/to a JSON object.
//
// We cannot use it here because we need to deserialize/serialize it from/to
// a string,
//
// "https://www.google.com/search?q={query}"
#[serde(deserialize_with = "deserialize_quicklink_link_from_string")]
#[serde(serialize_with = "serialize_quicklink_link_to_string")]
link: QuicklinkLink,
/// Specify the application to use to open this quicklink.
///
/// Only supported on macOS.
pub(crate) open_with: Option<String>,
}
/// Return name and optional default value of all the dynamic placeholder arguments.
///
/// NOTE that it is not a Rust associated function because we need to expose it
/// to the frontend code:
///
/// ```javascript
/// invoke('quicklink_link_arguments', { <A JSON that can be deserialized to `struct QuicklinkLink`> } )
/// ```
#[tauri::command]
pub(crate) fn quicklink_link_arguments(
quicklink_link: QuicklinkLink,
) -> IndexMap<String, Option<String>> {
let mut arguments_with_opt_default = IndexMap::new();
for component in quicklink_link.components.iter() {
if let QuicklinkLinkComponent::DynamicPlaceholder {
argument_name,
default,
} = component
{
arguments_with_opt_default.insert(argument_name.to_string(), default.as_ref().cloned());
}
}
arguments_with_opt_default
}
/// A quicklink consists of a sequence of components.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct QuicklinkLink {
components: Vec<QuicklinkLinkComponent>,
}
impl QuicklinkLink {
/// Quicklinks that accept arguments cannot produce a complete URL
/// without user-supplied arguments.
///
/// This function attempts to concatenate the URL using the provided arguments,
/// if any.
pub(crate) fn concatenate_url(
&self,
user_supplied_args: &Option<HashMap<String, String>>,
) -> String {
let mut out = String::new();
for component in self.components.iter() {
match component {
QuicklinkLinkComponent::StaticStr(str) => {
out.push_str(str.as_str());
}
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name,
default,
} => {
let opt_argument_value = {
let user_supplied_arg = user_supplied_args
.as_ref()
.and_then(|map| map.get(argument_name.as_str()));
if user_supplied_arg.is_some() {
user_supplied_arg
} else {
default.as_ref()
}
};
let argument_value_str = match opt_argument_value {
Some(str) => str.as_str(),
// None => an empty string
None => "",
};
out.push_str(argument_value_str);
}
}
}
out
}
}
/// Custom deserialization function for QuicklinkLink from string
fn deserialize_quicklink_link_from_string<'de, D>(
deserializer: D,
) -> Result<QuicklinkLink, D::Error>
where
D: serde::Deserializer<'de>,
{
let link_str = String::deserialize(deserializer)?;
let components = parse_quicklink_components(&link_str).map_err(serde::de::Error::custom)?;
Ok(QuicklinkLink { components })
}
/// Custom serialization function for QuicklinkLink to a string
fn serialize_quicklink_link_to_string<S>(
link: &QuicklinkLink,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut result = String::new();
for component in &link.components {
match component {
QuicklinkLinkComponent::StaticStr(s) => {
result.push_str(s);
}
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name,
default,
} => {
result.push('{');
// If it's a simple case (no default), just use the argument name
if default.is_none() {
result.push_str(argument_name);
} else {
// Use the full format with argument_name and default
result.push_str(&format!(
r#"argument_name: "{}", default: "{}""#,
argument_name,
default.as_ref().unwrap()
));
}
result.push('}');
}
}
}
serializer.serialize_str(&result)
}
/// A link component is either a static string, or a dynamic placeholder, e.g.,
///
/// "https://www.google.com/search?q={query}"
///
/// The above link can be split into the following components:
///
/// [StaticStr("https://www.google.com/search?q="), DynamicPlaceholder { argument_name: "query", default: None }]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) enum QuicklinkLinkComponent {
StaticStr(String),
/// For the valid formats of dynamic placeholder, see the doc comments of `fn parse_dynamic_placeholder()`
DynamicPlaceholder {
argument_name: String,
/// Will use this default value if this dynamic parameter is not supplied
/// by the user.
default: Option<String>,
},
}
#[derive(Debug, PartialEq, Deserialize, Serialize, Clone, Display, Copy)]
@@ -773,3 +952,601 @@ fn alter_extension_json_file(
Ok(())
}
/// Helper function to impl Deserialize for `QuicklinkLink`.
///
/// Parse a quicklink string into components, handling dynamic placeholders
fn parse_quicklink_components(input: &str) -> Result<Vec<QuicklinkLinkComponent>, String> {
let mut components = Vec::new();
let mut current_pos = 0;
let chars: Vec<char> = input.chars().collect();
while current_pos < chars.len() {
// Find the next opening brace
if let Some(open_pos) = chars[current_pos..].iter().position(|&c| c == '{') {
let absolute_open_pos = current_pos + open_pos;
// Add static string before the opening brace (if any)
if absolute_open_pos > current_pos {
let static_str: String = chars[current_pos..absolute_open_pos].iter().collect();
components.push(QuicklinkLinkComponent::StaticStr(static_str));
}
// Find the matching closing brace, handling nested braces
let mut brace_count = 1;
let mut close_pos = None;
for (i, &c) in chars[absolute_open_pos + 1..].iter().enumerate() {
match c {
'{' => brace_count += 1,
'}' => {
brace_count -= 1;
if brace_count == 0 {
close_pos = Some(i);
break;
}
}
_ => {}
}
}
if let Some(close_pos) = close_pos {
let absolute_close_pos = absolute_open_pos + 1 + close_pos;
// Extract the placeholder content
let placeholder_content: String = chars[absolute_open_pos + 1..absolute_close_pos]
.iter()
.collect();
let placeholder = parse_dynamic_placeholder(&placeholder_content)?;
components.push(placeholder);
current_pos = absolute_close_pos + 1;
} else {
return Err(format!(
"Unmatched opening brace at position {}",
absolute_open_pos
));
}
} else {
// No more opening braces, add the remaining string as static
if current_pos < chars.len() {
let static_str: String = chars[current_pos..].iter().collect();
components.push(QuicklinkLinkComponent::StaticStr(static_str));
}
break;
}
}
Ok(components)
}
/// Helper function to impl Deserialize for `QuicklinkLink`.
///
/// Parse the content inside braces into a DynamicPlaceholder.
///
/// It supports the following formats:
///
/// 1. {query}: should be parsed to DynamicPlaceholder {argument_name: "query", default: None }
/// 2. {argument_name: "query" }: should be parsed to DynamicPlaceholder {argument_name: "query", default: None }
/// 3. {argument_name: "query", default: "rust" }: should be parsed to DynamicPlaceholder {argument_name: "query", default: Some("rust") }
fn parse_dynamic_placeholder(content: &str) -> Result<QuicklinkLinkComponent, String> {
let trimmed = content.trim();
// Case 1: {query} - simple argument name
if !trimmed.contains(':') && !trimmed.contains(',') {
return Ok(QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: trimmed.to_string(),
default: None,
});
}
// Case 2 & 3: {argument_name: "query"} or {argument_name: "query", default: "rust"}
// Parse as a simplified JSON-like structure
let mut argument_name = None;
let mut default_value = None;
// Split by commas and process each part
let parts: Vec<&str> = trimmed.split(',').collect();
for part in parts {
let part = part.trim();
if let Some(colon_pos) = part.find(':') {
let key = part[..colon_pos].trim();
let value = part[colon_pos + 1..].trim();
// Remove quotes from value if present
let value = if (value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\''))
{
&value[1..value.len() - 1]
} else {
value
};
match key {
"argument_name" => argument_name = Some(value.to_string()),
"default" => default_value = Some(value.to_string()),
_ => return Err(format!("Unknown key '{}' in placeholder", key)),
}
}
}
let argument_name = argument_name.ok_or("Missing argument_name in placeholder")?;
Ok(QuicklinkLinkComponent::DynamicPlaceholder {
argument_name,
default: default_value,
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json;
#[test]
fn test_deserialize_quicklink_link_case1() {
// Case 1: {query} - simple argument name
let test_string = "https://www.google.com/search?q={query}";
let components = parse_quicklink_components(test_string).unwrap();
let link = QuicklinkLink { components };
assert_eq!(link.components.len(), 2);
match &link.components[0] {
QuicklinkLinkComponent::StaticStr(s) => {
assert_eq!(s, "https://www.google.com/search?q=")
}
_ => panic!("Expected StaticStr component"),
}
match &link.components[1] {
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name,
default,
} => {
assert_eq!(argument_name, "query");
assert_eq!(default, &None);
}
_ => panic!("Expected DynamicPlaceholder component"),
}
}
#[test]
fn test_deserialize_quicklink_link_case2() {
// Case 2: {argument_name: "query"} - explicit argument name
let test_string = r#"https://www.google.com/search?q={argument_name: "query"}"#;
let components = parse_quicklink_components(test_string).unwrap();
let link = QuicklinkLink { components };
assert_eq!(link.components.len(), 2);
match &link.components[0] {
QuicklinkLinkComponent::StaticStr(s) => {
assert_eq!(s, "https://www.google.com/search?q=")
}
_ => panic!("Expected StaticStr component"),
}
match &link.components[1] {
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name,
default,
} => {
assert_eq!(argument_name, "query");
assert_eq!(default, &None);
}
_ => panic!("Expected DynamicPlaceholder component"),
}
}
#[test]
fn test_deserialize_quicklink_link_case3() {
// Case 3: {argument_name: "query", default: "rust"} - with default value
let test_string =
r#"https://www.google.com/search?q={argument_name: "query", default: "rust"}"#;
let components = parse_quicklink_components(test_string).unwrap();
let link = QuicklinkLink { components };
assert_eq!(link.components.len(), 2);
match &link.components[0] {
QuicklinkLinkComponent::StaticStr(s) => {
assert_eq!(s, "https://www.google.com/search?q=")
}
_ => panic!("Expected StaticStr component"),
}
match &link.components[1] {
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name,
default,
} => {
assert_eq!(argument_name, "query");
assert_eq!(default, &Some("rust".to_string()));
}
_ => panic!("Expected DynamicPlaceholder component"),
}
}
#[test]
fn test_deserialize_quicklink_link_multiple_placeholders() {
// Test multiple placeholders in one string
let test_string = r#"https://example.com/{category}/search?q={query}&lang={argument_name: "language", default: "en"}"#;
let components = parse_quicklink_components(test_string).unwrap();
let link = QuicklinkLink { components };
assert_eq!(link.components.len(), 6);
// Check the components
match &link.components[0] {
QuicklinkLinkComponent::StaticStr(s) => assert_eq!(s, "https://example.com/"),
_ => panic!("Expected StaticStr component"),
}
match &link.components[1] {
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name,
default,
} => {
assert_eq!(argument_name, "category");
assert_eq!(default, &None);
}
_ => panic!("Expected DynamicPlaceholder component"),
}
match &link.components[2] {
QuicklinkLinkComponent::StaticStr(s) => assert_eq!(s, "/search?q="),
_ => panic!("Expected StaticStr component"),
}
match &link.components[3] {
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name,
default,
} => {
assert_eq!(argument_name, "query");
assert_eq!(default, &None);
}
_ => panic!("Expected DynamicPlaceholder component"),
}
match &link.components[4] {
QuicklinkLinkComponent::StaticStr(s) => assert_eq!(s, "&lang="),
_ => panic!("Expected StaticStr component"),
}
match &link.components[5] {
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name,
default,
} => {
assert_eq!(argument_name, "language");
assert_eq!(default, &Some("en".to_string()));
}
_ => panic!("Expected DynamicPlaceholder component"),
}
}
#[test]
fn test_deserialize_quicklink_link_no_placeholders() {
// Test string with no placeholders
let test_string = "https://www.google.com/search?q=fixed";
let components = parse_quicklink_components(test_string).unwrap();
let link = QuicklinkLink { components };
assert_eq!(link.components.len(), 1);
match &link.components[0] {
QuicklinkLinkComponent::StaticStr(s) => {
assert_eq!(s, "https://www.google.com/search?q=fixed")
}
_ => panic!("Expected StaticStr component"),
}
}
#[test]
fn test_deserialize_quicklink_link_error_unmatched_brace() {
// Test error case with unmatched brace
let test_string = "https://www.google.com/search?q={query";
let result = parse_quicklink_components(test_string);
assert!(result.is_err());
}
/// Unknown argument a and b
#[test]
fn test_deserialize_quicklink_link_unknown_arguments() {
let test_string = r#"https://www.google.com/search?q={a: "a", b: "b"}"#;
let result = parse_quicklink_components(test_string);
assert!(result.is_err());
}
#[test]
fn test_serialize_quicklink_link_empty_components() {
// Case 1: Empty components should result in empty string
let link = QuicklinkLink { components: vec![] };
let mut serializer = serde_json::Serializer::new(Vec::new());
serialize_quicklink_link_to_string(&link, &mut serializer).unwrap();
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
assert_eq!(serialized, r#""""#); // Empty string
}
#[test]
fn test_serialize_quicklink_link_static_str_only() {
// Case 2: Only StaticStr components
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
QuicklinkLinkComponent::StaticStr("rust".to_string()),
],
};
let mut serializer = serde_json::Serializer::new(Vec::new());
serialize_quicklink_link_to_string(&link, &mut serializer).unwrap();
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
assert_eq!(serialized, r#""https://www.google.com/search?q=rust""#);
}
#[test]
fn test_serialize_quicklink_link_dynamic_placeholder_only() {
// Case 3: Only DynamicPlaceholder components
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "query".to_string(),
default: None,
},
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "language".to_string(),
default: Some("en".to_string()),
},
],
};
let mut serializer = serde_json::Serializer::new(Vec::new());
serialize_quicklink_link_to_string(&link, &mut serializer).unwrap();
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
assert_eq!(
serialized,
r#""{query}{argument_name: \"language\", default: \"en\"}""#
);
}
#[test]
fn test_serialize_quicklink_link_mixed_components() {
// Case 4: Mix of StaticStr and DynamicPlaceholder components
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "query".to_string(),
default: None,
},
QuicklinkLinkComponent::StaticStr("&lang=".to_string()),
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "language".to_string(),
default: Some("en".to_string()),
},
],
};
let mut serializer = serde_json::Serializer::new(Vec::new());
serialize_quicklink_link_to_string(&link, &mut serializer).unwrap();
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
assert_eq!(
serialized,
r#""https://www.google.com/search?q={query}&lang={argument_name: \"language\", default: \"en\"}""#
);
}
#[test]
fn test_serialize_quicklink_link_dynamic_placeholder_no_default() {
// Additional test: DynamicPlaceholder without default value
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::StaticStr("https://example.com/".to_string()),
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "category".to_string(),
default: None,
},
QuicklinkLinkComponent::StaticStr("/items".to_string()),
],
};
let mut serializer = serde_json::Serializer::new(Vec::new());
serialize_quicklink_link_to_string(&link, &mut serializer).unwrap();
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
assert_eq!(serialized, r#""https://example.com/{category}/items""#);
}
#[test]
fn test_serialize_quicklink_link_dynamic_placeholder_with_default() {
// Additional test: DynamicPlaceholder with default value
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::StaticStr("https://api.example.com/".to_string()),
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "version".to_string(),
default: Some("v1".to_string()),
},
QuicklinkLinkComponent::StaticStr("/data".to_string()),
],
};
let mut serializer = serde_json::Serializer::new(Vec::new());
serialize_quicklink_link_to_string(&link, &mut serializer).unwrap();
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
assert_eq!(
serialized,
r#""https://api.example.com/{argument_name: \"version\", default: \"v1\"}/data""#
);
}
#[test]
fn test_quicklink_link_arguments_empty_components() {
let link = QuicklinkLink { components: vec![] };
let map = quicklink_link_arguments(link);
assert!(map.is_empty());
}
#[test]
fn test_quicklink_link_arguments_static_str_only() {
let link = QuicklinkLink {
components: vec![QuicklinkLinkComponent::StaticStr(
"https://api.example.com/".to_string(),
)],
};
let map = quicklink_link_arguments(link);
assert!(map.is_empty());
}
#[test]
fn test_quicklink_link_arguments_dynamic_placeholder_only() {
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "query".to_string(),
default: None,
},
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "language".to_string(),
default: Some("en".to_string()),
},
],
};
let map = quicklink_link_arguments(link);
let expected_map = {
let mut map = IndexMap::new();
map.insert("query".into(), None);
map.insert("language".into(), Some("en".into()));
map
};
assert_eq!(map, expected_map);
}
#[test]
fn test_quicklink_link_concatenate_url_static_components_only() {
// Case 1: the link (QuicklinkLink) only contains static str components
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
QuicklinkLinkComponent::StaticStr("rust".to_string()),
],
};
let result = link.concatenate_url(&None);
assert_eq!(result, "https://www.google.com/search?q=rust");
}
/// The link has 1 dynamic component with no default value, but `user_supplied_args` is None
#[test]
fn test_quicklink_link_concatenate_url_dynamic_no_default_no_args() {
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "query".to_string(),
default: None,
},
],
};
let result = link.concatenate_url(&None);
assert_eq!(result, "https://www.google.com/search?q=");
}
/// The link has 1 dynamic component with no default value, `user_supplied_args` is Some(hashmap),
/// but this dynamic argument is not provided in the hashmap
#[test]
fn test_quicklink_link_concatenate_url_dynamic_no_default_missing_from_args() {
use std::collections::HashMap;
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "query".to_string(),
default: None,
},
],
};
let mut user_args = HashMap::new();
user_args.insert("other_param".to_string(), "value".to_string());
let result = link.concatenate_url(&Some(user_args));
assert_eq!(result, "https://www.google.com/search?q=");
}
/// The link has 1 dynamic component with a default value, `user_supplied_args` is None
#[test]
fn test_quicklink_link_concatenate_url_dynamic_with_default_no_args() {
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "query".to_string(),
default: Some("rust".to_string()),
},
],
};
let result = link.concatenate_url(&None);
assert_eq!(result, "https://www.google.com/search?q=rust");
}
/// The link has 1 dynamic component with a default value, `user_supplied_args` is Some(hashmap),
/// this dynamic argument is not provided in the hashmap
#[test]
fn test_quicklink_link_concatenate_url_dynamic_with_default_missing_from_args() {
use std::collections::HashMap;
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "query".to_string(),
default: Some("rust".to_string()),
},
],
};
let mut user_args = HashMap::new();
user_args.insert("other_param".to_string(), "value".to_string());
let result = link.concatenate_url(&Some(user_args));
assert_eq!(result, "https://www.google.com/search?q=rust");
}
/// The link has 1 dynamic component with a default value, `user_supplied_args` is Some(hashmap),
/// hashmap contains the dynamic parameter.
///
/// (the user-supplied argument should be used, the default value should be ignored)
#[test]
fn test_quicklink_link_concatenate_url_dynamic_with_default_provided_in_args() {
use std::collections::HashMap;
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "query".to_string(),
default: Some("rust".to_string()),
},
],
};
let mut user_args = HashMap::new();
user_args.insert("query".to_string(), "python".to_string());
let result = link.concatenate_url(&Some(user_args));
assert_eq!(result, "https://www.google.com/search?q=python");
}
/// The link is empty
#[test]
fn test_quicklink_link_concatenate_url_empty_link() {
let link = QuicklinkLink { components: vec![] };
let result = link.concatenate_url(&None);
assert_eq!(result, "");
}
}

View File

@@ -419,7 +419,7 @@ impl ThirdPartyExtensionsSearchSource {
if event.state() == ShortcutState::Pressed {
async_runtime::spawn(async move {
let result = open(app_handle_clone, on_opened_clone).await;
let result = open(app_handle_clone, on_opened_clone, None).await;
if let Err(msg) = result {
log::warn!(
"failed to open extension [{}], error [{}]",
@@ -680,7 +680,7 @@ impl ThirdPartyExtensionsSearchSource {
if event.state() == ShortcutState::Pressed {
async_runtime::spawn(async move {
let result = open(app_handle_clone, on_opened_clone).await;
let result = open(app_handle_clone, on_opened_clone, None).await;
if let Err(msg) = result {
log::warn!(
"failed to open extension [{:?}], error [{}]",

View File

@@ -155,6 +155,7 @@ pub fn run() {
extension::built_in::application::add_app_search_path,
extension::built_in::application::remove_app_search_path,
extension::built_in::application::reindex_applications,
extension::quicklink_link_arguments,
extension::list_extensions,
extension::enable_extension,
extension::disable_extension,