feat: Add desktop Open In button (#350)
* feat: add OpenInAppButton and macOS path opener * feat: filter installed apps on macOS and use in OpenInAppButton * feat: fetch and display macOS app icons in OpenInAppButton * feat(desktop): cache and fetch installed macOS apps * feat: add force refresh and retry for installed apps * feat(OpenInAppButton): add Copy Path action in dropdown header * feat: enhance caching mechanism for apps discovery --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
9534e3d016
commit
b432437b02
@@ -1,6 +1,7 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
net::TcpListener,
|
||||
@@ -8,8 +9,9 @@ use std::{
|
||||
sync::Mutex,
|
||||
time::Duration,
|
||||
};
|
||||
use std::{fs, path::PathBuf};
|
||||
use std::{collections::{HashMap, HashSet}, fs, path::{Path, PathBuf}};
|
||||
use std::env;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
fn eval_in_main_window<R: tauri::Runtime>(app: &tauri::AppHandle<R>, script: &str) {
|
||||
@@ -367,6 +369,518 @@ fn desktop_set_auto_worktree_menu(app: tauri::AppHandle, enabled: bool) -> Resul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn desktop_open_path(path: String, app: Option<String>) -> Result<(), String> {
|
||||
let trimmed = path.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("Path is required".to_string());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let mut command = Command::new("open");
|
||||
if let Some(app_name) = app.as_ref().map(|value| value.trim()).filter(|value| !value.is_empty()) {
|
||||
command.arg("-a").arg(app_name);
|
||||
}
|
||||
command.arg(trimmed);
|
||||
command.spawn().map_err(|err| err.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
Err("desktop_open_path is only supported on macOS".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct InstalledAppInfo {
|
||||
name: String,
|
||||
icon_data_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct InstalledAppsCache {
|
||||
updated_at: u64,
|
||||
apps: Vec<InstalledAppInfo>,
|
||||
}
|
||||
|
||||
const INSTALLED_APPS_CACHE_TTL_SECS: u64 = 60 * 60 * 24;
|
||||
const INSTALLED_APPS_CACHE_FILE: &str = "discovered-apps.json";
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct InstalledAppsResponse {
|
||||
apps: Vec<InstalledAppInfo>,
|
||||
has_cache: bool,
|
||||
is_cache_stale: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn desktop_filter_installed_apps(apps: Vec<String>) -> Result<Vec<String>, String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let mut installed: Vec<String> = Vec::new();
|
||||
|
||||
for raw in apps {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let bundle_name = if trimmed.ends_with(".app") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("{trimmed}.app")
|
||||
};
|
||||
|
||||
if is_app_bundle_installed(&bundle_name) {
|
||||
installed.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(installed);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = apps;
|
||||
Err("desktop_filter_installed_apps is only supported on macOS".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn desktop_get_installed_apps(
|
||||
app: tauri::AppHandle,
|
||||
apps: Vec<String>,
|
||||
force: Option<bool>,
|
||||
) -> Result<InstalledAppsResponse, String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let cache_path = installed_apps_cache_path();
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|err| err.to_string())?
|
||||
.as_secs();
|
||||
|
||||
let cache = read_installed_apps_cache(&cache_path);
|
||||
let cached_apps = cache
|
||||
.as_ref()
|
||||
.map(|entry| entry.apps.clone())
|
||||
.unwrap_or_default();
|
||||
let has_cache = cache.is_some();
|
||||
let is_cache_stale = cache
|
||||
.as_ref()
|
||||
.map(|entry| now.saturating_sub(entry.updated_at) > INSTALLED_APPS_CACHE_TTL_SECS)
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_cache {
|
||||
if is_cache_stale {
|
||||
log::info!("[open-in] cache hit (stale): {} apps", cached_apps.len());
|
||||
} else {
|
||||
log::info!("[open-in] cache hit (fresh): {} apps", cached_apps.len());
|
||||
}
|
||||
if log::log_enabled!(log::Level::Info) {
|
||||
let names: Vec<String> = cached_apps.iter().map(|app| app.name.clone()).collect();
|
||||
log::info!("[open-in] cache apps: {:?}", names);
|
||||
}
|
||||
}
|
||||
|
||||
if !has_cache {
|
||||
log::info!("[open-in] cache missing: refreshing app list");
|
||||
let app_handle = app.clone();
|
||||
let app_names = apps.clone();
|
||||
let force_icon_refresh = false;
|
||||
let cached_icon_map: HashMap<String, String> = HashMap::new();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
log::info!("[open-in] scan start: {} candidates", app_names.len());
|
||||
let refreshed = build_installed_apps(&app_names, &cached_icon_map, force_icon_refresh);
|
||||
if log::log_enabled!(log::Level::Info) {
|
||||
let names: Vec<String> = refreshed.iter().map(|entry| entry.name.clone()).collect();
|
||||
log::info!("[open-in] scan apps: {:?}", names);
|
||||
}
|
||||
log::info!("[open-in] scan done: {} installed", refreshed.len());
|
||||
let cache_entry = InstalledAppsCache {
|
||||
updated_at: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|value| value.as_secs())
|
||||
.unwrap_or(0),
|
||||
apps: refreshed.clone(),
|
||||
};
|
||||
let cache_path = installed_apps_cache_path();
|
||||
let _ = write_installed_apps_cache(&cache_path, &cache_entry);
|
||||
dispatch_installed_apps_update(&app_handle, &refreshed);
|
||||
});
|
||||
} else if force.unwrap_or(false) {
|
||||
log::info!("[open-in] manual refresh: refreshing app list");
|
||||
let app_handle = app.clone();
|
||||
let app_names = apps.clone();
|
||||
let force_icon_refresh = true;
|
||||
let cached_icon_map: HashMap<String, String> = HashMap::new();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
log::info!("[open-in] scan start: {} candidates", app_names.len());
|
||||
let refreshed = build_installed_apps(&app_names, &cached_icon_map, force_icon_refresh);
|
||||
if log::log_enabled!(log::Level::Info) {
|
||||
let names: Vec<String> = refreshed.iter().map(|entry| entry.name.clone()).collect();
|
||||
log::info!("[open-in] scan apps: {:?}", names);
|
||||
}
|
||||
log::info!("[open-in] scan done: {} installed", refreshed.len());
|
||||
let cache_entry = InstalledAppsCache {
|
||||
updated_at: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|value| value.as_secs())
|
||||
.unwrap_or(0),
|
||||
apps: refreshed.clone(),
|
||||
};
|
||||
let cache_path = installed_apps_cache_path();
|
||||
let _ = write_installed_apps_cache(&cache_path, &cache_entry);
|
||||
dispatch_installed_apps_update(&app_handle, &refreshed);
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(InstalledAppsResponse {
|
||||
apps: cached_apps,
|
||||
has_cache,
|
||||
is_cache_stale,
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = apps;
|
||||
Err("desktop_get_installed_apps is only supported on macOS".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AppIconPayload {
|
||||
app: String,
|
||||
data_url: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn desktop_fetch_app_icons(apps: Vec<String>) -> Result<Vec<AppIconPayload>, String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let mut results: Vec<AppIconPayload> = Vec::new();
|
||||
|
||||
for raw in apps {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(app_path) = resolve_app_bundle_path(trimmed) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(icon_path) = resolve_app_icon_path(&app_path) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(data_url) = icon_to_data_url(&icon_path, trimmed) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
results.push(AppIconPayload {
|
||||
app: trimmed.to_string(),
|
||||
data_url,
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = apps;
|
||||
Err("desktop_fetch_app_icons is only supported on macOS".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn resolve_app_bundle_path(app_name: &str) -> Option<PathBuf> {
|
||||
if app_name.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let bundle_name = if app_name.ends_with(".app") {
|
||||
app_name.to_string()
|
||||
} else {
|
||||
format!("{app_name}.app")
|
||||
};
|
||||
|
||||
let candidates = [
|
||||
format!("/Applications/{bundle_name}"),
|
||||
format!("/System/Applications/{bundle_name}"),
|
||||
format!("/System/Applications/Utilities/{bundle_name}"),
|
||||
];
|
||||
|
||||
for candidate in candidates {
|
||||
let path = PathBuf::from(&candidate);
|
||||
if path.exists() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(home) = env::var_os("HOME") {
|
||||
let user_app_path = PathBuf::from(home).join("Applications").join(&bundle_name);
|
||||
if user_app_path.exists() {
|
||||
return Some(user_app_path);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(output) = Command::new("mdfind").args(["-name", &bundle_name]).output() {
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let path = PathBuf::from(trimmed);
|
||||
if path.exists() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn installed_apps_cache_path() -> PathBuf {
|
||||
let home = env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/"));
|
||||
home
|
||||
.join(".config")
|
||||
.join("openchamber")
|
||||
.join(INSTALLED_APPS_CACHE_FILE)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn read_installed_apps_cache(path: &Path) -> Option<InstalledAppsCache> {
|
||||
let bytes = fs::read(path).ok()?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn write_installed_apps_cache(path: &Path, cache: &InstalledAppsCache) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|err| err.to_string())?;
|
||||
}
|
||||
let payload = serde_json::to_vec(cache).map_err(|err| err.to_string())?;
|
||||
fs::write(path, payload).map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn build_installed_apps(
|
||||
apps: &[String],
|
||||
cached_icon_map: &HashMap<String, String>,
|
||||
force_icon_refresh: bool,
|
||||
) -> Vec<InstalledAppInfo> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut results = Vec::new();
|
||||
|
||||
for raw in apps {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() || !seen.insert(trimmed.to_string()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(app_path) = resolve_app_bundle_path(trimmed) {
|
||||
let icon_data_url = if force_icon_refresh {
|
||||
resolve_app_icon_path(&app_path).and_then(|icon| icon_to_data_url(&icon, trimmed))
|
||||
} else {
|
||||
cached_icon_map
|
||||
.get(trimmed)
|
||||
.cloned()
|
||||
.or_else(|| resolve_app_icon_path(&app_path).and_then(|icon| icon_to_data_url(&icon, trimmed)))
|
||||
};
|
||||
results.push(InstalledAppInfo {
|
||||
name: trimmed.to_string(),
|
||||
icon_data_url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn dispatch_installed_apps_update(app: &tauri::AppHandle, apps: &[InstalledAppInfo]) {
|
||||
let event = serde_json::to_string("openchamber:installed-apps-updated")
|
||||
.unwrap_or_else(|_| "\"openchamber:installed-apps-updated\"".into());
|
||||
let detail = serde_json::to_string(apps).unwrap_or_else(|_| "[]".into());
|
||||
let script = format!("window.dispatchEvent(new CustomEvent({event}, {{ detail: {detail} }}));");
|
||||
eval_in_main_window(app, &script);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn resolve_app_icon_path(app_path: &Path) -> Option<PathBuf> {
|
||||
if !app_path.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(icon_file) = read_bundle_icon_file(app_path) {
|
||||
let icon_path = app_path
|
||||
.join("Contents")
|
||||
.join("Resources")
|
||||
.join(&icon_file);
|
||||
if icon_path.exists() {
|
||||
return Some(icon_path);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(output) = Command::new("mdls")
|
||||
.args(["-name", "kMDItemIconFile", "-raw", &app_path.to_string_lossy()])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let icon_name = stdout.trim();
|
||||
if !icon_name.is_empty() && icon_name != "(null)" {
|
||||
let icon_file = if icon_name.ends_with(".icns") {
|
||||
icon_name.to_string()
|
||||
} else {
|
||||
format!("{icon_name}.icns")
|
||||
};
|
||||
let icon_path = app_path
|
||||
.join("Contents")
|
||||
.join("Resources")
|
||||
.join(icon_file);
|
||||
if icon_path.exists() {
|
||||
return Some(icon_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resources_path = app_path.join("Contents").join("Resources");
|
||||
if let Ok(entries) = fs::read_dir(resources_path) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if let Some(ext) = path.extension().and_then(|value| value.to_str()) {
|
||||
if ext.eq_ignore_ascii_case("icns") {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn read_bundle_icon_file(app_path: &Path) -> Option<String> {
|
||||
let plist_path = app_path.join("Contents").join("Info.plist");
|
||||
if !plist_path.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let output = Command::new("defaults")
|
||||
.args(["read", &plist_path.to_string_lossy(), "CFBundleIconFile"])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let icon_name = stdout.trim();
|
||||
if icon_name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let icon_file = if icon_name.ends_with(".icns") {
|
||||
icon_name.to_string()
|
||||
} else {
|
||||
format!("{icon_name}.icns")
|
||||
};
|
||||
|
||||
Some(icon_file)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn icon_to_data_url(icon_path: &Path, app_name: &str) -> Option<String> {
|
||||
if !icon_path.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sanitized: String = app_name
|
||||
.chars()
|
||||
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
|
||||
.collect();
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|value| value.as_millis())
|
||||
.unwrap_or(0);
|
||||
let tmp_path = env::temp_dir().join(format!("openchamber-icon-{sanitized}-{timestamp}.png"));
|
||||
|
||||
let status = Command::new("sips")
|
||||
.args([
|
||||
"-s",
|
||||
"format",
|
||||
"png",
|
||||
"-Z",
|
||||
"32",
|
||||
&icon_path.to_string_lossy(),
|
||||
"--out",
|
||||
&tmp_path.to_string_lossy(),
|
||||
])
|
||||
.status()
|
||||
.ok()?;
|
||||
|
||||
if !status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let bytes = fs::read(&tmp_path).ok()?;
|
||||
let _ = fs::remove_file(&tmp_path);
|
||||
if bytes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let encoded = general_purpose::STANDARD.encode(bytes);
|
||||
Some(format!("data:image/png;base64,{encoded}"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn is_app_bundle_installed(bundle_name: &str) -> bool {
|
||||
if bundle_name.trim().is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Ok(output) = Command::new("mdfind").args(["-name", bundle_name]).output() {
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if !stdout.trim().is_empty() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let app_path = format!("/Applications/{bundle_name}");
|
||||
let system_app_path = format!("/System/Applications/{bundle_name}");
|
||||
let utilities_path = format!("/System/Applications/Utilities/{bundle_name}");
|
||||
|
||||
if Path::new(&app_path).exists() || Path::new(&system_app_path).exists() || Path::new(&utilities_path).exists() {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(home) = env::var_os("HOME") {
|
||||
let user_app_path = PathBuf::from(home).join("Applications").join(bundle_name);
|
||||
if user_app_path.exists() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
const SIDECAR_NAME: &str = "openchamber-server";
|
||||
const SIDECAR_NOTIFY_PREFIX: &str = "[OpenChamberDesktopNotify] ";
|
||||
const HEALTH_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
@@ -1497,6 +2011,10 @@ fn main() {
|
||||
desktop_download_and_install_update,
|
||||
desktop_restart,
|
||||
desktop_set_auto_worktree_menu,
|
||||
desktop_open_path,
|
||||
desktop_filter_installed_apps,
|
||||
desktop_get_installed_apps,
|
||||
desktop_fetch_app_icons,
|
||||
desktop_hosts_get,
|
||||
desktop_hosts_set,
|
||||
desktop_host_probe,
|
||||
|
||||
Reference in New Issue
Block a user