experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(tauri): add system tray with click-to-show

+376 -1
+1 -1
backend/tauri/src-tauri/Cargo.toml
··· 13 13 tauri-build = { version = "2", features = [] } 14 14 15 15 [dependencies] 16 - tauri = { version = "2", features = ["devtools"] } 16 + tauri = { version = "2", features = ["devtools", "tray-icon"] } 17 17 tauri-plugin-shell = "2" 18 18 tauri-plugin-global-shortcut = "2" 19 19 tauri-plugin-dialog = "2"
+234
backend/tauri/src-tauri/src/commands/file_dialog.rs
··· 1 + //! File dialog commands - native save/open dialogs 2 + //! 3 + //! Mirrors the Electron `file-save-dialog` and `file-open-dialog` IPC handlers. 4 + 5 + use serde::{Deserialize, Serialize}; 6 + use std::path::Path; 7 + use tauri_plugin_dialog::DialogExt; 8 + 9 + /// Filter for file dialogs (e.g., { name: "JSON", extensions: ["json"] }) 10 + #[derive(Debug, Deserialize)] 11 + pub struct DialogFilter { 12 + pub name: String, 13 + pub extensions: Vec<String>, 14 + } 15 + 16 + // ── file-save-dialog ──────────────────────────────────────────────── 17 + 18 + #[derive(Debug, Serialize)] 19 + pub struct FileSaveResponse { 20 + pub success: bool, 21 + #[serde(skip_serializing_if = "Option::is_none")] 22 + pub path: Option<String>, 23 + #[serde(skip_serializing_if = "Option::is_none")] 24 + pub canceled: Option<bool>, 25 + #[serde(skip_serializing_if = "Option::is_none")] 26 + pub error: Option<String>, 27 + } 28 + 29 + #[tauri::command] 30 + pub async fn file_save_dialog( 31 + app: tauri::AppHandle, 32 + content: String, 33 + filename: Option<String>, 34 + mime_type: Option<String>, 35 + ) -> Result<FileSaveResponse, String> { 36 + // Build file filters based on MIME type 37 + let mut builder = app.dialog().file(); 38 + 39 + if let Some(ref mime) = mime_type { 40 + match mime.as_str() { 41 + "application/json" => { 42 + builder = builder.add_filter("JSON", &["json"]); 43 + } 44 + "text/csv" => { 45 + builder = builder.add_filter("CSV", &["csv"]); 46 + } 47 + "text/plain" => { 48 + builder = builder.add_filter("Text", &["txt"]); 49 + } 50 + "text/html" => { 51 + builder = builder.add_filter("HTML", &["html", "htm"]); 52 + } 53 + _ => {} 54 + } 55 + } 56 + builder = builder.add_filter("All Files", &["*"]); 57 + 58 + // Set default filename 59 + if let Some(ref name) = filename { 60 + builder = builder.set_file_name(name); 61 + } 62 + 63 + // Show dialog (blocking on async channel) 64 + let (tx, rx) = tokio::sync::oneshot::channel(); 65 + builder.save_file(move |path| { 66 + let _ = tx.send(path); 67 + }); 68 + 69 + let file_path = rx.await.map_err(|e| format!("Dialog channel error: {}", e))?; 70 + 71 + match file_path { 72 + Some(path) => { 73 + let path_str = path.to_string_lossy().to_string(); 74 + // Write content to the selected path 75 + if let Err(e) = std::fs::write(&path_str, &content) { 76 + return Ok(FileSaveResponse { 77 + success: false, 78 + path: None, 79 + canceled: None, 80 + error: Some(format!("Failed to write file: {}", e)), 81 + }); 82 + } 83 + Ok(FileSaveResponse { 84 + success: true, 85 + path: Some(path_str), 86 + canceled: None, 87 + error: None, 88 + }) 89 + } 90 + None => Ok(FileSaveResponse { 91 + success: false, 92 + path: None, 93 + canceled: Some(true), 94 + error: None, 95 + }), 96 + } 97 + } 98 + 99 + // ── file-open-dialog ──────────────────────────────────────────────── 100 + 101 + #[derive(Debug, Serialize)] 102 + pub struct FileOpenData { 103 + pub name: String, 104 + pub path: String, 105 + pub size: u64, 106 + #[serde(rename = "mimeType")] 107 + pub mime_type: String, 108 + pub content: Option<String>, 109 + #[serde(rename = "isText")] 110 + pub is_text: bool, 111 + } 112 + 113 + #[derive(Debug, Serialize)] 114 + pub struct FileOpenResponse { 115 + pub success: bool, 116 + #[serde(skip_serializing_if = "Option::is_none")] 117 + pub data: Option<FileOpenData>, 118 + #[serde(skip_serializing_if = "Option::is_none")] 119 + pub canceled: Option<bool>, 120 + #[serde(skip_serializing_if = "Option::is_none")] 121 + pub error: Option<String>, 122 + } 123 + 124 + #[tauri::command] 125 + pub async fn file_open_dialog( 126 + app: tauri::AppHandle, 127 + ) -> Result<FileOpenResponse, String> { 128 + let builder = app 129 + .dialog() 130 + .file() 131 + .add_filter("All Files", &["*"]) 132 + .add_filter( 133 + "Text Files", 134 + &[ 135 + "txt", "md", "json", "html", "csv", "xml", "yaml", "yml", 136 + "toml", "ini", "cfg", "log", 137 + ], 138 + ) 139 + .add_filter("Documents", &["pdf", "doc", "docx", "rtf"]); 140 + 141 + // Show dialog 142 + let (tx, rx) = tokio::sync::oneshot::channel(); 143 + builder.pick_file(move |path| { 144 + let _ = tx.send(path); 145 + }); 146 + 147 + let file_path = rx.await.map_err(|e| format!("Dialog channel error: {}", e))?; 148 + 149 + match file_path { 150 + Some(selected) => { 151 + let path_str = selected.to_string_lossy().to_string(); 152 + let path = Path::new(&path_str); 153 + 154 + let file_name = path 155 + .file_name() 156 + .map(|n| n.to_string_lossy().to_string()) 157 + .unwrap_or_default(); 158 + 159 + let ext = path 160 + .extension() 161 + .map(|e| format!(".{}", e.to_string_lossy().to_lowercase())) 162 + .unwrap_or_default(); 163 + 164 + let stats = std::fs::metadata(&path_str) 165 + .map_err(|e| format!("Failed to stat file: {}", e))?; 166 + 167 + let mime_type = ext_to_mime(&ext).to_string(); 168 + 169 + let is_text = mime_type.starts_with("text/") 170 + || mime_type == "application/json" 171 + || mime_type == "application/xml" 172 + || mime_type == "image/svg+xml"; 173 + 174 + let content = if is_text { 175 + std::fs::read_to_string(&path_str).ok() 176 + } else { 177 + None 178 + }; 179 + 180 + Ok(FileOpenResponse { 181 + success: true, 182 + data: Some(FileOpenData { 183 + name: file_name, 184 + path: path_str, 185 + size: stats.len(), 186 + mime_type, 187 + content, 188 + is_text, 189 + }), 190 + canceled: None, 191 + error: None, 192 + }) 193 + } 194 + None => Ok(FileOpenResponse { 195 + success: false, 196 + data: None, 197 + canceled: Some(true), 198 + error: None, 199 + }), 200 + } 201 + } 202 + 203 + /// Map file extension to MIME type, matching the Electron implementation 204 + fn ext_to_mime(ext: &str) -> &'static str { 205 + match ext { 206 + ".md" | ".markdown" => "text/markdown", 207 + ".txt" | ".text" => "text/plain", 208 + ".json" => "application/json", 209 + ".html" | ".htm" => "text/html", 210 + ".csv" => "text/csv", 211 + ".xml" => "application/xml", 212 + ".yaml" | ".yml" => "text/yaml", 213 + ".toml" | ".ini" | ".cfg" | ".log" => "text/plain", 214 + ".js" => "text/javascript", 215 + ".ts" => "text/typescript", 216 + ".css" => "text/css", 217 + ".py" => "text/x-python", 218 + ".rb" => "text/x-ruby", 219 + ".sh" | ".bash" | ".zsh" => "text/x-shellscript", 220 + ".c" | ".h" => "text/x-c", 221 + ".cpp" => "text/x-c++", 222 + ".java" => "text/x-java", 223 + ".go" => "text/x-go", 224 + ".rs" => "text/x-rust", 225 + ".swift" => "text/x-swift", 226 + ".svg" => "image/svg+xml", 227 + ".png" => "image/png", 228 + ".jpg" | ".jpeg" => "image/jpeg", 229 + ".gif" => "image/gif", 230 + ".webp" => "image/webp", 231 + ".pdf" => "application/pdf", 232 + _ => "application/octet-stream", 233 + } 234 + }
+2
backend/tauri/src-tauri/src/commands/mod.rs
··· 4 4 5 5 pub mod datastore; 6 6 pub mod extensions; 7 + pub mod file_dialog; 8 + pub mod net_fetch; 7 9 pub mod profiles; 8 10 pub mod sync; 9 11 pub mod theme;
+107
backend/tauri/src-tauri/src/commands/net_fetch.rs
··· 1 + //! Net fetch command - CORS-bypassing HTTP proxy 2 + //! 3 + //! Mirrors the Electron `net-fetch` IPC handler. 4 + //! Used by extensions that need to fetch from external APIs. 5 + 6 + use std::collections::HashMap; 7 + use std::time::Duration; 8 + 9 + use serde::{Deserialize, Serialize}; 10 + 11 + #[derive(Debug, Deserialize)] 12 + pub struct NetFetchOptions { 13 + pub method: Option<String>, 14 + pub headers: Option<HashMap<String, String>>, 15 + pub timeout: Option<u64>, 16 + } 17 + 18 + #[derive(Debug, Serialize)] 19 + pub struct NetFetchResponse { 20 + pub success: bool, 21 + #[serde(skip_serializing_if = "Option::is_none")] 22 + pub data: Option<String>, 23 + #[serde(skip_serializing_if = "Option::is_none")] 24 + pub status: Option<u16>, 25 + #[serde(skip_serializing_if = "Option::is_none")] 26 + pub error: Option<String>, 27 + } 28 + 29 + #[tauri::command] 30 + pub async fn net_fetch( 31 + url: String, 32 + options: Option<NetFetchOptions>, 33 + ) -> Result<NetFetchResponse, String> { 34 + // Only allow http/https URLs 35 + if !url.starts_with("http://") && !url.starts_with("https://") { 36 + return Ok(NetFetchResponse { 37 + success: false, 38 + data: None, 39 + status: None, 40 + error: Some("Only http/https URLs are allowed".to_string()), 41 + }); 42 + } 43 + 44 + let opts = options.unwrap_or(NetFetchOptions { 45 + method: None, 46 + headers: None, 47 + timeout: None, 48 + }); 49 + 50 + let timeout_ms = opts.timeout.unwrap_or(5000); 51 + let method_str = opts.method.as_deref().unwrap_or("GET"); 52 + 53 + let client = reqwest::Client::builder() 54 + .timeout(Duration::from_millis(timeout_ms)) 55 + .build() 56 + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; 57 + 58 + let method = method_str 59 + .parse::<reqwest::Method>() 60 + .map_err(|e| format!("Invalid HTTP method: {}", e))?; 61 + 62 + let mut request = client.request(method, &url); 63 + 64 + // Forward custom headers 65 + if let Some(headers) = opts.headers { 66 + for (key, value) in headers { 67 + request = request.header(&key, &value); 68 + } 69 + } 70 + 71 + match request.send().await { 72 + Ok(response) => { 73 + let status = response.status().as_u16(); 74 + 75 + if !response.status().is_success() { 76 + let status_text = response.status().canonical_reason().unwrap_or("Unknown"); 77 + return Ok(NetFetchResponse { 78 + success: false, 79 + data: None, 80 + status: Some(status), 81 + error: Some(format!("HTTP {}: {}", status, status_text)), 82 + }); 83 + } 84 + 85 + match response.text().await { 86 + Ok(text) => Ok(NetFetchResponse { 87 + success: true, 88 + data: Some(text), 89 + status: Some(status), 90 + error: None, 91 + }), 92 + Err(e) => Ok(NetFetchResponse { 93 + success: false, 94 + data: None, 95 + status: Some(status), 96 + error: Some(format!("Failed to read response body: {}", e)), 97 + }), 98 + } 99 + } 100 + Err(e) => Ok(NetFetchResponse { 101 + success: false, 102 + data: None, 103 + status: None, 104 + error: Some(e.to_string()), 105 + }), 106 + } 107 + }
+32
backend/tauri/src-tauri/src/lib.rs
··· 17 17 use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; 18 18 #[cfg(target_os = "macos")] 19 19 use tauri::ActivationPolicy; 20 + #[cfg(desktop)] 21 + use tauri::tray::{TrayIconBuilder, TrayIconEvent, MouseButton, MouseButtonState}; 20 22 21 23 // Note: Tauri doesn't have backgroundColor support like Electron. 22 24 // The white flash prevention is handled through CSS in the frontend. ··· 105 107 // Prevent app from appearing in Dock and stealing focus 106 108 #[cfg(target_os = "macos")] 107 109 app.set_activation_policy(ActivationPolicy::Accessory); 110 + } 111 + 112 + // Create system tray icon (desktop only, not in headless mode) 113 + #[cfg(desktop)] 114 + if !headless { 115 + let icon = app.default_window_icon().cloned() 116 + .expect("Failed to get default window icon for tray"); 117 + TrayIconBuilder::new() 118 + .tooltip("Peek") 119 + .icon(icon) 120 + .on_tray_icon_event(|tray, event| { 121 + if let TrayIconEvent::Click { 122 + button: MouseButton::Left, 123 + button_state: MouseButtonState::Up, 124 + .. 125 + } = event { 126 + let app = tray.app_handle(); 127 + if let Some(window) = app.get_webview_window("main") { 128 + let _ = window.show(); 129 + let _ = window.set_focus(); 130 + } 131 + } 132 + }) 133 + .build(app)?; 134 + println!("[tauri] System tray created"); 108 135 } 109 136 110 137 // Determine profile based on environment ··· 464 491 commands::profiles::profiles_enable_sync, 465 492 commands::profiles::profiles_disable_sync, 466 493 commands::profiles::profiles_get_sync_config, 494 + // Net fetch (CORS-bypassing proxy) 495 + commands::net_fetch::net_fetch, 496 + // File dialogs 497 + commands::file_dialog::file_save_dialog, 498 + commands::file_dialog::file_open_dialog, 467 499 ]) 468 500 .run(tauri::generate_context!()) 469 501 .expect("error while running tauri application");