//! `pdfs-app` — GTK4 / libadwaita desktop window for the Proton Drive Linux //! client. //! //! Slice 2 of the GUI phase: the settings/management window that complements the //! [`pdfs-tray`](crate) SNI icon. It presents four things in a Proton-purple, //! Google-Drive-style layout: //! //! * a **login form** (email / password / optional 2FA) driving //! [`pdfs_core::auth::login`]; logging in enables the systemd mount service; //! * a **read-only mount status** line (the mount is managed automatically); //! * a **cache usage** read-out from the on-disk [`ContentCache`]; and //! * the **pin list** with per-file unpin. //! //! State the GUI only displays (cache size, pins, login identity) is read //! straight off disk via `pdfs-core`, so the window is useful even with no mount //! daemon running; live mount status comes from the daemon over the control //! socket the CLI and tray already speak. use std::cell::{Cell, RefCell}; use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::process::Command; use std::rc::Rc; use std::time::Duration; use adw::prelude::*; use gtk4::gio; use gtk4::glib; use gtk4::glib::BoxedAnyObject; use pdfs_core::auth; use pdfs_core::cache::ContentCache; use pdfs_core::config::{AppDirs, DEFAULT_CACHE_BUDGET_BYTES}; use pdfs_core::control::{DirEntry, PhotoItem, Request, Response, send}; use pdfs_core::service; /// How many photos to pull per [`Request::PhotosTimeline`] page. const PHOTOS_PAGE: usize = 60; const APP_ID: &str = "io.narl.proton-drive-linux"; /// Proton brand purple, applied as the libadwaita accent so switches, buttons, /// links and the storage bar all pick it up. const PROTON_PURPLE: &str = "#6d4aff"; /// How often the window re-reads mount status, cache usage and the pin list. const REFRESH_INTERVAL: Duration = Duration::from_secs(2); /// Backoff between auto-retries of a Files/Photos load while the mount service /// is still coming up (see [`load_browser`] / [`load_gallery`]). const CONNECT_RETRY_INTERVAL: Duration = Duration::from_secs(2); /// All widgets the periodic refresh and the action handlers mutate, plus the /// resolved paths they act on. Wrapped in an [`Rc`] so handlers and the timeout /// closure share one instance. struct Ui { dirs: AppDirs, stack: adw::ViewStack, /// Header spinner shown while any open/load round-trip is in flight; ref- /// counted via [`Self::busy`] so concurrent operations don't stop it early. spinner: gtk4::Spinner, busy: Cell, /// Keys (relative path / photo uid) of open requests currently in flight, so /// a double-click on the same entry is a no-op instead of a second download. opening: RefCell>, // Login page. email: adw::EntryRow, password: adw::PasswordEntryRow, totp: adw::EntryRow, login_button: gtk4::Button, login_status: gtk4::Label, // Main page. account_row: adw::ActionRow, /// Read-only mount status line. The mount is driven by the systemd user /// service (enabled on login), not by the user — so this only reports. mount_row: adw::ActionRow, cache_bar: gtk4::ProgressBar, cache_label: gtk4::Label, pins_group: adw::PreferencesGroup, /// Rows currently shown under [`Self::pins_group`], so a refresh can clear /// the previous batch before re-adding. pin_rows: RefCell>, /// Whether the last refresh saw a live mount daemon. Gates the unpin /// buttons, which need the daemon to evict + re-hydrate. mounted: RefCell, /// Bottom switcher between the Account / Files / Photos pages; hidden on the /// login page so the user can't jump to pages that need a session. switcher: adw::ViewSwitcherBar, // Files (browser) page. /// Shared model behind the grid and column views; repopulated per directory. browser_model: gio::ListStore, browser_back: gtk4::Button, browser_crumb: gtk4::Label, browser_status: gtk4::Label, /// Shown beside [`Self::browser_status`] when a load failed because the mount /// service is down (not merely starting); restarts the service and reloads. browser_retry: gtk4::Button, /// Mountpoint-relative path the browser is showing (empty = root). browser_path: RefCell, // Photos (gallery) page. gallery_model: gio::ListStore, gallery_status: gtk4::Label, gallery_retry: gtk4::Button, gallery_more: gtk4::Button, } impl Ui { /// Begin a unit of background work: show + spin the header spinner. fn busy_begin(&self) { self.busy.set(self.busy.get() + 1); self.spinner.set_visible(true); self.spinner.start(); } /// End a unit of background work: stop the spinner once the last one is done. fn busy_end(&self) { let remaining = self.busy.get().saturating_sub(1); self.busy.set(remaining); if remaining == 0 { self.spinner.stop(); self.spinner.set_visible(false); } } } fn main() -> glib::ExitCode { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), ) .init(); let app = adw::Application::builder().application_id(APP_ID).build(); app.connect_startup(|_| { load_proton_theme(); spawn_tray(); }); app.connect_activate(build_window); app.run() } /// Spawn the tray icon process in the background. fn spawn_tray() { match Command::new("pdfs-tray").spawn() { Ok(_) => tracing::info!("spawned `pdfs-tray`"), Err(e) => tracing::error!("failed to spawn `pdfs-tray`: {e}"), } } /// Install a CSS provider that overrides libadwaita's accent colour with Proton /// purple, app-wide. Named-colour overrides recolour the stock widgets (switch, /// buttons, progress fill) without per-widget styling. fn load_proton_theme() { let css = format!( "@define-color accent_bg_color {PROTON_PURPLE};\n\ @define-color accent_color {PROTON_PURPLE};\n\ .brand-title {{ font-size: 1.6rem; font-weight: 800; }}\n\ .brand-icon {{ color: {PROTON_PURPLE}; }}\n\ .file-grid {{ padding: 6px; }}\n\ .file-tile {{ padding: 8px; border-radius: 10px; }}\n\ .file-tile:hover {{ background: alpha({PROTON_PURPLE}, 0.10); }}\n" ); let provider = gtk4::CssProvider::new(); provider.load_from_string(&css); if let Some(display) = gtk4::gdk::Display::default() { gtk4::style_context_add_provider_for_display( &display, &provider, gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, ); } } /// Build the application window, wire the two pages, kick off the refresh loop, /// and present it. fn build_window(app: &adw::Application) { let dirs = match AppDirs::new() { Ok(d) => d, Err(e) => { tracing::error!("cannot resolve app dirs: {e}"); return; } }; let stack = adw::ViewStack::new(); let (login_page, login_widgets) = build_login_page(); let (main_page, main_widgets) = build_main_page(); let (browser_page, browser_widgets) = build_browser_page(); let (gallery_page, gallery_widgets) = build_gallery_page(); // Login has no title, so it never appears in the switcher; the three signed-in // pages are titled + iconed so the bottom switcher lists them. stack.add_named(&login_page, Some("login")); stack.add_titled_with_icon( &main_page, Some("main"), "Account", "avatar-default-symbolic", ); stack.add_titled_with_icon(&browser_page, Some("browser"), "Files", "folder-symbolic"); stack.add_titled_with_icon( &gallery_page, Some("gallery"), "Photos", "image-x-generic-symbolic", ); let switcher = adw::ViewSwitcherBar::builder().stack(&stack).build(); // Header spinner, hidden until a background open/load is in flight. let spinner = gtk4::Spinner::new(); spinner.set_visible(false); let ui = Rc::new(Ui { dirs, stack: stack.clone(), spinner: spinner.clone(), busy: Cell::new(0), opening: RefCell::new(HashSet::new()), email: login_widgets.0, password: login_widgets.1, totp: login_widgets.2, login_button: login_widgets.3, login_status: login_widgets.4, account_row: main_widgets.0, mount_row: main_widgets.1, cache_bar: main_widgets.2, cache_label: main_widgets.3, pins_group: main_widgets.4, pin_rows: RefCell::new(Vec::new()), mounted: RefCell::new(false), switcher: switcher.clone(), browser_model: browser_widgets.0, browser_back: browser_widgets.1, browser_crumb: browser_widgets.2, browser_status: browser_widgets.3, browser_retry: browser_widgets.6, browser_path: RefCell::new(String::new()), gallery_model: gallery_widgets.0, gallery_status: gallery_widgets.1, gallery_retry: gallery_widgets.4, gallery_more: gallery_widgets.2, }); wire_login(&ui); wire_logout(&ui, &main_widgets.5); wire_browser(&ui, &browser_widgets.4, &browser_widgets.5); wire_gallery(&ui, &gallery_widgets.3); wire_retry(&ui); // Lazily load the Files / Photos pages the first time they're shown, so the // network round-trip only happens on demand rather than on every refresh. let ui_nav = ui.clone(); stack.connect_visible_child_name_notify(move |st| match st.visible_child_name().as_deref() { Some("browser") => load_browser(&ui_nav), Some("gallery") => load_gallery(&ui_nav, false), _ => {} }); // Header with a Proton-branded title; the content is the page stack inside a // clamp so it stays comfortably narrow on a wide window, Google-Drive style. let header = adw::HeaderBar::new(); let brand = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); let icon = gtk4::Image::from_icon_name("folder-remote-symbolic"); icon.add_css_class("brand-icon"); brand.append(&icon); brand.append(>k4::Label::new(Some("Proton Drive"))); header.set_title_widget(Some(&brand)); header.pack_end(&spinner); let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&header); toolbar.set_content(Some(&stack)); toolbar.add_bottom_bar(&switcher); let window = adw::ApplicationWindow::builder() .application(app) .title("Proton Drive") .default_width(760) .default_height(620) .content(&toolbar) .build(); refresh(&ui); // Periodic refresh while the window lives. The closure holds a strong `Rc`; // it is dropped when the source is removed on window close. let ui_tick = ui.clone(); let source = glib::timeout_add_local(REFRESH_INTERVAL, move || { refresh(&ui_tick); glib::ControlFlow::Continue }); let cell = RefCell::new(Some(source)); window.connect_close_request(move |_| { if let Some(id) = cell.borrow_mut().take() { id.remove(); } glib::Propagation::Proceed }); window.present(); } /// The login page: an email row, password row, optional 2FA row, a primary /// "Sign in" button and a status label, centred in a clamp. #[allow(clippy::type_complexity)] fn build_login_page() -> ( gtk4::Widget, ( adw::EntryRow, adw::PasswordEntryRow, adw::EntryRow, gtk4::Button, gtk4::Label, ), ) { let group = adw::PreferencesGroup::builder() .title("Sign in to Proton") .description("Use your Proton account to connect Drive.") .build(); let email = adw::EntryRow::builder().title("Email or username").build(); let password = adw::PasswordEntryRow::builder().title("Password").build(); let totp = adw::EntryRow::builder() .title("Two-factor code (if enabled)") .build(); group.add(&email); group.add(&password); group.add(&totp); let login_button = gtk4::Button::builder() .label("Sign in") .halign(gtk4::Align::Center) .build(); login_button.add_css_class("suggested-action"); login_button.add_css_class("pill"); let login_status = gtk4::Label::builder() .wrap(true) .justify(gtk4::Justification::Center) .build(); login_status.add_css_class("dim-label"); let header = gtk4::Box::new(gtk4::Orientation::Vertical, 4); let logo = gtk4::Image::from_icon_name("folder-remote-symbolic"); logo.set_pixel_size(64); logo.add_css_class("brand-icon"); let title = gtk4::Label::new(Some("Proton Drive")); title.add_css_class("brand-title"); header.append(&logo); header.append(&title); header.set_margin_bottom(12); let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 16); inner.set_margin_top(32); inner.set_margin_bottom(32); inner.set_margin_start(12); inner.set_margin_end(12); inner.append(&header); inner.append(&group); inner.append(&login_button); inner.append(&login_status); let clamp = adw::Clamp::builder() .maximum_size(420) .child(&inner) .build(); let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build(); ( scroll.upcast(), (email, password, totp, login_button, login_status), ) } /// The main (logged-in) page: account header, mount toggle, storage usage and /// the pin list. Returns the widgets the refresh loop updates plus the logout /// button to wire. #[allow(clippy::type_complexity)] fn build_main_page() -> ( gtk4::Widget, ( adw::ActionRow, adw::ActionRow, gtk4::ProgressBar, gtk4::Label, adw::PreferencesGroup, gtk4::Button, ), ) { // Account group: identity + sign-out. let account_group = adw::PreferencesGroup::new(); let account_row = adw::ActionRow::builder().title("Not signed in").build(); let avatar = adw::Avatar::new(40, None, true); account_row.add_prefix(&avatar); let logout_button = gtk4::Button::builder() .label("Sign out") .valign(gtk4::Align::Center) .build(); logout_button.add_css_class("flat"); account_row.add_suffix(&logout_button); account_group.add(&account_row); // Mount group: a read-only status line. The mount is managed automatically // by the systemd user service; there is no toggle to fiddle with. let mount_group = adw::PreferencesGroup::builder().title("Drive").build(); let mount_row = adw::ActionRow::builder() .title("Proton Drive") .subtitle("Not mounted") .build(); mount_group.add(&mount_row); // Storage group: a progress bar + "X of Y used" label. let storage_group = adw::PreferencesGroup::builder() .title("Storage") .description("Local cache for pinned and recently opened files.") .build(); let storage_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6); storage_box.set_margin_top(6); storage_box.set_margin_bottom(6); let cache_bar = gtk4::ProgressBar::new(); let cache_label = gtk4::Label::builder().halign(gtk4::Align::Start).build(); cache_label.add_css_class("dim-label"); storage_box.append(&cache_bar); storage_box.append(&cache_label); storage_group.add(&storage_box); // Pins group: filled in by refresh. let pins_group = adw::PreferencesGroup::builder() .title("Pinned files") .description("Kept available offline on this device.") .build(); let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 18); inner.set_margin_top(18); inner.set_margin_bottom(18); inner.set_margin_start(12); inner.set_margin_end(12); inner.append(&account_group); inner.append(&mount_group); inner.append(&storage_group); inner.append(&pins_group); let clamp = adw::Clamp::builder() .maximum_size(560) .child(&inner) .build(); let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build(); ( scroll.upcast(), ( account_row, mount_row, cache_bar, cache_label, pins_group, logout_button, ), ) } /// Connect the sign-in button: read the fields, run [`auth::login`] on a worker /// thread, and report the outcome back on the main loop. fn wire_login(ui: &Rc) { let ui = ui.clone(); let button = ui.login_button.clone(); button.connect_clicked(move |_| { let username = ui.email.text().to_string(); let password = ui.password.text().to_string(); let totp = ui.totp.text().to_string(); if username.is_empty() || password.is_empty() { ui.login_status.set_text("Enter your email and password."); return; } ui.login_button.set_sensitive(false); ui.login_status.set_text("Signing in…"); let rx = spawn_login(username, password, totp); let ui = ui.clone(); glib::spawn_future_local(async move { let result = rx .recv() .await .unwrap_or_else(|_| Err("login cancelled".into())); ui.login_button.set_sensitive(true); match result { Ok(()) => { ui.login_status.set_text(""); ui.password.set_text(""); ui.totp.set_text(""); // Enable+start the mount service now that we have a session. service::enable_start(); refresh(&ui); } Err(e) => ui.login_status.set_text(&format!("Sign-in failed: {e}")), } }); }); } /// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio /// runtime, returning a channel that yields the result once. The TOTP closure /// supplies whatever was typed in the 2FA field, erroring if the account needs a /// code but none was entered. fn spawn_login( username: String, password: String, totp: String, ) -> async_channel::Receiver> { let (tx, rx) = async_channel::bounded(1); std::thread::spawn(move || { let rt = match tokio::runtime::Builder::new_current_thread() .enable_all() .build() { Ok(rt) => rt, Err(e) => { let _ = tx.send_blocking(Err(e.to_string())); return; } }; let result = rt.block_on(async move { auth::login(&username, &password, || { let code = totp.trim(); if code.is_empty() { Err(pdfs_core::Error::Other("two-factor code required".into())) } else { Ok(code.to_string()) } }) .await .map_err(|e| e.to_string()) }); let _ = tx.send_blocking(result); }); rx } /// Connect the sign-out button: disable+stop the mount service (so the daemon /// isn't left running without credentials), forget the stored session, and drop /// back to the login page. fn wire_logout(ui: &Rc, button: >k4::Button) { let ui = ui.clone(); button.connect_clicked(move |_| { service::disable_stop(); if let Err(e) = auth::logout() { tracing::error!("logout failed: {e}"); } refresh(&ui); }); } /// Connect the Files/Photos "Retry" buttons (shown by [`browser_unreachable`] / /// [`gallery_unreachable`] when the mount is down): restart the systemd unit and /// reload the page. fn wire_retry(ui: &Rc) { let ui_browser = ui.clone(); ui.browser_retry.clone().connect_clicked(move |_| { service::restart(); load_browser(&ui_browser); }); let ui_gallery = ui.clone(); ui.gallery_retry.clone().connect_clicked(move |_| { service::restart(); load_gallery(&ui_gallery, false); }); } /// Re-read mount status, login identity, cache usage and the pin list, and /// repaint the window. Cheap, synchronous local reads — safe on the main loop. fn refresh(ui: &Rc) { // Login identity decides which page is shown. let session = auth::load().ok(); match &session { Some(s) => { // Only pull the user onto "main" when they're sitting on the login // page; otherwise leave whichever signed-in page they navigated to. if ui.stack.visible_child_name().as_deref() == Some("login") { ui.stack.set_visible_child_name("main"); } ui.switcher.set_reveal(true); ui.account_row.set_title(&s.username); ui.account_row.set_subtitle("Proton account"); } None => { ui.stack.set_visible_child_name("login"); ui.switcher.set_reveal(false); return; } } // Live mount status from the daemon, falling back to "not mounted". let socket = ui.dirs.control_socket(); let mount = match send(&socket, &Request::Status) { Ok(Response::Status { mountpoint, .. }) => Some(mountpoint), _ => None, }; *ui.mounted.borrow_mut() = mount.is_some(); // Read-only status: the service mounts automatically, we only report. ui.mount_row.set_subtitle(&match &mount { Some(mp) => format!("Mounted at {mp}"), None => "Not mounted".into(), }); // Cache usage + pins, read straight off disk so they work offline too. let cache = ContentCache::open( ui.dirs.content_cache_dir(), ui.dirs.pins_path(), DEFAULT_CACHE_BUDGET_BYTES, ); match cache { Ok(cache) => { let used = cache.usage(); let budget = cache.budget(); let fraction = if budget == 0 { 0.0 } else { (used as f64 / budget as f64).min(1.0) }; ui.cache_bar.set_fraction(fraction); ui.cache_label.set_text(&format!( "{} of {} used", human_bytes(used), human_bytes(budget) )); repaint_pins(ui, &cache.list_pins()); } Err(e) => ui.cache_label.set_text(&format!("Cache unavailable: {e}")), } } /// Replace the rows under the pins group with one [`adw::ActionRow`] per pin, /// each with an unpin button enabled only while a mount daemon is running. fn repaint_pins(ui: &Rc, pins: &[pdfs_core::cache::Pin]) { for row in ui.pin_rows.borrow_mut().drain(..) { ui.pins_group.remove(&row); } if pins.is_empty() { let row = adw::ActionRow::builder() .title("No pinned files") .subtitle("Right-click a file in the mount to keep it offline.") .build(); ui.pins_group.add(&row); ui.pin_rows.borrow_mut().push(row); return; } let mounted = *ui.mounted.borrow(); for pin in pins { let name = Path::new(&pin.path) .file_name() .and_then(|n| n.to_str()) .unwrap_or(&pin.path) .to_string(); let row = adw::ActionRow::builder() .title(&name) .subtitle(&pin.path) .build(); let icon = gtk4::Image::from_icon_name("emblem-documents-symbolic"); row.add_prefix(&icon); let unpin = gtk4::Button::builder() .icon_name("user-trash-symbolic") .valign(gtk4::Align::Center) .tooltip_text("Unpin (remove offline copy)") .sensitive(mounted) .build(); unpin.add_css_class("flat"); let ui_btn = ui.clone(); let path = pin.path.clone(); unpin.connect_clicked(move |_| { let socket = ui_btn.dirs.control_socket(); match send(&socket, &Request::Unpin { path: path.clone() }) { Ok(Response::Error { message }) => tracing::error!("unpin failed: {message}"), Ok(_) => refresh(&ui_btn), Err(e) => tracing::error!("unpin request failed: {e}"), } }); row.add_suffix(&unpin); ui.pins_group.add(&row); ui.pin_rows.borrow_mut().push(row); } } /// Run one blocking control-socket round-trip on a worker thread, returning a /// channel that yields the [`Response`] once. Browser/gallery requests reach the /// network through the daemon, so they must not block the GTK main loop the way /// the cheap [`Request::Status`] poll in [`refresh`] can. fn spawn_request( socket: PathBuf, req: Request, ) -> async_channel::Receiver> { let (tx, rx) = async_channel::bounded(1); std::thread::spawn(move || { let result = send(&socket, &req).map_err(|e| e.to_string()); let _ = tx.send_blocking(result); }); rx } /// The Files page: a Nautilus-style file manager. A back/breadcrumb header with /// a grid/list view toggle sits over a [`gtk4::Stack`] that swaps between an /// **icon grid** ([`gtk4::GridView`]) and a **column list** ([`gtk4::ColumnView`] /// with Name / Size / Modified columns). Both views are driven by one shared /// [`gio::ListStore`] of [`BoxedAnyObject`]-wrapped [`DirEntry`]s, so a directory /// load repopulates the model once and both views update. /// /// The factories that render entries — and the columns — need the [`Ui`] handle /// for activation and the right-click menu, so they're installed later in /// [`wire_browser`]; this builder only assembles the empty widgets. #[allow(clippy::type_complexity)] fn build_browser_page() -> ( gtk4::Widget, ( gio::ListStore, gtk4::Button, gtk4::Label, gtk4::Label, gtk4::GridView, gtk4::ColumnView, gtk4::Button, ), ) { let model = gio::ListStore::new::(); let back = gtk4::Button::builder() .icon_name("go-previous-symbolic") .tooltip_text("Up one folder") .valign(gtk4::Align::Center) .sensitive(false) .build(); back.add_css_class("flat"); let crumb = gtk4::Label::builder() .label("Proton Drive") .halign(gtk4::Align::Start) .hexpand(true) .ellipsize(gtk4::pango::EllipsizeMode::Start) .build(); crumb.add_css_class("heading"); // Linked grid/list toggle, top-right, Nautilus-style. let grid_toggle = gtk4::ToggleButton::builder() .icon_name("view-grid-symbolic") .tooltip_text("Grid view") .active(true) .build(); let list_toggle = gtk4::ToggleButton::builder() .icon_name("view-list-symbolic") .tooltip_text("List view") .build(); list_toggle.set_group(Some(&grid_toggle)); let toggles = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); toggles.add_css_class("linked"); toggles.append(&grid_toggle); toggles.append(&list_toggle); let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); header.append(&back); header.append(&crumb); header.append(&toggles); let status = gtk4::Label::builder().wrap(true).build(); status.add_css_class("dim-label"); status.set_visible(false); // Shown only when a load failed because the mount is down; restarts it. let retry = gtk4::Button::builder() .label("Retry") .halign(gtk4::Align::Start) .build(); retry.add_css_class("pill"); retry.set_visible(false); let status_box = gtk4::Box::new(gtk4::Orientation::Vertical, 8); status_box.append(&status); status_box.append(&retry); // Icon grid. let grid = gtk4::GridView::builder() .model(>k4::SingleSelection::new(Some(model.clone()))) .min_columns(2) .max_columns(10) .build(); grid.add_css_class("file-grid"); let grid_scroll = gtk4::ScrolledWindow::builder() .vexpand(true) .child(&grid) .build(); // Column list. let column_view = gtk4::ColumnView::builder() .model(>k4::SingleSelection::new(Some(model.clone()))) .build(); column_view.add_css_class("data-table"); let column_scroll = gtk4::ScrolledWindow::builder() .vexpand(true) .child(&column_view) .build(); // Stack swapped by the toggle buttons. let view_stack = gtk4::Stack::new(); view_stack.set_vexpand(true); view_stack.add_named(&grid_scroll, Some("grid")); view_stack.add_named(&column_scroll, Some("list")); let vs = view_stack.clone(); grid_toggle.connect_toggled(move |b| { if b.is_active() { vs.set_visible_child_name("grid"); } }); let vs = view_stack.clone(); list_toggle.connect_toggled(move |b| { if b.is_active() { vs.set_visible_child_name("list"); } }); let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); inner.set_margin_top(12); inner.set_margin_bottom(12); inner.set_margin_start(12); inner.set_margin_end(12); inner.append(&header); inner.append(&status_box); inner.append(&view_stack); ( inner.upcast(), (model, back, crumb, status, grid, column_view, retry), ) } /// Install the entry factories, columns, activation handlers and the back /// button. Split out from [`build_browser_page`] because every renderer needs /// the [`Ui`] handle to open entries and raise the context menu. fn wire_browser(ui: &Rc, grid: >k4::GridView, column_view: >k4::ColumnView) { // Back: pop one path segment and reload. let ui_back = ui.clone(); ui.browser_back.clone().connect_clicked(move |_| { { let mut path = ui_back.browser_path.borrow_mut(); *path = match path.rfind('/') { Some(i) => path[..i].to_string(), None => String::new(), }; } load_browser(&ui_back); }); // Grid tiles: a big icon over an ellipsized name, with a right-click menu. let factory = gtk4::SignalListItemFactory::new(); factory.connect_setup({ let ui = ui.clone(); move |_, item| { let item = item.downcast_ref::().unwrap(); let icon = gtk4::Image::builder().pixel_size(64).build(); let label = gtk4::Label::builder() .ellipsize(gtk4::pango::EllipsizeMode::End) .justify(gtk4::Justification::Center) .max_width_chars(13) .width_chars(13) .wrap(true) .lines(2) .build(); let tile = gtk4::Box::new(gtk4::Orientation::Vertical, 4); tile.add_css_class("file-tile"); tile.append(&icon); tile.append(&label); attach_context_menu(&ui, item, &tile); item.set_child(Some(&tile)); } }); factory.connect_bind(|_, item| { let item = item.downcast_ref::().unwrap(); let tile = item.child().and_downcast::().unwrap(); let icon = tile.first_child().and_downcast::().unwrap(); let label = tile.last_child().and_downcast::().unwrap(); let obj = item.item().and_downcast::().unwrap(); let entry = obj.borrow::(); icon.set_icon_name(Some(icon_base_for(&entry))); label.set_label(&display_name(&entry)); }); grid.set_factory(Some(&factory)); let ui_grid = ui.clone(); grid.connect_activate(move |grid, pos| { if let Some(entry) = entry_at(grid.model().as_ref(), pos) { activate_entry(&ui_grid, &entry); } }); // Column list: Name (icon + label, right-clickable), Size, Modified. column_view.append_column(&name_column(ui)); column_view.append_column(&text_column("Size", |e| { if e.is_dir { "—".to_string() } else { human_bytes(e.size) } })); column_view.append_column(&text_column("Modified", |e| format_modified(e.modified))); let ui_col = ui.clone(); column_view.connect_activate(move |view, pos| { if let Some(entry) = entry_at(view.model().as_ref(), pos) { activate_entry(&ui_col, &entry); } }); } /// Build the Name column: a small icon plus the (star-prefixed when pinned) name, /// with the same right-click menu the grid tiles carry. fn name_column(ui: &Rc) -> gtk4::ColumnViewColumn { let factory = gtk4::SignalListItemFactory::new(); factory.connect_setup({ let ui = ui.clone(); move |_, item| { let item = item.downcast_ref::().unwrap(); let icon = gtk4::Image::builder().pixel_size(16).build(); let label = gtk4::Label::builder().halign(gtk4::Align::Start).build(); let cell = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); cell.append(&icon); cell.append(&label); attach_context_menu(&ui, item, &cell); item.set_child(Some(&cell)); } }); factory.connect_bind(|_, item| { let item = item.downcast_ref::().unwrap(); let cell = item.child().and_downcast::().unwrap(); let icon = cell.first_child().and_downcast::().unwrap(); let label = cell.last_child().and_downcast::().unwrap(); let obj = item.item().and_downcast::().unwrap(); let entry = obj.borrow::(); icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry)))); label.set_label(&display_name(&entry)); }); let column = gtk4::ColumnViewColumn::new(Some("Name"), Some(factory)); column.set_expand(true); column } /// Build a trailing text column whose cell text is derived from each [`DirEntry`] /// by `render`. fn text_column( title: &str, render: impl Fn(&DirEntry) -> String + 'static, ) -> gtk4::ColumnViewColumn { let factory = gtk4::SignalListItemFactory::new(); factory.connect_setup(|_, item| { let item = item.downcast_ref::().unwrap(); let label = gtk4::Label::builder().halign(gtk4::Align::Start).build(); label.add_css_class("dim-label"); item.set_child(Some(&label)); }); factory.connect_bind(move |_, item| { let item = item.downcast_ref::().unwrap(); let label = item.child().and_downcast::().unwrap(); let obj = item.item().and_downcast::().unwrap(); let entry = obj.borrow::(); label.set_label(&render(&entry)); }); gtk4::ColumnViewColumn::new(Some(title), Some(factory)) } /// Attach a secondary-button [`gtk4::GestureClick`] to a cell that pops a context /// menu for whatever entry the owning `item` is currently bound to. Capturing the /// [`gtk4::ListItem`] (rather than a snapshot of the entry) keeps the menu correct /// as the view recycles cells while scrolling. fn attach_context_menu(ui: &Rc, item: >k4::ListItem, anchor: >k4::Box) { let gesture = gtk4::GestureClick::new(); gesture.set_button(gtk4::gdk::BUTTON_SECONDARY); let ui = ui.clone(); let item = item.clone(); let target = anchor.clone(); gesture.connect_pressed(move |_, _, x, y| { if let Some(obj) = item.item().and_downcast::() { let entry = obj.borrow::().clone(); show_context_menu(&ui, &entry, &target, x, y); } }); anchor.add_controller(gesture); } /// Pop a context menu next to `anchor` at the click point, offering Open and a /// Pin/Unpin toggle (files only). Built fresh per click because the items are /// entry-specific; it unparents itself once dismissed. fn show_context_menu(ui: &Rc, entry: &DirEntry, anchor: >k4::Box, x: f64, y: f64) { let menu = gtk4::Box::new(gtk4::Orientation::Vertical, 0); let popover = gtk4::Popover::builder() .has_arrow(false) .position(gtk4::PositionType::Bottom) .pointing_to(>k4::gdk::Rectangle::new(x as i32, y as i32, 1, 1)) .child(&menu) .build(); popover.set_parent(anchor); popover.connect_closed(|p| p.unparent()); let open = menu_item("Open", "document-open-symbolic"); let ui_open = ui.clone(); let entry_open = entry.clone(); let pop = popover.clone(); open.connect_clicked(move |_| { pop.popdown(); activate_entry(&ui_open, &entry_open); }); menu.append(&open); if !entry.is_dir { let (label, icon) = if entry.pinned { ("Unpin", "non-starred-symbolic") } else { ("Keep offline", "starred-symbolic") }; let pin = menu_item(label, icon); let ui_pin = ui.clone(); let entry_pin = entry.clone(); let pop = popover.clone(); pin.connect_clicked(move |_| { pop.popdown(); toggle_pin(&ui_pin, &entry_pin); }); menu.append(&pin); } popover.popup(); } /// A left-aligned, flat icon+label button for the context menu. fn menu_item(label: &str, icon: &str) -> gtk4::Button { let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); row.append(>k4::Image::from_icon_name(icon)); row.append( >k4::Label::builder() .label(label) .halign(gtk4::Align::Start) .hexpand(true) .build(), ); let button = gtk4::Button::builder().child(&row).build(); button.add_css_class("flat"); button } /// Fetch the [`DirEntry`] backing the model item at `pos`, if any. fn entry_at(model: Option<&impl IsA>, pos: u32) -> Option { let obj = model?.item(pos).and_downcast::()?; let entry = obj.borrow::().clone(); Some(entry) } /// Open an entry the Nautilus way: folders descend, files download-and-open. fn activate_entry(ui: &Rc, entry: &DirEntry) { let rel = entry_rel(ui, entry); if entry.is_dir { *ui.browser_path.borrow_mut() = rel; load_browser(ui); } else { // Ignore a repeat activation of a file already downloading, so an // impatient double-click doesn't kick off a second round-trip. if !ui.opening.borrow_mut().insert(rel.clone()) { return; } ui.busy_begin(); let rx = spawn_request( ui.dirs.control_socket(), Request::OpenFile { path: rel.clone() }, ); let ui = ui.clone(); glib::spawn_future_local(async move { let result = rx.recv().await; ui.busy_end(); ui.opening.borrow_mut().remove(&rel); match result { Ok(Ok(Response::FilePath { path })) => open_path(&path), Ok(Ok(Response::Error { message })) => { tracing::error!("open file failed: {message}") } _ => tracing::error!("open file request failed"), } }); } } /// Pin or unpin an entry through the daemon, then reload to reflect the new /// state. fn toggle_pin(ui: &Rc, entry: &DirEntry) { let rel = entry_rel(ui, entry); let req = if entry.pinned { Request::Unpin { path: rel } } else { Request::Pin { path: rel } }; let rx = spawn_request(ui.dirs.control_socket(), req); let ui = ui.clone(); glib::spawn_future_local(async move { match rx.recv().await { Ok(Ok(Response::Error { message })) => tracing::error!("pin toggle failed: {message}"), Ok(Ok(_)) => load_browser(&ui), _ => tracing::error!("pin toggle request failed"), } }); } /// Join the entry name onto the current browser directory to get its /// mountpoint-relative path. fn entry_rel(ui: &Rc, entry: &DirEntry) -> String { let base = ui.browser_path.borrow(); if base.is_empty() { entry.name.clone() } else { format!("{base}/{}", entry.name) } } /// The name shown under a tile / in the Name column, marked with a star when the /// file is kept offline. fn display_name(entry: &DirEntry) -> String { if entry.pinned { format!("★ {}", entry.name) } else { entry.name.clone() } } /// Pick a freedesktop icon base name for an entry from its kind / extension. /// Callers append `-symbolic` for the column view's small icons. fn icon_base_for(entry: &DirEntry) -> &'static str { if entry.is_dir { return "folder"; } let ext = entry .name .rsplit('.') .next() .unwrap_or("") .to_ascii_lowercase(); match ext.as_str() { "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" | "heic" | "tiff" => { "image-x-generic" } "mp4" | "mkv" | "mov" | "avi" | "webm" | "m4v" => "video-x-generic", "mp3" | "flac" | "wav" | "ogg" | "opus" | "m4a" => "audio-x-generic", "pdf" | "doc" | "docx" | "odt" => "x-office-document", "xls" | "xlsx" | "ods" | "csv" => "x-office-spreadsheet", "ppt" | "pptx" | "odp" => "x-office-presentation", "zip" | "tar" | "gz" | "xz" | "bz2" | "7z" | "rar" => "package-x-generic", _ => "text-x-generic", } } /// Format an epoch-seconds modification time as a short local date. fn format_modified(secs: i64) -> String { match glib::DateTime::from_unix_local(secs) { Ok(dt) => dt .format("%-d %b %Y") .map(|s| s.to_string()) .unwrap_or_default(), Err(_) => String::new(), } } /// Request the current browser directory from the daemon and repaint both views. fn load_browser(ui: &Rc) { let path = ui.browser_path.borrow().clone(); ui.browser_crumb.set_label(if path.is_empty() { "Proton Drive" } else { &path }); ui.browser_back.set_sensitive(!path.is_empty()); // Drop the previous folder's rows up front: a slow reply must not leave stale // entries visible, where clicking one would open with a wrong relative path. ui.browser_model.remove_all(); ui.browser_retry.set_visible(false); ui.browser_status.set_label("Loading…"); ui.browser_status.set_visible(true); ui.busy_begin(); let rx = spawn_request(ui.dirs.control_socket(), Request::ListDir { path }); let ui = ui.clone(); glib::spawn_future_local(async move { let result = rx.recv().await; ui.busy_end(); match result { Ok(Ok(Response::Entries { entries })) => repaint_browser(&ui, &entries), Ok(Ok(Response::Error { message })) => browser_message(&ui, &message), Ok(Ok(_)) => browser_message(&ui, "Unexpected reply from daemon."), Ok(Err(_)) | Err(_) => browser_unreachable(&ui), } }); } /// Clear the model and show a single status line instead. Hides the Retry button /// (used for "in-band" outcomes: empty folder, or an error the daemon returned). fn browser_message(ui: &Rc, message: &str) { ui.browser_model.remove_all(); ui.browser_retry.set_visible(false); ui.browser_status.set_label(message); ui.browser_status.set_visible(true); } /// The daemon didn't answer. Distinguish *still starting* (auto-retry, no /// button) from *down* (actionable error + Retry), so a cold start self-heals /// once the systemd mount comes up but a real failure stays visible. fn browser_unreachable(ui: &Rc) { if service::is_failed() || !service::is_active() { ui.browser_status .set_label("Couldn't reach Proton Drive. The mount service isn't running."); ui.browser_status.set_visible(true); ui.browser_retry.set_visible(true); return; } ui.browser_status.set_label("Connecting to Proton Drive…"); ui.browser_status.set_visible(true); ui.browser_retry.set_visible(false); let ui = ui.clone(); glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || { // Only keep polling while the Files page is the one on screen. if ui.stack.visible_child_name().as_deref() == Some("browser") { load_browser(&ui); } }); } /// Repopulate the shared model — folders first, then case-insensitive by name — /// which refreshes both the grid and the column list. fn repaint_browser(ui: &Rc, entries: &[DirEntry]) { ui.browser_model.remove_all(); if entries.is_empty() { browser_message(ui, "This folder is empty."); return; } ui.browser_status.set_visible(false); let mut sorted = entries.to_vec(); sorted.sort_by(|a, b| { b.is_dir .cmp(&a.is_dir) .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) }); for entry in sorted { ui.browser_model.append(&BoxedAnyObject::new(entry)); } } /// The Photos page: a [`gtk4::GridView`] of thumbnails backed by a /// [`gio::ListStore`] of [`BoxedAnyObject`]-wrapped [`PhotoItem`]s, plus a /// "Load more" button and a status label. fn build_gallery_page() -> ( gtk4::Widget, ( gio::ListStore, gtk4::Label, gtk4::Button, gtk4::GridView, gtk4::Button, ), ) { let model = gio::ListStore::new::(); let factory = gtk4::SignalListItemFactory::new(); factory.connect_setup(|_, item| { let item = item.downcast_ref::().unwrap(); let picture = gtk4::Picture::builder() .content_fit(gtk4::ContentFit::Cover) .width_request(150) .height_request(150) .build(); picture.add_css_class("card"); item.set_child(Some(&picture)); }); factory.connect_bind(|_, item| { let item = item.downcast_ref::().unwrap(); let picture = item.child().and_downcast::().unwrap(); let obj = item.item().and_downcast::().unwrap(); let photo = obj.borrow::(); match photo.thumb_path.as_deref() { Some(p) => match gtk4::gdk::Texture::from_filename(p) { Ok(texture) => picture.set_paintable(Some(&texture)), Err(_) => picture.set_paintable(gtk4::gdk::Paintable::NONE), }, None => picture.set_paintable(gtk4::gdk::Paintable::NONE), } }); let selection = gtk4::NoSelection::new(Some(model.clone())); let grid = gtk4::GridView::builder() .model(&selection) .factory(&factory) .min_columns(2) .max_columns(6) .build(); let status = gtk4::Label::builder().wrap(true).build(); status.add_css_class("dim-label"); status.set_visible(false); // Shown only when a load failed because the mount is down; restarts it. let retry = gtk4::Button::builder() .label("Retry") .halign(gtk4::Align::Center) .build(); retry.add_css_class("pill"); retry.set_visible(false); let more = gtk4::Button::builder() .label("Load more") .halign(gtk4::Align::Center) .build(); more.add_css_class("pill"); more.set_visible(false); let scroll = gtk4::ScrolledWindow::builder() .vexpand(true) .child(&grid) .build(); let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); inner.set_margin_top(12); inner.set_margin_bottom(12); inner.set_margin_start(12); inner.set_margin_end(12); inner.append(&status); inner.append(&retry); inner.append(&scroll); inner.append(&more); (inner.upcast(), (model, status, more, grid, retry)) } /// Wire the gallery: activating a tile downloads the photo and opens it; the /// "Load more" button appends the next page. fn wire_gallery(ui: &Rc, grid: >k4::GridView) { let ui_open = ui.clone(); grid.connect_activate(move |grid, pos| { let Some(obj) = grid.model().and_then(|m| m.item(pos)) else { return; }; let uid = obj .downcast_ref::() .map(|o| o.borrow::().uid.clone()); let Some(uid) = uid else { return }; // Ignore a repeat activation of a photo already downloading. if !ui_open.opening.borrow_mut().insert(uid.clone()) { return; } ui_open.busy_begin(); let rx = spawn_request( ui_open.dirs.control_socket(), Request::OpenPhoto { uid: uid.clone() }, ); let ui = ui_open.clone(); glib::spawn_future_local(async move { let result = rx.recv().await; ui.busy_end(); ui.opening.borrow_mut().remove(&uid); match result { Ok(Ok(Response::FilePath { path })) => open_path(&path), Ok(Ok(Response::Error { message })) => { tracing::error!("open photo failed: {message}") } _ => tracing::error!("open photo request failed"), } }); }); let ui_more = ui.clone(); ui.gallery_more.clone().connect_clicked(move |_| { load_gallery(&ui_more, true); }); } /// Fetch a timeline page from the daemon. When `append` is false the model is /// cleared first (fresh load); otherwise the next page is tacked on. fn load_gallery(ui: &Rc, append: bool) { if append { ui.gallery_status.set_visible(false); } else { // Fresh load: clear the grid and show Loading until the first page lands. ui.gallery_model.remove_all(); ui.gallery_retry.set_visible(false); ui.gallery_status.set_label("Loading…"); ui.gallery_status.set_visible(true); } let offset = ui.gallery_model.n_items() as usize; ui.gallery_more.set_sensitive(false); ui.busy_begin(); let rx = spawn_request( ui.dirs.control_socket(), Request::PhotosTimeline { offset, limit: PHOTOS_PAGE, }, ); let ui = ui.clone(); glib::spawn_future_local(async move { let result = rx.recv().await; ui.busy_end(); ui.gallery_more.set_sensitive(true); match result { Ok(Ok(Response::Photos { available, items })) => { if !available { gallery_message(&ui, "This account has no photos."); return; } for item in &items { ui.gallery_model.append(&BoxedAnyObject::new(item.clone())); } if ui.gallery_model.n_items() == 0 { gallery_message(&ui, "No photos yet."); } else { ui.gallery_status.set_visible(false); } // Offer "Load more" only when the page came back full. ui.gallery_more.set_visible(items.len() == PHOTOS_PAGE); } Ok(Ok(Response::Error { message })) => gallery_message(&ui, &message), Ok(Ok(_)) => gallery_message(&ui, "Unexpected reply from daemon."), Ok(Err(_)) | Err(_) => gallery_unreachable(&ui), } }); } /// Show a gallery status line and hide the pager + Retry button. fn gallery_message(ui: &Rc, message: &str) { ui.gallery_retry.set_visible(false); ui.gallery_status.set_label(message); ui.gallery_status.set_visible(true); ui.gallery_more.set_visible(false); } /// Photos counterpart of [`browser_unreachable`]: auto-retry while the mount is /// still starting, surface an actionable error + Retry once it's actually down. fn gallery_unreachable(ui: &Rc) { ui.gallery_more.set_visible(false); if service::is_failed() || !service::is_active() { ui.gallery_status .set_label("Couldn't reach Proton Drive. The mount service isn't running."); ui.gallery_status.set_visible(true); ui.gallery_retry.set_visible(true); return; } ui.gallery_status.set_label("Connecting to Proton Drive…"); ui.gallery_status.set_visible(true); ui.gallery_retry.set_visible(false); let ui = ui.clone(); glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || { if ui.stack.visible_child_name().as_deref() == Some("gallery") { load_gallery(&ui, false); } }); } /// Open a local path with the user's default handler. fn open_path(path: &str) { if let Err(e) = Command::new("xdg-open").arg(path).spawn() { tracing::error!("xdg-open {path} failed: {e}"); } } /// Format a byte count as a short binary-unit string (e.g. `1.2 GiB`). fn human_bytes(bytes: u64) -> String { const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; if bytes == 0 { return "0 B".into(); } let mut value = bytes as f64; let mut unit = 0; while value >= 1024.0 && unit < UNITS.len() - 1 { value /= 1024.0; unit += 1; } if unit == 0 { format!("{bytes} B") } else { format!("{value:.1} {}", UNITS[unit]) } }