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:
Iuliia Ivashko
2026-02-08 04:32:14 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 9534e3d016
commit b432437b02
14 changed files with 1140 additions and 9 deletions
+519 -1
View File
@@ -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,