Skip to main content

brows3r_lib/
lib.rs

1// Clippy lint configuration lives in `Cargo.toml` under `[lints.clippy]` —
2// single source of truth, also recognised by `cargo clippy --workspace`.
3
4pub mod bookmarks;
5pub mod cache;
6pub mod commands;
7pub mod diagnostics;
8pub mod diff;
9pub mod error;
10pub mod events;
11pub mod ids;
12pub mod locks;
13pub mod media_server;
14pub mod menus;
15pub mod notifications;
16pub mod path;
17pub mod profiles;
18pub mod s3;
19pub mod search;
20pub mod settings;
21pub mod transfers;
22pub mod updater;
23
24use std::path::PathBuf;
25use std::sync::Arc;
26
27use bookmarks::{
28    BookmarkStore, BookmarkStoreHandle, BookmarkStoreState, RecentsHandle, RecentsStore,
29};
30use cache::{store::CacheHandle, CacheConfig, CapabilityHandle};
31use diagnostics::DiagnosticsRedactorHandle;
32use diff::DiffStoreHandle;
33use locks::{lifecycle, LockRegistryHandle, ReleaseReason};
34use media_server::{start_on_localhost, TokenRegistry, TokenRegistryHandle};
35use notifications::NotificationLogHandle;
36use profiles::{KeychainHandle, ProfileStore, ProfileStoreHandle};
37use s3::cross_account::ConfirmationCacheHandle;
38use s3::multipart::{MultipartTable, MultipartTableHandle};
39use s3::{ClientPool, ProxyConfig, S3ClientPoolHandle};
40use search::SearchRegistryHandle;
41use settings::{Settings, SettingsHandle};
42use tauri::{Emitter, Manager};
43use tokio::sync::RwLock;
44use transfers::{TransferQueue, TransferQueueHandle};
45
46// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
47#[tauri::command]
48fn greet(name: &str) -> String {
49    format!("Hello, {}! You've been greeted from Rust!", name)
50}
51
52/// Resolve `${app_config_dir}/settings.json` from the Tauri app handle.
53///
54/// Falls back to a temp-dir path in tests / non-standard environments so
55/// the app does not panic on startup.
56fn settings_path(app: &tauri::App) -> PathBuf {
57    app.path()
58        .app_config_dir()
59        .unwrap_or_else(|_| std::env::temp_dir())
60        .join("settings.json")
61}
62
63/// Resolve `${app_config_dir}/profiles.json` from the Tauri app handle.
64fn profiles_path(app: &tauri::App) -> PathBuf {
65    app.path()
66        .app_config_dir()
67        .unwrap_or_else(|_| std::env::temp_dir())
68        .join("profiles.json")
69}
70
71/// Resolve `${app_config_dir}/cache.redb` from the Tauri app handle.
72fn cache_db_path(app: &tauri::App) -> PathBuf {
73    app.path()
74        .app_config_dir()
75        .unwrap_or_else(|_| std::env::temp_dir())
76        .join("cache.redb")
77}
78
79/// Resolve `${app_config_dir}/bookmarks.json` from the Tauri app handle.
80fn bookmarks_path(app: &tauri::App) -> PathBuf {
81    app.path()
82        .app_config_dir()
83        .unwrap_or_else(|_| std::env::temp_dir())
84        .join("bookmarks.json")
85}
86
87/// Resolve `${app_config_dir}/recents.json` from the Tauri app handle.
88fn recents_path(app: &tauri::App) -> PathBuf {
89    app.path()
90        .app_config_dir()
91        .unwrap_or_else(|_| std::env::temp_dir())
92        .join("recents.json")
93}
94
95/// Build the `MultipartTableHandle` used during `tauri::Builder::setup`,
96/// guaranteeing a usable table no matter how broken the on-disk state is.
97///
98/// Cascade:
99///   1. Share the SWR cache's redb database (best — single file handle).
100///   2. Open `$TMPDIR/brows3r_multipart_fallback.redb`, auto-recreated on
101///      stale-schema bumps and finally backed by `InMemoryBackend` if all
102///      filesystem paths fail.
103///   3. If `MultipartTable::new` still rejects the database (corrupt
104///      enough that even `begin_write`/`open_table` fails), spin up a
105///      pristine in-memory redb and call `MultipartTable::new` on it.
106///
107/// Step 3 is the load-bearing change vs. the previous `.expect()` chain:
108/// the app keeps starting and the multipart panel surfaces orphans by
109/// scanning S3 directly.
110fn init_multipart_table(cache_handle: &CacheHandle) -> MultipartTableHandle {
111    if let Some(db) = cache_handle.db() {
112        if let Ok(table) = MultipartTable::new(db) {
113            return MultipartTableHandle::new(table);
114        }
115    }
116
117    let fallback_path = std::env::temp_dir().join("brows3r_multipart_fallback.redb");
118    let (fallback_db, _was_in_memory) = cache::store::open_redb_or_in_memory(&fallback_path);
119
120    if let Ok(table) = MultipartTable::new(Arc::new(fallback_db)) {
121        return MultipartTableHandle::new(table);
122    }
123
124    // Final fallback: brand-new in-memory redb that has never seen any prior
125    // state. If `MultipartTable::new` still fails on this, redb itself is
126    // broken — surface loudly via panic so the cause shows up in stderr.
127    let pristine_in_memory = redb::Database::builder()
128        .create_with_backend(redb::backends::InMemoryBackend::new())
129        .expect("pristine in-memory redb cannot fail");
130    let table = MultipartTable::new(Arc::new(pristine_in_memory))
131        .expect("MultipartTable::new must succeed on a pristine in-memory redb");
132    MultipartTableHandle::new(table)
133}
134
135#[cfg_attr(mobile, tauri::mobile_entry_point)]
136pub fn run() {
137    tauri::Builder::default()
138        .plugin(tauri_plugin_opener::init())
139        .plugin(tauri_plugin_fs::init())
140        .plugin(tauri_plugin_dialog::init())
141        .plugin(tauri_plugin_shell::init())
142        .plugin(tauri_plugin_notification::init())
143        .plugin(tauri_plugin_updater::Builder::new().build())
144        .setup(|app| {
145            let path = settings_path(app);
146            // Use the sync loader — the setup callback is synchronous and the
147            // file is small, so a blocking read is acceptable here.
148            let settings = Settings::load_sync(&path);
149
150            // Derive values from settings before moving it into the handle.
151            // This avoids locking the async Mutex from within the synchronous
152            // setup callback (which would panic inside a Tokio runtime).
153            let cache_config = CacheConfig {
154                default_ttl_secs: settings.cache_ttl_secs,
155                ..CacheConfig::default()
156            };
157            let transfer_concurrency = settings.transfer_concurrency;
158
159            app.manage(SettingsHandle::new(settings, path));
160            app.manage(NotificationLogHandle::default());
161
162            // Diagnostics redactor: built once with Full level, reused by all
163            // diagnostics_collect calls.  The Arc makes it cheaply clonable.
164            let redactor = Arc::new(diagnostics::redact::Redactor::new());
165            app.manage::<DiagnosticsRedactorHandle>(redactor);
166
167            // Profile store: load manual profiles from disk; errors are
168            // non-fatal (store starts empty and the user can re-add profiles).
169            let profiles_path = profiles_path(app);
170            // Derive keychain fallback dir before moving profiles_path.
171            let keychain_dir = profiles_path
172                .parent()
173                .map(|p| p.to_path_buf())
174                .unwrap_or_else(std::env::temp_dir);
175            // Three-tier fallback so a corrupt/unreadable profiles.json
176            // never aborts startup: primary path → temp-dir copy → empty
177            // store anchored at the temp-dir path. The empty-store branch
178            // is genuinely safe — the user sees the "No profiles yet"
179            // empty state and can re-add profiles; their
180            // `~/.aws/{credentials,config}` are still discovered live by
181            // `ProfileStore::list` each call.
182            let temp_profiles_path = std::env::temp_dir().join("profiles.json");
183            let store = ProfileStore::load(&profiles_path)
184                .or_else(|_| ProfileStore::load(&temp_profiles_path))
185                .unwrap_or_else(|_| {
186                    // Last resort: empty store backed by the temp path. Any
187                    // mutating commands the user runs in this session will
188                    // try to flush to /tmp; if even that fails the flush
189                    // error surfaces via the existing AppError pipeline.
190                    eprintln!(
191                        "profile store failed to load from {} and {} — starting empty",
192                        profiles_path.display(),
193                        temp_profiles_path.display(),
194                    );
195                    ProfileStore::load(&temp_profiles_path)
196                        .unwrap_or_else(|_| ProfileStore::empty(temp_profiles_path.clone()))
197                });
198            // Snapshot every known profile's (id, compat_flags) so we can
199            // pre-register them with the S3 client pool below. Without this,
200            // a profile validated in a previous session has validated_at
201            // persisted (so the UI gate is open) but the pool has no entry
202            // for it, and the next buckets_list fails with pool_miss.
203            let initial_profiles: Vec<(ids::ProfileId, profiles::CompatFlags)> = store
204                .list()
205                .into_iter()
206                .map(|p| (p.id.clone(), p.compat_flags.clone()))
207                .collect();
208            app.manage(ProfileStoreHandle::new(store));
209
210            // Keychain: select best available backend at runtime.
211            // When the OS keychain is unavailable, select_backend falls back
212            // to the encrypted-file backend with a placeholder passphrase.
213            // The user-facing follow-up is the KeychainFallbackPrompt which
214            // collects a real passphrase via `keychain_fallback_unlock`.
215            let (backend, used_fallback) = profiles::keychain::select_backend(keychain_dir, "");
216            app.manage(KeychainHandle::from_box(backend));
217
218            if used_fallback {
219                // Emit on a short delay so the frontend's KeychainFallbackPrompt
220                // listener has time to mount before the event is published.
221                // Without the delay the event fires before App.tsx attaches its
222                // `listen("keychain:fallback-required", …)` handler and is lost.
223                let app_for_emit = app.app_handle().clone();
224                tauri::async_runtime::spawn(async move {
225                    tokio::time::sleep(std::time::Duration::from_secs(2)).await;
226                    let _ =
227                        app_for_emit.emit(events::EventKind::KeychainFallbackRequired.as_str(), ());
228                });
229            }
230
231            // S3 client pool: shared across all commands; uses system proxy by default.
232            let pool = ClientPool::new(ProxyConfig::System);
233            // Pre-register every known profile so the pool can build clients
234            // without waiting on profile_validate (which is the only other
235            // place that calls register_profile).
236            tauri::async_runtime::block_on(async {
237                for (profile_id, compat) in initial_profiles {
238                    pool.register_profile(profile_id, compat).await;
239                }
240            });
241            let pool_handle = S3ClientPoolHandle::new(pool);
242            // Clone before managing so the media server can reference the pool.
243            let pool_handle_for_media = pool_handle.clone();
244            app.manage(pool_handle);
245
246            // Authoritative SWR cache: redb-backed, opened at cache.redb.
247            // Falls back to an in-memory-only store if the file cannot be opened
248            // (e.g. read-only filesystem in sandboxed environments).
249            let cache_path = cache_db_path(app);
250            let cache_handle: CacheHandle =
251                cache::store::CacheStore::open(&cache_path, cache_config).unwrap_or_else(|_| {
252                    cache::store::CacheStore::in_memory(CacheConfig::default())
253                });
254
255            // Multipart bookkeeping table: shares the same redb Database as the
256            // SWR cache to avoid holding two file handles on cache.redb.
257            // Falls back to a temp-file redb (auto-recreated on stale schema
258            // via `open_redb_or_in_memory`) and ultimately to a fully
259            // in-memory redb so this branch never panics during startup.
260            // Worst case: this session does not persist multipart bookkeeping
261            // — orphan uploads in S3 can still be reconciled via the
262            // MultipartPanel.
263            let multipart_table_handle = init_multipart_table(&cache_handle);
264            app.manage(multipart_table_handle);
265
266            app.manage(cache_handle);
267
268            // Capability classification cache: in-memory, no disk persistence in v1.
269            app.manage(CapabilityHandle::default());
270
271            // Resource lock registry: in-memory, cleared at startup.
272            let lock_registry = LockRegistryHandle::default();
273            // Clear any locks left over from a prior crash; emit StartupCleanup events.
274            let leftover = lock_registry.inner().startup_cleanup();
275            for lock in &leftover {
276                // Best-effort — the frontend may not be ready yet; ignore errors.
277                let _ = locks::emit_released(app.app_handle(), lock, ReleaseReason::StartupCleanup);
278            }
279            // Start the background TTL scanner (every 150 s ≈ TTL/2 for default 5-min TTL).
280            let heartbeat_interval = std::time::Duration::from_secs(150);
281            lifecycle::start_heartbeat_loop_handle(
282                &lock_registry,
283                heartbeat_interval,
284                std::sync::Arc::new(app.app_handle().clone()),
285            );
286            app.manage(lock_registry);
287
288            // Transfer queue: wraps the registry with a concurrency cap from settings.
289            let queue = TransferQueue::new(transfer_concurrency);
290            app.manage(TransferQueueHandle::new(queue));
291
292            // Cross-account confirmation cache: in-memory, tokens expire after 5 min.
293            app.manage(ConfirmationCacheHandle::default());
294
295            // Diff preview store: in-memory, TTL 5 min per record.
296            app.manage(DiffStoreHandle::default());
297
298            // Search registry: tracks in-flight prefix searches by request_id.
299            app.manage(SearchRegistryHandle::new());
300
301            // Bookmark store: persisted JSON; errors fall back to empty store.
302            let bm_path = bookmarks_path(app);
303            let bm_store = BookmarkStore::load(&bm_path).unwrap_or_default();
304            let bm_handle: BookmarkStoreHandle =
305                Arc::new(RwLock::new(BookmarkStoreState::new(bm_store, bm_path)));
306            app.manage(bm_handle);
307
308            // Recents ring buffer: loaded from disk; at-exit flush is triggered
309            // by `recents_clear` or implicitly when the frontend tracks locations.
310            let rec_path = recents_path(app);
311            let rec_store = RecentsStore::load(rec_path);
312            let rec_handle: RecentsHandle = Arc::new(RwLock::new(rec_store));
313            app.manage(rec_handle);
314
315            // Loopback media server: binds to 127.0.0.1:0 at startup.
316            // A session UUID tags all tokens so they can be swept on exit via
317            // revoke_session.
318            //
319            // Failure here is *not* fatal — features that need media previews
320            // (Gallery/Detail PDF/image previews via `media:get-url`) will
321            // report `AppError::Internal` when the handle is missing. The
322            // browser, transfers, profiles, etc. all still work. Eat the
323            // panic so the window opens; an stderr log captures the cause.
324            let media_session_id = uuid::Uuid::new_v4().to_string();
325            let media_registry: TokenRegistryHandle = std::sync::Arc::new(TokenRegistry::new());
326            match tauri::async_runtime::block_on(start_on_localhost(
327                pool_handle_for_media,
328                media_registry,
329                media_session_id,
330            )) {
331                Ok(media_handle) => {
332                    app.manage(media_handle);
333                }
334                Err(e) => {
335                    eprintln!(
336                        "media server failed to start: {e:?} — media previews disabled this session"
337                    );
338                }
339            }
340
341            // Native menu: build and attach.
342            // Errors here are non-fatal — the app still works without a menu
343            // bar (e.g. in headless CI environments).
344            if let Ok(menu) = menus::build_menu(app.app_handle()) {
345                let _ = app.set_menu(menu);
346            }
347
348            Ok(())
349        })
350        .on_menu_event(|app, event| {
351            // Forward menu item activations as Tauri events so the frontend
352            // command bridge can dispatch them to the registry.
353            // The event id is already namespaced as "menu:<command-id>".
354            let _ = app.emit(event.id().0.as_str(), ());
355        })
356        .invoke_handler(tauri::generate_handler![
357            greet,
358            commands::settings_cmd::settings_get,
359            commands::settings_cmd::settings_update,
360            commands::notifications_cmd::notifications_list,
361            commands::notifications_cmd::notification_dismiss,
362            commands::profiles_cmd::profiles_list,
363            commands::profiles_cmd::profile_get,
364            commands::profiles_cmd::profile_create_manual,
365            commands::profiles_cmd::profile_update,
366            commands::profiles_cmd::profile_delete,
367            commands::profiles_cmd::profile_validate,
368            commands::profiles_cmd::keychain_fallback_unlock,
369            commands::inspector_cmd::bucket_inspect,
370            commands::inspector_cmd::object_inspect,
371            commands::inspector_cmd::object_head,
372            commands::inspector_cmd::capability_get,
373            commands::inspector_cmd::capability_clear,
374            commands::locks_cmd::locks_list,
375            commands::locks_cmd::lock_release_stale,
376            commands::buckets_cmd::buckets_list,
377            commands::buckets_cmd::bucket_region_get,
378            commands::objects_cmd::objects_list,
379            commands::objects_cmd::objects_list_flat,
380            commands::objects_cmd::object_copy,
381            commands::objects_cmd::object_move,
382            commands::objects_cmd::object_create_folder,
383            commands::objects_cmd::object_delete_batch,
384            commands::objects_cmd::object_set_metadata,
385            commands::objects_cmd::object_set_tags,
386            commands::objects_cmd::object_presign,
387            commands::objects_cmd::cross_account_confirm,
388            commands::transfers_cmd::transfer_download,
389            commands::transfers_cmd::transfer_upload,
390            commands::transfers_cmd::transfer_list,
391            commands::transfers_cmd::transfer_cancel,
392            commands::transfers_cmd::transfer_retry,
393            commands::transfers_cmd::transfer_upload_many,
394            commands::transfers_cmd::transfer_download_many,
395            commands::transfers_cmd::multipart_scan,
396            commands::transfers_cmd::multipart_abort,
397            commands::diff_cmd::diff_preview_create,
398            commands::diff_cmd::diff_preview_cancel,
399            commands::objects_cmd::object_set_storage_class,
400            commands::media_cmd::media_register,
401            commands::media_cmd::media_revoke,
402            commands::objects_cmd::object_get_text,
403            commands::objects_cmd::object_get_bytes,
404            commands::objects_cmd::object_put_text,
405            commands::search_cmd::search_local_filter,
406            commands::search_cmd::search_prefix,
407            commands::search_cmd::search_cancel,
408            commands::bookmarks_cmd::bookmarks_list,
409            commands::bookmarks_cmd::bookmark_add,
410            commands::bookmarks_cmd::bookmark_remove,
411            commands::bookmarks_cmd::bookmark_update,
412            commands::bookmarks_cmd::recents_list,
413            commands::bookmarks_cmd::recent_track,
414            commands::bookmarks_cmd::recents_clear,
415            commands::updater_cmd::updater_check,
416            commands::updater_cmd::updater_install,
417            commands::diagnostics_cmd::diagnostics_collect,
418            commands::diagnostics_cmd::diagnostics_export,
419        ])
420        .run(tauri::generate_context!())
421        .expect("error while running tauri application");
422}
423
424#[cfg(test)]
425mod tests {
426    // These imports exercise name resolution at compile time,
427    // proving each plugin crate is correctly linked.
428    use tauri_plugin_dialog::init as dialog_init;
429    use tauri_plugin_fs::init as fs_init;
430    use tauri_plugin_notification::init as notification_init;
431    use tauri_plugin_shell::init as shell_init;
432
433    #[test]
434    fn plugin_init_symbols_are_reachable() {
435        // We cannot construct a full Tauri app in a unit test, but we can
436        // verify the init symbols resolve at link time. Bind to a concrete
437        // Runtime via turbofish so Rust can infer the generic.
438        type R = tauri::Wry;
439        let _ = fs_init::<R>;
440        let _ = dialog_init::<R>;
441        let _ = shell_init::<R>;
442        let _ = notification_init::<R>;
443    }
444
445    #[test]
446    fn updater_plugin_builder_is_constructible() {
447        // Verify the tauri-plugin-updater crate is correctly linked.
448        // We cannot call `.build()` without a Tauri runtime, but constructing
449        // the builder proves the dep resolves at link time.
450        let _builder = tauri_plugin_updater::Builder::new();
451    }
452}