The header subtitle shows the path inside a shared folder and a header
back button replaces the Back row. An adw::Banner counts pending
invitations and scrolls to them.
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.
Themes style the insides of list items with descendant selectors, and
those reached through a popover parented to a grid tile, spacing out
every menu section. Menus now live under the outermost list view.
Pin the split view's sidebar so the wide breakpoint no longer opens the
pane by itself, and show a "No item selected" state instead of closing
it when a navigation clears the entry. The pane follows the header
toggle alone, which also tracks an overlay closed by a click outside.
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.
A click between the files in the grid or list drops the selection, as
in a file manager, unless Ctrl or Shift is held. Backspace goes back
while the focus is on the files.
Group the feed by day with a time column and filter chips, fold a
conflict logged on every daemon run into one row with a count, and put
the conflicts still open in a Needs attention section. Conflict rows
open the Sync page's resolve dialog and read as resolved once the copy
is gone.
Revealing the pane on the first click of a double-click reflowed the files under the pointer, so the second click missed and the folder did not open. The pane now follows a header toggle, Alt+Enter and a Details menu item.
The timeline pages itself in 1.5 screens before the end instead of offering a Load more button, and a year-marked month scrubber jumps to any month, loading long pages until it is reached.
Auto-hiding controls, zoom and pan, a docked details panel, paging past the loaded photos with the library total in the counter, inline video playback, Save a Copy under the real name with an error toast, and Open With through the app chooser.
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.
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.
An adw::Breakpoint at 1000sp uncollapses the details OverlaySplitView, so
the pane sits beside the files instead of over them. The pane gains
Sharing and Versions sections, filled from ListShare and ListRevisions
when an entry is selected, with Share…, Copy Link and Versions… buttons.
The grid and column view wrap one MultiSelection instead of one each, so
a selection survives switching layout, and both views enable rubberband
selection.
Context menus are PopoverMenus built from gio::Menu through a small
ActionMenu helper, so they are keyboard navigable and read as menus. My
Files follows the item order of UI_UX_PLAN.md §6, adds Open With…, Copy
Link and Versions…, and gains a background menu on empty space and the
Menu key / Shift+F10. The photo tile menu and the ⋮ row menus on Shared
by Me and Computers use the same helper.
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.
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`.
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.
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.
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.
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.
- 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.
Audit of every GUI page, the photo viewer, the tray and the backend
capabilities a sync UI needs. Lists found bugs, a glossary, a new
information architecture (Sync hub, Preferences dialog) and a phased
roadmap including pause/resume and conflict resolution.
packaging/aur/ now holds the full content of three AUR repositories:
proton-drive-for-linux builds the latest tag from source,
proton-drive-for-linux-git builds main, and proton-drive-for-linux-bin
installs the binaries attached to the GitHub release. Each carries a
PKGBUILD, a generated .SRCINFO, an ignore-everything .gitignore, and the
0BSD license the AUR expects for package sources.
update.sh bumps the release-based packages and regenerates the .SRCINFO
files; push.sh publishes each package to its AUR remote. The README now
points users at the AUR packages and keeps the in-tree PKGBUILD as the
development build.
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.
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.
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.
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.
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.
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.
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.
The Photos grid laid every photo out as a centre-cropped square. That is
why portraits showed black bars and why a panorama was unrecognisable at
tile size: the tile's shape had nothing to do with the photo's.
Rows are now justified. Photos are taken in capture order until they no
longer fit the target height, and the row is then scaled to land exactly
on the right margin, so each tile is its own photo's shape and nothing is
cropped or letterboxed. The last row of a day stays at the target height
rather than being stretched — a day holding two photos should look like
a short day, not like a layout bug. Ratios come from the daemon where it
has one; where it has not, the tile is laid out square and the day
re-flows once a decoded thumbnail proves the real shape, on the existing
debounce so a screenful of decodes costs one re-flow. Extreme ratios are
clamped, so one strip of film cannot decide what a row looks like.
Ctrl+scroll and Ctrl+± now move the target row height, and the grid is
tighter to match: 2 px gaps, smaller heading margins, a 6 px radius and
no card behind each tile, so the page reads as photographs rather than
as a deck of cards.
Thumbnails also stopped disappearing on scroll. The texture cache was
evicting in insertion order — which is precisely what a scroll back up
asks for again — so it is now least-recently-used and holds 1500 rather
than 600, which at the new density is several screens instead of under
two. A batch tops itself up with the photos just past the rows on
screen, in the direction of travel, and once the queue is short the
near-miss tiles are fetched rather than dropped.
Teardown flagged the FUSE worker pool closed but never joined it, so the
process could drop the tokio runtime while a worker was still inside a
job — which is where 2026-09-18's "A Tokio 1.x context was found, but it
is being shutdown" panic came from. `Workers::stop_and_join` now waits
for the jobs in flight, bounded at 10 s so a worker wedged on the network
cannot hold the stop the user asked for; what is still stuck is named in
the log and shutdown continues around it.
The queues stay unbounded — the only caller that could feel backpressure
is fuser's dispatch loop, and blocking that is the stall the pool exists
to avoid — but a lane now logs its depth every 256 jobs, so a queue that
keeps climbing is visible before it is reported as a slow mount.
Documents the hang as B91, with the journal evidence, what made it
undiagnosable, and the candidates the evidence does not yet separate.
A supervisor thread ticks every 15 s and does three things: it WARNs
about a worker that has held one job for two minutes, a control request
that never returned, or a lane that has not completed anything while its
queue is non-empty; it samples resident size and the pending-op count
into the log every five minutes; and it answers the systemd watchdog.
The watchdog ping is deliberately not a heartbeat from a thread that
does nothing else — that would keep the unit "healthy" through exactly
the hang it is meant to catch. It goes out only after a real round trip
over the control socket, so it means "a client can still talk to this
daemon", which is the property that failed on 2026-09-18.
`sd_notify` is spoken directly rather than through libsystemd (READY,
WATCHDOG, STOPPING), including the abstract-namespace address; off
systemd every call is a no-op.
The unit gains `NotifyAccess=all` (rather than `Type=notify`, which
would fail the unit on the start timeout when no login is stored yet),
`WatchdogSec=120`, `Restart=always` — a hang is not a failing exit code
— and `MemoryHigh`/`MemoryMax` as a backstop against the 5.2 G growth
the hung run showed.
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.
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.
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
- 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.
- 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).
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.