diff --git a/AGENTS.md b/AGENTS.md index 9683b97..19adcce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,9 +34,10 @@ project-specific; CI remains the authority for mechanical formatting rules. minimum width. On launch, fit the window height to its content when the screen allows it; show a scrollbar only when the available display height requires one. -- Preserve tray behavior: left click opens the main window, right click opens - the control menu. Hidden-window polling follows the user's preference; the - independent watchdog remains responsible for background health monitoring. +- Preserve tray behavior: left click toggles the main window between visible + and hidden, right click opens the control menu. Hidden-window polling follows + the user's preference; the independent watchdog remains responsible for + background health monitoring. - Prefer explanation at the point of confusion. Overview cards should lead to the relevant detail section, advertise clickability through pointer, hover, pressed and keyboard-focus states, and distinguish protected pending uploads @@ -134,6 +135,25 @@ project-specific; CI remains the authority for mechanical formatting rules. limiter fixes. The updater must keep that reviewed build until the project publishes a replacement; never overwrite it with an official binary that lacks the backend command. +- Proton has deprecated the share-scoped Drive routes + (`/drive/shares/{shareId}/links*`, `events*`, `folders*`, `files/*`) used + throughout the pinned build's go-proton-api and Proton-API-Bridge dependency + stack and announced removal within roughly twelve months (Proton engineer + notice, September 2026). Migrating that stack to the volume-scoped + `/drive/volumes/{volumeId}` and `/drive/v2` routes is a hard prerequisite for + public packaging. Until then keep PDrive API-friendly by design: no component + may poll Proton's events endpoints more than once per 30 seconds once + event-based invalidation exists, and the guarded metadata refresh must remain + a manual, user-initiated path that is never automated into a polling loop. + The public route reference is the official Proton Drive SDK + (`ProtonDriveApps/sdk`, TypeScript): `client/js/src/internal/apiService/ +driveTypes.ts` holds the generated OpenAPI contract. Semantics to preserve: + routes are volume-scoped (`volumeID` replaces `shareID`; node identity is + `volumeId:linkId`), children listings return link IDs only with + `More`/`AnchorID` pagination and a `FoldersOnly` filter, metadata is fetched + in batches of up to 100 IDs via `POST /drive/v2/volumes/{volumeID}/links`, + and the SDK's event pollers run at 30 s for the own volume and 60 s for + others with jitter and Fibonacci backoff. - Avoid new runtime dependencies when Python's standard library, GTK 3, and the installed GI stack are sufficient. Do not add WebKit merely to render local documentation. @@ -175,6 +195,8 @@ project-specific; CI remains the authority for mechanical formatting rules. - `docs/EVERYDAY_USE.md`: task-oriented normal operation in Nemo and the GUI. - `docs/OPERATIONS.md`: complete operational reference. - `docs/TROUBLESHOOTING.md`: symptom-led diagnosis and recovery. +- `docs/PROTON_API_V2_MIGRATION.md`: engineering reference for the Proton + Drive volume-scoped v2 route migration of the pinned rclone stack. ## Verification diff --git a/VERSION b/VERSION index ee94dd8..ac39a10 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.3 +0.9.0 diff --git a/bin/pdrive-prerequisites b/bin/pdrive-prerequisites index b85ea2a..330dd5c 100755 --- a/bin/pdrive-prerequisites +++ b/bin/pdrive-prerequisites @@ -7,9 +7,9 @@ umask 077 readonly target_rclone="${PDRIVE_REAL_RCLONE:-${HOME}/.local/libexec/rclone-bin}" readonly minimum_safe_rclone='v1.76.0' readonly minimum_safe_beta_build=10204 -readonly pdrive_rclone_release='pdrive-v1.76.0-beta.10204.2' +readonly pdrive_rclone_release='pdrive-v1.76.0-beta.10204.3' readonly pdrive_rclone_url="${PDRIVE_RCLONE_URL:-https://github.com/oss-singularity/rclone/releases/download/${pdrive_rclone_release}/rclone-pdrive-linux-amd64}" -readonly pdrive_rclone_sha256="${PDRIVE_RCLONE_SHA256:-85da18d19fd5e4a0969ed0fcc1ec43d00a90dfa7a06ce5483fd681c619db390d}" +readonly pdrive_rclone_sha256="${PDRIVE_RCLONE_SHA256:-a5dcbd149533520e376eeed15ed0bfcb3adaba1f5e17291c3d1e178c73252bf6}" readonly curl_bin="${PDRIVE_CURL_BIN:-$(command -v curl || true)}" usage() { diff --git a/bin/pdrive-state b/bin/pdrive-state index 4111495..1c52ca2 100755 --- a/bin/pdrive-state +++ b/bin/pdrive-state @@ -21,7 +21,7 @@ from typing import Any SCHEMA_VERSION = 1 -TOOL_VERSION = "0.8.3" +TOOL_VERSION = "0.9.0" RECENT_TRANSFER_WINDOW_SECONDS = 24 * 60 * 60 RECENT_TRANSFER_LIMIT = 24 MOUNT_LOG_TAIL_BYTES = 512 * 1024 diff --git a/bin/pdrive-ui b/bin/pdrive-ui index 4bba7c1..9f1b797 100755 --- a/bin/pdrive-ui +++ b/bin/pdrive-ui @@ -60,7 +60,7 @@ PLATFORM_ADAPTER = load_platform_adapter() APP_ID = "io.github.claudiuschuster.PDriveControl" -VERSION = "0.8.3" +VERSION = "0.9.0" REFRESH_INTERVAL_SECONDS = 2 REFRESH_INTERVAL_OPTIONS = (1, 2, 5, 10) CAPACITY_REFRESH_INTERVAL_SECONDS = 5 * 60 @@ -6689,6 +6689,15 @@ class PDriveApplication(Gtk.Application): self.window.schedule_content_fit() self.window.request_refresh() + def toggle_window(self, *_args: Any) -> None: + if self.window is None: + self.activate() + return + if self.window.get_visible(): + self.window.hide() + else: + self.show_window() + def on_window_delete(self, window: Gtk.Window, _event: Gdk.Event) -> bool: if not self.force_quit and self.preferences["close_to_tray"] and not self.demo: window.hide() @@ -6717,7 +6726,7 @@ class PDriveApplication(Gtk.Application): if tray_supports_distinct_clicks() or AyatanaAppIndicator3 is None: self.status_icon = Gtk.StatusIcon.new_from_icon_name(APP_ID) self.status_icon.set_title("PDrive Control Center") - self.status_icon.connect("activate", self.show_window) + self.status_icon.connect("activate", self.toggle_window) self.status_icon.connect("popup-menu", self.popup_status_menu, menu) else: self.indicator = AyatanaAppIndicator3.Indicator.new( diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 1313fce..e2c3a1b 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -489,12 +489,13 @@ fit runs. Each opening can request at most one growth resize after the first dashboard state arrives, so an allocation that has not caught up cannot add the same overflow repeatedly. Smaller screens retain normal scrolling. -On X11 Cinnamon the tray uses GTK StatusIcon so a left click opens/focuses the -Control Center and a right click opens the existing Open, Open `/pdrive`, and -Quit menu. Ayatana AppIndicator remains the compatibility backend on displays -that cannot provide distinct primary/context clicks. Hiding or quitting the UI -never stops the mount; monitoring, recovery and desktop error notifications -remain the responsibility of `pdrive-watch.timer`. +On X11 Cinnamon the tray uses GTK StatusIcon so a left click toggles the Control +Center between the visible window and the hidden tray state, and a right click +opens the existing Open, Open `/pdrive`, and Quit menu. Ayatana AppIndicator +remains the compatibility backend on displays that cannot provide distinct +primary/context clicks. Hiding or quitting the UI never stops the mount; +monitoring, recovery and desktop error notifications remain the responsibility +of `pdrive-watch.timer`. ## Runtime bandwidth @@ -604,6 +605,12 @@ rclone's `vfs/forget` only clears the upper directory cache. A new process is required to guarantee that the Proton backend's deeper in-memory metadata maps are empty. +Keep this refresh a deliberate, occasional operation. Never wrap +`pdrive-refresh --refresh` in a cron job or timer: Proton has warned that +clients rescanning folders or polling aggressively face progressively harder +throttling, and the confirmation gates above exist exactly to make each +metadata rebuild an intentional act. + ## Controlled restart and cooldown ```bash diff --git a/docs/PROTON_API_V2_MIGRATION.md b/docs/PROTON_API_V2_MIGRATION.md new file mode 100644 index 0000000..21dc06c --- /dev/null +++ b/docs/PROTON_API_V2_MIGRATION.md @@ -0,0 +1,206 @@ +# Proton Drive API v2 Migration Reference + +This document is the engineering reference for migrating the PDrive rclone +stack from Proton's deprecated share-scoped Drive routes to the volume-scoped +v2 routes. It records the deprecation background, the complete route and +wire-format mapping, the semantics extracted from the official SDK, the +implementation in the three affected repositories, and the verification +status. It is intentionally public: it contains no account, host or +deployment details. + +## Background and timeline + +- The pinned PDrive rclone build family (`pdrive-v1.76.0-beta.10204.x`) talks + to Proton Drive through `github.com/rclone/go-proton-api` (v1.0.4) and + `github.com/rclone/Proton-API-Bridge` (v1.0.5 + PDrive fixes). +- On 2026-09-16 a Proton engineer stated in the rclone forum thread + "Proton Drive x rclone" (post 25) that the routes + `/drive/shares/{shareId}/links*`, `/drive/shares/{shareId}/events*`, + `/drive/shares/{shareId}/folders*` and `/drive/shares/{shareId}/files/*` + have been deprecated for years and will be removed within roughly 6-12 + months. Replacements are volume-scoped `/drive/volumes/{volumeId}/...` and + `/drive/v2/...` routes. +- The same notice asks clients to poll events endpoints at most every 30 + seconds and warns about aggressive `children`-endpoint rescan traffic. +- Proton linked the official Proton Drive SDK + (`ProtonDriveApps/sdk`, TypeScript) as the reference implementation. Its + committed, generated OpenAPI contract is + `client/js/src/internal/apiService/driveTypes.ts` and is the authoritative + public description of the v2 surface. + +## What the old stack used + +Every drive call in the bridge addressed the **main share**: + +| Operation | Deprecated route | +| ------------------------------------------------------- | ---------------------------------------------------------------------- | +| Fetch one link | `GET /drive/shares/{shareId}/links/{linkId}` | +| List folder children (full metadata, offset pagination) | `GET /drive/shares/{shareId}/folders/{linkId}/children` | +| Create draft file | `POST /drive/shares/{shareId}/files` | +| Create draft revision | `POST /drive/shares/{shareId}/files/{linkId}/revisions` | +| List revisions | `GET /drive/shares/{shareId}/files/{linkId}/revisions` | +| Revision detail with blocks | `GET /drive/shares/{shareId}/files/{linkId}/revisions/{revisionId}` | +| Commit revision | `PUT /drive/shares/{shareId}/files/{linkId}/revisions/{revisionId}` | +| Delete revision | `DELETE /drive/shares/{shareId}/files/{linkId}/revisions/{revisionId}` | +| Create folder | `POST /drive/shares/{shareId}/folders` | +| Move/rename link | `PUT /drive/shares/{shareId}/links/{linkId}/move` | +| Name-hash availability probe | `POST /drive/shares/{shareId}/links/{linkId}/checkAvailableHashes` | +| Trash children | `POST /drive/shares/{shareId}/folders/{linkId}/trash_multiple` | +| Delete children (drafts) | `POST /drive/shares/{shareId}/folders/{linkId}/delete_multiple` | +| Empty trash | `DELETE /drive/shares/{shareId}/trash` | +| Block upload links (ShareID field) | `POST /drive/blocks` | + +Volume and share discovery (`GET /drive/volumes`, `GET /drive/shares`) is not +part of the deprecation and continues to work; `ShareMetadata.VolumeID` was +already delivered by the API. + +## v2 replacement mapping + +The SDK's `driveTypes.ts` OpenAPI contract defines 41 volume-scoped v2 +routes. The mapping implemented for PDrive: + +| v1 call | v2 replacement | Notes | +| --------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| Get link | `POST /drive/v2/volumes/{volumeId}/links` with `{LinkIDs:[...]}` | Batch endpoint; up to 100 IDs per request (SDK limit), missing links are silently omitted from the response | +| Children listing | `GET /drive/v2/volumes/{volumeId}/folders/{linkId}/children` then batch metadata | Listing returns **link IDs only** plus `More`/`AnchorID` anchor pagination and a `FoldersOnly` filter | +| Create draft file | `POST /drive/v2/volumes/{volumeId}/files` | Request body unchanged | +| Create draft revision | `POST /drive/v2/volumes/{volumeId}/files/{linkId}/revisions` | All body fields optional; SDK sends no options | +| List revisions | `GET /drive/v2/volumes/{volumeId}/files/{linkId}/revisions` | Response decode-compatible | +| Revision detail | `GET /drive/v2/volumes/{volumeId}/files/{linkId}/revisions/{revisionId}` | Supports `PageSize`/`FromBlockIndex`/`NoBlockUrls` | +| Commit revision | `PUT /drive/v2/volumes/{volumeId}/files/{linkId}/revisions/{revisionId}` | Body compatible; new optional `ChecksumVerified` defaults to false | +| Delete revision | `DELETE /drive/v2/volumes/{volumeId}/files/{linkId}/revisions/{revisionId}` | Also the SDK's draft-revision deletion route | +| Create folder | `POST /drive/v2/volumes/{volumeId}/folders` | Request body unchanged | +| Move/rename | `PUT /drive/v2/volumes/{volumeId}/links/{linkId}/move` | Body compatible (`OriginalHash` already existed) | +| Hash availability | `POST /drive/v2/volumes/{volumeId}/links/{linkId}/checkAvailableHashes` | Body compatible; response adds per-entry `ClientUID` | +| Trash links | `POST /drive/v2/volumes/{volumeId}/trash_multiple` | Addressed to the volume, no parent folder in the path | +| Delete draft links | `POST /drive/v2/volumes/{volumeId}/delete_multiple` | The SDK deletes drafts through this route, **not** through `/trash/delete_multiple` | +| Delete trashed nodes | `POST /drive/v2/volumes/{volumeId}/trash/delete_multiple` | Distinct from draft deletion | +| Empty trash | `DELETE /drive/volumes/{volumeId}/trash` | Unversioned volume route | +| Block uploads | `POST /drive/blocks` with `VolumeID` | `ShareID` in this request is deprecated ("pass VolumeID instead") | +| Share events | `GET /drive/v2/volumes/{volumeId}/events/{eventId}` (+ `/drive/volumes/{volumeId}/events/latest`) | Not used by PDrive today; relevant for future cache invalidation | + +## Wire-format differences + +The v2 link payload renames and reshapes several fields relative to the v1 +children listing. The Go layer converts them back into the existing `Link` +model so the bridge and rclone backend keep working unchanged: + +| v1 field | v2 field | Handling | +| -------------------------------------- | ----------------------------------- | --------------------------------- | +| `Link.Hash` (name hash on the link) | `Link.NameHash` | mapped to `Link.Hash` | +| `Link.MIMEType` | `File.MediaType` | mapped to `Link.MIMEType` | +| Link size (repeated on link) | `File.ActiveRevision.EncryptedSize` | mapped to `Link.Size` | +| `File.ActiveRevision.ID` | `File.ActiveRevision.RevisionID` | mapped to `RevisionMetadata.ID` | +| `File.ActiveRevision.Size` | `File.ActiveRevision.EncryptedSize` | mapped to `RevisionMetadata.Size` | +| `Folder.NodeHashKey`/`XAttr` placement | unchanged, under `Folder` | direct | + +Multi-item responses use per-link results +(`Responses: [{LinkID, Response:{Code, Error}}]`) and remain 200-OK even when +individual items fail; per-item codes must be checked as before. The v1 +error codes (2500 name exists, draft conflicts, 422/409 shapes) are +unchanged in v2, so the existing `ErrFileNameExist`/`ErrFolderNameExist`/ +`ErrADraftExist` mapping continues to work. + +The v1 `ShowAll=0/1` children filter has no v2 equivalent: v2 listings +return all states and the client filters. `ListVolumeChildren` therefore +filters non-active links client-side when `showAll` is false, preserving the +bridge's existing active-only semantics. + +## Semantics extracted from the official SDK + +From `ProtonDriveApps/sdk` (`client/js/src/internal/nodes/apiService.ts`, +`events/`): + +- Metadata batching: 100 link IDs per `POST .../links` request, maximum + concurrency 15 across volumes. +- Children listings: anchor pagination (`More` + `AnchorID`), stop when + either signals the end. +- Events polling: own volume every 30 s, other volumes every 60 s, plus up + to 1 s jitter and Fibonacci backoff (1,1,2,3,5,8,13) on failures. PDrive + does not poll events today; any future event-based cache invalidation must + adopt these bounds (30 s minimum is also Proton's stated hard limit). +- Draft deletion uses `POST /drive/v2/volumes/{volumeId}/delete_multiple` + (see `upload/apiService.ts: deleteDraft`); draft revision deletion reuses + the revision DELETE route. +- `/drive/blocks` request carries `VolumeID` (the `ShareID` field is + explicitly deprecated in the contract). + +## Implementation + +Three repositories carry the migration. No rclone backend code changes were +required because the backend consumes the bridge API and go-proton-api types +only. + +### go-proton-api (new fork: `oss-singularity/go-proton-api`) + +Branch adds a volume-scoped layer alongside the untouched v1 client: + +- `volume_link_types.go`: `VolumeLinkDetails` wire types and `ToLink()` + conversion (handles all renames above, including thumbnail presence and + folder `XAttr`). +- `volume_link.go`: `ListVolumeChildrenIDs` (anchor pagination), + `GetVolumeLinks`/`GetVolumeLink` (batched, 100 per request), + `ListVolumeChildren` (active-state filtering), + `MoveVolumeLink`, `CheckVolumeAvailableHashes`. +- `volume_link_file.go`: `CreateVolumeFile` (keeps 422/2500 and 409 draft + error mapping), `CreateVolumeRevision`, `ListVolumeRevisions`, + `GetVolumeRevisionAllBlocks`, `CommitVolumeRevision`, + `DeleteVolumeRevision`. +- `volume_link_folder.go`: `CreateVolumeFolder`, `TrashVolumeLinks`, + `DeleteVolumeLinks` (volume `delete_multiple`), `EmptyVolumeTrash`. +- `block_types.go`: `BlockUploadReq.VolumeID` with the deprecated `ShareID` + kept for unmigrated callers. +- Unit tests (`volume_link_test.go`) drive the routes against httptest + stubs: pagination, batch requests, state filtering, error-code mapping. + +### Proton-API-Bridge (`oss-singularity/Proton-API-Bridge`) + +All 15 drive call sites switch from `MainShare.ShareID` to +`MainShare.VolumeID` and the new volume methods. The main share resolution +(`drive.go`) now captures the active volume's ID while selecting the main +share and additionally verifies the share's `VolumeID` matches, failing +closed on inconsistency. `moveToTrash` loses its (now meaningless) parent +folder argument. Block upload requests carry `VolumeID`. + +### rclone fork (`oss-singularity/rclone`) + +No backend changes. The pinned release branch wires `go.mod` replaces to the +two fork commits above and stamps the PDrive version, exactly like previous +pinned builds. + +## Verification status + +Offline (all green): + +- go-proton-api: full `go test` suite plus new v2 unit tests, `go vet`. +- Bridge: `go build`, `go vet`, test suite (integration tests skip without + credentials, as before). +- rclone: full binary build against the migrated workspace, + `backend/protondrive` tests, `data-bandwidth` backend command present. + +Live verification checklist (operator-driven, against a real account): + +1. Mount start: service active, RC socket owner-only, new PID, `/pdrive` + writable — this exercises volume listing, share resolution and the root + link batch fetch. +2. Navigation in Nemo: listing folders (children + batch metadata), + opening a file (revision detail with block URLs). +3. Upload a new file: draft creation, block upload (`VolumeID` field), + revision commit; verify the file in the Proton web app. +4. Overwrite an existing file (new draft revision on an existing link). +5. Rename/move, delete to trash, empty trash. +6. Check the Proton web app for interop: file readable, correct name/size. + +Rollback: restore the previous pinned binary and restart the mount service +(the VFS cache namespace is unaffected by the route migration because no +mount options change). + +## Follow-ups + +- Upstream contributions of the volume-route layer to `rclone/go-proton-api` + and `rclone/Proton-API-Bridge` once the live behavior is confirmed. +- Optional future work: event-based metadata-cache invalidation bounded by + the SDK cadence (30 s own volume) — a separate, deliberate feature. +- Proton expects the deprecated routes to disappear within about 12 months + of the notice; PDrive must not ship a pinned build using them once a v2 + build is validated. diff --git a/tests/test-ui-widgets.sh b/tests/test-ui-widgets.sh index 7b91a45..2f0534d 100755 --- a/tests/test-ui-widgets.sh +++ b/tests/test-ui-widgets.sh @@ -1352,6 +1352,32 @@ if display_type == "GdkBroadwayDisplay": else: assert app.status_icon is not None assert app.indicator is None +# A left tray click hides a visible window; the next click reveals it again. +assert window.get_visible() +if app.status_icon is not None: + app.status_icon.emit("activate") + while module.Gtk.events_pending(): + module.Gtk.main_iteration_do(False) + assert not window.get_visible() +else: + app.toggle_window() + assert not window.get_visible() +app.show_window() +assert window.get_visible() +if app.status_icon is not None: + app.status_icon.emit("activate") + while module.Gtk.events_pending(): + module.Gtk.main_iteration_do(False) + assert not window.get_visible() + app.status_icon.emit("activate") + while module.Gtk.events_pending(): + module.Gtk.main_iteration_do(False) + assert window.get_visible() +else: + app.toggle_window() + assert not window.get_visible() + app.toggle_window() + assert window.get_visible() app.preferences["close_to_tray"] = False app.preferences["start_in_tray"] = False app.configure_tray()