mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
Rows, toasts and status page descriptions parse their text as markup by default. Shared item names, inviter addresses, device names, link URLs, paths, EXIF values and daemon errors went in unescaped, so a name with `&` showed as an empty row and other names were drawn as markup. Those rows and toasts now use plain text, and status page descriptions are escaped (B232).
1805 lines
73 KiB
Rust
1805 lines
73 KiB
Rust
#[path = "../activation.rs"]
|
|
pub(crate) mod activation;
|
|
#[path = "../compat.rs"]
|
|
pub(crate) mod compat;
|
|
pub(crate) mod dates;
|
|
pub(crate) mod events;
|
|
#[path = "../i18n.rs"]
|
|
pub(crate) mod i18n;
|
|
pub(crate) mod pages;
|
|
pub(crate) mod reload;
|
|
#[path = "../theme.rs"]
|
|
pub(crate) mod theme;
|
|
pub(crate) mod widgets;
|
|
|
|
use compat::*;
|
|
use events::*;
|
|
use pages::activity::*;
|
|
use pages::albums::*;
|
|
use pages::browser::*;
|
|
use pages::devices::*;
|
|
use pages::duplicates::*;
|
|
use pages::locations::*;
|
|
use pages::login::*;
|
|
use pages::photo_viewer::*;
|
|
use pages::photos::*;
|
|
use pages::places::*;
|
|
use pages::places_map::*;
|
|
use pages::shared::*;
|
|
use pages::shared_by_me::*;
|
|
use pages::status::*;
|
|
use pages::takeout::*;
|
|
use pages::trash::*;
|
|
use pages::verify::*;
|
|
use reload::{LoadTicket, Loader, replace_items};
|
|
use theme::{load_resources, proton_theme_active, set_proton_theme};
|
|
use widgets::compat::*;
|
|
use widgets::crumb::*;
|
|
use widgets::details::*;
|
|
use widgets::file_list::*;
|
|
use widgets::menu::*;
|
|
use widgets::scrubber::*;
|
|
use widgets::share_dialog::*;
|
|
use widgets::thumbnails::*;
|
|
use widgets::versions_dialog::*;
|
|
|
|
pub(crate) use i18n::{
|
|
country_name, gettext, gettext_f, gettext_noop, human_bytes, ngettext_f, pgettext,
|
|
};
|
|
|
|
use std::cell::{Cell, RefCell};
|
|
|
|
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use std::process::Command;
|
|
|
|
use std::rc::Rc;
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
use adw::prelude::*;
|
|
|
|
use gtk4::gio;
|
|
|
|
use gtk4::glib;
|
|
|
|
use gtk4::glib::BoxedAnyObject;
|
|
|
|
use pdfs_core::auth;
|
|
|
|
use pdfs_core::config::{AppDirs, FileSort, FilesView};
|
|
|
|
use pdfs_core::control::{
|
|
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
|
|
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, MappingProgress, PendingOpInfo,
|
|
PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response,
|
|
RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
|
SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress, ThumbnailBuildStatus, Topic,
|
|
TransferDirection, TransferItem, send,
|
|
};
|
|
|
|
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
|
|
|
use pdfs_core::service;
|
|
|
|
const APP_ID: &str = "io.narl.proton-drive-linux";
|
|
|
|
/// 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);
|
|
|
|
/// How often the gallery asks whether the timeline refresh it started has
|
|
/// finished.
|
|
const PHOTOS_REFRESH_POLL: Duration = Duration::from_secs(2);
|
|
|
|
/// How long the gallery keeps following one timeline refresh. A library of tens
|
|
/// of thousands of photos is re-read in batches over several minutes; past this
|
|
/// the daemon carries on and the page catches up on its next load.
|
|
const PHOTOS_REFRESH_FOLLOW_LIMIT: Duration = Duration::from_secs(15 * 60);
|
|
|
|
/// 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,
|
|
/// Wraps the whole window content; every non-blocking outcome (a completed
|
|
/// rename, a failed upload, a purge) is reported here rather than in a modal,
|
|
/// so an action never interrupts what the user is doing next.
|
|
toasts: adw::ToastOverlay,
|
|
/// How many open/load round-trips are in flight. While any is, every page
|
|
/// header shows its spinner (see [`page_frame`]); ref-counted so concurrent
|
|
/// operations don't hide it early.
|
|
busy: Cell<u32>,
|
|
/// 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<HashSet<String>>,
|
|
/// Resolved login identity, cached so the periodic [`refresh`] never hits the
|
|
/// keyring (a DBus round-trip). Populated at startup and updated only on
|
|
/// login / logout. `None` = signed out.
|
|
session: RefCell<Option<auth::StoredSession>>,
|
|
/// Whether this run already asked for the mailbox password of a locked
|
|
/// account, so a refresh never re-opens a dialog the user cancelled.
|
|
unlock_offered: Cell<bool>,
|
|
/// Whether the last refresh saw a live mount daemon. Gates the unpin buttons
|
|
/// (which need the daemon to evict + re-hydrate) and every mutating action.
|
|
mounted: RefCell<bool>,
|
|
/// Sidebar destination list (My files, Photos, …, Sync). Selecting a row swaps
|
|
/// the page stack; [`sync_sidebar`] mirrors navigation that starts elsewhere.
|
|
sidebar: gtk4::ListBox,
|
|
/// The sidebar/content split. Collapsed while signed out, so the login page
|
|
/// owns the whole window and no destination is reachable without a session.
|
|
nav: adw::NavigationSplitView,
|
|
/// Shared thumbnails for ordinary image files outside the Photos gallery.
|
|
/// One cache and request queue serves Files, search, Shared and Trash, so
|
|
/// the same image is downloaded and decoded only once.
|
|
pub(crate) file_thumbs: FileThumbnailState,
|
|
|
|
// Per-page state. Each page module owns its own struct; `Ui` keeps only
|
|
// what more than one page genuinely shares.
|
|
pub(crate) login: LoginState,
|
|
pub(crate) status: StatusState,
|
|
pub(crate) browser: BrowserState,
|
|
pub(crate) details: DetailsState,
|
|
pub(crate) trash: TrashState,
|
|
pub(crate) gallery: GalleryState,
|
|
pub(crate) shared: SharedState,
|
|
pub(crate) shared_by_me: SharedByMeState,
|
|
pub(crate) devices: DevicesState,
|
|
pub(crate) locations: LocationsState,
|
|
pub(crate) activity: ActivityState,
|
|
pub(crate) takeout: TakeoutState,
|
|
}
|
|
|
|
impl Ui {
|
|
/// Begin a unit of background work: show + spin the header spinner.
|
|
fn busy_begin(&self) {
|
|
self.busy.set(self.busy.get() + 1);
|
|
set_busy_spinners(true);
|
|
}
|
|
|
|
/// 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 {
|
|
set_busy_spinners(false);
|
|
}
|
|
}
|
|
|
|
/// Remember a decoded thumbnail, evicting the oldest once the cache is full.
|
|
/// Scrolling back over a day then repaints from memory rather than decoding
|
|
/// the same JPEGs off disk again.
|
|
fn store_texture(&self, uid: &str, texture: gtk4::gdk::Texture) {
|
|
let mut cache = self.gallery.photo_tex.borrow_mut();
|
|
let mut order = self.gallery.photo_tex_order.borrow_mut();
|
|
if cache.insert(uid.to_string(), texture).is_none() {
|
|
order.push_back(uid.to_string());
|
|
}
|
|
while order.len() > TEXTURE_CACHE_MAX {
|
|
if let Some(old) = order.pop_front() {
|
|
cache.remove(&old);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Mark a cached thumbnail as freshly used, so eviction takes the tiles
|
|
/// nobody has looked at rather than the oldest ones.
|
|
///
|
|
/// Insertion order alone evicts the top of the timeline first — exactly what
|
|
/// a scroll back up then asks for again.
|
|
fn touch_texture(&self, uid: &str) {
|
|
let mut order = self.gallery.photo_tex_order.borrow_mut();
|
|
if order.back().is_some_and(|back| back == uid) {
|
|
return;
|
|
}
|
|
if let Some(at) = order.iter().position(|held| held == uid) {
|
|
order.remove(at);
|
|
order.push_back(uid.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn main() -> glib::ExitCode {
|
|
let verify = std::env::args().nth(1).as_deref() == Some(VERIFY_ARG);
|
|
let logs = tracing_subscriber::fmt().with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
|
);
|
|
// The verification window's stdout carries its token back to the app.
|
|
if verify {
|
|
logs.with_writer(std::io::stderr).init();
|
|
} else {
|
|
logs.init();
|
|
}
|
|
i18n::init();
|
|
if verify {
|
|
return run_verification_window();
|
|
}
|
|
|
|
// The tray launches the app with arguments ("--page locations",
|
|
// "--confirm-stop"), which a running instance must receive too.
|
|
let app = adw::Application::builder()
|
|
.application_id(APP_ID)
|
|
.flags(gio::ApplicationFlags::HANDLES_COMMAND_LINE)
|
|
.build();
|
|
app.connect_startup(|_| {
|
|
load_resources("/de/nils/protondrivelinux/style.css");
|
|
if let Ok(dirs) = AppDirs::new() {
|
|
set_proton_theme(dirs.load_config().proton_theme.unwrap_or(false));
|
|
}
|
|
// Refresh the file manager's right-click pin/unpin scripts, so they always
|
|
// match the installed `pdfs`.
|
|
pdfs_core::shell::install_file_manager_scripts();
|
|
if !AppDirs::new().is_ok_and(|dirs| dirs.load_config().tray_hidden) {
|
|
spawn_tray();
|
|
}
|
|
});
|
|
app.connect_activate(present_window);
|
|
app.connect_command_line(|app, command| {
|
|
let args: Vec<String> = command
|
|
.arguments()
|
|
.iter()
|
|
.skip(1)
|
|
.map(|arg| arg.to_string_lossy().into_owned())
|
|
.collect();
|
|
app.activate();
|
|
match parse_launch(&args) {
|
|
Launch::Window => {}
|
|
Launch::Page(page) => app.activate_action("show-page", Some(&page.to_variant())),
|
|
Launch::ConfirmStop => app.activate_action("confirm-stop", None),
|
|
}
|
|
glib::ExitCode::SUCCESS
|
|
});
|
|
app.run()
|
|
}
|
|
|
|
/// What a launch of `pdfs-app` asks for, beyond showing the window.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum Launch {
|
|
Window,
|
|
/// `--page <name>`: open on a stack page, such as "locations" for Sync.
|
|
Page(String),
|
|
/// `--confirm-stop`: ask whether to stop Proton Drive (from the tray).
|
|
ConfirmStop,
|
|
}
|
|
|
|
fn parse_launch(args: &[String]) -> Launch {
|
|
let mut args = args.iter();
|
|
while let Some(arg) = args.next() {
|
|
match arg.as_str() {
|
|
"--confirm-stop" => return Launch::ConfirmStop,
|
|
"--page" => {
|
|
if let Some(page) = args.next() {
|
|
return Launch::Page(page.clone());
|
|
}
|
|
}
|
|
other => {
|
|
if let Some(page) = other.strip_prefix("--page=") {
|
|
return Launch::Page(page.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Launch::Window
|
|
}
|
|
|
|
/// Raise the window if there is one, else build it. A second launch reaches
|
|
/// the running instance, which must not open a second window.
|
|
fn present_window(app: &adw::Application) {
|
|
match app.windows().first() {
|
|
Some(window) => window.present(),
|
|
None => build_window(app),
|
|
}
|
|
}
|
|
|
|
/// Spawn the tray icon process in the background.
|
|
pub(crate) 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}"),
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
};
|
|
// On a fresh install the daemon has not run yet to make these, and the
|
|
// Takeout page keeps its staged archives in the state dir (B194).
|
|
if let Err(e) = dirs.ensure() {
|
|
tracing::warn!("cannot create the app's directories: {e}");
|
|
}
|
|
|
|
let stack = adw::ViewStack::new();
|
|
let (login_page, login_widgets) = build_login_page();
|
|
let main_widgets = build_main_page();
|
|
let (browser_page, browser_widgets) = build_browser_page();
|
|
let (gallery_page, gallery_widgets) = build_gallery_page();
|
|
let (shared_page, shared_widgets) = build_shared_page();
|
|
let (shared_by_me_page, shared_by_me_widgets) = build_shared_by_me_page();
|
|
let (devices_page, devices_widgets) = build_devices_page();
|
|
let (activity_page, activity_widgets) = build_activity_page();
|
|
let (locations_page, locations_widgets) = build_locations_page(&activity_page);
|
|
let (trash_page, trash_widgets) = build_trash_page();
|
|
let (takeout_page, takeout_widgets) = build_takeout_page();
|
|
// The login page has no title and no actions, but it still needs a header
|
|
// bar: it is the only thing carrying the window controls.
|
|
let login_frame = adw::ToolbarView::new();
|
|
login_frame.add_top_bar(&{
|
|
let header = adw::HeaderBar::new();
|
|
header.set_show_title(false);
|
|
header
|
|
});
|
|
login_frame.set_content(Some(&login_page));
|
|
stack.add_named(&login_frame, Some("login"));
|
|
stack.add_named(&browser_page, Some("browser"));
|
|
stack.add_named(&gallery_page, Some("gallery"));
|
|
stack.add_named(&shared_by_me_page, Some("sharedbyme"));
|
|
stack.add_named(&shared_page, Some("shared"));
|
|
stack.add_named(&devices_page, Some("devices"));
|
|
stack.add_named(&locations_page, Some("locations"));
|
|
stack.add_named(&trash_page, Some("trash"));
|
|
stack.add_named(&takeout_page, Some("takeout"));
|
|
|
|
// Sidebar: the signed-in destinations. Selecting a row swaps the page stack;
|
|
// `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g.
|
|
// login lands on Files).
|
|
let (sidebar_page, sidebar_list) = build_sidebar(&main_widgets.footer);
|
|
|
|
// Every page brings its own header bar (see `page_frame`), so the content
|
|
// side is the bare stack.
|
|
let content_page = adw::NavigationPage::builder()
|
|
.title("Proton Drive")
|
|
.child(&stack)
|
|
.build();
|
|
let split = adw::NavigationSplitView::builder()
|
|
.sidebar(&sidebar_page)
|
|
.content(&content_page)
|
|
.min_sidebar_width(200.0)
|
|
.max_sidebar_width(240.0)
|
|
.build();
|
|
|
|
// Toasts float over everything, so a report from a background action reaches
|
|
// the user whichever page they're on.
|
|
let toasts = adw::ToastOverlay::new();
|
|
toasts.set_child(Some(&split));
|
|
|
|
let ui = Rc::new(Ui {
|
|
dirs,
|
|
stack: stack.clone(),
|
|
toasts: toasts.clone(),
|
|
busy: Cell::new(0),
|
|
opening: RefCell::new(HashSet::new()),
|
|
session: RefCell::new(auth::load().ok()),
|
|
unlock_offered: Cell::new(false),
|
|
mounted: RefCell::new(false),
|
|
sidebar: sidebar_list.clone(),
|
|
nav: split.clone(),
|
|
file_thumbs: FileThumbnailState::new(),
|
|
login: login_widgets,
|
|
status: StatusState {
|
|
status_inflight: Cell::new(false),
|
|
transfers_group: locations_widgets.transfers_group.clone(),
|
|
prefs: main_widgets.prefs.clone(),
|
|
account_name: main_widgets.account_name.clone(),
|
|
avatar: main_widgets.avatar.clone(),
|
|
status_icon: main_widgets.status_icon.clone(),
|
|
status_spinner: main_widgets.status_spinner.clone(),
|
|
status_title: main_widgets.status_title.clone(),
|
|
status_detail: main_widgets.status_detail.clone(),
|
|
transfer_rows: RefCell::new(Vec::new()),
|
|
transfers_inflight: Cell::new(false),
|
|
cache_bar: main_widgets.cache_bar.clone(),
|
|
cache_label: main_widgets.cache_label.clone(),
|
|
quota_box: main_widgets.quota_box.clone(),
|
|
quota_bar: main_widgets.quota_bar.clone(),
|
|
quota_label: main_widgets.quota_label.clone(),
|
|
quota_inflight: Cell::new(false),
|
|
quota_checked_at: Cell::new(None),
|
|
autostart_row: main_widgets.autostart_row.clone(),
|
|
budget_row: main_widgets.budget_row.clone(),
|
|
upload_limit_row: main_widgets.upload_limit_row.clone(),
|
|
download_limit_row: main_widgets.download_limit_row.clone(),
|
|
mountpoint_row: main_widgets.mountpoint_row.clone(),
|
|
theme_row: main_widgets.theme_row.clone(),
|
|
online_map_row: main_widgets.online_map_row.clone(),
|
|
tray_row: main_widgets.tray_row.clone(),
|
|
language_row: main_widgets.language_row.clone(),
|
|
settings_suppress: Cell::new(false),
|
|
budget_source: RefCell::new(None),
|
|
limit_source: RefCell::new(None),
|
|
pins_expanded: Cell::new(false),
|
|
pins_group: main_widgets.pins_group.clone(),
|
|
pin_rows: RefCell::new(Vec::new()),
|
|
pins_state: RefCell::new(None),
|
|
notified_mounted: Cell::new(None),
|
|
active_transfers: Cell::new(0),
|
|
transfer_batch: RefCell::new(None),
|
|
},
|
|
browser: BrowserState {
|
|
files: browser_widgets.files.clone(),
|
|
history: RefCell::new(Vec::new()),
|
|
future: RefCell::new(Vec::new()),
|
|
crumb: browser_widgets.crumb.clone(),
|
|
content: browser_widgets.content.clone(),
|
|
status: browser_widgets.status.clone(),
|
|
retry: browser_widgets.retry.clone(),
|
|
split: browser_widgets.split.clone(),
|
|
path: RefCell::new(String::new()),
|
|
search: browser_widgets.search.clone(),
|
|
search_scope: browser_widgets.search_scope.clone(),
|
|
actions: browser_widgets.actions.clone(),
|
|
view_button: browser_widgets.view_button.clone(),
|
|
view: Cell::new(FilesView::default()),
|
|
listing: RefCell::new(Vec::new()),
|
|
build_thumbnails: browser_widgets.build_thumbnails.clone(),
|
|
thumbnail_build_row: browser_widgets.thumbnail_build_row.clone(),
|
|
thumbnail_progress: browser_widgets.thumbnail_progress.clone(),
|
|
thumbnail_status: browser_widgets.thumbnail_status.clone(),
|
|
thumbnail_poll: RefCell::new(None),
|
|
thumbnail_build_running: Cell::new(false),
|
|
thumbnail_cancel_pending: Cell::new(false),
|
|
search_source: RefCell::new(None),
|
|
loader: Loader::new(&browser_widgets.content),
|
|
listed: RefCell::new(None),
|
|
bulk: browser_widgets.bulk.clone(),
|
|
bulk_label: browser_widgets.bulk_label.clone(),
|
|
bulk_trash: browser_widgets.bulk_trash.clone(),
|
|
bulk_pin: browser_widgets.bulk_pin.clone(),
|
|
bulk_unpin: browser_widgets.bulk_unpin.clone(),
|
|
bulk_move: browser_widgets.bulk_move.clone(),
|
|
empty_actions: browser_widgets.empty_actions.clone(),
|
|
summary: browser_widgets.summary.clone(),
|
|
zoom: browser_widgets.zoom.clone(),
|
|
},
|
|
details: DetailsState {
|
|
details: browser_widgets.details,
|
|
details_entry: RefCell::new(None),
|
|
details_suppress: Cell::new(false),
|
|
selection: browser_widgets.files.selection.clone(),
|
|
},
|
|
trash: TrashState {
|
|
files: trash_widgets.files.clone(),
|
|
content: trash_widgets.content.clone(),
|
|
status: trash_widgets.status.clone(),
|
|
retry: trash_widgets.retry.clone(),
|
|
empty: trash_widgets.empty.clone(),
|
|
subtitle: trash_widgets.subtitle.clone(),
|
|
selection_bar: trash_widgets.selection_bar.clone(),
|
|
selection_label: trash_widgets.selection_label.clone(),
|
|
loader: Loader::new(&trash_widgets.content),
|
|
},
|
|
gallery: GalleryState {
|
|
model: gallery_widgets.model.clone(),
|
|
groups: gallery_widgets.groups.clone(),
|
|
row_height: Cell::new(ROW_DEFAULT),
|
|
scrolling_down: Cell::new(true),
|
|
scroll_offset: Cell::new(0.0),
|
|
content: gallery_widgets.content.clone(),
|
|
status: gallery_widgets.status.clone(),
|
|
retry: gallery_widgets.retry.clone(),
|
|
pager: gallery_widgets.pager.clone(),
|
|
scrubber: gallery_widgets.scrubber.clone(),
|
|
months: RefCell::new(Vec::new()),
|
|
scrub_source: RefCell::new(None),
|
|
jump: Cell::new(None),
|
|
burst: Cell::new(false),
|
|
import_banner: gallery_widgets.import_banner.clone(),
|
|
upload: gallery_widgets.upload.clone(),
|
|
empty_actions: gallery_widgets.empty_actions.clone(),
|
|
title: gallery_widgets.title.clone(),
|
|
albums: gallery_widgets.albums.clone(),
|
|
albums_stack: gallery_widgets.albums_stack.clone(),
|
|
albums_status: gallery_widgets.albums_status.clone(),
|
|
view_switch: gallery_widgets.view_switch.clone(),
|
|
albums_loading: Cell::new(false),
|
|
places: gallery_widgets.places.clone(),
|
|
places_stack: gallery_widgets.places_stack.clone(),
|
|
places_status: gallery_widgets.places_status.clone(),
|
|
places_loading: Cell::new(false),
|
|
places_mapping: gallery_widgets.places_mapping.clone(),
|
|
places_mapping_label: gallery_widgets.places_mapping_label.clone(),
|
|
places_mapping_bar: gallery_widgets.places_mapping_bar.clone(),
|
|
places_poll: RefCell::new(None),
|
|
places_map: gallery_widgets.places_map.clone(),
|
|
places_layout: gallery_widgets.places_layout.clone(),
|
|
place_count: Cell::new(0),
|
|
place: RefCell::new(None),
|
|
album: RefCell::new(None),
|
|
timeline_stale: Cell::new(false),
|
|
album_list: RefCell::new(Vec::new()),
|
|
back: gallery_widgets.back.clone(),
|
|
filters: gallery_widgets.filters.clone(),
|
|
kind: Cell::new(None),
|
|
tabs: gallery_widgets.tabs.clone(),
|
|
counts: Cell::new(None),
|
|
dates: gallery_widgets.dates.clone(),
|
|
filter_btn: gallery_widgets.filter_btn.clone(),
|
|
filter_count: gallery_widgets.filter_count.clone(),
|
|
clear_filters: gallery_widgets.clear_filters.clone(),
|
|
date_ranges: RefCell::new(vec![None]),
|
|
range: Cell::new(None),
|
|
favorites_row: gallery_widgets.favorites_row.clone(),
|
|
favorites: Cell::new(false),
|
|
not_in_album_row: gallery_widgets.not_in_album_row.clone(),
|
|
not_in_album: Cell::new(false),
|
|
memories: gallery_widgets.memories.clone(),
|
|
memory_cards: gallery_widgets.memory_cards.clone(),
|
|
has_memories: Cell::new(false),
|
|
date_suppress: Cell::new(false),
|
|
reload_pending: Cell::new(false),
|
|
loading: Cell::new(false),
|
|
has_more: Cell::new(false),
|
|
library: Cell::new(true),
|
|
page_waiters: RefCell::new(Vec::new()),
|
|
width: Cell::new(0),
|
|
photo_tex: RefCell::new(HashMap::new()),
|
|
photo_tex_order: RefCell::new(VecDeque::new()),
|
|
photo_nothumb: RefCell::new(HashSet::new()),
|
|
thumb_wanted: RefCell::new(HashMap::new()),
|
|
thumb_queue: RefCell::new(VecDeque::new()),
|
|
thumb_inflight: Cell::new(false),
|
|
decode_queue: RefCell::new(VecDeque::new()),
|
|
decode_idle: Cell::new(false),
|
|
thumb_source: RefCell::new(None),
|
|
relayout_source: RefCell::new(None),
|
|
bound: RefCell::new(BTreeMap::new()),
|
|
tile_pools: RefCell::new(HashMap::new()),
|
|
list: gallery_widgets.list.clone(),
|
|
selecting: Cell::new(false),
|
|
selected: RefCell::new(HashSet::new()),
|
|
select_anchor: RefCell::new(None),
|
|
select_btn: gallery_widgets.select_btn.clone(),
|
|
select_bar: gallery_widgets.select_bar.clone(),
|
|
select_label: gallery_widgets.select_label.clone(),
|
|
select_trash: gallery_widgets.select_trash.clone(),
|
|
select_album: gallery_widgets.select_album.clone(),
|
|
},
|
|
shared: SharedState::new(&shared_widgets),
|
|
shared_by_me: SharedByMeState::new(&shared_by_me_widgets),
|
|
devices: DevicesState::new(&devices_widgets),
|
|
locations: LocationsState {
|
|
content: locations_widgets.content.clone(),
|
|
status: locations_widgets.status.clone(),
|
|
retry: locations_widgets.retry.clone(),
|
|
group: locations_widgets.group.clone(),
|
|
rows: RefCell::new(Vec::new()),
|
|
inflight: Cell::new(false),
|
|
loader: Loader::new(&locations_widgets.content),
|
|
card: SyncCard {
|
|
icon: locations_widgets.card_icon.clone(),
|
|
title: locations_widgets.card_title.clone(),
|
|
detail: locations_widgets.card_detail.clone(),
|
|
pause: locations_widgets.pause.clone(),
|
|
paused: Cell::new(false),
|
|
sign_in: locations_widgets.card_sign_in.clone(),
|
|
restart: locations_widgets.card_restart.clone(),
|
|
},
|
|
queue: QueueState {
|
|
group: locations_widgets.queue_group.clone(),
|
|
retry_all: locations_widgets.retry_all.clone(),
|
|
rows: RefCell::new(Vec::new()),
|
|
painted: RefCell::new(Vec::new()),
|
|
inflight: Cell::new(false),
|
|
},
|
|
conflicts: ConflictsState {
|
|
group: locations_widgets.conflicts_group.clone(),
|
|
rows: RefCell::new(Vec::new()),
|
|
painted: RefCell::new(Vec::new()),
|
|
inflight: Cell::new(false),
|
|
},
|
|
views: locations_widgets.views.clone(),
|
|
banner: locations_widgets.banner.clone(),
|
|
failing: Cell::new(0),
|
|
},
|
|
activity: ActivityState {
|
|
content: activity_widgets.content.clone(),
|
|
status: activity_widgets.status.clone(),
|
|
retry: activity_widgets.retry.clone(),
|
|
model: activity_widgets.model.clone(),
|
|
feed: activity_widgets.feed.clone(),
|
|
inflight: Cell::new(false),
|
|
loader: Loader::new(&activity_widgets.content),
|
|
items: RefCell::new(Vec::new()),
|
|
filter: Cell::new(ActivityFilter::All),
|
|
conflicts: RefCell::new(None),
|
|
conflicts_inflight: Cell::new(false),
|
|
conflicts_at: Cell::new(None),
|
|
show_scratch: Cell::new(false),
|
|
limit: Cell::new(ACTIVITY_PAGE),
|
|
},
|
|
takeout: TakeoutState {
|
|
archives: RefCell::new(Vec::new()),
|
|
list_group: takeout_widgets.list_group.clone(),
|
|
rows: RefCell::new(Vec::new()),
|
|
dropzone: takeout_widgets.dropzone.clone(),
|
|
scan_button: takeout_widgets.scan_button.clone(),
|
|
import_button: takeout_widgets.import_button.clone(),
|
|
cancel_button: takeout_widgets.cancel_button.clone(),
|
|
clear_button: takeout_widgets.clear_button.clone(),
|
|
progress_group: takeout_widgets.progress_group.clone(),
|
|
progress_label: takeout_widgets.progress_label.clone(),
|
|
progress_bar: takeout_widgets.progress_bar.clone(),
|
|
summary_group: takeout_widgets.summary_group.clone(),
|
|
summary_rows: RefCell::new(Vec::new()),
|
|
inflight: Cell::new(false),
|
|
running: Cell::new(false),
|
|
dry_run: Cell::new(false),
|
|
},
|
|
});
|
|
wire_login(&ui);
|
|
let ui_status = ui.clone();
|
|
main_widgets
|
|
.status_button
|
|
.connect_clicked(move |_| ui_status.stack.set_visible_child_name("locations"));
|
|
wire_settings(
|
|
&ui,
|
|
&main_widgets.purge_button,
|
|
&main_widgets.mountpoint_button,
|
|
);
|
|
wire_sidebar(&ui);
|
|
wire_browser(&ui);
|
|
wire_bulk(
|
|
&ui,
|
|
&browser_widgets.bulk_clear,
|
|
&browser_widgets.empty_upload,
|
|
&browser_widgets.empty_new_folder,
|
|
);
|
|
wire_browser_actions(&ui, &browser_widgets.build_thumbnails);
|
|
wire_details(&ui);
|
|
wire_search(&ui);
|
|
wire_gallery(
|
|
&ui,
|
|
&gallery_widgets.list,
|
|
&gallery_widgets.scroll,
|
|
&gallery_widgets.select_done,
|
|
);
|
|
wire_gallery_empty(
|
|
&ui,
|
|
&gallery_widgets.empty_upload,
|
|
&gallery_widgets.empty_import,
|
|
);
|
|
wire_albums(&ui);
|
|
wire_places(&ui);
|
|
wire_trash(&ui, &trash_widgets);
|
|
wire_shared(&ui, &shared_widgets);
|
|
wire_shared_by_me(&ui, &shared_by_me_widgets);
|
|
wire_devices(&ui, &devices_widgets);
|
|
wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder);
|
|
wire_activity(&ui, &activity_widgets);
|
|
wire_takeout(&ui, &takeout_widgets);
|
|
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| {
|
|
// Rows from the page being left must not keep full-size image downloads
|
|
// alive in the daemon. The page being entered establishes a fresh
|
|
// thumbnail generation as it paints.
|
|
cancel_file_thumbnails(&ui_nav);
|
|
sync_sidebar(&ui_nav);
|
|
match st.visible_child_name().as_deref() {
|
|
Some("browser") => load_browser(&ui_nav),
|
|
// The Albums and Places listings are re-read in place. Loading the
|
|
// timeline instead would swap it in under the Albums or Places toggle.
|
|
Some("gallery") if !ui_nav.gallery.in_collection() => {
|
|
match ui_nav.gallery.view_switch.active() {
|
|
VIEW_ALBUMS => load_albums(&ui_nav),
|
|
VIEW_PLACES => load_places(&ui_nav),
|
|
_ => load_gallery(&ui_nav, false),
|
|
}
|
|
}
|
|
Some("gallery") => load_gallery(&ui_nav, false),
|
|
// Every visit reads the page again. The rows already on screen stay
|
|
// up while it does, so arriving costs no "Loading…" flash.
|
|
Some("sharedbyme") => load_shared_by_me(&ui_nav),
|
|
Some("shared") => load_shared(&ui_nav),
|
|
Some("devices") => load_devices(&ui_nav),
|
|
Some("locations") => load_sync_view(&ui_nav),
|
|
Some("trash") => load_trash(&ui_nav),
|
|
// The import runs in the daemon and outlives this page, so arriving
|
|
// here asks straight away whether one is in flight.
|
|
Some("takeout") => refresh_takeout(&ui_nav),
|
|
_ => {}
|
|
}
|
|
});
|
|
|
|
let window = adw::ApplicationWindow::builder()
|
|
.application(app)
|
|
.title("Proton Drive")
|
|
.default_width(980)
|
|
.default_height(680)
|
|
.content(&toasts)
|
|
.build();
|
|
// A wide window docks the details pane beside the files instead of laying it
|
|
// over them.
|
|
let wide = adw::Breakpoint::new(
|
|
adw::BreakpointCondition::parse("min-width: 1000sp").expect("static breakpoint"),
|
|
);
|
|
wide.add_setter(&ui.browser.split, "collapsed", Some(&false.to_value()));
|
|
window.add_breakpoint(wide);
|
|
install_shortcuts(&ui, &window);
|
|
install_window_actions(&ui, &window);
|
|
install_launch_actions(&ui, app, &window);
|
|
app.set_accels_for_action("win.preferences", &["<Control>comma"]);
|
|
app.set_accels_for_action("win.shortcuts", &["<Control>question"]);
|
|
|
|
refresh(&ui);
|
|
// From here on the daemon says when something changed; nothing polls.
|
|
follow_daemon(&ui, &window);
|
|
|
|
window.present();
|
|
}
|
|
|
|
/// Whether `window` is minimised or otherwise out of sight (another workspace,
|
|
/// fully covered), as far as the compositor tells us.
|
|
fn window_hidden(window: &adw::ApplicationWindow) -> bool {
|
|
if !window.is_visible() {
|
|
return true;
|
|
}
|
|
window
|
|
.surface()
|
|
.and_downcast::<gtk4::gdk::Toplevel>()
|
|
.is_some_and(|toplevel| {
|
|
toplevel.state().intersects(
|
|
gtk4::gdk::ToplevelState::MINIMIZED | gtk4::gdk::ToplevelState::SUSPENDED,
|
|
)
|
|
})
|
|
}
|
|
|
|
/// The sidebar destinations, in order: the row index is the index into this table,
|
|
/// and each entry is `(stack page name, label, icon)`. The places in the account
|
|
/// come first; the rows from [`SIDEBAR_SYNC_SECTION`] on are about this computer's
|
|
/// sync, set off by a separator.
|
|
const DESTINATIONS: [(&str, &str, &str); 7] = [
|
|
("browser", gettext_noop("My files"), "folder-symbolic"),
|
|
(
|
|
"gallery",
|
|
gettext_noop("Photos"),
|
|
"image-x-generic-symbolic",
|
|
),
|
|
(
|
|
"shared",
|
|
gettext_noop("Shared with me"),
|
|
"pdfs-people-symbolic",
|
|
),
|
|
(
|
|
"sharedbyme",
|
|
gettext_noop("Shared by me"),
|
|
"pdfs-share-symbolic",
|
|
),
|
|
("devices", gettext_noop("Computers"), "computer-symbolic"),
|
|
("trash", gettext_noop("Trash"), "user-trash-symbolic"),
|
|
// Translators: sidebar entry for the page listing this computer's synced folders.
|
|
("locations", gettext_noop("Sync"), "pdfs-sync-symbolic"),
|
|
];
|
|
|
|
/// Index of the first row in the sidebar's sync section.
|
|
const SIDEBAR_SYNC_SECTION: i32 = 6;
|
|
|
|
/// The navigation sidebar: the destinations over a footer carrying the sync
|
|
/// status, the account quota and the account menu. Returns the page (for the
|
|
/// split view) and the list (to drive + reflect the current page).
|
|
fn build_sidebar(footer: >k4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
|
|
let list = gtk4::ListBox::new();
|
|
list.set_selection_mode(gtk4::SelectionMode::Single);
|
|
list.add_css_class("navigation-sidebar");
|
|
for (_, label, icon) in DESTINATIONS {
|
|
let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
|
row_box.append(>k4::Image::from_icon_name(icon));
|
|
let title = gtk4::Label::builder()
|
|
.label(gettext(label))
|
|
.xalign(0.0)
|
|
.hexpand(true)
|
|
.build();
|
|
row_box.append(&title);
|
|
// A count of what waits there; see `set_sidebar_badge`.
|
|
let badge = gtk4::Label::builder()
|
|
.visible(false)
|
|
.valign(gtk4::Align::Center)
|
|
.build();
|
|
badge.add_css_class("sidebar-badge");
|
|
badge.add_css_class("numeric");
|
|
row_box.append(&badge);
|
|
let row = gtk4::ListBoxRow::builder().child(&row_box).build();
|
|
list.append(&row);
|
|
}
|
|
list.set_header_func(|row, _| {
|
|
if row.index() == SIDEBAR_SYNC_SECTION {
|
|
let separator = gtk4::Separator::new(gtk4::Orientation::Horizontal);
|
|
separator.set_margin_top(6);
|
|
separator.set_margin_bottom(6);
|
|
row.set_header(Some(&separator));
|
|
} else {
|
|
row.set_header(gtk4::Widget::NONE);
|
|
}
|
|
});
|
|
|
|
let brand = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
|
let icon = gtk4::Image::from_icon_name(APP_ID);
|
|
icon.set_pixel_size(24);
|
|
brand.append(&icon);
|
|
let name = gtk4::Label::new(Some("Proton Drive"));
|
|
name.add_css_class("heading");
|
|
brand.append(&name);
|
|
|
|
let header = adw::HeaderBar::new();
|
|
header.set_title_widget(Some(&brand));
|
|
|
|
let scroll = gtk4::ScrolledWindow::builder()
|
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
|
.vexpand(true)
|
|
.child(&list)
|
|
.build();
|
|
let toolbar = adw::ToolbarView::new();
|
|
toolbar.add_top_bar(&header);
|
|
toolbar.set_content(Some(&scroll));
|
|
toolbar.add_bottom_bar(footer);
|
|
|
|
let page = adw::NavigationPage::builder()
|
|
.title("Proton Drive")
|
|
.child(&toolbar)
|
|
.build();
|
|
(page, list)
|
|
}
|
|
|
|
thread_local! {
|
|
/// Every page header's busy spinner. There is one per page because each
|
|
/// page has its own header bar; [`Ui::busy_begin`] shows them all, so the one
|
|
/// on screen is always the right one.
|
|
static BUSY_SPINNERS: RefCell<Vec<Spinner>> = const { RefCell::new(Vec::new()) };
|
|
}
|
|
|
|
fn set_busy_spinners(visible: bool) {
|
|
BUSY_SPINNERS.with(|spinners| {
|
|
for spinner in spinners.borrow().iter() {
|
|
spinner.set_visible(visible);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// A destination page's frame: its own header bar over the page content, the
|
|
/// way GNOME apps lay a split view out. The title sits in the middle with the
|
|
/// busy spinner beside it; the page packs its actions into the returned header
|
|
/// bar and may give the returned title a subtitle.
|
|
fn page_frame(
|
|
title: &str,
|
|
content: &impl IsA<gtk4::Widget>,
|
|
) -> (adw::ToolbarView, adw::HeaderBar, adw::WindowTitle) {
|
|
let window_title = adw::WindowTitle::new(title, "");
|
|
let (toolbar, header) = page_frame_with(&window_title, content);
|
|
(toolbar, header, window_title)
|
|
}
|
|
|
|
/// [`page_frame`] with `title` in the header's title slot instead of a plain
|
|
/// title, such as a view switcher.
|
|
fn page_frame_with(
|
|
title: &impl IsA<gtk4::Widget>,
|
|
content: &impl IsA<gtk4::Widget>,
|
|
) -> (adw::ToolbarView, adw::HeaderBar) {
|
|
let spinner = spinner();
|
|
spinner.set_visible(false);
|
|
BUSY_SPINNERS.with(|spinners| spinners.borrow_mut().push(spinner.clone()));
|
|
let title_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
|
title_box.append(title);
|
|
title_box.append(&spinner);
|
|
|
|
let header = adw::HeaderBar::new();
|
|
header.set_title_widget(Some(&title_box));
|
|
let toolbar = adw::ToolbarView::new();
|
|
toolbar.add_top_bar(&header);
|
|
toolbar.set_content(Some(content));
|
|
(toolbar, header)
|
|
}
|
|
|
|
/// The sidebar destination a page belongs under. Most pages are their own
|
|
/// destination; a sub-page reached from one (Import, off Photos) has no row of
|
|
/// its own and answers with its parent, so the sidebar highlights where the user
|
|
/// came from rather than clearing — which would read as "nowhere".
|
|
fn destination_of(page: &str) -> &str {
|
|
match page {
|
|
"takeout" => "gallery",
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
/// Selecting a sidebar row navigates the page stack. The reverse direction (stack
|
|
/// → sidebar highlight) is [`sync_sidebar`], so the two can't fight: this handler
|
|
/// only ever writes the stack.
|
|
fn wire_sidebar(ui: &Rc<Ui>) {
|
|
let ui_row = ui.clone();
|
|
ui.sidebar.connect_row_selected(move |_, row| {
|
|
let Some(row) = row else { return };
|
|
let Some((page, _, _)) = DESTINATIONS.get(row.index() as usize) else {
|
|
return;
|
|
};
|
|
// Compared by *destination*, not by page: while a sub-page is open its
|
|
// parent's row is the selected one, and re-asserting it here would kick
|
|
// the user off the sub-page the moment they arrived.
|
|
let current = ui_row.stack.visible_child_name();
|
|
if current.as_deref().map(destination_of) != Some(*page) {
|
|
ui_row.stack.set_visible_child_name(page);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Re-fetch the visible page from the server, bypassing every layer of cache
|
|
/// between it and the account.
|
|
///
|
|
/// The daemon keeps persisted listings (folders, trash, photos), and those are
|
|
/// dropped with [`Request::Refresh`] before re-asking. The pages the daemon
|
|
/// always fetches live (sharing, devices, activity) are simply loaded again.
|
|
///
|
|
/// The daemon's events keep every page current on their own. This is for F5,
|
|
/// when the account changed somewhere the daemon has not heard about yet.
|
|
fn reload_current_page(ui: &Rc<Ui>) {
|
|
match ui.stack.visible_child_name().as_deref() {
|
|
Some("browser") => {
|
|
let path = ui.browser.path.borrow().clone();
|
|
refresh_then(ui, RefreshScope::Dir { path }, load_browser);
|
|
}
|
|
// One scope covers the whole photos view, so Refresh reloads whichever of
|
|
// the views — a grid, or the timeline, an album or a place — is on screen.
|
|
Some("gallery") => refresh_then(ui, RefreshScope::Photos { full: false }, |ui| {
|
|
match ui.gallery.content.visible_child_name().as_deref() {
|
|
Some("albums") => load_albums(ui),
|
|
Some("places") => load_places(ui),
|
|
_ => load_gallery(ui, false),
|
|
}
|
|
}),
|
|
Some("trash") => refresh_then(ui, RefreshScope::Trash, load_trash),
|
|
Some("shared") => load_shared(ui),
|
|
Some("sharedbyme") => load_shared_by_me(ui),
|
|
Some("devices") => load_devices(ui),
|
|
Some("locations") => reload_sync_view(ui),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Drop a daemon-side cached listing, then run the page's loader to re-fetch it.
|
|
///
|
|
/// The loader runs even when the invalidation failed: it is the loader that knows
|
|
/// how to report an unreachable daemon on its own page, and a refresh that fails
|
|
/// silently would read as a dead button.
|
|
fn refresh_then(ui: &Rc<Ui>, scope: RefreshScope, load: fn(&Rc<Ui>)) {
|
|
// A photos refresh answers as soon as it has started, because re-reading a
|
|
// large library takes minutes. The page is therefore loaded twice: once from
|
|
// what the daemon already holds, and again when the refresh has landed.
|
|
let follow = matches!(scope, RefreshScope::Photos { .. });
|
|
let rx = spawn_request(ui.dirs.control_socket(), Request::Refresh { scope });
|
|
let ui = ui.clone();
|
|
glib::spawn_future_local(async move {
|
|
let _ = rx.recv().await;
|
|
load(&ui);
|
|
if follow {
|
|
follow_photos_refresh(&ui, load);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Watch the timeline refresh the daemon just started, and load the page again
|
|
/// when it finishes.
|
|
///
|
|
/// Giving up after [`PHOTOS_REFRESH_FOLLOW_LIMIT`] is not a failure: the refresh
|
|
/// keeps running in the daemon and the next visit to the page shows its result.
|
|
/// It only stops this front-end from polling a daemon forever.
|
|
fn follow_photos_refresh(ui: &Rc<Ui>, load: fn(&Rc<Ui>)) {
|
|
let ui = ui.clone();
|
|
let give_up_at = Instant::now() + PHOTOS_REFRESH_FOLLOW_LIMIT;
|
|
glib::spawn_future_local(async move {
|
|
loop {
|
|
glib::timeout_future(PHOTOS_REFRESH_POLL).await;
|
|
// The person may have left the gallery in the meantime; reloading a
|
|
// page nobody is looking at would only cost the daemon work.
|
|
if ui.stack.visible_child_name().as_deref() != Some("gallery") {
|
|
return;
|
|
}
|
|
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotosRefreshStatus);
|
|
match rx.recv().await {
|
|
Ok(Ok(Response::PhotosRefresh { running: true })) => {
|
|
if Instant::now() >= give_up_at {
|
|
return;
|
|
}
|
|
}
|
|
Ok(Ok(Response::PhotosRefresh { running: false })) => {
|
|
load(&ui);
|
|
return;
|
|
}
|
|
// An older daemon does not know the request, and anything else
|
|
// means we cannot tell — either way, stop asking.
|
|
_ => return,
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Highlight the sidebar row for whichever page the stack is showing, so
|
|
/// navigation that doesn't start in the sidebar (login landing on Files, the tray
|
|
/// raising the window) still moves the selection.
|
|
fn sync_sidebar(ui: &Rc<Ui>) {
|
|
let Some(current) = ui.stack.visible_child_name() else {
|
|
return;
|
|
};
|
|
let current = destination_of(¤t);
|
|
let index = DESTINATIONS
|
|
.iter()
|
|
.position(|(page, _, _)| *page == current);
|
|
match index.and_then(|i| ui.sidebar.row_at_index(i as i32)) {
|
|
Some(row) => {
|
|
if ui.sidebar.selected_row().as_ref() != Some(&row) {
|
|
ui.sidebar.select_row(Some(&row));
|
|
}
|
|
}
|
|
// The login page has no destination row.
|
|
None => ui.sidebar.unselect_all(),
|
|
}
|
|
}
|
|
|
|
/// Show `count` on the sidebar row of `page`, or nothing when it is 0.
|
|
pub(crate) fn set_sidebar_badge(ui: &Ui, page: &str, count: u64) {
|
|
let Some(index) = DESTINATIONS.iter().position(|(p, _, _)| *p == page) else {
|
|
return;
|
|
};
|
|
let Some(badge) = ui
|
|
.sidebar
|
|
.row_at_index(index as i32)
|
|
.and_then(|row| row.child())
|
|
.and_then(|row_box| row_box.last_child())
|
|
.and_downcast::<gtk4::Label>()
|
|
else {
|
|
return;
|
|
};
|
|
badge.set_visible(count > 0);
|
|
badge.set_label(&count.to_string());
|
|
}
|
|
|
|
/// Show a transient toast. Non-blocking by design: an action's outcome is
|
|
/// reported without stealing focus or forcing a click, so the user can keep
|
|
/// working while a slow upload lands.
|
|
///
|
|
/// A toast's title is Pango markup unless told otherwise. Messages carry file
|
|
/// names and daemon errors, and one with a `&` or `<` in it showed as an empty
|
|
/// toast, so every toast here is plain text.
|
|
fn toast(ui: &Rc<Ui>, message: &str) {
|
|
ui.toasts.add_toast(
|
|
adw::Toast::builder()
|
|
.title(message)
|
|
.use_markup(false)
|
|
.build(),
|
|
);
|
|
}
|
|
|
|
/// Show a toast that offers one follow-up action — an Undo, or a way to the page
|
|
/// the outcome landed on.
|
|
///
|
|
/// Given a longer timeout than a plain toast: an action the user has to notice,
|
|
/// read and reach is not the same ask as a line of confirmation they can ignore.
|
|
fn toast_action(ui: &Rc<Ui>, message: &str, label: &str, action: impl Fn(&Rc<Ui>) + 'static) {
|
|
let toast = adw::Toast::builder()
|
|
.title(message)
|
|
.use_markup(false)
|
|
.button_label(label)
|
|
.timeout(8)
|
|
.build();
|
|
let ui_action = ui.clone();
|
|
toast.connect_button_clicked(move |_| action(&ui_action));
|
|
ui.toasts.add_toast(toast);
|
|
}
|
|
|
|
/// Show a toast for a failure. Same surface as [`toast`], but the message is
|
|
/// prefixed with what was being attempted, since a bare daemon error ("no such
|
|
/// file") reads as noise without it.
|
|
fn toast_error(ui: &Rc<Ui>, what: &str, detail: &str) {
|
|
let detail = detail.trim();
|
|
let message = if detail.is_empty() {
|
|
what.to_string()
|
|
} else {
|
|
// Translators: a failed action's toast. {what} says what failed, such as
|
|
// "Couldn't rename"; {detail} is the reason, usually untranslated text
|
|
// from the mount service.
|
|
gettext_f("{what}: {detail}", &[("what", what), ("detail", detail)])
|
|
};
|
|
tracing::warn!("{message}");
|
|
let toast = adw::Toast::builder()
|
|
.title(&message)
|
|
.use_markup(false)
|
|
.timeout(6)
|
|
.build();
|
|
ui.toasts.add_toast(toast);
|
|
}
|
|
|
|
/// Headline for a failed request, chosen from its [`ErrorKind`] rather than from
|
|
/// the text the daemon happened to assemble.
|
|
///
|
|
/// The daemon's prose names the layer that failed (`"enumerate nodes: …"`), which
|
|
/// is right for a log and wrong for a user. `kind` is the part that says what the
|
|
/// person in front of the screen should understand, so the headline comes from it
|
|
/// and the prose is demoted to the detail line.
|
|
///
|
|
/// `fallback` is the caller's own description of the action ("Couldn't rename"),
|
|
/// used where the class carries no better wording than the caller already has.
|
|
fn error_headline(kind: ErrorKind, fallback: &str) -> &str {
|
|
match kind {
|
|
ErrorKind::Offline => static_gettext(gettext_noop("You're offline")),
|
|
ErrorKind::NotFound => static_gettext(gettext_noop("That's not there any more")),
|
|
ErrorKind::Denied => static_gettext(gettext_noop("You don't have access to that")),
|
|
ErrorKind::Conflict => static_gettext(gettext_noop("Something changed this first")),
|
|
ErrorKind::Quota => static_gettext(gettext_noop("Your Proton Drive is full")),
|
|
ErrorKind::Invalid | ErrorKind::Remote | ErrorKind::Internal => fallback,
|
|
}
|
|
}
|
|
|
|
thread_local! {
|
|
/// Translations handed out by [`static_gettext`], one per msgid.
|
|
static STATIC_TRANSLATIONS: RefCell<HashMap<&'static str, &'static str>> =
|
|
RefCell::new(HashMap::new());
|
|
}
|
|
|
|
/// [`gettext`] for a helper that has to return `&'static str`. The language is
|
|
/// fixed for the life of the process, so each msgid is translated once and the
|
|
/// result kept; the set of msgids is a handful of literals, so the kept strings
|
|
/// are bounded.
|
|
fn static_gettext(msgid: &'static str) -> &'static str {
|
|
STATIC_TRANSLATIONS.with(|cache| {
|
|
*cache
|
|
.borrow_mut()
|
|
.entry(msgid)
|
|
.or_insert_with(|| Box::leak(gettext(msgid).into_boxed_str()))
|
|
})
|
|
}
|
|
|
|
/// Report a failed request, letting its class pick the wording.
|
|
///
|
|
/// Prefer this to [`toast_error`] anywhere a [`Response::Error`] is being shown:
|
|
/// being offline is the common case and deserves to read as a state of the
|
|
/// network rather than as a fault in whatever the user just did.
|
|
fn toast_failure(ui: &Rc<Ui>, what: &str, message: &str, kind: ErrorKind) {
|
|
match kind {
|
|
// The detail here is always some inner layer's EIO. Nothing in it helps.
|
|
ErrorKind::Offline => toast_error(ui, error_headline(kind, what), ""),
|
|
_ => toast_error(ui, error_headline(kind, what), message),
|
|
}
|
|
}
|
|
|
|
/// Back the main menu's entries with window actions.
|
|
fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|
let preferences = gio::SimpleAction::new("preferences", None);
|
|
let ui_prefs = ui.clone();
|
|
let win = window.clone();
|
|
preferences.connect_activate(move |_, _| {
|
|
// Signed out there is nothing to configure: every page needs a session.
|
|
if ui_prefs.session.borrow().is_some() {
|
|
ui_prefs.status.prefs.present(Some(&win));
|
|
}
|
|
});
|
|
window.add_action(&preferences);
|
|
|
|
let sign_out_action = gio::SimpleAction::new("sign-out", None);
|
|
let ui_out = ui.clone();
|
|
sign_out_action.connect_activate(move |_, _| sign_out(&ui_out));
|
|
window.add_action(&sign_out_action);
|
|
|
|
// The Photos page's import banner and page menu lead here. The import is a
|
|
// staged, hours-long migration, so it gets its own page rather than a file
|
|
// chooser: the archives need reviewing before anything is sent, and the run
|
|
// needs somewhere to report.
|
|
let show_import = gio::SimpleAction::new("show-import", None);
|
|
let ui_import = ui.clone();
|
|
show_import.connect_activate(move |_, _| ui_import.stack.set_visible_child_name("takeout"));
|
|
window.add_action(&show_import);
|
|
|
|
// The Photos page menu's Find Duplicates. The finder opens over the page:
|
|
// a review, not a view of it.
|
|
let find_duplicates = gio::SimpleAction::new("find-duplicates", None);
|
|
let ui_duplicates = ui.clone();
|
|
find_duplicates.connect_activate(move |_, _| show_duplicates(&ui_duplicates));
|
|
window.add_action(&find_duplicates);
|
|
|
|
// The Albums view's New Album button.
|
|
let new_album = gio::SimpleAction::new("new-album", None);
|
|
let ui_album = ui.clone();
|
|
new_album.connect_activate(move |_, _| prompt_new_album(&ui_album, Vec::new()));
|
|
window.add_action(&new_album);
|
|
|
|
let shortcuts = gio::SimpleAction::new("shortcuts", None);
|
|
let win = window.clone();
|
|
shortcuts.connect_activate(move |_, _| show_shortcuts(&win));
|
|
window.add_action(&shortcuts);
|
|
|
|
let about = gio::SimpleAction::new("about", None);
|
|
let win = window.clone();
|
|
let ui_about = ui.clone();
|
|
about.connect_activate(move |_, _| {
|
|
let dialog = adw::AboutDialog::builder()
|
|
.application_name(gettext("Proton Drive for Linux"))
|
|
.application_icon("io.narl.proton-drive-linux")
|
|
.version(env!("CARGO_PKG_VERSION"))
|
|
.developer_name("Nils Pukropp")
|
|
.website("https://proton-drive.narl.io")
|
|
.issue_url("https://github.com/narrrl/proton-drive-linux/issues")
|
|
.copyright("© 2026 Nils Pukropp (contact@narl.io)")
|
|
.license_type(gtk4::License::MitX11)
|
|
.comments(gettext("Files-on-demand Proton Drive for the Linux desktop.\n\nUnofficial client — not affiliated with, endorsed by, or supported by Proton AG."))
|
|
.debug_info(debug_info(&ui_about))
|
|
.debug_info_filename("proton-drive-linux-debug.txt")
|
|
.build();
|
|
dialog.add_link(
|
|
&gettext("Source Code"),
|
|
"https://github.com/narrrl/proton-drive-linux",
|
|
);
|
|
dialog.add_link(
|
|
&gettext("Proton Terms of Service"),
|
|
"https://proton.me/legal/terms",
|
|
);
|
|
dialog.add_link(
|
|
&gettext("Proton Privacy Policy"),
|
|
"https://proton.me/legal/privacy",
|
|
);
|
|
// The text is Pango markup: an ampersand must be written as "&".
|
|
dialog.add_legal_section(
|
|
&gettext("Disclaimer"),
|
|
None,
|
|
gtk4::License::Custom,
|
|
Some(&gettext(
|
|
"This is an unofficial, community-built client. It is not affiliated with, endorsed by, or supported by Proton AG.\n\n“Proton” and “Proton Drive” are trademarks of Proton AG, used here only to describe the service this software connects to. Your use of Proton Drive is governed by Proton's own terms of service and privacy policy.\n\nThis software is provided “as is”, without warranty of any kind. Keep a copy of any data you cannot afford to lose.",
|
|
)),
|
|
);
|
|
dialog.add_legal_section(
|
|
"GeoNames",
|
|
None,
|
|
gtk4::License::Custom,
|
|
Some(&gettext(
|
|
"Places are named with data from <a href=\"https://www.geonames.org/\">GeoNames</a>, licensed under <a href=\"https://creativecommons.org/licenses/by/4.0/\">CC BY 4.0</a>.",
|
|
)),
|
|
);
|
|
dialog.add_legal_section(
|
|
"Natural Earth",
|
|
None,
|
|
gtk4::License::Custom,
|
|
Some(&gettext(
|
|
"The places map is drawn from <a href=\"https://www.naturalearthdata.com/\">Natural Earth</a> land outlines and country borders, which are in the public domain.",
|
|
)),
|
|
);
|
|
dialog.add_legal_section(
|
|
"OpenStreetMap",
|
|
None,
|
|
gtk4::License::Custom,
|
|
Some(&gettext(
|
|
"The street map, when turned on, comes from <a href=\"https://openfreemap.org/\">OpenFreeMap</a> in the <a href=\"https://www.openmaptiles.org/\">OpenMapTiles</a> schema. Map data © <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors, available under the Open Database License.",
|
|
)),
|
|
);
|
|
dialog.add_legal_section(
|
|
"proton-sdk-rs",
|
|
Some("© 2026 Nils Pukropp (contact@narl.io)"),
|
|
gtk4::License::MitX11,
|
|
None,
|
|
);
|
|
// Translators: replace with your name(s) and email address(es), one
|
|
// per line, to be credited in the About dialog.
|
|
let credits = gettext("translator-credits");
|
|
if credits != "translator-credits" {
|
|
dialog.set_translator_credits(&credits);
|
|
}
|
|
dialog.present(Some(&win));
|
|
});
|
|
window.add_action(&about);
|
|
}
|
|
|
|
/// Back the launch arguments (see [`Launch`]) with app actions, so a running
|
|
/// instance can act on them.
|
|
fn install_launch_actions(ui: &Rc<Ui>, app: &adw::Application, window: &adw::ApplicationWindow) {
|
|
let show_page = gio::SimpleAction::new("show-page", Some(glib::VariantTy::STRING));
|
|
let ui_page = ui.clone();
|
|
show_page.connect_activate(move |_, param| {
|
|
let Some(page) = param.and_then(|p| p.str()) else {
|
|
return;
|
|
};
|
|
// Signed out, the login page owns the window; unknown names are ignored.
|
|
if ui_page.session.borrow().is_none() {
|
|
return;
|
|
}
|
|
// Signed in, but Proton refused the session (the tray's "Sign In
|
|
// Again…"): the login page, with the rest of the app still reachable.
|
|
if page == "login" {
|
|
show_login(&ui_page);
|
|
return;
|
|
}
|
|
// Activity lives on as the Sync page's History view.
|
|
if page == "activity" {
|
|
ui_page.locations.views.set_visible_child_name("history");
|
|
ui_page.stack.set_visible_child_name("locations");
|
|
} else if ui_page.stack.child_by_name(page).is_some() {
|
|
ui_page.stack.set_visible_child_name(page);
|
|
}
|
|
});
|
|
app.add_action(&show_page);
|
|
|
|
let confirm_stop = gio::SimpleAction::new("confirm-stop", None);
|
|
let win = window.clone();
|
|
let app_stop = app.clone();
|
|
let ui_stop = ui.clone();
|
|
confirm_stop.connect_activate(move |_, _| {
|
|
let dialog = adw::AlertDialog::builder()
|
|
.heading(gettext("Stop Proton Drive?"))
|
|
.body(gettext("My files goes offline and nothing syncs until you connect again. Proton Drive starts again at your next login."))
|
|
.build();
|
|
dialog.add_response("cancel", &gettext("Cancel"));
|
|
dialog.add_response("stop", &pgettext("verb", "Stop"));
|
|
dialog.set_response_appearance("stop", adw::ResponseAppearance::Destructive);
|
|
dialog.set_default_response(Some("cancel"));
|
|
dialog.set_close_response("cancel");
|
|
let app = app_stop.clone();
|
|
let ui = ui_stop.clone();
|
|
dialog.connect_response(Some("stop"), move |_, _| {
|
|
let app = app.clone();
|
|
let ui = ui.clone();
|
|
glib::spawn_future_local(async move {
|
|
let stopped = gio::spawn_blocking(service::stop).await.unwrap_or(false);
|
|
if stopped {
|
|
pdfs_core::tray::quit(&ui.dirs);
|
|
app.quit();
|
|
} else {
|
|
toast_error(
|
|
&ui,
|
|
&gettext("Couldn't stop Proton Drive"),
|
|
&gettext("The Proton Drive service did not stop."),
|
|
);
|
|
}
|
|
});
|
|
});
|
|
dialog.present(Some(&win));
|
|
});
|
|
app.add_action(&confirm_stop);
|
|
}
|
|
|
|
/// The About dialog's Troubleshooting text: what a bug report needs and what
|
|
/// used to sit in a "Developer" group on the Settings page.
|
|
fn debug_info(ui: &Rc<Ui>) -> String {
|
|
let config = ui.dirs.load_config();
|
|
format!(
|
|
"App version: {}\nUser agent: {}\nlibadwaita: {}.{}.{}\nGTK: {}.{}.{}\n\
|
|
Mountpoint: {}\nControl socket: {}\nMount service running: {}\n",
|
|
config.resolved_app_version(),
|
|
config.resolved_user_agent(),
|
|
adw::major_version(),
|
|
adw::minor_version(),
|
|
adw::micro_version(),
|
|
gtk4::major_version(),
|
|
gtk4::minor_version(),
|
|
gtk4::micro_version(),
|
|
ui.dirs.resolved_mountpoint(&config).display(),
|
|
ui.dirs.control_socket().display(),
|
|
if *ui.mounted.borrow() { "yes" } else { "no" },
|
|
)
|
|
}
|
|
|
|
/// The keyboard-shortcut cheatsheet behind the menu entry, listing exactly what
|
|
/// [`install_shortcuts`], the Photos page and the photo viewer bind.
|
|
fn show_shortcuts(window: &adw::ApplicationWindow) {
|
|
const GROUPS: [(&str, &[(&str, &str)]); 4] = [
|
|
(
|
|
gettext_noop("General"),
|
|
&[
|
|
("<Control>f", gettext_noop("Search Drive")),
|
|
("F5", gettext_noop("Refresh")),
|
|
("<Control>comma", gettext_noop("Preferences")),
|
|
("<Control>question", gettext_noop("Keyboard shortcuts")),
|
|
],
|
|
),
|
|
(
|
|
gettext_noop("Files"),
|
|
&[
|
|
("<Control>n", gettext_noop("New folder")),
|
|
("<Control>u", gettext_noop("Upload files")),
|
|
("<Alt>Left <Alt>Right", gettext_noop("Back / forward")),
|
|
("<Alt>Up", gettext_noop("Parent folder")),
|
|
("<Control>1 <Control>2", gettext_noop("Grid / list")),
|
|
("F2", gettext_noop("Rename")),
|
|
("Delete", gettext_noop("Move to Trash")),
|
|
("Escape", gettext_noop("Clear the selection")),
|
|
],
|
|
),
|
|
(
|
|
gettext_noop("Photos"),
|
|
&[
|
|
("<Control>plus", gettext_noop("Larger thumbnails")),
|
|
("<Control>minus", gettext_noop("Smaller thumbnails")),
|
|
("<Control>0", gettext_noop("Reset thumbnail size")),
|
|
("<Control>a", gettext_noop("Select all")),
|
|
],
|
|
),
|
|
(
|
|
gettext_noop("Photo Viewer"),
|
|
&[
|
|
("Left Right", gettext_noop("Previous / next photo")),
|
|
("Home End", gettext_noop("First / last photo")),
|
|
("i", gettext_noop("Show details")),
|
|
("f", gettext_noop("Fullscreen")),
|
|
("Delete", gettext_noop("Move to Trash")),
|
|
("Escape", gettext_noop("Close")),
|
|
],
|
|
),
|
|
];
|
|
let groups: Vec<_> = GROUPS
|
|
.iter()
|
|
.map(|(title, keys)| {
|
|
let keys = keys
|
|
.iter()
|
|
.map(|(accel, action)| (*accel, gettext(action)))
|
|
.collect();
|
|
(gettext(title), keys)
|
|
})
|
|
.collect();
|
|
present_shortcuts(window, &groups);
|
|
}
|
|
|
|
/// Window-level keyboard shortcuts, so the browser is usable without the mouse:
|
|
/// Ctrl+F focuses search, Ctrl+N makes a folder, Ctrl+U uploads, F2 renames and
|
|
/// Delete trashes the selected entry, Escape closes the details pane.
|
|
fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|
let controller = gtk4::EventControllerKey::new();
|
|
let ui = ui.clone();
|
|
controller.connect_key_pressed(move |_, key, _, state| {
|
|
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
|
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
|
|
let on_browser = ui.stack.visible_child_name().as_deref() == Some("browser");
|
|
let on_trash = ui.stack.visible_child_name().as_deref() == Some("trash");
|
|
match key {
|
|
// Refresh works on every page, so it is matched before the
|
|
// browser-only bindings.
|
|
gtk4::gdk::Key::F5 => reload_current_page(&ui),
|
|
gtk4::gdk::Key::r | gtk4::gdk::Key::R if ctrl => reload_current_page(&ui),
|
|
// Search lives on Files, but the shortcut works from anywhere:
|
|
// wanting to find a file is not a reason to first have to remember
|
|
// which page owns the search box.
|
|
gtk4::gdk::Key::f | gtk4::gdk::Key::F if ctrl => {
|
|
if !on_browser {
|
|
ui.stack.set_visible_child_name("browser");
|
|
}
|
|
ui.browser.search.grab_focus();
|
|
}
|
|
gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui),
|
|
gtk4::gdk::Key::_1 | gtk4::gdk::Key::_2 if ctrl && on_browser => set_files_view(
|
|
&ui,
|
|
FilesView {
|
|
list: key == gtk4::gdk::Key::_2,
|
|
..ui.browser.view.get()
|
|
},
|
|
),
|
|
gtk4::gdk::Key::_1 | gtk4::gdk::Key::_2 if ctrl && visible_file_list(&ui).is_some() => {
|
|
if let Some(list) = visible_file_list(&ui) {
|
|
list.show_list(key == gtk4::gdk::Key::_2);
|
|
}
|
|
}
|
|
gtk4::gdk::Key::Left if alt && on_browser => {
|
|
ui.browser.actions.activate_action("back", None)
|
|
}
|
|
gtk4::gdk::Key::Right if alt && on_browser => {
|
|
ui.browser.actions.activate_action("forward", None)
|
|
}
|
|
gtk4::gdk::Key::Up if alt && on_browser => {
|
|
ui.browser.actions.activate_action("up", None)
|
|
}
|
|
// A focused text field takes Backspace first, so this only fires
|
|
// with the focus on the files, as in a file manager.
|
|
gtk4::gdk::Key::BackSpace if on_browser => {
|
|
ui.browser.actions.activate_action("back", None)
|
|
}
|
|
gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui),
|
|
gtk4::gdk::Key::Menu if visible_file_list(&ui).is_some() => {
|
|
if let Some(list) = visible_file_list(&ui) {
|
|
list.popup_keyboard_menu(&ui);
|
|
}
|
|
}
|
|
gtk4::gdk::Key::Return | gtk4::gdk::Key::KP_Enter if alt && on_browser => {
|
|
toggle_details(&ui)
|
|
}
|
|
gtk4::gdk::Key::F10
|
|
if visible_file_list(&ui).is_some()
|
|
&& state.contains(gtk4::gdk::ModifierType::SHIFT_MASK) =>
|
|
{
|
|
if let Some(list) = visible_file_list(&ui) {
|
|
list.popup_keyboard_menu(&ui);
|
|
}
|
|
}
|
|
gtk4::gdk::Key::F2 if on_browser => {
|
|
// Renaming is one name at a time; saying so beats a key that
|
|
// silently does nothing with several items highlighted.
|
|
if selected_entries(&ui).len() > 1 {
|
|
toast(&ui, &gettext("Select a single item to rename it"));
|
|
} else if let Some(entry) = selected_entry(&ui) {
|
|
prompt_rename(&ui, &entry);
|
|
}
|
|
}
|
|
// Acts on the whole selection: Delete with five files highlighted
|
|
// means those five, not whichever one the details pane happens to
|
|
// be describing.
|
|
gtk4::gdk::Key::Delete if on_browser => {
|
|
let entries = selected_entries(&ui);
|
|
if !entries.is_empty() {
|
|
trash_entries(&ui, entries);
|
|
}
|
|
}
|
|
gtk4::gdk::Key::Delete if on_trash => {
|
|
let entries = ui.trash.files.selected();
|
|
if !entries.is_empty() {
|
|
prompt_delete_forever(&ui, &entries);
|
|
}
|
|
}
|
|
gtk4::gdk::Key::Escape
|
|
if on_browser
|
|
&& (ui.browser.split.shows_sidebar() || ui.browser.bulk.reveals_child()) =>
|
|
{
|
|
clear_selection(&ui);
|
|
sync_bulk_bar(&ui);
|
|
clear_details(&ui);
|
|
ui.details.details.toggle.set_active(false);
|
|
}
|
|
gtk4::gdk::Key::Escape
|
|
if !on_browser
|
|
&& visible_file_list(&ui)
|
|
.is_some_and(|list| list.selection.selection().size() > 0) =>
|
|
{
|
|
if let Some(list) = visible_file_list(&ui) {
|
|
list.selection.unselect_all();
|
|
}
|
|
}
|
|
_ => return glib::Propagation::Proceed,
|
|
}
|
|
glib::Propagation::Stop
|
|
});
|
|
window.add_controller(controller);
|
|
}
|
|
|
|
/// Send a desktop notification through the app's GIO channel. `id` replaces any
|
|
/// earlier notification with the same id, so a flapping mount updates one
|
|
/// notification instead of stacking a column of them.
|
|
fn notify(id: &str, title: &str, body: &str) {
|
|
let Some(app) = gio::Application::default() else {
|
|
return;
|
|
};
|
|
let notification = gio::Notification::new(title);
|
|
notification.set_body(Some(body));
|
|
notification.set_priority(gio::NotificationPriority::Low);
|
|
app.send_notification(Some(id), ¬ification);
|
|
}
|
|
|
|
/// 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<Result<Response, pdfs_core::Error>> {
|
|
let (tx, rx) = async_channel::bounded(1);
|
|
std::thread::spawn(move || {
|
|
let result = send(&socket, &req);
|
|
let _ = tx.send_blocking(result);
|
|
});
|
|
rx
|
|
}
|
|
|
|
/// The top-level window, for parenting dialogs.
|
|
fn ui_window(ui: &Rc<Ui>) -> Option<gtk4::Window> {
|
|
ui.stack.root().and_downcast::<gtk4::Window>()
|
|
}
|
|
|
|
/// Ask before an action that has no Undo. `on_confirm` runs only when the user
|
|
/// picks `action`; Cancel is the default, so Enter never destroys anything.
|
|
fn confirm_destructive(
|
|
parent: &impl IsA<gtk4::Widget>,
|
|
heading: &str,
|
|
body: &str,
|
|
action: &str,
|
|
on_confirm: impl Fn() + 'static,
|
|
) {
|
|
let dialog = adw::AlertDialog::builder()
|
|
.heading(heading)
|
|
.body(body)
|
|
.build();
|
|
dialog.add_response("cancel", &gettext("Cancel"));
|
|
dialog.add_response("confirm", action);
|
|
dialog.set_response_appearance("confirm", adw::ResponseAppearance::Destructive);
|
|
dialog.set_default_response(Some("cancel"));
|
|
dialog.set_close_response("cancel");
|
|
dialog.connect_response(Some("confirm"), move |_, _| on_confirm());
|
|
dialog.present(Some(parent));
|
|
}
|
|
|
|
/// A dim, non-interactive placeholder row for an empty section.
|
|
fn dim_row(text: &str) -> adw::ActionRow {
|
|
let row = adw::ActionRow::builder().title(text).build();
|
|
row.add_css_class("dim-label");
|
|
row.set_activatable(false);
|
|
row
|
|
}
|
|
|
|
/// Open a URL in the user's default browser.
|
|
fn open_uri(url: &str) {
|
|
if let Err(e) = gio::AppInfo::launch_default_for_uri(url, None::<&gio::AppLaunchContext>) {
|
|
tracing::warn!("open uri {url}: {e}");
|
|
}
|
|
}
|
|
|
|
/// Uppercase the first character of a role word for read-only display.
|
|
fn capitalize(s: &str) -> String {
|
|
let mut chars = s.chars();
|
|
match chars.next() {
|
|
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
|
|
None => String::new(),
|
|
}
|
|
}
|
|
|
|
/// Whether the Proton Drive service is down (failed, or not running at all).
|
|
/// Asks systemd on a worker thread: `systemctl` can take seconds to answer,
|
|
/// and the window must not freeze while it does.
|
|
pub(crate) async fn service_down() -> bool {
|
|
gio::spawn_blocking(|| service::is_failed() || !service::is_active())
|
|
.await
|
|
.unwrap_or(true)
|
|
}
|
|
|
|
/// Restart the Proton Drive service off the main thread, then run `then`.
|
|
pub(crate) fn restart_service_then(ui: &Rc<Ui>, then: impl FnOnce(&Rc<Ui>) + 'static) {
|
|
let ui = ui.clone();
|
|
glib::spawn_future_local(async move {
|
|
let _ = gio::spawn_blocking(service::restart).await;
|
|
then(&ui);
|
|
});
|
|
}
|
|
|
|
/// A page's answer to "the daemon didn't reply". It tells *still starting*
|
|
/// (poll again, no button) from *down* (an error with Retry, which restarts the
|
|
/// service), so a cold start heals itself but a real failure stays visible.
|
|
/// `status` paints the page's status view; `reload` loads the page again, and
|
|
/// runs only while `page` is still the one on screen.
|
|
pub(crate) fn service_unreachable(
|
|
ui: &Rc<Ui>,
|
|
page: &'static str,
|
|
status: impl Fn(&Rc<Ui>, &str, &str, &str, bool) + 'static,
|
|
reload: impl Fn(&Rc<Ui>) + 'static,
|
|
) {
|
|
let ui = ui.clone();
|
|
glib::spawn_future_local(async move {
|
|
if service_down().await {
|
|
status(
|
|
&ui,
|
|
"network-offline-symbolic",
|
|
&gettext("Not connected"),
|
|
&gettext("The Proton Drive service isn't running."),
|
|
true,
|
|
);
|
|
return;
|
|
}
|
|
status(
|
|
&ui,
|
|
"network-idle-symbolic",
|
|
&gettext("Connecting…"),
|
|
&gettext("Waiting for the Proton Drive service to start."),
|
|
false,
|
|
);
|
|
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
|
if ui.stack.visible_child_name().as_deref() == Some(page) {
|
|
reload(&ui);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/// How long a copy button shows its check after copying.
|
|
const COPIED_FEEDBACK: Duration = Duration::from_millis(1500);
|
|
|
|
/// Confirm a copy on the button itself: its icon turns into a check for a
|
|
/// moment, then back to `icon`.
|
|
pub(crate) fn flash_copied(button: >k4::Button, icon: &'static str) {
|
|
button.set_icon_name("object-select-symbolic");
|
|
let button = button.downgrade();
|
|
glib::timeout_add_local_once(COPIED_FEEDBACK, move || {
|
|
if let Some(button) = button.upgrade() {
|
|
button.set_icon_name(icon);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Open a local path with the handler the user configured — `xdg-open` unless
|
|
/// `open_with` in `config.json` overrides it for this kind of file.
|
|
fn open_path(path: &str) {
|
|
let path = Path::new(path);
|
|
pdfs_core::opener::open_default(path, path.is_dir());
|
|
}
|
|
|
|
/// [`open_path`] for a file the daemon materialised into the content cache,
|
|
/// where the on-disk name is a content hash and carries no extension for the
|
|
/// rules (or `xdg-open`) to key off. `name` is the Drive name it was opened as.
|
|
pub(crate) fn open_named_path(path: &str, name: &str) {
|
|
pdfs_core::opener::open_default_named(Path::new(path), name, false);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_tray_can_launch_the_app_on_a_page_or_at_the_stop_prompt() {
|
|
let args = |list: &[&str]| list.iter().map(|a| a.to_string()).collect::<Vec<_>>();
|
|
assert_eq!(parse_launch(&args(&[])), Launch::Window);
|
|
assert_eq!(
|
|
parse_launch(&args(&["--page", "locations"])),
|
|
Launch::Page("locations".into())
|
|
);
|
|
assert_eq!(
|
|
parse_launch(&args(&["--page=activity"])),
|
|
Launch::Page("activity".into())
|
|
);
|
|
assert_eq!(
|
|
parse_launch(&args(&["--confirm-stop"])),
|
|
Launch::ConfirmStop
|
|
);
|
|
// A trailing --page with no name is no request at all.
|
|
assert_eq!(parse_launch(&args(&["--page"])), Launch::Window);
|
|
}
|
|
|
|
/// One file of a shot, for the group-switch tests.
|
|
fn member(uid: &str, name: Option<&str>, kind: PhotoKind) -> PhotoItem {
|
|
PhotoItem {
|
|
uid: uid.into(),
|
|
capture_time: 0,
|
|
thumb_path: None,
|
|
name: name.map(str::to_string),
|
|
ratio: None,
|
|
no_thumb: false,
|
|
kind,
|
|
favorite: false,
|
|
group_size: 2,
|
|
has_raw: kind == PhotoKind::Raw,
|
|
}
|
|
}
|
|
|
|
/// The lightbox steps through a shot's files and comes back round, so a
|
|
/// burst of three is as reachable as a RAW+JPEG pair.
|
|
#[test]
|
|
fn the_group_switch_cycles_through_every_file() {
|
|
let members = [
|
|
member("jpeg", Some("IMG_1.JPG"), PhotoKind::Photo),
|
|
member("raw", Some("IMG_1.CR2"), PhotoKind::Raw),
|
|
member("clip", None, PhotoKind::Video),
|
|
];
|
|
let next = |uid| next_group_member(&members, uid).map(|item| item.uid.as_str());
|
|
assert_eq!(next("jpeg"), Some("raw"));
|
|
assert_eq!(next("raw"), Some("clip"));
|
|
assert_eq!(next("clip"), Some("jpeg"));
|
|
// A photo that is not in the group leaves the button alone rather than
|
|
// jumping somewhere arbitrary.
|
|
assert_eq!(next("elsewhere"), None);
|
|
assert!(next_group_member(&[], "jpeg").is_none());
|
|
}
|
|
|
|
/// The switch names what it will show; a file whose name the daemon has not
|
|
/// resolved still gets a sentence rather than a blank.
|
|
#[test]
|
|
fn a_group_member_without_a_name_is_still_described() {
|
|
assert_eq!(
|
|
member_label(&member("raw", Some("IMG_1.CR2"), PhotoKind::Raw)),
|
|
"IMG_1.CR2"
|
|
);
|
|
assert_eq!(
|
|
member_label(&member("raw", None, PhotoKind::Raw)),
|
|
"the raw file"
|
|
);
|
|
assert_eq!(
|
|
member_label(&member("clip", Some(""), PhotoKind::Video)),
|
|
"the video"
|
|
);
|
|
}
|
|
}
|