Commit Graph
124 Commits
Author SHA1 Message Date
nvrl 0d4163b9bf fix(service): start with the graphical session, not default.target (#24)
default.target is reached before the desktop exports DISPLAY/WAYLAND_DISPLAY
to the user manager, so After=graphical-session.target never applied. The
daemon's first keyring call D-Bus-activated the Secret Service without a
display; on KDE ksecretd aborted, the first start failed, and every login
left a coredump.

Install into graphical-session.target instead, and use reenable when the
front-ends enable the unit so existing default.target.wants links are
replaced rather than kept alongside the new one.

Fixes #23
2026-09-30 19:01:50 +02:00
nvrl a181f1a5f0 fix(core,fuse): keep working through network drops
The mount only ever went from offline to online: nothing marked it
offline again, so after the Wi-Fi dropped every create, mkdir, delete and
rename called Drive, waited out the SDK's retries for up to two minutes
and answered EIO. Background loops kept polling the dead link.

Connectivity now lives in pdfs-fuse's new link module and goes both
ways. A call that gets no answer (a transport error or a request
timeout) marks the mount offline; a probe thread checks every 2 s,
backing off to 30 s, and on recovery re-lists folders served from the
database, retries failed operations and wakes the drain. Interactive
calls get a 20 s deadline, so a dead link costs one slow call instead of
many.

While offline:
- create, mkdir, unlink, rmdir and rename fall back to the offline
  queue instead of answering EIO;
- folders list from the database even when not marked listed, and are
  re-listed once the link returns;
- reads of uncached data wait up to 30 s for the link, then retry once
  within the 120 s read deadline;
- statfs serves the last quota instead of fetching, and quota refreshes
  are single-flight;
- the event and photo event loops wait for the link instead of polling.

A create whose request timed out may have landed anyway. When the drain
replays it and finds a same-named twin of the same kind, created within
the last two minutes and still empty, it adopts that node instead of
making a conflict copy. The drain also leaves alone a name still held
by a queued trash.
2026-09-30 03:25:00 +02:00
nvrl a2992b3b9d fix(core,fuse): keep replaced and vanished files from failing saves, reads and syncs
Fixes from the account acceptance runs:

- B104: a reader open across a rename-over keeps working; the replaced
  node is unlinked like any other open file instead of forgotten.
- B105: a drain worker holding a revision withdrawn by an unlink no
  longer uploads it as a recovered-… conflict copy (Db::op_exists).
- B106: an open victim's queued ops are withdrawn at trash time, so a
  repeated editor save no longer fails with AlreadyExists; the staged
  blob goes with the last close.
- B107: a remote read gives up after 120 s with EIO instead of hanging
  its caller; the timer is built inside the runtime.
- B108: a mirror upload whose local file was deleted after the scan is
  skipped rather than counted as an error.

The acceptance script reports the failing line for non-assert errors,
ignores queue ops that predate the run, purges with delete-forever
first, and diagnoses a malformed SQLite database against a local replay.
2026-09-29 16:20:51 +02:00
nvrl 7b67db5ced fix(core,fuse,cli): harden writes, trash and cleanup found by account acceptance runs
The FUSE acceptance suite now runs on the signed-in account by default and
cleans up after itself; its first live runs found three daemon bugs.

- B101: a pwrite past EOF now authors the hole it leaves, so truncated bytes
  no longer come back from the base. A write attached to a create while that
  create uploads becomes a revision of the real uid instead of being dropped.
- B102: removing a folder drops the queued revisions of files below it, found
  through the node tree, with their blobs and in-flight uploads. `pdfs rm`
  now drops queued ops too.
- B103: each server call of a trash refresh times out after 120 s, so one hung
  call can no longer freeze the trash listing until restart.

Acceptance suite: account mode with a manifest, lock and reaper; cleanup by
uid; queued ops naming a test root count as leftovers; new edge-case,
throughput and B100/B101 cases; the wide-directory case is split from the
deep tree and gets a doubled budget. Adds `pdfs trash --json`.
2026-09-29 10:09:44 +02:00
nvrl 261d5dbf84 feat(core,fuse,cli): reuse cached content and take several sources in moves
- A mirror download first copies the file out of the content cache when the
  same revision is cached whole or block by block, so moving a file out of an
  on-demand folder into a mirror costs no traffic for what was already read.
- `pdfs move a b c DEST` moves every source, reports each failure and exits
  non-zero if any failed.
- Mirror-to-mirror moves rename with RENAME_NOREPLACE, so a name that
  appeared at the destination after the check is never overwritten.
- The local copy a move out of a mirror drops is checked again just before
  it is deleted; anything written into it meanwhile keeps the copy and is
  uploaded as new by the next pass.
2026-09-29 00:38:40 +02:00
nvrl 5f9d07aff1 feat(fuse,core,cli,gui): move between locations without transferring content
A move request with two absolute paths is resolved against every
location (My files, on-demand and mirror synced folders) and made as a
server-side move_node. The local side is levelled afterwards: mounts
forget and re-list, a mirror folder renames its copy and baseline along
or drops the copy once Drive holds it.

Anything only this disk holds is refused up front: queued ops, open
writes, unsynced or ignored mirror files and symlinks. The GUI Move
dialog gains a top-level list of locations.
2026-09-29 00:26:56 +02:00
nvrl d18655538e feat(core,fuse,gui): push daemon events, polish Photos, adopt newer widgets
The app and the tray stop polling. pdfs-core adds Request::Subscribe: the
connection stays open and carries Event lines (status, transfers, and
Changed with a Topic), with a heartbeat while quiet. In pdfs-fuse, an
EventHub gives each subscriber a mailbox, code that changes something
publishes its topic, and a sampler publishes the status, transfers and sync
progress when they differ. The app follows the feed on one thread, paints
status and transfers as they arrive, and reloads the page on screen when its
topic changes, including after changes made with the pdfs CLI. A hidden
window keeps the changes and catches up when focused. Front-ends fall back
to polling a daemon that predates subscriptions. Refresh buttons leave the
page headers; F5 and Ctrl+R still reload from the server.

Photos gets a scrubber overlaid on the right edge of the timeline in place
of the slider and scrollbar. It shows the years, appears when the pointer
comes near or the timeline scrolls, names the month under the pointer and
jumps to it on drag. Upload becomes a split button whose menu holds the
Google Photos import. Tiles are pooled per row and rebound instead of
rebuilt, hover scales the thumbnail to 1.02 without a shadow, and tiles
lose their tooltip.

On libadwaita 1.8 or newer the app uses AdwSpinner, AdwToggleGroup,
AdwButtonRow and AdwShortcutsDialog, and keeps look-alike stand-ins on
older releases (src/compat.rs, widgets/compat.rs). It still builds against
libadwaita 1.5 and picks at run time, creating the newer widgets by class
name through GtkBuilder, so every package gets them. PDFS_STAND_IN_WIDGETS
forces the stand-ins.

The Sync and share-link icons draw in full again: GTK drops group
transforms from symbolic icons, so their coordinates are baked into the
paths. All catalogs are updated and complete.
2026-09-28 16:37:23 +02:00
nvrl da7ea0ec56 feat(core,gui): split Sync into views, tidy Activity, rework Computers
The Sync page gets Overview, Folders and History views under a switcher in
the header. Overview holds the status card, conflicts, transfers and the
queue; Folders holds the folder list, with Add Folder and a popover that
explains the sync modes; History is the former Activity page, which leaves
the sidebar. A banner in Folders and History points back to Overview when
changes fail or conflict.

Activity hides save-by-rename temporaries (pdfs-core gains
syncignore::is_scratch_name), with a toggle to show them again. Runs of
similar events fold into one expandable row, "Show Older" pages further back
through what the daemon keeps, and titles and details are translated
sentences built from the daemon's targets instead of its raw text.
Conflict copies say when they were made.

Computers lists every device in one group, this computer first with a
"This computer" pill; its row opens Sync's folder list. Another computer's
row browses its backup read-only in the shared file list, and its menu
offers Browse Files, Restore, Rename, "Continue This Backup Here…" (the old
identity adoption, now explained in its dialog) and Remove. Subtitles read
"last backup" or "no backups yet", and an empty list links to the download
page. DirEntry derives Default for the browse view's synthesized entries.

The sidebar shows badges for sync problems and waiting invitations, polled
every five minutes away from their pages. All catalogs are updated and
complete.
2026-09-28 15:14:59 +02:00
nvrl 127b71efd0 feat(core,fuse,gui): rebuild Trash and shared pages on one file list
The list and grid view from My files moves into widgets/file_list.rs as
FileList, so Trash, Shared with me and Shared by me share its columns,
selection, context menus, keyboard handling and list/grid switch instead of
each drawing its own rows.

Trash shows where each item came from and when it was deleted. Proton does
not expose the trash time through the SDK yet, so the daemon records when it
first sees a node in the trash (migration V35, trash_seen) and reports that;
the date becomes exact once proton-sdk-rs exposes Node::trash_time. Restoring
offers a Show action, and opening a trashed item explains that it needs to be
restored first.

Shared with me splits files and saved links into two views. Files show who
shared them, with a warning pill for unverified senders, the access they give
and when they were shared. Pending invitations sit in a banner that opens a
dialog to accept or decline each one. Several items can be left at once.
Saved links get their own list with open, copy and remove actions.

Shared by me gains a filter (all, links, people, expired links) and columns
for location, people and link state, with a copy button for live links.
Several items can stop being shared at once, the page refreshes after the
share dialog closes, and a reload rebinds only the rows that changed so the
selection survives.

All catalogs are updated and complete.
2026-09-28 14:35:37 +02:00
nvrl 2339b17d97 feat(core,gui): keep pages up while they reload
Pages no longer swap their content for a "Loading…" status on every load.
A new Loader keeps the last rows on screen and only shows the loading
state if the reply takes longer than 300 ms; a newer load always
supersedes an older one, so a late reply is dropped instead of painted.
Reloading what is on screen keeps it usable; loading something else greys
the old rows out and clears the selection, since their paths would
resolve against the new folder.

My files, search results and Trash now swap only the rows that changed,
so selection, scroll position and thumbnails survive a refresh. Activity
is one sectioned ListView: open conflicts first, then a section per day,
with the filter bar pinned above the list.

Polls give up after 5 s instead of 120 s. A daemon that is running but
doesn't answer now reads as "Not responding" in the app and the tray
rather than as disconnected.
2026-09-28 13:51:28 +02:00
nvrl a90eeecda9 feat(core,fuse,gui): find photos that look alike
The duplicate finder gains an Exact / Similar switch. Similar asks the
daemon for PhotoSimilar, which starts a background pass that hashes the
thumbnail of every shot not hashed yet and replies at once with the sets
found so far and how far hashing has got. The dialog shows a progress bar
and asks again until the pass is done, then lists the sets.

The pass takes thumbnails from the server only: a photo without one is
skipped for the run instead of downloaded whole, and the dialog says how
many photos could not be compared. Decoding and hashing are spread over
the machine's cores.

Every similar photo starts ticked Keep, since photos that look alike can
be different shots. A similar photo stands for its whole shot, so one not
kept goes to Trash with the rest of its shot.
2026-09-28 09:16:54 +02:00
nvrl c98ed0d83b feat(core): perceptual hashes for finding similar photos
A 64-bit difference hash of each thumbnail, stored in the new
photos.similar_hash column (schema 34), and the queries the similar-photo
finder needs: what is left to hash, progress, and the sets of shots that
look alike. Sets made only of byte-identical copies are left to the
duplicate finder. A refresh keeps a hash unless the photo's bytes changed.

The daemon pass, the control request and the GUI are not built yet; see
PLAN.md.
2026-09-28 09:16:54 +02:00
nvrl fde9c2d271 feat(core,fuse,gui): Proton theme, photos on the map and an opt-in street map
- The Proton accent preference becomes a whole Proton theme that also
  holds against a GTK theme imported from gtk.css; folders get Proton's
  purple folder icon while it is on.
- Places map markers show one of each town's photos with a count, and
  zoomed in on a town every photo sits where it was taken
  (Request::PlaceLocations). Photos taken on one spot are listed in a
  popover. "Show on map" in the photo viewer goes to the Places map.
- The built-in map draws country borders, sea and land in their own
  colours.
- An opt-in street map from OpenFreeMap (vector tiles drawn with cairo,
  cached on disk up to 256 MB), with the OpenStreetMap credit on the map
  and in About.
- Translations for all catalogs; changelog folded into 2.3.0.
2026-09-28 03:12:57 +02:00
nvrl b3c03a7162 feat(core,fuse,gui): places on a world map
The Places view gets a Grid/Map toggle. The map shows a bubble per town,
sized by its number of photos, over the outline of the world's land. Towns
whose bubbles would overlap merge into one that counts all their photos, and
clicking it zooms in until they part; clicking a single town opens it like a
card does. Drag to pan, scroll or pinch to zoom, and the buttons zoom in,
zoom out or frame every place again. Hovering names the town, or the first
few towns of a merged bubble.

No tile server is involved: none that is free has terms allowing an app like
this one to use it. The map is drawn from Natural Earth's 1:50m land
polygons (public domain), trimmed and simplified by
scripts/natural-earth-land.py into crates/pdfs-gui/data/land.txt, in Web
Mercator. The About dialog credits Natural Earth.

PlaceInfo carries the town's latitude and longitude; a daemon older than the
map sends none, and its towns are left off the map.
2026-09-28 02:12:59 +02:00
nvrl 8749d6c2c6 feat(core,fuse,gui): show how far photo locations have been read
The first timeline refresh after the Places update reads the metadata of
the whole library, and until it is done the Places view has nothing to show
and no sign that anything is happening.

The refresh now records how many photos it has resolved out of how many,
counting photos read by an earlier pass as done. Response::Places carries
it as mapping (absent from older daemons, which parse as None). The Places
view shows "Mapping photos: 120 of 480" with a bar while it is set, asks
again every two seconds, and loads the new places once the pass ends. An
empty listing during the pass says the photos are being mapped rather than
that there are no places.
2026-09-28 02:12:59 +02:00
nvrl 753056418c feat(core,fuse,gui): places view from photo locations
Photos now know where they were taken. proton-drive-rs 0.7.0 reads the
Location from a photo's extended attributes, and the timeline refresh stores
it with the photo (schema v33). The migration marks every photo unresolved
once, so the next refresh backfills locations from node metadata without
downloading anything.

Towns are looked up offline: pdfs-core bundles GeoNames' cities15000 table,
trimmed by scripts/geonames-cities.py, and files each photo under the
nearest town within 50 km. The new PhotoPlaces and PlacePhotos requests list
the towns and page one town's photos.

The Photos page gets a third view, Places, with a card per town (newest
photo, town, country, count). A place opens in the gallery like an album.
Country names come translated from the iso-codes catalogs, now an optional
dependency. The About dialog credits GeoNames (CC BY 4.0).
2026-09-28 02:12:59 +02:00
nvrl 2f14c1688f feat(core,fuse,gui): find and trash duplicate photos
A "Find duplicates" button in the Photos header opens a review of every
set of files that share a content hash. Each set shows its copies side by
side with a Keep toggle; the copy in the most albums, then in the larger
RAW+JPEG group, then the oldest, is kept by default, and a set can't lose
its last kept copy. The copies not kept move to Trash one file at a time.

- core: Db::photo_duplicates, Request::PhotoDuplicates, and a files_only
  flag on TrashNodes (defaults to false, so old clients still trash whole
  groups).
- core: deleting a group's representative now clears the group key of its
  remaining files instead of leaving them pointing at a missing uid.
- fuse: handlers for both requests.
2026-09-28 02:12:59 +02:00
nvrl 7dbf818d83 feat(core,fuse,gui): "On this day" strip above the photos timeline
Add a PhotosOnThisDay control request, served from the stored timeline:
photos whose local capture date matches today's in earlier years, one
row per group. The GUI shows a card per year ("3 years ago") with the
year's newest photo as its cover; clicking a card scrolls the timeline
to that day. The strip is revealed only at the top of the whole,
unfiltered timeline, so it gives the grid its room back once you
scroll or filter.
2026-09-28 02:12:59 +02:00
nvrl 5c1da9d7a5 feat(core,fuse,gui,cli): filter the photos timeline to photos in no album
A new pill next to the favorites filter narrows the timeline to the photos
no album of ours holds yet, so what is left to file is easy to find. A
RAW+JPEG pair counts as filed when any of its files is in an album.

PhotosTimeline gains an optional not_in_album flag; older front-ends omit it
and get the whole timeline. Album contents are fetched lazily, so the first
filtered page fetches the contents of any album not yet listed. Photos added
to an album while the filter is on leave the timeline at once.
2026-09-28 02:12:58 +02:00
nvrl e6cb52339d fix(fuse,core): list and resolve conflict copies in synced folders, log each once (B99)
The conflict sweep flagged divergent copies anywhere in the tree but kept its
dedupe set in memory, so every daemon start logged them again. The conflict
listing only covered My Files, so copies in on-demand sync folders were never
listed, and the Activity page showed them as resolved while they were still on disk.

List copies in synced folders by their absolute local path, route resolves
to the mount that serves them, flag only what the listing can show, and
consult the activity log before flagging a copy again.
2026-09-27 23:28:00 +02:00
nvrl 90e110e893 feat(fuse,core): thumbnails for videos and for our own uploads
Videos without a server thumbnail fell through to the local fallback,
which downloaded the whole file, failed to decode it as an image and
wrote the video off for good. Grab one frame with ffmpeg instead, a
second in or from the start for shorter clips. Probe for ffmpeg before
downloading, bound every run, and skip videos over 256 MiB.
Migration V32 gives the videos written off so far another try.

Those videos had no server thumbnail because our uploads never sent one.
Every upload path (bulk upload, drained mount writes, conflict copies,
new revisions and folder sync) now attaches a thumbnail rendered from
the local file, kept under the 60 KiB Proton's own clients use. Files
that are not images or videos, or cannot be decoded, upload bare as
before; a .ts file only counts as video with an MPEG-TS sync byte.

ffmpeg is a new optional dependency in the PKGBUILDs.
2026-09-23 20:29:15 +02:00
nvrl d9064ef880 feat(core,gui): gettext translations and language preference
The app, the tray and the search prompt route every user-visible string
through GNU gettext in the `pdfs` text domain, with named placeholders and
plural forms. po/ holds the template, update and build scripts, and catalogs
for de, en_GB, es, fr, it, nl, pl and pt_BR. Preferences gains a Language row
that overrides the system language from the next start (config `language`).

Packages compile the catalogs into /usr/share/locale and the desktop files
carry translated descriptions and keywords. docs/TRANSLATING.md describes
the workflow.
2026-09-23 08:07:50 +02:00
nvrl 52651caf8e feat(core,fuse,gui,cli): restore to this computer per device
ListDeviceRestorableFolders and RestoreDeviceFolders read another
registered device's folders and attach them with that device's share,
without changing this machine's cached device. The Computers page moves
restore into each computer's menu, and pdfs sync restore takes --device.
2026-09-23 04:15:17 +02:00
nvrl 15985f8684 feat(core,fuse,gui,cli): stop sharing and share dialog polish
StopSharing / StopSharingByUid remove the public link, external
invitations, invitations and members, trying every step and reporting
what is still shared. Shared by Me gets a Stop Sharing… item and
pdfs unshare <path> works without an id.

The Share dialog validates addresses inline, shows spinners on buttons
while requests run, offers a link expiry and shows an existing link's
expiry.
2026-09-23 04:09:32 +02:00
nvrl f6136ce344 feat(core,gui): tray states and menu
The tray icon follows the drive's phase (synced, syncing, paused,
offline, needs attention) and reports NeedsAttention while changes keep
failing. The menu gains an issues item that opens the Sync page, Sign In
when signed out, Hide Tray Icon (persisted as tray_hidden) and Stop
Proton Drive, which pdfs-app confirms via --confirm-stop. A second
pdfs-app launch raises the running window.
2026-09-23 03:55:51 +02:00
nvrl 43d1afe705 feat(core,fuse,gui,cli): album management
New requests CreateAlbum, RenameAlbum, DeleteAlbum, AddToAlbum and
RemoveFromAlbum, backed by proton-drive-rs 0.6.6. The Albums view gains a
New Album button and a card menu with Rename and Delete; photos gain Add
to Album (menu and selection bar) and Remove from Album inside an album.
CLI: pdfs new-album, rename-album, delete-album, add-to-album,
remove-from-album.

Also fixes the album view state: the timeline filters are hidden on the
album grid, the header counts albums, Back keeps the grid and its scroll
position, and the timeline is only reloaded when an album replaced it.
2026-09-23 03:25:16 +02:00
nvrl 29e5769dec feat(core,fuse,gui,cli): search scope and location column
Request::Search takes an optional folder scope (serde default, so older
front-ends keep searching everywhere). The GUI offers a This folder toggle
beside the search box and shows a Location column for hits; the CLI gains
pdfs search --in. Pins report whether a pinned file is cached, so pin-derived
search hits badge correctly.
2026-09-23 03:12:18 +02:00
nvrl 91a5f21340 docs(core,fuse,gui,cli): US spelling and correct schema lines
User-visible strings, doc comments and docs now say "Favorite" and
"color", matching the `pdfs favorite` command; UI_UX_PLAN.md §8 records
the choice. The changelog's 1.11.x and Unreleased entries said schema 30,
but V31 (`photos.resolved_at`) shipped in 1.11.0. Adds the missing
release links for 1.10.0 to 1.11.1.
2026-09-23 03:01:27 +02:00
nvrl 8351382ef2 feat(core,fuse,gui,cli): upload and download bandwidth limits (phase 6)
Every transfer charges the bytes it moves to its direction's rate limit,
a virtual clock shared by all transfers with a one-second burst, and sleeps
off any overshoot. On runtime workers the sleep goes through block_in_place
so FUSE reads keep their threads.

The caps are set live with SetBandwidthLimits, persisted to config and
applied at mount start. Preferences gains a Network group with debounced
MiB/s editors, and the CLI gains `pdfs sync limit --up/--down`.
2026-09-23 02:43:42 +02:00
nvrl b58b60010e feat(core,fuse,gui,cli): pause a single mirrored folder (phase 6) 2026-09-23 02:36:45 +02:00
nvrl 8bc52edeb4 feat(core,gui): file browser menus, sort, history and folder-picker move (phase 4)
The My Files header gets a New menu, a grid/list split button whose menu
sorts by name, size or date (persisted as files_view in the config), and a
folder menu for Build Thumbnails and Open in File Manager. Navigation
keeps a back/forward history (Alt+Left/Right, mouse buttons, Alt+Up).
Move to… browses Drive folders and handles a whole selection, rename
preselects the stem, and files dropped from a file manager upload into
the folder on screen.
2026-09-23 02:21:39 +02:00
nvrl 792eee75a4 feat(core,fuse,gui,cli): list and resolve sync conflicts (phase 3)
Conflict copies under My Files are paired with their original and listed
over the control socket. The Sync page shows them with a resolve dialog
(keep original, keep copy, keep both under a new name), and the CLI gains
`pdfs conflicts` and `pdfs conflicts resolve`. Every resolution goes
through the Proton trash, and files that are open, being written or still
owed an upload are refused.
2026-09-23 02:14:56 +02:00
nvrl 067561564e feat(core,fuse,gui,cli): pause/resume sync and a visible upload queue (phase 2)
Backend (docs/UI_UX_PLAN.md B-1, B-2):
- Request::SetSyncPaused { paused, until } pauses the drain worker and the
  mirror reconcile. FUSE reads, write staging, the watcher and the poll keep
  running. The pause is persisted in the `state` table (`sync_paused_until`),
  so it survives a restart; a timed pause ends by itself through a timer
  thread, re-armed on mount. Resuming wakes the drain and runs a full
  reconcile. Response::Status gains `paused` and `paused_until`.
- Request::ListPendingOps lists the queue with paths, attempts, last error,
  next attempt and parked/failing flags. Request::RetryPendingOp { id } makes
  one backed-off op (or every failed one) due now. Parked ops are never
  un-parked and nothing is dropped from the queue.

Front ends:
- Sync page: a status card with Pause/Resume (split button with 1 h, 8 h and
  24 h pauses) and a "Waiting to Upload" list with Retry now and Retry All.
- The sidebar strip and the tray show the paused state; the tray gains Pause
  Sync / Resume Sync and names failing changes.
- CLI: `pdfs sync pause [--for 30m|1h|2d]`, `pdfs sync resume`,
  `pdfs sync queue`, `pdfs sync retry [id]`; `pdfs status` reports a pause.

Not in this phase: merging Activity into the Sync page, discarding a queued
op (needs a recovery copy first), and per-folder pause.
2026-09-23 02:06:41 +02:00
nvrl cf9e1665ca feat(core,gui): phase 1 shell — per-page headers, sidebar footer, Preferences dialog
Restructure the app shell per docs/UI_UX_PLAN.md phase 1:

- Every destination page gets its own AdwHeaderBar via page_frame(), with
  page actions packed into it and a busy spinner beside the title.
- The sidebar is regrouped (files, then a Sync section) with a primary menu
  (Preferences, Keyboard Shortcuts, About) and a footer showing sync state,
  storage quota and the account with Sign Out.
- The Settings page is gone: startup, folder location, appearance and cache
  options move into an AdwPreferencesDialog (Ctrl+,); transfers move to the
  Sync page; developer details move into About's debug info.
- Inline CSS moves to a gresource stylesheet using libadwaita colour
  variables. The system accent is the default; the Proton purple accent is
  an opt-in preference (new `proton_accent` config key, defaults off).
- Add a keyboard shortcuts dialog (Ctrl+?).

libadwaita requirement stays at 1.5, so Ubuntu 24.04 remains supported.
2026-09-23 01:50:37 +02:00
nvrl cc308ac25a fix(core,fuse,gui): UI/UX quick wins from the audit (phase 0)
- Move Google Takeout import out of Settings; Photos owns it, and the
  Takeout page's back button returns there.
- Trash from the file browser is Undo-only (no confirm); F2 on a
  multi-selection explains why it does nothing.
- Ask before sign-out, rejecting an invitation, removing someone's access,
  removing a public link, and switching a folder to on-demand.
- Clearer offline wording ("Make available offline" / "Make online only")
  with dedicated offline, online-only and upload symbolic icons; the pin row
  is hidden for folders.
- Search hits report whether a file is cached, so search results show the
  offline badge.
- Activity log: readable Empty Trash / link entries, distinct icons, and
  error/warning styling for failures and conflicts.
- Proper singular/plural counts instead of "item(s)" / "archive(s)".
- Name earlier versions by save time instead of revision id; capitalise
  the public link role; stop-syncing uses a stop icon, not a trash can.
- Label the Photos Upload and "Show on map" buttons; toast when a
  thumbnail build finishes; the versions button follows the mount state.
- Mode switches and device mutations reload both sync pages.
2026-09-23 01:34:35 +02:00
nvrl 860d29cfe8 fix(core,fuse): un-park abandoned creates, retire orphan staged bytes, serialise refreshes
Four faults found by reading the journal for issues that were never fixed,
tracked as B94-B97 in docs/BUGS.md.

A create parked behind a temp-file rename (B70) had no exit but that rename,
so 43 uploads sat in the queue for weeks holding their staged bytes. The
primary drain worker now sweeps parked creates while the queue is idle: one
whose node is gone is discarded, and one older than PARK_EXPIRY_MS that nobody
holds open is un-parked rather than dropped, since staged bytes are the only
copy of the write. An open node stays parked however old it is.

.tmp and .temp leave TRANSIENT_SUFFIXES. The list served two mechanisms with
different consequences: ignoring a name during mirror reconcile is reversible,
parking a create is not, and a Takeout index.tmp is a final name that nothing
will ever rename.

Staged blobs that match no queued op and name no node were kept forever and
warned about once per blob per mount (95 blobs, ~445 MiB, oldest two months).
Blobs past STAGING_ORPHAN_RETAIN are retired; the rest are one aggregated
warning.

Refresh control requests now run one per scope. Every scope is individually
cheap, but duplicates contend for the database lock the running refresh holds,
which left five in flight for twenty-three minutes. request_kind names the
scope so the report says which view wedged. The photo metadata pass also stops
at shutdown instead of running into the cancelled runtime and logging one
"skipping undecryptable photo" per node.
2026-09-19 20:29:48 +02:00
nvrl 6cc8f72bab fix(core,fuse,gui,cli): read only the photos a refresh has not read
A timeline refresh resolved every photo's node to learn its name, media
type, favourite tag, content hash and photo relation — 63 round-trips for
a 12,445-photo library, minutes of work, repeated every time the timeline
went stale sixty seconds later. None of that metadata changes on its own,
and when it does change the event feed names the node it changed.

`photos.resolved_at` (schema v31) records that a photo's node was read.
The refresh resolves the photos without that mark: the ones it has never
read, and the ones a remote event marked stale. An existing timeline is
backfilled as resolved, so upgrading does not re-read the library once.

Remote changes now name what they changed instead of asking for
everything. `classify_photo_events` turns a `NodeUpdated` into
`photos_mark_unresolved(uid)`, so a favourite toggled on a phone costs one
node read rather than the whole library; a local toggle is claimed as our
own echo, because the feed replays it and a `NodeUpdated` carries nothing
that says what changed. A continuity loss and `refresh photos --full` drop
every mark, which is the way back to a whole-library read.

Along the way: `photos_replace` never kept a photo's name, so a photo the
SDK could not decrypt was renamed to nothing on every refresh — and the
tab split and the RAW+JPEG grouping are both derived from the name. It is
kept now, and a row that *was* resolved is authoritative instead, so a
photo unlinked from its main elsewhere finally loses its `main_uid`.

`TIMELINE_ENRICH_CHUNK` is 150, matching the SDK's own `MAX_BATCH_COUNT`:
a chunk of 200 was split there into a full request and a 50-uid tail.
2026-09-19 17:39:49 +02:00
nvrl 376f3edb28 fix(core,fuse,gui,cli): pair a Pixel raw with its JPEG, and stop refresh timing out
Three things the 12,445-photo library showed that the tests did not.

A Pixel raw capture is two files named after the role each plays —
`PXL_….RAW-01.COVER.jpg` and `PXL_….RAW-02.ORIGINAL.dng` — so their stems differ
and the pairing rule never saw one shot. The marker is now cut off before names
are compared, narrowly: digits, then one role word, so an ordinary name that
happens to contain `.raw-` keeps its stem. Fed the real library's first 40 rows
through the grouping, they collapse to 21 tiles.

A raw that has a JPEG beside it is now painted with that JPEG's thumbnail, which
the server usually already holds. The Raw tab costs no raw download and no raw
decode for those tiles. Raws are found in one batched read, so an ordinary page
pays nothing for the lookup.

Photos could not be moved to Trash at all: they live on the photo volume, which
the mount never holds, so no `nodes` row exists, every authority answered
`Unknown`, and the write check refused the whole library with "trash access: not
allowed". Membership of our own timeline is the authority that does exist for a
photo, and it now answers. An album photo on someone else's volume still fails
closed.

Refreshing the gallery then reported `io: Resource temporarily unavailable (os
error 11)` (B92). The daemon held the control reply until the whole timeline had
been re-read — 63 batches of 200 nodes for this library, minutes of work against
a 120 s read bound. The daemon now answers as soon as the refresh is running,
`PhotosRefreshStatus` reports when it is done, and both front ends follow it: the
gallery reloads the page when it clears, and `pdfs refresh photos` blocks so the
command still means what it says. Each refresh logs its photo count, batch count
and duration.

Released as 1.11.0.
2026-09-19 17:08:33 +02:00
nvrl 09c0684a5e fix(core): keep pre-v30 photos on the page until a refresh groups them
A database written before the grouping column opens with `group_key` NULL on
every row, and NULL matches nothing: the join dropped the whole timeline until
the first refresh ran. `COALESCE(group_key, uid)` is what makes such a row its
own group, which is how the gallery behaved before the column existed.

Covered by a migration test that rewinds a fixture to v29.
2026-09-19 15:41:24 +02:00
nvrl c1df3b6cc2 feat(gui): show a grouped shot as one tile, and reach its other files
The grouping landed in the daemon; this is what it looks like. A tile whose shot
was stored as more than one file badges that in the corner — "RAW" when one of
the members is a raw file, since that is the word the person is looking for, and
a plain count otherwise (a live photo, a burst). The lightbox gains a control
that steps through the group's files, which is the only way to reach the RAW now
that the grid shows the shot once.

Deleting says what it will do: the daemon takes the whole group, so the
confirmation names the file count when a selection stands for more files than
tiles.
2026-09-19 15:39:14 +02:00
nvrl afd71713d5 feat(core,fuse): group the RAW and the JPEG of one shot
A camera that writes both puts two files on the volume, and the timeline showed
two tiles of the same photo. They are one shot to the person who took it.

Schema v30 records what ties them together: the server's own duplicate hash and
main-photo relation, plus a `group_key` every member of a shot carries. The key
is computed when the timeline is replaced, in three steps — the server relation
first, because it knows about live photos and bursts and we do not have to guess
at them; then same capture day, same file-name stem and one member raw while the
other is not, which is the pair a camera writes; otherwise the photo is its own
group. The member the grid shows is the non-raw one, since a JPEG decodes in
milliseconds and is what the person expects to see.

The gallery therefore pages by group and carries `group_size` and `has_raw` on
each entry, and the date scrubber counts what the page shows. The Raw tab is the
exception and still lists every raw file, because that tab exists to answer
"which raws do I have". Album pages are not grouped at all: an album is a list
someone made, and it shows exactly what they put in it.

`TimelineRow` is a struct now. It had grown to five positional fields and was
about to grow to seven, at which point nobody can read a call site.
2026-09-19 15:34:17 +02:00
nvrl c6bdba024f feat(gui,fuse): delete photos from the gallery and notice remote deletions
The gallery could show photos and nothing else: a photo deleted on the phone
stayed on the page for minutes, and a photo you wanted gone had to be deleted
on another device. Both are now handled here.

Deleting needs a uid-addressed request, because `Request::Delete` takes a
mountpoint path and the photos volume is not in the FUSE mount. `TrashNodes`
trashes through the SDK and answers per-uid outcomes, so a partial failure is
reportable; the daemon then drops those rows with `Db::photos_delete`, album
membership included, rather than leaving the gallery to wait for the next
timeline refresh. The GUI removes the tiles immediately with an Undo toast and
puts back anything the server refused — the photos are in Proton's trash and
stay recoverable there either way. Selection is Ctrl/Shift+click, the Select
toggle or Delete in the lightbox.

Remote deletions arrive on their own now. The Drive volume and the photos
volume have separate event streams, so the photos volume gets its own poller
and its own cursor. Trash and delete events remove rows; anything else only
marks the timeline stale, because there is no inode space to converge on this
volume. With the timeline TTL down to 60 s and an explicit Refresh that waits
for the server instead of only clearing the freshness stamp, a photo deleted
elsewhere leaves the grid in about ten seconds.
2026-09-19 15:25:04 +02:00
nvrl f24ac8a429 feat(fuse,core,cli): report what the daemon is doing, for the next hang
On 2026-09-18 the daemon hung: 43 minutes of CPU over 20 hours, 5.2 GiB
resident, silent from 17:10 until a manual restart the next day, and no
panic. Nothing in the journal said what it had been waiting on, and
nothing in the daemon could be asked.

- The worker pool publishes per-thread state: whether the thread is busy,
  how long it has been in that state, and the label of the job in hand.
  Every FUSE handler that hands work to the pool now names its job
  (`read`, `lookup`, `rename`), so a stuck worker is named rather than
  merely counted. Queue depths and per-lane completion counts are
  mirrored outside the queue lock.
- `Request::Diagnostics` / `pdfs diagnostics` reports those, plus
  resident size, the control handler count against its limit, the
  in-flight control requests with their ages, and the pending-op count.
- Built to answer while the daemon is wedged: atomics and `try_lock`
  only, and the one field that needs the database reads it on a throwaway
  thread with a 500 ms deadline.
2026-09-19 14:40:26 +02:00
nvrl f78f663740 feat(core,fuse): restore the whole trashed tree, and bump the SDK to 0.6.5
The trash is a flat list of uids, but a delete has a shape: a folder, and
often items inside it that were trashed separately beforehand. Restoring
one of those in isolation is not what it means — a folder came back
without the contents deleted with it, and a file came back inside a
folder that is still trashed, which is to say nowhere the user can see.

- Schema 29: `trash.parent_uid`, filled by the trash refresh from the
  node's parent, plus an index for the walk.
- `expand_restore` takes the connected piece of the trashed tree around
  what was asked for (every trashed descendant, every trashed ancestor)
  and orders it into waves, shallowest first: the server cannot put a
  node back under a parent that is still trashed. A uid the listing does
  not know about is restored on its own, because the listing is
  materialised in chunks and can be stale.
- `Core::restore` restores wave by wave, still streaming per-node
  outcomes, so one node the server refuses leaves the rest restored.

The SDK bump brings the 6cbf2f44 port: permanent API errors are no
longer retried on block transfers, expired block URLs are re-minted on
the whole-file and public-link download paths, and a server-supplied
Retry-After is capped at 120 s.
2026-09-19 14:31:40 +02:00
nvrl b095b9f6b0 fix(core,fuse): never stat a foreign mount from the local-file indexer
The "This computer" indexer walks $HOME, and `default_excludes` only kept it
out of the Drive mountpoint and our own state/cache dirs. A home directory
that also holds third-party FUSE mounts (sshfs, rclone) got walked into.

`scan` does ask for `same_file_system(true)`, which looks sufficient and is
not: `ignore` implements it as `is_same_file_system` -> `device_num(path)` ->
`path.metadata()`, run after the directory is queued. Learning that a mount is
foreign requires stat'ing it first, and against a mount whose server is gone
that stat never returns.

The consequence is worse than a stalled scan. The blocked thread sits in
uninterruptible FUSE wait, so it ignores SIGKILL: the daemon can never exit,
systemd's stop times out, and the unreapable process keeps the cache.db lock
the next start needs, turning one dead mount into a permanent restart loop.
No userspace recovery exists once a thread is in that state, so the fix has to
be to never enter it.

Exclude every mountpoint strictly below the scan root, read by path from
/proc/self/mounts (octal-unescaped, so a path with a space still matches).
`filter_entry` runs before the device stat, so the walker never touches them.
Re-read per scan rather than cached at startup: a mount that appeared since
the daemon started is the likeliest to be half-alive.

This does not change what is indexed — a nested mount is a different device,
so it was already going to be skipped. Only the stat in front of that decision
goes away.

Refs: docs/BUGS.md B90
2026-09-16 23:05:06 +02:00
nvrl b2f62f70e1 chore(deps,core,gui): upgrade dependencies to latest
- keyring 3 -> keyring-core 1 + dbus-secret-service-keyring-store 1
  (crypto-rust). Same libdbus Secret Service backend; with no target the
  store matches items keyring 3 wrote, so stored sessions survive.
- ksni 0.2 -> 0.3 via the blocking API, with assume_sni_available so a
  tray that starts before the SNI watcher waits instead of failing. Drops
  the clap 2 / atty chain (Dependabot atty advisory).
- gtk4 0.11, libadwaita 0.9, webkit6 0.6, rusqlite 0.40, fuser 0.18,
  directories 6, sha1/sha2 0.11, base64 0.23.
2026-09-15 15:07:00 +02:00
nvrl eb56f4a1e0 feat(fuse,core,gui,cli): one-request move+rename drain, show who shared an item
- A queued rename that also changes folder now lands as one `move-multiple`
  request carrying the target name instead of rename-then-move, so there is
  no half-applied state and no second call for stale name requirements to
  hit (B46 follow-up). A destination name collision moves under a conflict
  name in one step; same-folder renames still use the rename endpoint.
- Shared with me shows the inviter and invite date (GUI subtitle, `pdfs
  shared-with-me`), with a warning when the invitation signature does not
  verify against the claimed inviter's keys. `DirEntry` gains `shared_by`,
  `shared_at` and `shared_by_unverified`, all serde-defaulted.
- Bump `proton-sdk` / `proton-drive-rs` to 0.6.3 (anonymous-node moves,
  `TrashTime` trashed detection).
2026-09-15 13:59:13 +02:00
nvrl d7e07d042d Merge pull request #22 from kacperpaczos/fix/login-poll-backoff
fix(cli): back off while waiting for a stored session
2026-09-01 16:38:29 +02:00
nvrl 45852082c0 fix(cli,core): wake the parked login-wait daemon on sign-in
The login backoff had no wake-up path. A unit started at boot sits in
wait_for_session and reaches the ceiling within ~90s; the user then signs
in and `enable --now` is a no-op on an already-active unit, so the daemon
kept sleeping and the mount appeared up to a full interval late.

- service::enable_start now runs `enable` + `restart` instead of
  `enable --now`, so the GUI login and the tray's Connect interrupt the
  sleep immediately and still start a unit that is down.
- `pdfs login` restarts the unit when it is already active. It stays out
  of `enable` on purpose, so a headless setup driving `pdfs daemon` by
  hand is left alone.
- Lower the backoff ceiling from 60s to 15s. It is still an 80% cut in
  keyring/D-Bus wakeups (28,800/day -> 5,760/day) and it bounds the
  worst-case latency for paths that never touch systemd.

Also corrects the flat-3s-poll figure in the doc comment: 86400/3 is
28,800 checks a day, not 48,000.
2026-09-01 16:28:25 +02:00
nvrl 99ad3838e1 fix(GALLERY) fixed takeout date conversion + repair command 2026-08-28 13:06:50 +02:00