feat: supports app search across all platforms (#249)

* feat: supports app search across all platforms

* style: remove commented code
This commit is contained in:
ayangweb
2025-03-06 17:50:44 +08:00
committed by GitHub
parent af11b7915d
commit 6abb19c8a8
5 changed files with 224 additions and 292 deletions

193
src-tauri/Cargo.lock generated
View File

@@ -136,6 +136,34 @@ version = "1.0.97"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f"
[[package]]
name = "applications"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3854b0be0ff49d616ac728fef23f743a17c0dc304cfd061e28021eb1ea220af"
dependencies = [
"anyhow",
"cocoa 0.25.0",
"core-foundation 0.9.4",
"glob",
"image",
"ini",
"lnk",
"objc",
"parselnk",
"plist",
"regex",
"serde",
"serde_derive",
"serde_json",
"tauri-icns",
"thiserror 1.0.69",
"walkdir",
"winapi",
"windows-icons",
"winreg 0.52.0",
]
[[package]]
name = "arbitrary"
version = "1.4.1"
@@ -696,8 +724,10 @@ checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c"
dependencies = [
"android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
@@ -705,6 +735,7 @@ dependencies = [
name = "coco"
version = "0.1.0"
dependencies = [
"applications",
"async-trait",
"base64 0.13.1",
"dirs 5.0.1",
@@ -754,6 +785,22 @@ dependencies = [
"walkdir",
]
[[package]]
name = "cocoa"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c"
dependencies = [
"bitflags 1.3.2",
"block",
"cocoa-foundation 0.1.2",
"core-foundation 0.9.4",
"core-graphics 0.23.2",
"foreign-types 0.5.0",
"libc",
"objc",
]
[[package]]
name = "cocoa"
version = "0.26.0"
@@ -762,14 +809,28 @@ checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2"
dependencies = [
"bitflags 2.9.0",
"block",
"cocoa-foundation",
"cocoa-foundation 0.2.0",
"core-foundation 0.10.0",
"core-graphics",
"core-graphics 0.24.0",
"foreign-types 0.5.0",
"libc",
"objc",
]
[[package]]
name = "cocoa-foundation"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
dependencies = [
"bitflags 1.3.2",
"block",
"core-foundation 0.9.4",
"core-graphics-types 0.1.3",
"libc",
"objc",
]
[[package]]
name = "cocoa-foundation"
version = "0.2.0"
@@ -779,7 +840,7 @@ dependencies = [
"bitflags 2.9.0",
"block",
"core-foundation 0.10.0",
"core-graphics-types",
"core-graphics-types 0.2.0",
"libc",
"objc",
]
@@ -815,6 +876,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "configparser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe1d7dcda7d1da79e444bdfba1465f2f849a58b07774e1df473ee77030cb47a7"
[[package]]
name = "const-random"
version = "0.1.18"
@@ -896,6 +963,19 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core-graphics"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"core-graphics-types 0.1.3",
"foreign-types 0.5.0",
"libc",
]
[[package]]
name = "core-graphics"
version = "0.24.0"
@@ -904,11 +984,22 @@ checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.9.0",
"core-foundation 0.10.0",
"core-graphics-types",
"core-graphics-types 0.2.0",
"foreign-types 0.5.0",
"libc",
]
[[package]]
name = "core-graphics-types"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"libc",
]
[[package]]
name = "core-graphics-types"
version = "0.2.0"
@@ -1277,8 +1368,8 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67fd9ae1736d6ebb2e472740fbee86fb2178b8d56feb98a6751411d4c95b7e72"
dependencies = [
"cocoa",
"core-graphics",
"cocoa 0.26.0",
"core-graphics 0.24.0",
"dunce",
"gdk",
"gdkx11",
@@ -2671,6 +2762,15 @@ dependencies = [
"cfb",
]
[[package]]
name = "ini"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a9271a5dfd4228fa56a78d7508a35c321639cc71f783bb7a5723552add87bce"
dependencies = [
"configparser",
]
[[package]]
name = "inotify"
version = "0.9.6"
@@ -3000,6 +3100,20 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5"
[[package]]
name = "lnk"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e066ce29d4da51727b57c404c1270e3fa2a5ded0db1a4cb67c61f7a132421b2c"
dependencies = [
"bitflags 1.3.2",
"byteorder",
"chrono",
"log",
"num-derive 0.3.3",
"num-traits",
]
[[package]]
name = "lock_api"
version = "0.4.12"
@@ -3317,6 +3431,17 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-derive"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "num-derive"
version = "0.4.2"
@@ -3883,6 +4008,19 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "parselnk"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0088616e6efe53ab79907b9313f4743eb3f8a16eb1e0014af810164808906dc3"
dependencies = [
"bitflags 1.3.2",
"byteorder",
"chrono",
"thiserror 1.0.69",
"widestring 0.4.3",
]
[[package]]
name = "paste"
version = "1.0.15"
@@ -4481,7 +4619,7 @@ dependencies = [
"maybe-rayon",
"new_debug_unreachable",
"noop_proc_macro",
"num-derive",
"num-derive 0.4.2",
"num-traits",
"once_cell",
"paste",
@@ -5224,7 +5362,7 @@ checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08"
dependencies = [
"bytemuck",
"cfg_aliases",
"core-graphics",
"core-graphics 0.24.0",
"foreign-types 0.5.0",
"js-sys",
"log",
@@ -5430,7 +5568,7 @@ checksum = "7e7f38988a68dfb559899ea307b97577f008d3254f6cfdd219a67e27ce34c95b"
dependencies = [
"bitflags 2.9.0",
"core-foundation 0.10.0",
"core-graphics",
"core-graphics 0.24.0",
"crossbeam-channel",
"dispatch",
"dlopen2",
@@ -5590,6 +5728,16 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-icns"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03b7eb4d0d43724ba9ba6a6717420ee68aee377816a3edbb45db8c18862b1431"
dependencies = [
"byteorder",
"png",
]
[[package]]
name = "tauri-macros"
version = "2.0.5"
@@ -5611,9 +5759,9 @@ source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2#d20d53af786f1e2b
dependencies = [
"bitflags 2.9.0",
"block",
"cocoa",
"cocoa 0.26.0",
"core-foundation 0.10.0",
"core-graphics",
"core-graphics 0.24.0",
"objc",
"objc-foundation",
"objc_id",
@@ -5789,7 +5937,7 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a99d8b8076a4f0916d100c5efade757b9b80a5f6da2bb69c333ab4a72e92f6"
dependencies = [
"core-graphics",
"core-graphics 0.24.0",
"macos-accessibility-client",
"serde",
"tauri",
@@ -6990,6 +7138,12 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082"
[[package]]
name = "widestring"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c168940144dd21fd8046987c16a46a33d5fc84eec29ef9dcddc2ac9e31526b7c"
[[package]]
name = "widestring"
version = "1.1.0"
@@ -7154,6 +7308,19 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-icons"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42db1b1d99853c231d69b7fdc9782e90fa3004afed14a94c2eba79cac43b5f5a"
dependencies = [
"base64 0.22.1",
"glob",
"image",
"winapi",
"windows 0.59.0",
]
[[package]]
name = "windows-implement"
version = "0.52.0"
@@ -7742,7 +7909,7 @@ dependencies = [
"percent-encoding",
"scopeguard",
"thiserror 2.0.12",
"widestring",
"widestring 1.1.0",
"windows 0.59.0",
"xcb",
]

View File

@@ -40,8 +40,8 @@ tauri-plugin-process = "2"
tauri-plugin-drag = "2"
tauri-plugin-macos-permissions = "2"
tauri-plugin-fs-pro = "2"
tauri-plugin-screenshots = "2"
applications = "0.3.0"
tokio-native-tls = "0.3" # For wss connections
tokio = { version = "1", features = ["full"] }

View File

@@ -122,6 +122,7 @@ pub fn run() {
// server::get_coco_server_connectors,
server::websocket::connect_to_server,
server::websocket::disconnect,
get_app_search_source
])
.setup(|app| {
let registry = SearchSourceRegistry::default();
@@ -186,15 +187,6 @@ pub fn run() {
.build(ctx)
.expect("error while running tauri application");
// Create a single Tokio runtime instance
let rt = RT::new().expect("Failed to create Tokio runtime");
let app_handle = app.handle().clone();
rt.spawn(async move {
init_app_search_source(&app_handle).await;
let _ = server::connector::refresh_all_connectors(&app_handle).await;
let _ = server::datasource::refresh_all_datasources(&app_handle).await;
});
app.run(|app_handle, event| match event {
#[cfg(target_os = "macos")]
tauri::RunEvent::Reopen {
@@ -237,18 +229,8 @@ pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
}
async fn init_app_search_source<R: Runtime>(app_handle: &AppHandle<R>) {
// Run the slow application directory search in the background
let dir = vec![
dirs::home_dir().map(|home| home.join("Applications")), // Resolve `~/Applications`
Some(PathBuf::from("/Applications")),
Some(PathBuf::from("/System/Applications")),
Some(PathBuf::from("/System/Applications/Utilities")),
];
// Remove any `None` values if `home_dir()` fails
let app_dirs: Vec<PathBuf> = dir.into_iter().flatten().collect();
let application_search = local::application::ApplicationSearchSource::new(1000f64, app_dirs);
let application_search =
local::application::ApplicationSearchSource::new(app_handle.clone(), 1000f64).await;
// Register the application search source
let registry = app_handle.state::<SearchSourceRegistry>();
@@ -482,3 +464,10 @@ fn open_settings(app: &tauri::AppHandle) {
.unwrap();
}
}
#[tauri::command]
async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) {
init_app_search_source(&app_handle).await;
let _ = server::connector::refresh_all_connectors(&app_handle).await;
let _ = server::datasource::refresh_all_datasources(&app_handle).await;
}

View File

@@ -2,288 +2,59 @@ use crate::common::document::{DataSourceReference, Document};
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
use crate::common::traits::{SearchError, SearchSource};
use crate::local::LOCAL_QUERY_SOURCE_TYPE;
use applications::{AppInfo, AppInfoContext};
use async_trait::async_trait;
use base64::encode;
use dirs::data_dir;
use fuzzy_prefix_search::Trie;
use hostname;
use plist::Value;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use walkdir::WalkDir;
use tauri::{AppHandle, Runtime};
use tauri_plugin_fs_pro::{icon, name};
pub struct ApplicationSearchSource {
base_score: f64,
app_dirs: Vec<PathBuf>,
icons: HashMap<String, PathBuf>, // Map app names to their icon paths
application_paths: fuzzy_prefix_search::Trie<String>, // Cached search locations
}
/// Extracts the app icon from the `.app` bundle or system icons and converts it to PNG format.
fn extract_icon_from_app_bundle(app_dir: &Path, app_data_folder: &Path) -> Option<PathBuf> {
// First, check if the icon is specified in the info.plist (e.g., CFBundleIconFile)
if let Some(icon_names) = get_icon_names_from_info_plist(app_dir) {
for icon_name in icon_names {
// Attempt to find the icon in the Resources folder
let icns_path = app_dir.join(format!("Contents/Resources/{}", icon_name));
if icns_path.exists() {
// If the icon exists, convert it to PNG
if let Some(output_path) =
convert_icns_to_png(&app_dir, &icns_path, app_data_folder)
{
return Some(output_path);
}
} else {
// If the icon name doesn't end with .icns, try appending it
if !icon_name.ends_with(".icns") {
let icns_path_with_extension =
app_dir.join(format!("Contents/Resources/{}.icns", icon_name));
if icns_path_with_extension.exists() {
if let Some(output_path) = convert_icns_to_png(
&app_dir,
&icns_path_with_extension,
app_data_folder,
) {
return Some(output_path);
}
}
}
}
}
}
// Attempt to get the ICNS file from the app bundle (Contents/Resources/AppIcon.icns)
if let Some(icon_path) = get_icns_from_app_bundle(app_dir) {
if let Some(output_path) = convert_icns_to_png(&app_dir, &icon_path, app_data_folder) {
return Some(output_path);
}
}
// Fallback: Check for PNG icon in the Resources folder
if let Some(png_icon_path) = get_png_from_resources(app_dir) {
if let Some(output_path) = convert_png_to_png(&png_icon_path, app_data_folder) {
return Some(output_path);
}
}
// Fallback: If no icon found, return a default system icon
if let Some(system_icon_path) = get_system_icon(app_dir) {
if let Some(output_path) = convert_icns_to_png(&app_dir, &system_icon_path, app_data_folder)
{
return Some(output_path);
}
}
None
}
fn get_icon_names_from_info_plist(app_dir: &Path) -> Option<Vec<String>> {
let plist_path = app_dir.join("Contents/Info.plist");
if plist_path.exists() {
// Use `Value::from_file` directly, which parses the plist into a `Value` type
if let Ok(plist_value) = Value::from_file(plist_path) {
// Check if the plist value is a dictionary
if let Some(icon_value) = plist_value.as_dictionary() {
// Collect all icon-related keys
let mut icons = Vec::new();
// Check CFBundleIconFile
if let Some(icon_file) = icon_value.get("CFBundleIconFile") {
if let Some(icon_name) = icon_file.as_string() {
icons.push(icon_name.to_string());
}
}
// Check CFBundleIconName (for default icon)
if let Some(icon_name) = icon_value.get("CFBundleIconName") {
if let Some(name) = icon_name.as_string() {
icons.push(name.to_string());
}
}
// Check CFBundleTypeIconFile
if let Some(type_icon_file) = icon_value.get("CFBundleTypeIconFile") {
if let Some(icon_name) = type_icon_file.as_string() {
icons.push(icon_name.to_string());
}
}
// If there are any icons found, return them
if !icons.is_empty() {
return Some(icons);
}
}
}
}
None
}
/// Tries to get the ICNS icon from the `.app` bundle.
fn get_icns_from_app_bundle(app_dir: &Path) -> Option<PathBuf> {
let icns_path = app_dir.join("Contents/Resources/AppIcon.icns");
if icns_path.exists() {
Some(icns_path)
} else {
None
}
}
/// Tries to get a PNG icon from the `.app` bundle's Resources folder.
fn get_png_from_resources(app_dir: &Path) -> Option<PathBuf> {
let png_path = app_dir.join("Contents/Resources/Icon.png");
if png_path.exists() {
Some(png_path)
} else {
None
}
}
/// Converts an ICNS file to PNG using macOS's `sips` command.
fn convert_icns_to_png(
app_dir: &Path,
icns_path: &Path,
app_data_folder: &Path,
) -> Option<PathBuf> {
if let Some(app_name) = app_dir.file_name().and_then(|name| name.to_str()) {
let icon_storage_dir = app_data_folder.join("coco-appIcons");
fs::create_dir_all(&icon_storage_dir).ok();
let output_png_path = icon_storage_dir.join(format!("{}.png", app_name));
if output_png_path.exists() {
return Some(output_png_path);
}
// dbg!("Converting ICNS to PNG:", &output_png_path);
// Run the `sips` command to convert the ICNS to PNG
let status = Command::new("sips")
.arg("-s")
.arg("format")
.arg("png")
.arg(icns_path)
.arg("--out")
.arg(&output_png_path)
.stdout(Stdio::null()) // Redirect stdout to null
.stderr(Stdio::null()) // Redirect stderr to null
.status();
if let Ok(status) = status {
if status.success() {
return Some(output_png_path);
}
} else {
dbg!("Failed to convert ICNS to PNG:", &output_png_path);
}
}
None
}
/// Converts a PNG file to PNG (essentially just copying it to a new location).
fn convert_png_to_png(png_path: &Path, app_data_folder: &Path) -> Option<PathBuf> {
if let Some(app_name) = png_path
.parent()
.and_then(|p| p.file_name())
.and_then(|name| name.to_str())
{
let icon_storage_dir = app_data_folder.join("coco-appIcons");
fs::create_dir_all(&icon_storage_dir).ok();
let output_png_path = icon_storage_dir.join(format!("{}.png", app_name));
// Copy the PNG file to the output directory
if let Err(_e) = fs::copy(png_path, &output_png_path) {
return None;
}
return Some(output_png_path);
}
None
}
/// Fallback function to fetch a system icon if the app doesn't have its own.
fn get_system_icon(_app_dir: &Path) -> Option<PathBuf> {
// Just a placeholder for getting a default icon if no app-specific icon is found
let default_icon_path = Path::new("/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/GenericApplicationIcon.icns");
if default_icon_path.exists() {
Some(default_icon_path.to_path_buf())
} else {
None
}
icons: HashMap<String, PathBuf>,
application_paths: Trie<String>,
}
impl ApplicationSearchSource {
pub fn new(base_score: f64, app_dirs: Vec<PathBuf>) -> Self {
pub async fn new<R: Runtime>(app_handle: AppHandle<R>, base_score: f64) -> Self {
let application_paths = Trie::new();
let mut icons = HashMap::new();
// Collect search locations as strings
let applications = Trie::new();
let mut ctx = AppInfoContext::new(vec![]);
ctx.refresh_apps().unwrap(); // must refresh apps before getting them
let apps = ctx.get_all_apps();
// Iterate over the directories to find .app files and extract icons
for app_dir in &app_dirs {
// Use WalkDir to recursively get all files in app_dir
for entry in WalkDir::new(app_dir).into_iter().filter_map(Result::ok) {
let file_path = entry.path();
if file_path.is_dir() && file_path.extension() == Some("app".as_ref()) {
if let Some(app_data_folder) = data_dir() {
let file_path_str = file_path.to_string_lossy().to_string(); // Convert to owned String if needed
if file_path
.parent()
.unwrap()
.to_str()
.unwrap()
.contains(".app/Contents/")
{
continue;
}
let search_word = file_path
.file_name()
.unwrap() // unwrap() might panic if there's no file name
.to_str()
.unwrap() // unwrap() might panic if it's not valid UTF-8
.trim_end_matches(".app")
.to_lowercase(); // to_lowercase returns a String, which is owned
for app in &apps {
let path = if cfg!(target_os = "macos") {
app.app_desktop_path.clone()
} else {
app.app_path_exe.clone().unwrap()
};
let search_word = name(path.clone()).await;
let icon = icon(app_handle.clone(), path.clone(), Some(256))
.await
.unwrap();
let path_string = path.to_string_lossy().into_owned();
//TODO, replace this hard-coded name to actual local app name in case it may change
if search_word.is_empty() || search_word.eq("coco-ai") {
continue;
}
let search_word_ref = search_word.as_str(); // Get a reference to the string slice
applications.insert(search_word_ref, file_path_str.clone());
if let Some(icon_path) =
extract_icon_from_app_bundle(&file_path, &app_data_folder)
{
icons.insert(file_path_str, icon_path);
} else {
dbg!("No icon found for:", &file_path);
}
}
}
if search_word.is_empty() || search_word.eq("coco-ai") {
continue;
}
application_paths.insert(&search_word, path_string.clone());
icons.insert(path_string, icon);
}
ApplicationSearchSource {
base_score,
app_dirs,
icons,
application_paths: applications,
application_paths,
}
}
}
/// Extracts the clean app name by removing `.app`
fn clean_app_name(path: &Path) -> Option<String> {
path.file_name()?
.to_str()
.map(|name| name.trim_end_matches(".app").to_string())
}
#[async_trait]
impl SearchSource for ApplicationSearchSource {
fn get_type(&self) -> QuerySource {
@@ -337,7 +108,7 @@ impl SearchSource for ApplicationSearchSource {
let file_name_str = result.word;
let file_path_str = result.data.get(0).unwrap().to_string();
let file_path = PathBuf::from(file_path_str.clone());
let cleaned_file_name = clean_app_name(&file_path).unwrap();
let cleaned_file_name = name(file_path).await;
total_hits += 1;
let mut doc = Document::new(
Some(DataSourceReference {